Refactor: Remove legacy data access objects and logging utilities
- Deleted MemoryStore, OverviewStore, SessionStore, StreamingSessionStore, and TranscriptEventStore classes to streamline database interactions. - Removed logger and rolling log utilities to simplify logging mechanisms. - Updated index file to reflect the removal of stores and logging functionalities.
This commit is contained in:
Vendored
+160
-512
File diff suppressed because one or more lines are too long
+5
-106
@@ -9,19 +9,11 @@ import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/co
|
||||
// Import command handlers
|
||||
import { install } from '../commands/install.js';
|
||||
import { uninstall } from '../commands/uninstall.js';
|
||||
import { status } from '../commands/status.js';
|
||||
import { logs } from '../commands/logs.js';
|
||||
import { loadContext } from '../commands/load-context.js';
|
||||
import { trash } from '../commands/trash.js';
|
||||
import { viewTrash } from '../commands/trash-view.js';
|
||||
import { emptyTrash } from '../commands/trash-empty.js';
|
||||
import { restore } from '../commands/restore.js';
|
||||
import { changelog } from '../commands/changelog.js';
|
||||
import { doctor } from '../commands/doctor.js';
|
||||
import { storeMemory } from '../commands/store-memory.js';
|
||||
import { storeOverview } from '../commands/store-overview.js';
|
||||
import { updateSessionMetadata } from '../commands/update-session-metadata.js';
|
||||
import { generateTitle } from '../commands/generate-title.js';
|
||||
|
||||
const program = new Command();
|
||||
// </Block> =======================================
|
||||
@@ -65,32 +57,7 @@ program
|
||||
.action(uninstall);
|
||||
// </Block> =======================================
|
||||
|
||||
|
||||
// <Block> 1.6 ====================================
|
||||
// Status Command Definition
|
||||
// Natural pattern: Define command with its handler
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('Check installation status of Claude Memory System')
|
||||
.action(status);
|
||||
|
||||
// Doctor command
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Run environment and pipeline diagnostics for rolling memory')
|
||||
.option('--json', 'Output JSON instead of text')
|
||||
.action(async (options: any) => {
|
||||
try {
|
||||
await doctor(options);
|
||||
} catch (error: any) {
|
||||
console.error(`doctor failed: ${error.message || error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.7 ====================================
|
||||
// Logs Command Definition
|
||||
// Natural pattern: Define command with its options and handler
|
||||
// Logs command
|
||||
@@ -105,20 +72,6 @@ program
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.8 ====================================
|
||||
// Load-Context Command Definition
|
||||
// Natural pattern: Define command with its options and handler
|
||||
// Load-context command
|
||||
program
|
||||
.command('load-context')
|
||||
.description('Load compressed memories for current session')
|
||||
.option('--project <name>', 'Filter by project name')
|
||||
.option('--count <n>', 'Number of memories to load', '10')
|
||||
.option('--raw', 'Output raw JSON instead of formatted text')
|
||||
.option('--format <type>', 'Output format: json, session-start, or default')
|
||||
.action(loadContext);
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.9 ====================================
|
||||
// Trash and Restore Commands Definition
|
||||
// Natural pattern: Define commands for safe file operations
|
||||
|
||||
@@ -166,63 +119,9 @@ program
|
||||
.action(restore);
|
||||
// </Block> =======================================
|
||||
|
||||
// Store memory command (for SDK streaming)
|
||||
program
|
||||
.command('store-memory')
|
||||
.description('Store a memory to all storage layers (used by SDK)')
|
||||
.requiredOption('--id <id>', 'Memory ID')
|
||||
.requiredOption('--project <project>', 'Project name')
|
||||
.requiredOption('--session <session>', 'Session ID')
|
||||
.requiredOption('--date <date>', 'Date (YYYY-MM-DD)')
|
||||
.requiredOption('--title <title>', 'Memory title (3-8 words)')
|
||||
.requiredOption('--subtitle <subtitle>', 'Memory subtitle (max 24 words)')
|
||||
.requiredOption('--facts <json>', 'Atomic facts as JSON array')
|
||||
.option('--concepts <json>', 'Concept tags as JSON array')
|
||||
.option('--files <json>', 'Files touched as JSON array')
|
||||
.action(storeMemory);
|
||||
|
||||
// Store overview command (for SDK streaming)
|
||||
program
|
||||
.command('store-overview')
|
||||
.description('Store a session overview (used by SDK)')
|
||||
.requiredOption('--project <project>', 'Project name')
|
||||
.requiredOption('--session <session>', 'Session ID')
|
||||
.requiredOption('--content <content>', 'Overview content')
|
||||
.action(storeOverview);
|
||||
|
||||
// Update session metadata command (for SDK streaming)
|
||||
program
|
||||
.command('update-session-metadata')
|
||||
.description('Update session title and subtitle (used by SDK)')
|
||||
.requiredOption('--project <project>', 'Project name')
|
||||
.requiredOption('--session <session>', 'Session ID')
|
||||
.requiredOption('--title <title>', 'Session title (3-6 words)')
|
||||
.option('--subtitle <subtitle>', 'Session subtitle (max 20 words)')
|
||||
.action(updateSessionMetadata);
|
||||
|
||||
// Changelog command
|
||||
program
|
||||
.command('changelog')
|
||||
.description('Generate CHANGELOG.md from claude-mem memories')
|
||||
.option('--historical <n>', 'Number of versions to search (default: current version only)')
|
||||
.option('--generate <version>', 'Generate changelog for a specific version')
|
||||
.option('--start <time>', 'Start time for memory search (ISO format)')
|
||||
.option('--end <time>', 'End time for memory search (ISO format)')
|
||||
.option('--update', 'Update CHANGELOG.md from JSONL entries')
|
||||
.option('--preview', 'Preview the generated changelog')
|
||||
.option('-v, --verbose', 'Show detailed output')
|
||||
.action(changelog);
|
||||
|
||||
// Generate title command
|
||||
program
|
||||
.command('generate-title <prompt>')
|
||||
.description('Generate a session title and subtitle from a prompt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--oneline', 'Output as single line (title - subtitle)')
|
||||
.option('--session-id <id>', 'Claude session ID to update')
|
||||
.option('--save', 'Save the generated title to the database (requires --session-id)')
|
||||
.action(generateTitle);
|
||||
|
||||
// <Block> 1.9 ====================================
|
||||
// Hook Commands Definition
|
||||
// Natural pattern: Define hook commands for Claude Code integration
|
||||
// Hook commands (for Claude Code hook integration)
|
||||
program
|
||||
.command('context')
|
||||
@@ -282,8 +181,8 @@ async function readStdin(): Promise<string> {
|
||||
program.parse();
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.12 ===================================
|
||||
// <Block> 1.11 ===================================
|
||||
// Module Exports for Programmatic Use
|
||||
// Export database and utility classes for hooks and external consumers
|
||||
export { DatabaseManager, StreamingSessionStore, migrations, initializeDatabase, getDatabase } from '../services/sqlite/index.js';
|
||||
export { DatabaseManager, migrations, initializeDatabase, getDatabase } from '../services/sqlite/index.js';
|
||||
// </Block> =======================================
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getClaudePath } from '../shared/settings.js';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
type: 'Added' | 'Changed' | 'Fixed' | 'Removed' | 'Deprecated' | 'Security';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
generatedAt?: string; // When this changelog entry was created
|
||||
}
|
||||
|
||||
interface MemorySearchResult {
|
||||
version: string;
|
||||
text: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
export async function changelog(options: OptionValues): Promise<void> {
|
||||
try {
|
||||
// Handle --update flag to regenerate CHANGELOG.md from JSONL
|
||||
if (options.update) {
|
||||
await updateChangelogFromJsonl(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current version and project name from package.json
|
||||
const packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
let currentVersion = 'unknown';
|
||||
let projectName = 'unknown';
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
currentVersion = packageData.version || 'unknown';
|
||||
projectName = packageData.name || path.basename(process.cwd());
|
||||
} catch (e) {
|
||||
projectName = path.basename(process.cwd());
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate versions to search for based on flags
|
||||
const versionsToSearch: string[] = [];
|
||||
let historicalCount = options.historical || 1; // Default to current version only
|
||||
|
||||
// Handle --generate flag for specific version
|
||||
if (options.generate) {
|
||||
versionsToSearch.push(options.generate);
|
||||
historicalCount = 1; // Single version mode
|
||||
console.log(`🎯 Generating changelog for specific version: ${options.generate}`);
|
||||
} else if (currentVersion !== 'unknown') {
|
||||
// Normal mode: use current version or historical versions
|
||||
const parts = currentVersion.split('.');
|
||||
if (parts.length === 3) {
|
||||
let major = parseInt(parts[0]);
|
||||
let minor = parseInt(parts[1]);
|
||||
let patch = parseInt(parts[2]);
|
||||
|
||||
for (let i = 0; i < historicalCount; i++) {
|
||||
versionsToSearch.push(`${major}.${minor}.${patch}`);
|
||||
|
||||
// Decrement version
|
||||
if (patch === 0) {
|
||||
if (minor === 0) {
|
||||
// Can't go lower than x.0.0
|
||||
break;
|
||||
}
|
||||
minor--;
|
||||
patch = 9;
|
||||
} else {
|
||||
patch--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (versionsToSearch.length === 0) {
|
||||
console.log('⚠️ Could not determine versions to search. Please check package.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if current version already has a changelog entry
|
||||
const projectChangelogDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '',
|
||||
'.claude-mem',
|
||||
'projects'
|
||||
);
|
||||
const changelogJsonlPath = path.join(projectChangelogDir, `${projectName}-changelog.jsonl`);
|
||||
|
||||
let hasCurrentVersion = false;
|
||||
|
||||
if (fs.existsSync(changelogJsonlPath)) {
|
||||
const existingLines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
|
||||
for (const line of existingLines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.version === currentVersion) {
|
||||
hasCurrentVersion = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.historical && !options.generate && historicalCount === 1) {
|
||||
if (hasCurrentVersion) {
|
||||
console.log(`❌ Version ${currentVersion} already has changelog entries.`);
|
||||
console.log('\n📝 Workflow:');
|
||||
console.log(' 1. Make your code updates');
|
||||
console.log(' 2. Build and test: bun run build');
|
||||
console.log(' 3. Bump version: npm version patch');
|
||||
console.log(' 4. Generate changelog: claude-mem changelog');
|
||||
console.log(' 5. Commit and push\n');
|
||||
console.log(`💡 Or use --historical 1 to regenerate this version's changelog`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get npm publish times for all versions we need
|
||||
let versionTimeRanges: Array<{version: string, startTime: string, endTime: string}> = [];
|
||||
|
||||
// Check if custom time range is provided
|
||||
if (options.start && options.end) {
|
||||
// Use custom time range for the specified version
|
||||
const version = options.generate || currentVersion;
|
||||
versionTimeRanges.push({
|
||||
version,
|
||||
startTime: options.start,
|
||||
endTime: options.end
|
||||
});
|
||||
|
||||
console.log(`📅 Using custom time range for ${version}:`);
|
||||
console.log(` Start: ${new Date(options.start).toLocaleString()}`);
|
||||
console.log(` End: ${new Date(options.end).toLocaleString()}`);
|
||||
} else {
|
||||
try {
|
||||
const npmTimeData = execSync(`npm view ${projectName} time --json`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000
|
||||
});
|
||||
const publishTimes = JSON.parse(npmTimeData);
|
||||
|
||||
// For historical mode, we need one extra previous version to get proper time ranges
|
||||
// E.g., for 3 versions, we need 4 timestamps to create 3 ranges
|
||||
let extraPrevVersion = '';
|
||||
if (historicalCount > 1) {
|
||||
// Get the version before our oldest version in the search list
|
||||
const oldestVersion = versionsToSearch[versionsToSearch.length - 1];
|
||||
const parts = oldestVersion.split('.');
|
||||
const major = parseInt(parts[0]);
|
||||
const minor = parseInt(parts[1]);
|
||||
const patch = parseInt(parts[2]);
|
||||
|
||||
if (patch > 0) {
|
||||
extraPrevVersion = `${major}.${minor}.${patch - 1}`;
|
||||
} else if (minor > 0) {
|
||||
// Look for highest patch of previous minor
|
||||
const prevMinorPrefix = `${major}.${minor - 1}.`;
|
||||
const prevMinorVersions = Object.keys(publishTimes)
|
||||
.filter(v => v.startsWith(prevMinorPrefix))
|
||||
.sort((a, b) => {
|
||||
const aPatch = parseInt(a.split('.')[2] || '0');
|
||||
const bPatch = parseInt(b.split('.')[2] || '0');
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
if (prevMinorVersions.length > 0) {
|
||||
extraPrevVersion = prevMinorVersions[0];
|
||||
}
|
||||
} else if (major > 0) {
|
||||
// Look for highest version of previous major
|
||||
const prevMajorPrefix = `${major - 1}.`;
|
||||
const prevMajorVersions = Object.keys(publishTimes)
|
||||
.filter(v => v.startsWith(prevMajorPrefix))
|
||||
.sort((a, b) => {
|
||||
const [, aMinor, aPatch] = a.split('.').map(Number);
|
||||
const [, bMinor, bPatch] = b.split('.').map(Number);
|
||||
if (aMinor !== bMinor) return bMinor - aMinor;
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
if (prevMajorVersions.length > 0) {
|
||||
extraPrevVersion = prevMajorVersions[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (options.verbose && extraPrevVersion && publishTimes[extraPrevVersion]) {
|
||||
console.log(`📍 Using ${extraPrevVersion} as start boundary for time ranges`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build time ranges for each version
|
||||
for (let i = 0; i < versionsToSearch.length; i++) {
|
||||
const version = versionsToSearch[i];
|
||||
|
||||
// Start time:
|
||||
// - For the first (newest) version, use the publish time of the version before it
|
||||
// - For middle versions, use the publish time of the next version in our list
|
||||
// - For the last (oldest) version, use the extra previous version we found
|
||||
let startTime = '2000-01-01T00:00:00Z'; // Default to old date
|
||||
|
||||
if (i === 0) {
|
||||
// First (newest) version - find its immediate predecessor
|
||||
const versionParts = version.split('.');
|
||||
const major = parseInt(versionParts[0]);
|
||||
const minor = parseInt(versionParts[1]);
|
||||
const patch = parseInt(versionParts[2]);
|
||||
|
||||
let prevVersion = '';
|
||||
if (patch > 0) {
|
||||
prevVersion = `${major}.${minor}.${patch - 1}`;
|
||||
} else if (minor > 0) {
|
||||
// Look for highest patch of previous minor
|
||||
const prevMinorPrefix = `${major}.${minor - 1}.`;
|
||||
const prevMinorVersions = Object.keys(publishTimes)
|
||||
.filter(v => v.startsWith(prevMinorPrefix))
|
||||
.sort((a, b) => {
|
||||
const aPatch = parseInt(a.split('.')[2] || '0');
|
||||
const bPatch = parseInt(b.split('.')[2] || '0');
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
if (prevMinorVersions.length > 0) {
|
||||
prevVersion = prevMinorVersions[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (publishTimes[prevVersion]) {
|
||||
startTime = publishTimes[prevVersion];
|
||||
}
|
||||
} else if (i < versionsToSearch.length - 1) {
|
||||
// Middle versions - use the next version in our list
|
||||
const prevVersionInList = versionsToSearch[i + 1];
|
||||
if (publishTimes[prevVersionInList]) {
|
||||
startTime = publishTimes[prevVersionInList];
|
||||
}
|
||||
} else {
|
||||
// Last (oldest) version - use the extra previous version
|
||||
if (extraPrevVersion && publishTimes[extraPrevVersion]) {
|
||||
startTime = publishTimes[extraPrevVersion];
|
||||
}
|
||||
}
|
||||
|
||||
// End time is this version's publish time (or now for unreleased)
|
||||
let endTime = publishTimes[version] || new Date().toISOString();
|
||||
|
||||
versionTimeRanges.push({ version, startTime, endTime });
|
||||
|
||||
if (options.verbose) {
|
||||
console.log(`📅 Version ${version}: ${new Date(startTime).toLocaleString()} - ${new Date(endTime).toLocaleString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Always log what we're doing for single version
|
||||
if (historicalCount === 1) {
|
||||
const latestRange = versionTimeRanges[0];
|
||||
if (latestRange) {
|
||||
console.log(`📦 Using npm time range for ${latestRange.version}: ${new Date(latestRange.startTime).toLocaleString()} - ${new Date(latestRange.endTime).toLocaleString()}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('❌ Could not fetch npm publish times. Cannot proceed without time ranges.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 Searching memories for versions: ${versionsToSearch.join(', ')}`);
|
||||
console.log(`📦 Project: ${projectName}\n`);
|
||||
|
||||
// Phase 1: Search for version-related memories using MCP tools
|
||||
// ALWAYS use time range search - no other method
|
||||
const searchPrompt = versionTimeRanges.length > 0 ?
|
||||
`You are helping generate a changelog by searching for memories within specific time ranges for multiple versions.
|
||||
|
||||
PROJECT: ${projectName}
|
||||
VERSION TIME RANGES:
|
||||
${versionTimeRanges.map(r => `- Version ${r.version}: ${new Date(r.startTime).toLocaleDateString()} to ${new Date(r.endTime).toLocaleDateString()}`).join('\n')}
|
||||
|
||||
YOUR TASK:
|
||||
Use mcp__claude-mem__chroma_query_documents to search for memories for each version time range.
|
||||
|
||||
SEARCH STRATEGY:
|
||||
${versionTimeRanges.map(r => {
|
||||
const startDate = new Date(r.startTime);
|
||||
const endDate = new Date(r.endTime);
|
||||
|
||||
// Generate all date prefixes between start and end
|
||||
const datePrefixes: string[] = [];
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
// Add day prefix like "2025-09-09"
|
||||
const dayPrefix = currentDate.toISOString().split('T')[0];
|
||||
datePrefixes.push(dayPrefix);
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return `
|
||||
Version ${r.version} (${new Date(r.startTime).toLocaleDateString()} to ${new Date(r.endTime).toLocaleDateString()}):
|
||||
1. Search for memories from these dates: ${datePrefixes.join(', ')}
|
||||
2. Make multiple calls to mcp__claude-mem__chroma_query_documents:
|
||||
- collection_name: "claude_memories"
|
||||
- query_texts: Include the project name AND date in each query:
|
||||
* "${projectName} ${datePrefixes[0]} feature"
|
||||
* "${projectName} ${datePrefixes[0]} fix"
|
||||
* "${projectName} ${datePrefixes[0]} change"
|
||||
* "${projectName} ${datePrefixes[0]} improvement"
|
||||
* "${projectName} ${datePrefixes[0]} refactor"
|
||||
- n_results: 50
|
||||
3. The date in the query text helps semantic search find memories from that day
|
||||
4. Assign memories to this version if their timestamp falls within:
|
||||
- Start: ${r.startTime}
|
||||
- End: ${r.endTime}`;
|
||||
}).join('\n')}
|
||||
|
||||
IMPORTANT:
|
||||
- Always include project name and date in query_texts for best results
|
||||
- Semantic search will naturally find memories near those dates
|
||||
- Group returned memories by version based on their timestamp metadata
|
||||
|
||||
Return a JSON object with this structure:
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"version": "version_number",
|
||||
"text": "memory content",
|
||||
"metadata": {metadata object with timestamp},
|
||||
"relevance": "high/medium/low"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Group memories by the version they belong to based on timestamp.
|
||||
Start searching now.` :
|
||||
`ERROR: No time ranges available. This should never happen.`;
|
||||
|
||||
if (versionTimeRanges.length === 0) {
|
||||
console.log('❌ No time ranges available. Cannot search memories.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log('📝 Calling Claude to search memories...');
|
||||
}
|
||||
|
||||
// Call Claude with MCP tools to search memories
|
||||
const searchResponse = await query({
|
||||
prompt: searchPrompt,
|
||||
options: {
|
||||
allowedTools: [
|
||||
'mcp__claude-mem__chroma_query_documents',
|
||||
'mcp__claude-mem__chroma_get_documents'
|
||||
],
|
||||
pathToClaudeCodeExecutable: getClaudePath()
|
||||
}
|
||||
});
|
||||
|
||||
// Extract memories from response
|
||||
let memoriesJson = '';
|
||||
if (searchResponse && typeof searchResponse === 'object' && Symbol.asyncIterator in searchResponse) {
|
||||
for await (const message of searchResponse) {
|
||||
if (message?.type === 'assistant' && message?.message?.content) {
|
||||
const content = message.message.content;
|
||||
if (typeof content === 'string') {
|
||||
memoriesJson += content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
memoriesJson += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse memories
|
||||
let memories: MemorySearchResult[] = [];
|
||||
try {
|
||||
// Extract JSON from response (might be wrapped in markdown)
|
||||
const jsonMatch = memoriesJson.match(/```json\n([\s\S]*?)\n```/) ||
|
||||
memoriesJson.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
|
||||
if (parsed.memories && Array.isArray(parsed.memories)) {
|
||||
memories = parsed.memories;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('⚠️ Could not parse memory search results:', e);
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log('\n⚠️ No version-related memories found for this version.');
|
||||
console.log(' This is normal for the first release or when no changes were tracked.');
|
||||
console.log(' Creating a placeholder changelog entry...');
|
||||
|
||||
// Create a minimal placeholder entry
|
||||
const placeholderEntry: ChangelogEntry = {
|
||||
version: versionsToSearch[0], // Use the first (current) version
|
||||
date: todayStr,
|
||||
type: 'Changed',
|
||||
description: 'Initial release or minor updates',
|
||||
timestamp: new Date().toISOString(),
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save the placeholder entry
|
||||
if (!fs.existsSync(projectChangelogDir)) {
|
||||
fs.mkdirSync(projectChangelogDir, { recursive: true });
|
||||
}
|
||||
|
||||
const jsonlContent = JSON.stringify(placeholderEntry) + '\n';
|
||||
fs.appendFileSync(changelogJsonlPath, jsonlContent);
|
||||
|
||||
console.log(`✅ Created placeholder changelog entry for v${versionsToSearch[0]}`);
|
||||
|
||||
// Generate the CHANGELOG.md with the placeholder
|
||||
await updateChangelogFromJsonl(options);
|
||||
|
||||
return; // Exit successfully
|
||||
}
|
||||
|
||||
console.log(`✅ Found ${memories.length} version-related memories\n`);
|
||||
|
||||
// Get system date for accuracy
|
||||
const systemDate = execSync('date "+%Y-%m-%d %H:%M:%S %Z"').toString().trim();
|
||||
const todayStr = systemDate.split(' ')[0]; // YYYY-MM-DD format
|
||||
|
||||
// Phase 2: Generate changelog entries from memories
|
||||
const changelogPrompt = `Analyze these memories and generate changelog entries.
|
||||
|
||||
PROJECT: ${projectName}
|
||||
DATE: ${todayStr}
|
||||
|
||||
MEMORIES BY VERSION:
|
||||
${versionsToSearch.map(version => {
|
||||
const versionMemories = memories.filter(m => m.version === version);
|
||||
if (versionMemories.length === 0) return `### Version ${version}\nNo memories found.`;
|
||||
return `### Version ${version} (${versionMemories.length} memories):
|
||||
${versionMemories.map((m, i) => `${i + 1}. ${m.text}`).join('\n')}`;
|
||||
}).join('\n\n')}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Extract concrete changes, fixes, and additions from the memories
|
||||
2. Categorize each change as: Added, Changed, Fixed, Removed, Deprecated, or Security
|
||||
3. Write clear, user-facing descriptions
|
||||
4. Start each entry with an action verb
|
||||
5. Focus on what matters to users, not internal implementation details
|
||||
|
||||
Return ONLY a JSON array with this structure:
|
||||
[
|
||||
{
|
||||
"version": "3.6.1",
|
||||
"type": "Added",
|
||||
"description": "New feature description"
|
||||
},
|
||||
{
|
||||
"version": "3.6.1",
|
||||
"type": "Fixed",
|
||||
"description": "Bug fix description"
|
||||
}
|
||||
]`;
|
||||
|
||||
console.log('🔄 Generating changelog entries...');
|
||||
|
||||
// Call Claude to generate changelog entries
|
||||
const changelogResponse = await query({
|
||||
prompt: changelogPrompt,
|
||||
options: {
|
||||
allowedTools: [],
|
||||
pathToClaudeCodeExecutable: getClaudePath()
|
||||
}
|
||||
});
|
||||
|
||||
// Extract JSON from response
|
||||
let entriesJson = '';
|
||||
if (changelogResponse && typeof changelogResponse === 'object' && Symbol.asyncIterator in changelogResponse) {
|
||||
for await (const message of changelogResponse) {
|
||||
if (message?.type === 'assistant' && message?.message?.content) {
|
||||
const content = message.message.content;
|
||||
if (typeof content === 'string') {
|
||||
entriesJson += content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
entriesJson += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse changelog entries
|
||||
let entries: ChangelogEntry[] = [];
|
||||
try {
|
||||
// Extract JSON (might be wrapped in markdown)
|
||||
const jsonMatch = entriesJson.match(/```json\n([\s\S]*?)\n```/) ||
|
||||
entriesJson.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
|
||||
if (Array.isArray(parsed)) {
|
||||
const generatedAt = new Date().toISOString();
|
||||
entries = parsed.map(e => ({
|
||||
...e,
|
||||
date: todayStr,
|
||||
timestamp: e.timestamp || generatedAt, // Memory timestamp if available
|
||||
generatedAt: generatedAt // When this changelog was generated
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('⚠️ Could not parse changelog entries:', e);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('⚠️ No changelog entries generated.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure project changelog directory exists
|
||||
if (!fs.existsSync(projectChangelogDir)) {
|
||||
fs.mkdirSync(projectChangelogDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Save entries to project JSONL file
|
||||
console.log(`\n💾 Saving ${entries.length} changelog entries to ${path.basename(changelogJsonlPath)}`);
|
||||
|
||||
// When using --historical or --generate, remove old entries for the versions being regenerated
|
||||
if ((options.historical && historicalCount > 1) || options.generate) {
|
||||
let existingEntries: ChangelogEntry[] = [];
|
||||
if (fs.existsSync(changelogJsonlPath)) {
|
||||
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Keep entries that are NOT in the versions we're regenerating
|
||||
if (!versionsToSearch.includes(entry.version)) {
|
||||
existingEntries.push(entry);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rewrite the file with filtered entries plus new ones
|
||||
const allEntries = [...existingEntries, ...entries];
|
||||
const jsonlContent = allEntries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
||||
fs.writeFileSync(changelogJsonlPath, jsonlContent);
|
||||
console.log(`🔄 Regenerated entries for versions: ${versionsToSearch.join(', ')}`);
|
||||
} else {
|
||||
// Append new entries to JSONL
|
||||
const jsonlContent = entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
||||
fs.appendFileSync(changelogJsonlPath, jsonlContent);
|
||||
}
|
||||
|
||||
// Now generate markdown from all JSONL entries
|
||||
console.log('\n📝 Generating CHANGELOG.md from entries...');
|
||||
|
||||
// Read all entries from JSONL
|
||||
let allEntries: ChangelogEntry[] = [];
|
||||
if (fs.existsSync(changelogJsonlPath)) {
|
||||
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
allEntries.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group entries by version
|
||||
const entriesByVersion = new Map<string, ChangelogEntry[]>();
|
||||
for (const entry of allEntries) {
|
||||
if (!entriesByVersion.has(entry.version)) {
|
||||
entriesByVersion.set(entry.version, []);
|
||||
}
|
||||
entriesByVersion.get(entry.version)!.push(entry);
|
||||
}
|
||||
|
||||
// Generate markdown
|
||||
let markdown = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n';
|
||||
|
||||
// Sort versions in descending order
|
||||
const sortedVersions = Array.from(entriesByVersion.keys()).sort((a, b) => {
|
||||
const aParts = a.split('.').map(Number);
|
||||
const bParts = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const version of sortedVersions) {
|
||||
const versionEntries = entriesByVersion.get(version)!;
|
||||
const date = versionEntries[0].date || todayStr;
|
||||
|
||||
markdown += `\n## [${version}] - ${date}\n\n`;
|
||||
|
||||
// Group by type
|
||||
const types: Array<ChangelogEntry['type']> = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
||||
for (const type of types) {
|
||||
const typeEntries = versionEntries.filter(e => e.type === type);
|
||||
if (typeEntries.length > 0) {
|
||||
markdown += `### ${type}\n`;
|
||||
for (const entry of typeEntries) {
|
||||
markdown += `- ${entry.description}\n`;
|
||||
}
|
||||
markdown += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the CHANGELOG.md
|
||||
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
|
||||
fs.writeFileSync(changelogPath, markdown);
|
||||
|
||||
console.log(`✅ Generated CHANGELOG.md with ${allEntries.length} total entries across ${entriesByVersion.size} versions!`);
|
||||
|
||||
if (options.preview) {
|
||||
console.log('\n📄 Preview:\n');
|
||||
console.log(markdown.split('\n').slice(0, 30).join('\n'));
|
||||
if (markdown.split('\n').length > 30) {
|
||||
console.log('\n... (truncated for preview)');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating changelog:', error instanceof Error ? error.message : error);
|
||||
if (error instanceof Error && error.stack) {
|
||||
console.error('Stack:', error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChangelogFromJsonl(options: OptionValues): Promise<void> {
|
||||
try {
|
||||
// Get project name from package.json
|
||||
const packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
let projectName = 'unknown';
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageData = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
projectName = packageData.name || path.basename(process.cwd());
|
||||
} catch (e) {
|
||||
projectName = path.basename(process.cwd());
|
||||
}
|
||||
}
|
||||
|
||||
const projectChangelogDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '',
|
||||
'.claude-mem',
|
||||
'projects'
|
||||
);
|
||||
const changelogJsonlPath = path.join(projectChangelogDir, `${projectName}-changelog.jsonl`);
|
||||
|
||||
if (!fs.existsSync(changelogJsonlPath)) {
|
||||
console.log('❌ No changelog entries found. Generate some first with: claude-mem changelog');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('📝 Updating CHANGELOG.md from JSONL entries...');
|
||||
|
||||
// Read all entries from JSONL
|
||||
let allEntries: ChangelogEntry[] = [];
|
||||
const lines = fs.readFileSync(changelogJsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
allEntries.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) {
|
||||
console.log('❌ No valid entries found in JSONL file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Group entries by version
|
||||
const entriesByVersion = new Map<string, ChangelogEntry[]>();
|
||||
for (const entry of allEntries) {
|
||||
if (!entriesByVersion.has(entry.version)) {
|
||||
entriesByVersion.set(entry.version, []);
|
||||
}
|
||||
entriesByVersion.get(entry.version)!.push(entry);
|
||||
}
|
||||
|
||||
// Generate markdown
|
||||
let markdown = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n';
|
||||
|
||||
// Sort versions in descending order
|
||||
const sortedVersions = Array.from(entriesByVersion.keys()).sort((a, b) => {
|
||||
const aParts = a.split('.').map(Number);
|
||||
const bParts = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (aParts[i] !== bParts[i]) return bParts[i] - aParts[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const version of sortedVersions) {
|
||||
const versionEntries = entriesByVersion.get(version)!;
|
||||
const date = versionEntries[0].date;
|
||||
|
||||
markdown += `\n## [${version}] - ${date}\n\n`;
|
||||
|
||||
// Group by type
|
||||
const types: Array<ChangelogEntry['type']> = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
|
||||
for (const type of types) {
|
||||
const typeEntries = versionEntries.filter(e => e.type === type);
|
||||
if (typeEntries.length > 0) {
|
||||
markdown += `### ${type}\n`;
|
||||
for (const entry of typeEntries) {
|
||||
markdown += `- ${entry.description}\n`;
|
||||
}
|
||||
markdown += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write the CHANGELOG.md
|
||||
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
|
||||
fs.writeFileSync(changelogPath, markdown);
|
||||
|
||||
console.log(`✅ Updated CHANGELOG.md with ${allEntries.length} entries across ${entriesByVersion.size} versions!`);
|
||||
|
||||
if (options.preview) {
|
||||
console.log('\n📄 Preview:\n');
|
||||
console.log(markdown.split('\n').slice(0, 30).join('\n'));
|
||||
if (markdown.split('\n').length > 30) {
|
||||
console.log('\n... (truncated for preview)');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating changelog:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { getClaudePath } from '../shared/settings.js';
|
||||
import { DatabaseManager } from '../services/sqlite/Database.js';
|
||||
import { StreamingSessionStore } from '../services/sqlite/StreamingSessionStore.js';
|
||||
import { migrations } from '../services/sqlite/migrations.js';
|
||||
|
||||
/**
|
||||
* Generate a session title and subtitle from a user prompt
|
||||
* CLI command that uses Agent SDK (like changelog.ts)
|
||||
*
|
||||
* Can be called in two modes:
|
||||
* 1. Standalone: generate-title "user prompt" --json
|
||||
* 2. With session: generate-title "user prompt" --session-id <id> --save
|
||||
*/
|
||||
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Prompt is required'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --session-id provided, validate that session exists
|
||||
let streamingStore: StreamingSessionStore | null = null;
|
||||
let sessionRecord = null;
|
||||
|
||||
if (options.sessionId) {
|
||||
try {
|
||||
const dbManager = DatabaseManager.getInstance();
|
||||
for (const migration of migrations) {
|
||||
dbManager.registerMigration(migration);
|
||||
}
|
||||
const db = await dbManager.initialize();
|
||||
streamingStore = new StreamingSessionStore(db);
|
||||
|
||||
sessionRecord = streamingStore.getByClaudeSessionId(options.sessionId);
|
||||
if (!sessionRecord) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session not found: ${options.sessionId}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Database error: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a title and subtitle generator for claude-mem session metadata.
|
||||
|
||||
Your job is to analyze a user's request and generate:
|
||||
1. A concise title (3-8 words)
|
||||
2. A one-sentence subtitle (max 20 words)
|
||||
|
||||
TITLE GUIDELINES:
|
||||
- 3-8 words maximum
|
||||
- Scannable and clear
|
||||
- Captures the core action or topic
|
||||
- Professional and informative
|
||||
- Examples:
|
||||
* "Dark Mode Implementation"
|
||||
* "Authentication Bug Fix"
|
||||
* "API Rate Limiting Setup"
|
||||
* "React Component Refactoring"
|
||||
|
||||
SUBTITLE GUIDELINES:
|
||||
- One sentence, max 20 words
|
||||
- Descriptive and specific
|
||||
- Focus on the outcome or benefit
|
||||
- Use active voice when possible
|
||||
- Examples:
|
||||
* "Adding theme toggle and dark color scheme support to the application"
|
||||
* "Resolving login timeout issue affecting user session persistence"
|
||||
* "Implementing request throttling to prevent API quota exhaustion"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
You must output EXACTLY two lines:
|
||||
Line 1: Title only (no prefix, no quotes)
|
||||
Line 2: Subtitle only (no prefix, no quotes)
|
||||
|
||||
EXAMPLE:
|
||||
|
||||
User request: "Help me add dark mode to my app"
|
||||
|
||||
Output:
|
||||
Dark Mode Implementation
|
||||
Adding theme toggle and dark color scheme support to the application
|
||||
|
||||
USER REQUEST:
|
||||
${prompt}
|
||||
|
||||
Now generate the title and subtitle (two lines exactly):`;
|
||||
|
||||
try {
|
||||
const response = await query({
|
||||
prompt: systemPrompt,
|
||||
options: {
|
||||
allowedTools: [],
|
||||
pathToClaudeCodeExecutable: getClaudePath()
|
||||
}
|
||||
});
|
||||
|
||||
// Extract text from response (same pattern as changelog.ts)
|
||||
let fullResponse = '';
|
||||
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
|
||||
for await (const message of response) {
|
||||
if (message?.type === 'assistant' && message?.message?.content) {
|
||||
const content = message.message.content;
|
||||
if (typeof content === 'string') {
|
||||
fullResponse += content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
fullResponse += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the response - expecting exactly 2 lines
|
||||
const lines = fullResponse.trim().split('\n').filter(line => line.trim().length > 0);
|
||||
|
||||
if (lines.length < 2) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Could not generate title and subtitle',
|
||||
response: fullResponse
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const title = lines[0].trim();
|
||||
const subtitle = lines[1].trim();
|
||||
|
||||
// If --save and we have a session, update the database
|
||||
if (options.save && streamingStore && sessionRecord) {
|
||||
try {
|
||||
streamingStore.update(sessionRecord.id, {
|
||||
title,
|
||||
subtitle
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to save title: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Output format depends on options
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
subtitle,
|
||||
sessionId: sessionRecord?.claude_session_id
|
||||
}, null, 2));
|
||||
} else if (options.oneline) {
|
||||
console.log(`${title} - ${subtitle}`);
|
||||
} else {
|
||||
console.log(title);
|
||||
console.log(subtitle);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Unknown error generating title'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import fs from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PathDiscovery } from '../services/path-discovery.js';
|
||||
import {
|
||||
createCompletionMessage,
|
||||
createContextualError,
|
||||
createUserFriendlyError,
|
||||
formatTimeAgo,
|
||||
outputSessionStartContent
|
||||
} from '../prompts/templates/context/ContextTemplates.js';
|
||||
import { getStorageProvider, needsMigration } from '../shared/storage.js';
|
||||
import { MemoryRow, OverviewRow } from '../services/sqlite/types.js';
|
||||
import { createStores } from '../services/sqlite/index.js';
|
||||
import { getRollingSettings } from '../shared/rolling-settings.js';
|
||||
import { rollingLog } from '../shared/rolling-log.js';
|
||||
|
||||
interface TrashStatus {
|
||||
folderCount: number;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
function formatDateHeader(date = new Date()): string {
|
||||
return date.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function wordWrap(text: string, maxWidth: number, prefix: string): string {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = prefix;
|
||||
const continuationPrefix = ' '.repeat(prefix.length);
|
||||
|
||||
for (const word of words) {
|
||||
const needsSpace = currentLine !== prefix && currentLine !== continuationPrefix;
|
||||
const testLine = currentLine + (needsSpace ? ' ' : '') + word;
|
||||
|
||||
if (testLine.length <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = continuationPrefix + word;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildProjectMatcher(projectName: string): (value?: string) => boolean {
|
||||
const aliases = new Set<string>();
|
||||
aliases.add(projectName);
|
||||
aliases.add(projectName.replace(/-/g, '_'));
|
||||
aliases.add(projectName.replace(/_/g, '-'));
|
||||
return (value?: string) => !!value && aliases.has(value);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getTrashStatus(): TrashStatus {
|
||||
const trashDir = PathDiscovery.getInstance().getTrashDirectory();
|
||||
|
||||
if (!fs.existsSync(trashDir)) {
|
||||
return { folderCount: 0, fileCount: 0, totalSize: 0, isEmpty: true };
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(trashDir);
|
||||
if (items.length === 0) {
|
||||
return { folderCount: 0, fileCount: 0, totalSize: 0, isEmpty: true };
|
||||
}
|
||||
|
||||
let folderCount = 0;
|
||||
let fileCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = join(trashDir, item);
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
folderCount++;
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
|
||||
totalSize += stats.size;
|
||||
}
|
||||
|
||||
return { folderCount, fileCount, totalSize, isEmpty: false };
|
||||
}
|
||||
|
||||
async function renderRollingSessionStart(projectOverride?: string): Promise<void> {
|
||||
const settings = getRollingSettings();
|
||||
|
||||
if (!settings.sessionStartEnabled) {
|
||||
console.log('Rolling session-start output disabled in settings.');
|
||||
rollingLog('info', 'session-start output skipped (disabled)', {
|
||||
project: projectOverride
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stores = await createStores();
|
||||
const projectName = projectOverride || PathDiscovery.getCurrentProjectName();
|
||||
|
||||
// Get all overviews for this project (oldest to newest)
|
||||
const allOverviews = stores.overviews.getAllForProject(projectName);
|
||||
|
||||
// Limit to last 10 overviews
|
||||
const recentOverviews = allOverviews.slice(-10);
|
||||
|
||||
// If no data at all, show friendly message
|
||||
if (recentOverviews.length === 0) {
|
||||
console.log('===============================================================================');
|
||||
console.log(`What's new | ${formatDateHeader()}`);
|
||||
console.log('===============================================================================');
|
||||
console.log('No previous sessions found for this project.');
|
||||
console.log('Start working and claude-mem will automatically capture context for future sessions.');
|
||||
console.log('===============================================================================');
|
||||
const trashStatus = getTrashStatus();
|
||||
if (!trashStatus.isEmpty) {
|
||||
const formattedSize = formatSize(trashStatus.totalSize);
|
||||
console.log(
|
||||
`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``
|
||||
);
|
||||
console.log('===============================================================================');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Output header
|
||||
console.log('===============================================================================');
|
||||
console.log(`What's new | ${formatDateHeader()}`);
|
||||
console.log('===============================================================================');
|
||||
|
||||
// Output each overview with timestamp, memory names, and files touched (oldest to newest)
|
||||
recentOverviews.forEach((overview) => {
|
||||
const date = new Date(overview.created_at);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
|
||||
console.log(`[${year}-${month}-${day} at ${displayHours}:${minutes} ${ampm}]`);
|
||||
|
||||
// Get memories for this session to show titles, subtitles, files, and keywords
|
||||
const sessionMemories = stores.memories.getBySessionId(overview.session_id);
|
||||
|
||||
// Extract memory titles and subtitles
|
||||
const memories = sessionMemories
|
||||
.map(m => ({ title: m.title, subtitle: m.subtitle }))
|
||||
.filter(m => m.title);
|
||||
|
||||
// Extract unique files touched across all memories
|
||||
const allFilesTouched = new Set<string>();
|
||||
const allKeywords = new Set<string>();
|
||||
|
||||
sessionMemories.forEach(m => {
|
||||
if (m.files_touched) {
|
||||
try {
|
||||
const files = JSON.parse(m.files_touched);
|
||||
if (Array.isArray(files)) {
|
||||
files.forEach(f => allFilesTouched.add(f));
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
if (m.keywords) {
|
||||
// Keywords are comma-separated
|
||||
m.keywords.split(',').forEach(k => allKeywords.add(k.trim()));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
|
||||
// Always show overview content
|
||||
console.log(wordWrap(overview.content, 80, ''));
|
||||
|
||||
// Display files touched if any
|
||||
if (allFilesTouched.size > 0) {
|
||||
console.log('');
|
||||
console.log(wordWrap(`- ${Array.from(allFilesTouched).join(', ')}`, 80, ''));
|
||||
}
|
||||
|
||||
// Display keywords/tags if any
|
||||
if (allKeywords.size > 0) {
|
||||
console.log('');
|
||||
console.log(wordWrap(`Tags: ${Array.from(allKeywords).join(', ')}`, 80, ''));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
});
|
||||
|
||||
console.log('===============================================================================');
|
||||
const trashStatus = getTrashStatus();
|
||||
if (!trashStatus.isEmpty) {
|
||||
const formattedSize = formatSize(trashStatus.totalSize);
|
||||
console.log(
|
||||
`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``
|
||||
);
|
||||
console.log('===============================================================================');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadContext(options: OptionValues = {}): Promise<void> {
|
||||
try {
|
||||
// Check if migration is needed and warn the user
|
||||
if (await needsMigration()) {
|
||||
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
|
||||
}
|
||||
|
||||
const storage = await getStorageProvider();
|
||||
|
||||
// If using JSONL fallback, use original implementation
|
||||
if (storage.backend === 'jsonl') {
|
||||
return await loadContextFromJSONL(options);
|
||||
}
|
||||
|
||||
// SQLite implementation - fetch data using storage provider
|
||||
let recentMemories: MemoryRow[] = [];
|
||||
let recentOverviews: OverviewRow[] = [];
|
||||
|
||||
// Auto-detect current project for session-start format if no project specified
|
||||
let projectToUse = options.project;
|
||||
if (!projectToUse && options.format === 'session-start') {
|
||||
projectToUse = PathDiscovery.getCurrentProjectName();
|
||||
}
|
||||
|
||||
if (options.format === 'session-start') {
|
||||
await renderRollingSessionStart(projectToUse);
|
||||
return;
|
||||
}
|
||||
|
||||
const overviewLimit = options.format === 'json' ? 5 : 3;
|
||||
|
||||
if (projectToUse) {
|
||||
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
|
||||
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, overviewLimit);
|
||||
} else {
|
||||
recentMemories = await storage.getRecentMemories(10);
|
||||
recentOverviews = await storage.getRecentOverviews(overviewLimit);
|
||||
}
|
||||
|
||||
// Convert SQLite rows to JSONL format for compatibility with existing output functions
|
||||
const memoriesAsJSON = recentMemories.map(row => ({
|
||||
type: 'memory',
|
||||
text: row.text,
|
||||
document_id: row.document_id,
|
||||
keywords: row.keywords,
|
||||
session_id: row.session_id,
|
||||
project: row.project,
|
||||
timestamp: row.created_at,
|
||||
archive: row.archive_basename
|
||||
}));
|
||||
|
||||
const overviewsAsJSON = recentOverviews.map(row => ({
|
||||
type: 'overview',
|
||||
content: row.content,
|
||||
session_id: row.session_id,
|
||||
project: row.project,
|
||||
timestamp: row.created_at
|
||||
}));
|
||||
|
||||
// If no data found, show appropriate messages
|
||||
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.format === 'json') {
|
||||
// For JSON format, combine last 10 of each type
|
||||
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
|
||||
console.log(JSON.stringify(recentObjects));
|
||||
} else {
|
||||
// Default format - show last 10 memories and last 3 overviews
|
||||
const totalCount = memoriesAsJSON.length + overviewsAsJSON.length;
|
||||
|
||||
console.log(createCompletionMessage('Context loading', totalCount, 'recent entries found'));
|
||||
|
||||
// Show memories first
|
||||
memoriesAsJSON.forEach((obj) => {
|
||||
console.log(`${obj.text} | ${obj.document_id} | ${obj.keywords}`);
|
||||
});
|
||||
|
||||
// Then show overviews
|
||||
overviewsAsJSON.forEach((obj) => {
|
||||
console.log(`**Overview:** ${obj.content}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display trash status if not empty (except for JSON format to avoid breaking JSON parsing)
|
||||
if (options.format !== 'json') {
|
||||
const trashStatus = getTrashStatus();
|
||||
if (!trashStatus.isEmpty) {
|
||||
const formattedSize = formatSize(trashStatus.totalSize);
|
||||
console.log(`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (options.format === 'session-start') {
|
||||
console.log(createContextualError('CONNECTION_FAILED', errorMessage));
|
||||
} else {
|
||||
console.log(createUserFriendlyError('Context loading', errorMessage, 'Check file permissions and try again'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original JSONL-based implementation for fallback compatibility
|
||||
*/
|
||||
async function loadContextFromJSONL(options: OptionValues = {}): Promise<void> {
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const indexPath = pathDiscovery.getIndexPath();
|
||||
|
||||
// Auto-detect current project for session-start format if no project specified
|
||||
let projectToUse = options.project;
|
||||
if (!projectToUse && options.format === 'session-start') {
|
||||
projectToUse = PathDiscovery.getCurrentProjectName();
|
||||
}
|
||||
|
||||
// Check if index file exists
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
if (options.format === 'session-start') {
|
||||
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
if (options.format === 'session-start') {
|
||||
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse JSONL format - each line is a JSON object
|
||||
const jsonObjects: any[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
// Skip lines that don't look like JSON (could be legacy format)
|
||||
if (!line.trim().startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
const obj = JSON.parse(line);
|
||||
jsonObjects.push(obj);
|
||||
} catch (e) {
|
||||
// Skip malformed JSON lines
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonObjects.length === 0) {
|
||||
if (options.format === 'session-start') {
|
||||
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate memories, overviews, and other types
|
||||
const memories = jsonObjects.filter(obj => obj.type === 'memory');
|
||||
const overviews = jsonObjects.filter(obj => obj.type === 'overview');
|
||||
const sessions = jsonObjects.filter(obj => obj.type === 'session');
|
||||
|
||||
// Filter each type by project if specified
|
||||
// Handle both hyphen and underscore formats since index has mixed entries
|
||||
let filteredMemories = memories;
|
||||
let filteredOverviews = overviews;
|
||||
let filteredSessions = sessions;
|
||||
if (projectToUse) {
|
||||
const matchesProject = buildProjectMatcher(projectToUse);
|
||||
filteredMemories = memories.filter(obj => matchesProject(obj.project));
|
||||
filteredOverviews = overviews.filter(obj => matchesProject(obj.project));
|
||||
filteredSessions = sessions.filter(obj => matchesProject(obj.project));
|
||||
}
|
||||
|
||||
if (options.format === 'session-start') {
|
||||
// Get last 10 memories and last 10 overviews for session-start
|
||||
const recentMemories = filteredMemories.slice(-10);
|
||||
const recentOverviews = filteredOverviews.slice(-10);
|
||||
const recentSessions = filteredSessions.slice(-10);
|
||||
|
||||
// Combine them for the display
|
||||
const recentObjects = [...recentSessions, ...recentMemories, ...recentOverviews];
|
||||
|
||||
// Find most recent timestamp for last session info
|
||||
let lastSessionTime = 'recently';
|
||||
const timestamps = recentObjects
|
||||
.map(obj => {
|
||||
// Get timestamp from JSON object
|
||||
return obj.timestamp ? new Date(obj.timestamp) : null;
|
||||
})
|
||||
.filter(date => date !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
lastSessionTime = formatTimeAgo(timestamps[0]);
|
||||
}
|
||||
|
||||
// Use dual-stream output for session start formatting
|
||||
outputSessionStartContent({
|
||||
projectName: projectToUse || 'your project',
|
||||
memoryCount: recentMemories.length,
|
||||
lastSessionTime,
|
||||
recentObjects
|
||||
});
|
||||
|
||||
} else if (options.format === 'json') {
|
||||
// For JSON format, combine last 10 of each type
|
||||
const recentMemories = filteredMemories.slice(-10);
|
||||
const recentOverviews = filteredOverviews.slice(-3);
|
||||
const recentObjects = [...recentMemories, ...recentOverviews];
|
||||
console.log(JSON.stringify(recentObjects));
|
||||
} else {
|
||||
// Default format - show last 10 memories and last 3 overviews
|
||||
const recentMemories = filteredMemories.slice(-10);
|
||||
const recentOverviews = filteredOverviews.slice(-3);
|
||||
const totalCount = recentMemories.length + recentOverviews.length;
|
||||
|
||||
console.log(createCompletionMessage('Context loading', totalCount, 'recent entries found'));
|
||||
|
||||
// Show memories first
|
||||
recentMemories.forEach((obj) => {
|
||||
console.log(`${obj.text} | ${obj.document_id} | ${obj.keywords}`);
|
||||
});
|
||||
|
||||
// Then show overviews
|
||||
recentOverviews.forEach((obj) => {
|
||||
console.log(`**Overview:** ${obj.content}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createStores } from '../services/sqlite/index.js';
|
||||
|
||||
/**
|
||||
* Store a memory to all three storage layers
|
||||
* Called by SDK via bash during streaming memory capture
|
||||
*/
|
||||
export async function storeMemory(options: OptionValues): Promise<void> {
|
||||
const { id, project, session, date, title, subtitle, facts, concepts, files } = options;
|
||||
|
||||
// Validate required fields
|
||||
if (!id || !project || !session || !date) {
|
||||
console.error('Error: All fields required: --id, --project, --session, --date');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate hierarchical fields (required for v2 format)
|
||||
if (!title || !subtitle || !facts) {
|
||||
console.error('Error: Hierarchical format required: --title, --subtitle, --facts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stores = await createStores();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Ensure session exists
|
||||
const sessionExists = await stores.sessions.has(session);
|
||||
if (!sessionExists) {
|
||||
await stores.sessions.create({
|
||||
session_id: session,
|
||||
project,
|
||||
created_at: timestamp,
|
||||
source: 'save'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse JSON arrays if provided as strings
|
||||
let factsArray: string | undefined;
|
||||
let conceptsArray: string | undefined;
|
||||
let filesArray: string | undefined;
|
||||
|
||||
try {
|
||||
factsArray = facts ? JSON.stringify(JSON.parse(facts)) : undefined;
|
||||
} catch (e) {
|
||||
factsArray = facts; // Store as-is if not valid JSON
|
||||
}
|
||||
|
||||
try {
|
||||
conceptsArray = concepts ? JSON.stringify(JSON.parse(concepts)) : undefined;
|
||||
} catch (e) {
|
||||
conceptsArray = concepts; // Store as-is if not valid JSON
|
||||
}
|
||||
|
||||
try {
|
||||
filesArray = files ? JSON.stringify(JSON.parse(files)) : undefined;
|
||||
} catch (e) {
|
||||
filesArray = files; // Store as-is if not valid JSON
|
||||
}
|
||||
|
||||
// Layer 1: SQLite Memory Index
|
||||
const memoryExists = stores.memories.hasDocumentId(id);
|
||||
if (!memoryExists) {
|
||||
stores.memories.create({
|
||||
document_id: id,
|
||||
text: '', // Deprecated: hierarchical fields replace narrative text
|
||||
keywords: '',
|
||||
session_id: session,
|
||||
project,
|
||||
created_at: timestamp,
|
||||
origin: 'streaming-sdk',
|
||||
// Hierarchical fields (v2)
|
||||
title: title || undefined,
|
||||
subtitle: subtitle || undefined,
|
||||
facts: factsArray,
|
||||
concepts: conceptsArray,
|
||||
files_touched: filesArray
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 2: ChromaDB - Store hierarchical memory
|
||||
if (factsArray) {
|
||||
const factsJson = JSON.parse(factsArray);
|
||||
const conceptsJson = conceptsArray ? JSON.parse(conceptsArray) : [];
|
||||
const filesJson = filesArray ? JSON.parse(filesArray) : [];
|
||||
|
||||
// Store each atomic fact as a separate ChromaDB document
|
||||
factsJson.forEach((fact: string, idx: number) => {
|
||||
spawnSync('claude-mem', [
|
||||
'chroma_add_documents',
|
||||
'--collection_name', 'claude_memories',
|
||||
'--documents', JSON.stringify([fact]),
|
||||
'--ids', JSON.stringify([`${id}_fact_${String(idx).padStart(3, '0')}`]),
|
||||
'--metadatas', JSON.stringify([{
|
||||
type: 'fact',
|
||||
parent_id: id,
|
||||
fact_index: idx,
|
||||
title,
|
||||
subtitle,
|
||||
project,
|
||||
session_id: session,
|
||||
created_at: timestamp,
|
||||
created_at_epoch: Date.parse(timestamp),
|
||||
keywords: '',
|
||||
concepts: JSON.stringify(conceptsJson),
|
||||
files_touched: JSON.stringify(filesJson),
|
||||
origin: 'streaming-sdk'
|
||||
}])
|
||||
]);
|
||||
});
|
||||
|
||||
// Store full narrative with hierarchical metadata
|
||||
spawnSync('claude-mem', [
|
||||
'chroma_add_documents',
|
||||
'--collection_name', 'claude_memories',
|
||||
'--documents', JSON.stringify([`${title}\n${subtitle}\n\n${factsJson.join('\n')}`]),
|
||||
'--ids', JSON.stringify([id]),
|
||||
'--metadatas', JSON.stringify([{
|
||||
type: 'narrative',
|
||||
title,
|
||||
subtitle,
|
||||
facts_count: factsJson.length,
|
||||
project,
|
||||
session_id: session,
|
||||
created_at: timestamp,
|
||||
created_at_epoch: Date.parse(timestamp),
|
||||
keywords: '',
|
||||
concepts: JSON.stringify(conceptsJson),
|
||||
files_touched: JSON.stringify(filesJson),
|
||||
origin: 'streaming-sdk'
|
||||
}])
|
||||
]);
|
||||
}
|
||||
|
||||
// Success output (SDK will see this)
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
memory_id: id,
|
||||
project,
|
||||
session,
|
||||
date,
|
||||
timestamp,
|
||||
hierarchical: !!(title && subtitle && facts)
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Unknown error storing memory'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { createStores } from '../services/sqlite/index.js';
|
||||
|
||||
/**
|
||||
* Store a session overview
|
||||
* Called by SDK via bash at session end
|
||||
*/
|
||||
export async function storeOverview(options: OptionValues): Promise<void> {
|
||||
const { project, session, content } = options;
|
||||
|
||||
// Validate required fields
|
||||
if (!project || !session || !content) {
|
||||
console.error('Error: All fields required: --project, --session, --content');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stores = await createStores();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Create one overview per session (rolling log architecture)
|
||||
stores.overviews.upsert({
|
||||
session_id: session,
|
||||
content,
|
||||
created_at: timestamp,
|
||||
project,
|
||||
origin: 'streaming-sdk'
|
||||
});
|
||||
|
||||
// Success output (SDK will see this)
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
project,
|
||||
session,
|
||||
timestamp
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Unknown error storing overview'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
|
||||
/**
|
||||
* Update session metadata (title/subtitle) in the streaming session JSON file
|
||||
* Called by SDK when generating session title at the start
|
||||
*/
|
||||
export async function updateSessionMetadata(options: OptionValues): Promise<void> {
|
||||
const { project, session, title, subtitle } = options;
|
||||
|
||||
// Validate required fields
|
||||
if (!project || !session) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing required fields: --project, --session'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing required field: --title'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load existing session file
|
||||
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
|
||||
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session file not found: ${sessionFile}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sessionData: any = {};
|
||||
try {
|
||||
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to parse session file'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
sessionData.promptTitle = title;
|
||||
if (subtitle) {
|
||||
sessionData.promptSubtitle = subtitle;
|
||||
}
|
||||
sessionData.updatedAt = new Date().toISOString();
|
||||
|
||||
// Write back to file
|
||||
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
||||
|
||||
// Output success
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
subtitle: subtitle || null,
|
||||
project,
|
||||
session
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Unknown error updating session metadata'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Claude Memory System - Core Constants
|
||||
*
|
||||
* This file contains debug logging templates used throughout the application.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// DEBUG AND LOGGING TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Debug logging message templates
|
||||
*/
|
||||
export const DEBUG_MESSAGES = {
|
||||
COMPRESSION_STARTED: '🚀 COMPRESSION STARTED',
|
||||
TRANSCRIPT_PATH: (path: string) => `📁 Transcript Path: ${path}`,
|
||||
SESSION_ID: (id: string) => `🔍 Session ID: ${id}`,
|
||||
PROJECT_NAME: (name: string) => `📝 PROJECT NAME: ${name}`,
|
||||
CLAUDE_SDK_CALL: '🤖 Calling Claude SDK to analyze and populate memory database...',
|
||||
TRANSCRIPT_STATS: (size: number, count: number) =>
|
||||
`📊 Transcript size: ${size} characters, ${count} messages`,
|
||||
COMPRESSION_COMPLETE: (count: number) => `✅ COMPRESSION COMPLETE\n Total summaries extracted: ${count}`,
|
||||
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
|
||||
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
|
||||
} as const;
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Time utilities for formatting relative timestamps
|
||||
*/
|
||||
|
||||
export function formatRelativeTime(timestamp: string | Date): string {
|
||||
try {
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
return diffMinutes === 1 ? '1 minute ago' : `${diffMinutes} minutes ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else if (diffWeeks === 1) {
|
||||
return '1 week ago';
|
||||
} else if (diffWeeks < 4) {
|
||||
return `${diffWeeks} weeks ago`;
|
||||
} else if (diffMonths === 1) {
|
||||
return '1 month ago';
|
||||
} else if (diffMonths < 12) {
|
||||
return `${diffMonths} months ago`;
|
||||
} else {
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Return a fallback for invalid timestamps
|
||||
return 'Recently';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTimestamp(entry: any): Date | null {
|
||||
// Try multiple timestamp fields that might exist
|
||||
const possibleFields = ['timestamp', 'created_at', 'date', 'time'];
|
||||
|
||||
for (const field of possibleFields) {
|
||||
if (entry[field]) {
|
||||
try {
|
||||
const date = new Date(entry[field]);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid timestamp found, return null
|
||||
return null;
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
# Hook Prompts System
|
||||
|
||||
This directory contains the centralized prompt configuration for all streaming hooks.
|
||||
|
||||
## Quick Edit Guide
|
||||
|
||||
**Want to change hook prompts?** Edit this file:
|
||||
```
|
||||
hook-prompts.config.ts
|
||||
```
|
||||
|
||||
Then rebuild and reinstall:
|
||||
```bash
|
||||
bun run build
|
||||
bun run dev:install
|
||||
```
|
||||
|
||||
## Files in This Directory
|
||||
|
||||
### hook-prompts.config.ts
|
||||
**EDIT THIS FILE** to change prompt content.
|
||||
|
||||
Contains:
|
||||
- `SYSTEM_PROMPT` - Initial instructions for SDK (190 lines)
|
||||
- `TOOL_MESSAGE` - Format for tool responses (10 lines)
|
||||
- `END_MESSAGE` - Session completion request (10 lines)
|
||||
- `HOOK_CONFIG` - Shared settings (truncation limits, SDK options)
|
||||
|
||||
Uses `{{variableName}}` template syntax.
|
||||
|
||||
### hook-prompt-renderer.ts
|
||||
**DON'T EDIT** unless adding new variables or changing rendering logic.
|
||||
|
||||
Contains:
|
||||
- `renderSystemPrompt()` - Processes system prompt template
|
||||
- `renderToolMessage()` - Processes tool message template
|
||||
- `renderEndMessage()` - Processes end message template
|
||||
- Template substitution and auto-truncation logic
|
||||
|
||||
### templates/context/ContextTemplates.ts
|
||||
Session start message formatting (separate from hook prompts).
|
||||
|
||||
## Template Variables Reference
|
||||
|
||||
### SYSTEM_PROMPT Variables
|
||||
```typescript
|
||||
{
|
||||
project: string; // Project name (e.g., "claude-mem-source")
|
||||
sessionId: string; // Claude Code session ID
|
||||
date: string; // YYYY-MM-DD format
|
||||
userPrompt: string; // Auto-truncated to 200 chars
|
||||
}
|
||||
```
|
||||
|
||||
### TOOL_MESSAGE Variables
|
||||
```typescript
|
||||
{
|
||||
toolName: string; // Tool name (e.g., "Read", "Bash")
|
||||
toolResponse: string; // Auto-truncated to 20000 chars
|
||||
userPrompt: string; // Auto-truncated to 200 chars
|
||||
timestamp: string; // Full ISO timestamp
|
||||
timeFormatted: string; // HH:MM:SS format (auto-generated)
|
||||
}
|
||||
```
|
||||
|
||||
### END_MESSAGE Variables
|
||||
```typescript
|
||||
{
|
||||
project: string; // Project name
|
||||
sessionId: string; // Claude Code session ID
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Hooks
|
||||
|
||||
### user-prompt-submit-streaming.js
|
||||
```javascript
|
||||
import { renderSystemPrompt, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||
|
||||
const prompt = renderSystemPrompt({
|
||||
project,
|
||||
sessionId: session_id,
|
||||
date,
|
||||
userPrompt: prompt || ''
|
||||
});
|
||||
|
||||
query({
|
||||
prompt,
|
||||
options: {
|
||||
model: HOOK_CONFIG.sdk.model,
|
||||
allowedTools: HOOK_CONFIG.sdk.allowedTools,
|
||||
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### post-tool-use-streaming.js
|
||||
```javascript
|
||||
import { renderToolMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||
|
||||
const message = renderToolMessage({
|
||||
toolName: tool_name,
|
||||
toolResponse: toolResponseStr,
|
||||
userPrompt: prompt || '',
|
||||
timestamp: timestamp || new Date().toISOString()
|
||||
});
|
||||
|
||||
query({
|
||||
prompt: message,
|
||||
options: {
|
||||
model: HOOK_CONFIG.sdk.model,
|
||||
maxTokens: HOOK_CONFIG.sdk.maxTokensTool
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### stop-streaming.js
|
||||
```javascript
|
||||
import { renderEndMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||
|
||||
const message = renderEndMessage({
|
||||
project,
|
||||
sessionId: claudeSessionId
|
||||
});
|
||||
|
||||
query({
|
||||
prompt: message,
|
||||
options: {
|
||||
model: HOOK_CONFIG.sdk.model,
|
||||
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Edit `HOOK_CONFIG` in `hook-prompts.config.ts`:
|
||||
|
||||
```typescript
|
||||
export const HOOK_CONFIG = {
|
||||
// Truncation limits for template variables
|
||||
maxUserPromptLength: 200, // Increase to show more context
|
||||
maxToolResponseLength: 20000, // Increase for larger outputs
|
||||
|
||||
// SDK configuration
|
||||
sdk: {
|
||||
model: 'claude-sonnet-4-5', // Change model version
|
||||
allowedTools: ['Bash'], // Add more tools if needed
|
||||
maxTokensSystem: 8192, // Token limit for system prompt
|
||||
maxTokensTool: 8192, // Token limit for tool messages
|
||||
maxTokensEnd: 2048, // Token limit for end message
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Example: Editing a Prompt
|
||||
|
||||
### Before
|
||||
```typescript
|
||||
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||
|
||||
Tool: {{toolName}}
|
||||
User Context: "{{userPrompt}}"
|
||||
|
||||
\`\`\`
|
||||
{{toolResponse}}
|
||||
\`\`\`
|
||||
|
||||
Analyze and store if meaningful.`;
|
||||
```
|
||||
|
||||
### After
|
||||
```typescript
|
||||
export const TOOL_MESSAGE = `# Analysis Request {{timeFormatted}}
|
||||
|
||||
Executed: {{toolName}}
|
||||
Context: "{{userPrompt}}"
|
||||
Priority: High
|
||||
|
||||
Output:
|
||||
\`\`\`
|
||||
{{toolResponse}}
|
||||
\`\`\`
|
||||
|
||||
IMPORTANT: Only store if this contains:
|
||||
- New code patterns or logic
|
||||
- Architecture decisions
|
||||
- Error messages with solutions
|
||||
- Configuration changes
|
||||
|
||||
Skip trivial operations.`;
|
||||
```
|
||||
|
||||
### Apply Changes
|
||||
```bash
|
||||
bun run build && bun run dev:install
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### DRY Compliance
|
||||
- **Before**: 3 files with 188 lines of hardcoded prompts
|
||||
- **After**: 1 config file with all prompts centralized
|
||||
|
||||
### Maintainability
|
||||
- Change prompts without touching hook implementation
|
||||
- Type-safe template variables
|
||||
- Consistent formatting across all hooks
|
||||
- Version-controlled prompt history
|
||||
|
||||
### Flexibility
|
||||
- Easy A/B testing of different instructions
|
||||
- Simple to adjust truncation limits
|
||||
- Quick model/token configuration changes
|
||||
- Template variables prevent copy-paste errors
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See `/Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md` for:
|
||||
- Detailed editing guide
|
||||
- Troubleshooting common issues
|
||||
- Adding new template variables
|
||||
- Advanced customization
|
||||
- Migration notes
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Hook Prompt Renderer
|
||||
*
|
||||
* Simple template rendering for hook prompts.
|
||||
* Handles variable substitution and auto-truncation.
|
||||
*/
|
||||
|
||||
import {
|
||||
PROMPTS,
|
||||
HOOK_CONFIG,
|
||||
type SystemPromptVariables,
|
||||
type ToolMessageVariables,
|
||||
type EndMessageVariables,
|
||||
} from './hook-prompts.config.js';
|
||||
|
||||
// =============================================================================
|
||||
// TEMPLATE RENDERING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Simple template variable substitution
|
||||
* Replaces {{variableName}} with actual values
|
||||
*/
|
||||
function substituteVariables(
|
||||
template: string,
|
||||
variables: Record<string, string>
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
// Replace all occurrences of this placeholder
|
||||
result = result.split(placeholder).join(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis if it exceeds maxLength
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + (text.length > maxLength ? '...' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for tool message header
|
||||
* Extracts HH:MM:SS from ISO timestamp
|
||||
*/
|
||||
function formatTime(timestamp: string): string {
|
||||
const timePart = timestamp.split('T')[1];
|
||||
if (!timePart) return '';
|
||||
return timePart.slice(0, 8); // HH:MM:SS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC RENDERING FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Render system prompt for SDK session initialization
|
||||
*/
|
||||
export function renderSystemPrompt(
|
||||
variables: SystemPromptVariables
|
||||
): string {
|
||||
// Auto-truncate userPrompt
|
||||
const userPromptTruncated = truncate(
|
||||
variables.userPrompt,
|
||||
HOOK_CONFIG.maxUserPromptLength
|
||||
);
|
||||
|
||||
return substituteVariables(PROMPTS.system, {
|
||||
project: variables.project,
|
||||
sessionId: variables.sessionId,
|
||||
date: variables.date,
|
||||
userPrompt: userPromptTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tool message for SDK processing
|
||||
*/
|
||||
export function renderToolMessage(
|
||||
variables: ToolMessageVariables
|
||||
): string {
|
||||
// Auto-truncate userPrompt and toolResponse
|
||||
const userPromptTruncated = truncate(
|
||||
variables.userPrompt,
|
||||
HOOK_CONFIG.maxUserPromptLength
|
||||
);
|
||||
|
||||
const toolResponseTruncated = truncate(
|
||||
variables.toolResponse,
|
||||
HOOK_CONFIG.maxToolResponseLength
|
||||
);
|
||||
|
||||
// Format timestamp
|
||||
const timeFormatted = formatTime(variables.timestamp);
|
||||
|
||||
return substituteVariables(PROMPTS.tool, {
|
||||
toolName: variables.toolName,
|
||||
toolResponse: toolResponseTruncated,
|
||||
userPrompt: userPromptTruncated,
|
||||
timestamp: variables.timestamp,
|
||||
timeFormatted,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render end message for session completion
|
||||
*/
|
||||
export function renderEndMessage(
|
||||
variables: EndMessageVariables
|
||||
): string {
|
||||
return substituteVariables(PROMPTS.end, {
|
||||
project: variables.project,
|
||||
sessionId: variables.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERIC RENDERER (for convenience)
|
||||
// =============================================================================
|
||||
|
||||
export type PromptType = 'system' | 'tool' | 'end';
|
||||
|
||||
export type PromptVariables<T extends PromptType> = T extends 'system'
|
||||
? SystemPromptVariables
|
||||
: T extends 'tool'
|
||||
? ToolMessageVariables
|
||||
: T extends 'end'
|
||||
? EndMessageVariables
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Generic prompt renderer - dispatches to specific renderer based on type
|
||||
*/
|
||||
export function renderPrompt<T extends PromptType>(
|
||||
type: T,
|
||||
variables: PromptVariables<T>
|
||||
): string {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return renderSystemPrompt(variables as SystemPromptVariables);
|
||||
case 'tool':
|
||||
return renderToolMessage(variables as ToolMessageVariables);
|
||||
case 'end':
|
||||
return renderEndMessage(variables as EndMessageVariables);
|
||||
default:
|
||||
throw new Error(`Unknown prompt type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
export { HOOK_CONFIG, PROMPTS };
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* Hook Prompts Configuration
|
||||
*
|
||||
* Centralized configuration for all streaming hook prompts.
|
||||
* This is the SINGLE SOURCE OF TRUTH for hook prompt content.
|
||||
*
|
||||
* EDITING GUIDE:
|
||||
* - Use {{variableName}} for template variables
|
||||
* - Available variables are listed in each prompt's interface
|
||||
* - All prompts are processed through renderPrompt() function
|
||||
* - Changes here apply to all hooks automatically after rebuild
|
||||
*
|
||||
* LIFECYCLE FLOW:
|
||||
* 1. user-prompt-submit: Initializes SDK session with systemPrompt
|
||||
* 2. post-tool-use: Feeds tool responses using toolMessage (repeats N times)
|
||||
* 3. stop: Ends session and requests overview using endMessage
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// =============================================================================
|
||||
|
||||
export interface SystemPromptVariables {
|
||||
project: string;
|
||||
sessionId: string;
|
||||
date: string;
|
||||
userPrompt: string; // Auto-truncated to maxUserPromptLength
|
||||
}
|
||||
|
||||
export interface ToolMessageVariables {
|
||||
toolName: string;
|
||||
toolResponse: string; // Auto-truncated to maxToolResponseLength
|
||||
userPrompt: string; // Auto-truncated to maxUserPromptLength
|
||||
timestamp: string; // Full ISO timestamp
|
||||
timeFormatted: string; // HH:MM:SS format
|
||||
}
|
||||
|
||||
export interface EndMessageVariables {
|
||||
project: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SHARED CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
export const HOOK_CONFIG = {
|
||||
// Truncation limits for template variables
|
||||
maxUserPromptLength: 200,
|
||||
maxToolResponseLength: 20000,
|
||||
|
||||
// SDK configuration (used by hooks)
|
||||
sdk: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
allowedTools: ['Bash'],
|
||||
maxTokensSystem: 8192,
|
||||
maxTokensTool: 8192,
|
||||
maxTokensEnd: 2048,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// PHASE 1: SYSTEM PROMPT (user-prompt-submit-streaming.js)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* System prompt that initializes the SDK session.
|
||||
* Instructs the SDK on how to process tool responses and store memories.
|
||||
*
|
||||
* Variables:
|
||||
* - {{project}}: Project name (from cwd basename)
|
||||
* - {{sessionId}}: Claude Code session ID
|
||||
* - {{date}}: Current date (YYYY-MM-DD)
|
||||
* - {{userPrompt}}: User's initial prompt (truncated to 200 chars)
|
||||
*/
|
||||
export const SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
|
||||
|
||||
# SESSION CONTEXT
|
||||
- Project: {{project}}
|
||||
- Session: {{sessionId}}
|
||||
- Date: {{date}}
|
||||
- User Request: "{{userPrompt}}"
|
||||
|
||||
# YOUR JOB
|
||||
|
||||
## FIRST: Generate Session Title
|
||||
|
||||
IMMEDIATELY generate a title and subtitle for this session based on the user request.
|
||||
|
||||
Use this bash command:
|
||||
\`\`\`bash
|
||||
claude-mem update-session-metadata \\
|
||||
--project "{{project}}" \\
|
||||
--session "{{sessionId}}" \\
|
||||
--title "Short title (3-6 words)" \\
|
||||
--subtitle "One sentence description (max 20 words)"
|
||||
\`\`\`
|
||||
|
||||
Example for "Help me add dark mode to my app":
|
||||
- Title: "Dark Mode Implementation"
|
||||
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
|
||||
|
||||
## THEN: Process Tool Responses
|
||||
|
||||
You will receive a stream of tool responses. For each one:
|
||||
|
||||
1. ANALYZE: Does this contain information worth remembering?
|
||||
2. DECIDE: Should I store this or skip it?
|
||||
3. EXTRACT: What are the key semantic concepts?
|
||||
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
|
||||
5. STORE: Use bash to save the hierarchical memory
|
||||
6. TRACK: Keep count of stored memories (001, 002, 003...)
|
||||
|
||||
# WHAT TO STORE
|
||||
|
||||
Store these:
|
||||
- File contents with logic, algorithms, or patterns
|
||||
- Search results revealing project structure
|
||||
- Build errors or test failures with context
|
||||
- Code revealing architecture or design decisions
|
||||
- Git diffs with significant changes
|
||||
- Command outputs showing system state
|
||||
|
||||
Skip these:
|
||||
- Simple status checks (git status with no changes)
|
||||
- Trivial edits (one-line config changes)
|
||||
- Repeated operations
|
||||
- Binary data or noise
|
||||
- Anything without semantic value
|
||||
|
||||
# HIERARCHICAL MEMORY FORMAT
|
||||
|
||||
Each memory has FOUR components:
|
||||
|
||||
## 1. TITLE (3-8 words)
|
||||
A scannable headline that captures the core action or topic.
|
||||
Examples:
|
||||
- "SDK Transcript Cleanup Implementation"
|
||||
- "Hook System Architecture Analysis"
|
||||
- "ChromaDB Migration Planning"
|
||||
|
||||
## 2. SUBTITLE (max 24 words)
|
||||
A concise, memorable summary that captures the essence of the change.
|
||||
Examples:
|
||||
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
|
||||
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
|
||||
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
|
||||
|
||||
Guidelines:
|
||||
- Clear and descriptive
|
||||
- Focus on the outcome or benefit
|
||||
- Use active voice when possible
|
||||
- Keep it professional and informative
|
||||
|
||||
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
|
||||
Individual, searchable statements that can be vector-embedded separately.
|
||||
Each fact is ONE specific piece of information.
|
||||
|
||||
Examples:
|
||||
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
|
||||
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
|
||||
- "Uses fs.unlink with graceful error handling for missing files"
|
||||
- "Checks two transcript path formats for backward compatibility"
|
||||
|
||||
Guidelines:
|
||||
- Start with filename or component when relevant
|
||||
- Be specific: include paths, function names, actual values
|
||||
- Each fact stands alone (no pronouns like "it" or "this")
|
||||
- 50-150 characters target
|
||||
- Focus on searchable technical details
|
||||
|
||||
## 4. NARRATIVE (512-1024 tokens, same as current format)
|
||||
The full contextual story for deep dives:
|
||||
|
||||
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
|
||||
|
||||
This is the detailed explanation for when someone needs full context.
|
||||
|
||||
# STORAGE COMMAND FORMAT
|
||||
|
||||
Store using this EXACT bash command structure:
|
||||
\`\`\`bash
|
||||
claude-mem store-memory \\
|
||||
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
|
||||
--title "Your Title Here" \\
|
||||
--subtitle "Your concise subtitle here" \\
|
||||
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
|
||||
--concepts '["concept1", "concept2", "concept3"]' \\
|
||||
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
|
||||
--project "{{project}}" \\
|
||||
--session "{{sessionId}}" \\
|
||||
--date "{{date}}"
|
||||
\`\`\`
|
||||
|
||||
CRITICAL FORMATTING RULES:
|
||||
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
|
||||
- Use double quotes inside the JSON arrays: "item"
|
||||
- Use double quotes around simple string values: --title "Title"
|
||||
- Escape any quotes in the content properly
|
||||
- Sequential numbering: 001, 002, 003, etc.
|
||||
|
||||
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
|
||||
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
|
||||
|
||||
# EXAMPLE MEMORY
|
||||
|
||||
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
|
||||
|
||||
Your storage command:
|
||||
\`\`\`bash
|
||||
claude-mem store-memory \\
|
||||
--id "claude-mem_abc123_2025-10-01_001" \\
|
||||
--title "SDK Transcript Auto-Cleanup" \\
|
||||
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
|
||||
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
|
||||
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
|
||||
--files '["hooks/stop-streaming.js"]' \\
|
||||
--project "claude-mem" \\
|
||||
--session "abc123" \\
|
||||
--date "2025-10-01"
|
||||
\`\`\`
|
||||
|
||||
# STATE TRACKING
|
||||
|
||||
CRITICAL: Keep track of your memory counter across all tool messages.
|
||||
- Start at 001
|
||||
- Increment for each stored memory
|
||||
- Never repeat numbers
|
||||
- Each session has separate numbering
|
||||
|
||||
# SESSION END
|
||||
|
||||
At the end (when I send "SESSION ENDING"), generate an overview using:
|
||||
\`\`\`bash
|
||||
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
|
||||
\`\`\`
|
||||
|
||||
# IMPORTANT REMINDERS
|
||||
|
||||
- You're processing a DIFFERENT Claude Code session (not your own)
|
||||
- Use Bash tool to call claude-mem commands
|
||||
- Keep subtitles clear and informative (max 24 words)
|
||||
- Each fact is ONE specific thing (not multiple ideas)
|
||||
- Be selective - quality over quantity
|
||||
- Always increment memory numbers
|
||||
- Facts should be searchable (specific file names, paths, functions)
|
||||
|
||||
Ready for tool responses.`;
|
||||
|
||||
// =============================================================================
|
||||
// PHASE 2: TOOL MESSAGE (post-tool-use-streaming.js)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Message format for each tool response sent to the SDK.
|
||||
* The SDK analyzes this and decides whether to store a memory.
|
||||
*
|
||||
* Variables:
|
||||
* - {{timeFormatted}}: Time portion of timestamp (HH:MM:SS)
|
||||
* - {{toolName}}: Name of the tool that was used
|
||||
* - {{userPrompt}}: User's original prompt (truncated to 200 chars)
|
||||
* - {{toolResponse}}: Full tool response (truncated to 20000 chars)
|
||||
*/
|
||||
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||
|
||||
Tool: {{toolName}}
|
||||
User Context: "{{userPrompt}}"
|
||||
|
||||
\`\`\`
|
||||
{{toolResponse}}
|
||||
\`\`\`
|
||||
|
||||
Analyze and store if meaningful.`;
|
||||
|
||||
// =============================================================================
|
||||
// PHASE 3: END MESSAGE (stop-streaming.js)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Message sent to SDK when session ends.
|
||||
* Requests the SDK to generate and store a session overview.
|
||||
*
|
||||
* Variables:
|
||||
* - {{project}}: Project name
|
||||
* - {{sessionId}}: Claude Code session ID
|
||||
*/
|
||||
export const END_MESSAGE = `# SESSION ENDING
|
||||
|
||||
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
|
||||
|
||||
Store it using Bash:
|
||||
\`\`\`bash
|
||||
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
|
||||
\`\`\`
|
||||
|
||||
Focus on: what was done, current state, key decisions, outcomes.`;
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
export const PROMPTS = {
|
||||
system: SYSTEM_PROMPT,
|
||||
tool: TOOL_MESSAGE,
|
||||
end: END_MESSAGE,
|
||||
} as const;
|
||||
@@ -1,491 +0,0 @@
|
||||
/**
|
||||
* Context Templates for Human-Readable Formatting
|
||||
*
|
||||
* Essential templates for user-facing messages in the memory system.
|
||||
* Focused on session start messages, error handling, and operation feedback.
|
||||
* Previously included Handlebars templates for session start formatting; current
|
||||
* version renders directly via console for clarity and performance.
|
||||
*/
|
||||
|
||||
import { formatRelativeTime, parseTimestamp } from '../../../lib/time-utils.js';
|
||||
|
||||
// =============================================================================
|
||||
// TERMINAL WIDTH & WORD WRAPPING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Determines target wrap width based on:
|
||||
* 1) CLAUDE_MEM_WRAP_WIDTH env override
|
||||
* 2) TTY columns (capped at 120)
|
||||
* 3) Fallback default of 80
|
||||
*/
|
||||
function getWrapWidth(): number {
|
||||
const env = process.env.CLAUDE_MEM_WRAP_WIDTH;
|
||||
if (env) {
|
||||
const n = parseInt(env, 10);
|
||||
if (!Number.isNaN(n) && n > 40 && n <= 200) return n;
|
||||
}
|
||||
// Default to classic 80 columns unless overridden
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a single logical line to the given width, preserving leading indentation.
|
||||
* Also avoids wrapping pure separator lines (====, ----, etc.).
|
||||
*/
|
||||
function wrapSingleLine(line: string, width: number): string {
|
||||
if (!line) return '';
|
||||
// Don't wrap long separator lines
|
||||
if (/^[\-=\u2014_\u2500\u2550]{5,}$/.test(line.trim())) return line;
|
||||
|
||||
// If already short enough, return as-is
|
||||
if (line.length <= width) return line;
|
||||
|
||||
const indentMatch = line.match(/^\s*/);
|
||||
const indent = indentMatch ? indentMatch[0] : '';
|
||||
const content = line.slice(indent.length);
|
||||
const avail = Math.max(10, width - indent.length); // keep some minimum
|
||||
|
||||
const words = content.split(/(\s+)/); // keep whitespace tokens
|
||||
const out: string[] = [];
|
||||
let current = '';
|
||||
|
||||
const pushLine = () => {
|
||||
out.push(indent + current.trimEnd());
|
||||
current = '';
|
||||
};
|
||||
|
||||
for (const token of words) {
|
||||
if (token === '') continue;
|
||||
// If token itself is longer than available width, hard-break it
|
||||
if (!/\s/.test(token) && token.length > avail) {
|
||||
if (current.trim().length > 0) pushLine();
|
||||
let start = 0;
|
||||
while (start < token.length) {
|
||||
const chunk = token.slice(start, start + avail);
|
||||
out.push(indent + chunk);
|
||||
start += avail;
|
||||
}
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent.length + current.length + token.length > width) {
|
||||
pushLine();
|
||||
}
|
||||
current += token;
|
||||
}
|
||||
|
||||
if (current.trim().length > 0 || out.length === 0) pushLine();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a block of text (possibly multi-line) to the given width.
|
||||
* Preserves blank lines and wraps each line independently.
|
||||
*/
|
||||
function wrapText(text: string, width: number): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => wrapSingleLine(line, width))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Create a full-width horizontal line with the given character */
|
||||
function makeLine(char: string = '─', width: number = getWrapWidth()): string {
|
||||
if (!char || char.length === 0) char = '-';
|
||||
// Repeat and slice to exact width to avoid multi-column surprises
|
||||
return char.repeat(width).slice(0, width);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SESSION START MESSAGES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Creates a completion message after context operations
|
||||
*/
|
||||
export function createCompletionMessage(
|
||||
operation: string,
|
||||
count?: number,
|
||||
details?: string
|
||||
): string {
|
||||
const countInfo = count !== undefined ? ` (${count} items)` : '';
|
||||
const detailInfo = details ? `\n${details}` : '';
|
||||
const width = getWrapWidth();
|
||||
return wrapText(
|
||||
`✅ ${operation} completed successfully${countInfo}${detailInfo}`,
|
||||
width
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERROR MESSAGES (USER-FRIENDLY)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Creates user-friendly error messages with helpful suggestions
|
||||
*/
|
||||
export function createUserFriendlyError(
|
||||
operation: string,
|
||||
error: string,
|
||||
suggestion?: string
|
||||
): string {
|
||||
const suggestionText = suggestion ? `\n\n💡 ${suggestion}` : '';
|
||||
const width = getWrapWidth();
|
||||
return wrapText(
|
||||
`❌ ${operation} encountered an issue: ${error}${suggestionText}`,
|
||||
width
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common error scenarios with built-in suggestions
|
||||
*/
|
||||
export const ERROR_SCENARIOS = {
|
||||
NO_MEMORIES: (projectName: string) => ({
|
||||
message: `No previous memories found for ${projectName}`,
|
||||
suggestion:
|
||||
'This appears to be your first session. Memories will be created as you work.',
|
||||
}),
|
||||
|
||||
CONNECTION_FAILED: () => ({
|
||||
message: 'Could not connect to memory system',
|
||||
suggestion:
|
||||
'Try restarting Claude Code or check if the MCP server is properly configured.',
|
||||
}),
|
||||
|
||||
SEARCH_FAILED: (query: string) => ({
|
||||
message: `Search for "${query}" didn't return any results`,
|
||||
suggestion:
|
||||
'Try using different keywords or check if memories exist for this project.',
|
||||
}),
|
||||
|
||||
LOAD_TIMEOUT: () => ({
|
||||
message: 'Memory loading timed out',
|
||||
suggestion:
|
||||
'The operation is taking longer than expected. You can continue without loaded context.',
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates contextual error messages based on common scenarios
|
||||
*/
|
||||
export function createContextualError(
|
||||
scenario: keyof typeof ERROR_SCENARIOS,
|
||||
...args: string[]
|
||||
): string {
|
||||
const errorInfo = (ERROR_SCENARIOS[scenario] as any)(...args);
|
||||
return createUserFriendlyError(
|
||||
'Memory system',
|
||||
errorInfo.message,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TIME AND DATE FORMATTING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Formats timestamps into human-readable "time ago" format
|
||||
*/
|
||||
export function formatTimeAgo(timestamp: string | Date): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SESSION START TEMPLATE SYSTEM (data processing only)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Interface for memory entry data structure
|
||||
*/
|
||||
interface MemoryEntry {
|
||||
summary: string;
|
||||
keywords?: string;
|
||||
location?: string;
|
||||
sessionId?: string;
|
||||
number?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for grouped session memories
|
||||
*/
|
||||
interface SessionGroup {
|
||||
sessionId: string;
|
||||
memories: MemoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for overview with timestamp
|
||||
*/
|
||||
interface OverviewEntry {
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
timeAgo?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for session-grouped overviews
|
||||
*/
|
||||
interface SessionOverviewGroup {
|
||||
sessionId: string;
|
||||
overviews: OverviewEntry[];
|
||||
earliestTimestamp?: Date;
|
||||
timeAgo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure data processing function - converts JSON objects into structured memory entries
|
||||
* No formatting is done here, only data parsing and cleaning
|
||||
*/
|
||||
function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
|
||||
if (recentObjects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter only memory type objects and convert to MemoryEntry format
|
||||
return recentObjects
|
||||
.filter((obj) => obj.type === 'memory')
|
||||
.map((obj) => {
|
||||
const entry: MemoryEntry = {
|
||||
summary: obj.text || '',
|
||||
sessionId: obj.session_id || '',
|
||||
};
|
||||
|
||||
// Add optional fields if present
|
||||
if (obj.keywords) {
|
||||
entry.keywords = obj.keywords;
|
||||
}
|
||||
if (obj.document_id && !obj.document_id.includes('Session:')) {
|
||||
entry.location = obj.document_id;
|
||||
}
|
||||
|
||||
return entry;
|
||||
})
|
||||
.filter((entry) => entry.summary.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups memories by session ID and adds numbering
|
||||
*/
|
||||
function groupMemoriesBySession(memories: MemoryEntry[]): SessionGroup[] {
|
||||
const sessionMap = new Map<string, MemoryEntry[]>();
|
||||
|
||||
// Group memories by session ID
|
||||
memories.forEach((memory) => {
|
||||
const sessionId = memory.sessionId;
|
||||
if (sessionId) {
|
||||
if (!sessionMap.has(sessionId)) {
|
||||
sessionMap.set(sessionId, []);
|
||||
}
|
||||
sessionMap.get(sessionId)!.push(memory);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to session groups with numbering
|
||||
return Array.from(sessionMap.entries()).map(
|
||||
([sessionId, sessionMemories]) => {
|
||||
const numberedMemories = sessionMemories.map((memory, index) => ({
|
||||
...memory,
|
||||
number: index + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
memories: numberedMemories,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups overviews by session ID and calculates session timestamps
|
||||
*/
|
||||
function groupOverviewsBySession(
|
||||
overviews: OverviewEntry[]
|
||||
): SessionOverviewGroup[] {
|
||||
const sessionMap = new Map<string, OverviewEntry[]>();
|
||||
|
||||
// Group overviews by session ID
|
||||
overviews.forEach((overview) => {
|
||||
const sessionId = overview.sessionId || 'unknown';
|
||||
if (!sessionMap.has(sessionId)) {
|
||||
sessionMap.set(sessionId, []);
|
||||
}
|
||||
sessionMap.get(sessionId)!.push(overview);
|
||||
});
|
||||
|
||||
// Convert to session groups with timestamps
|
||||
return Array.from(sessionMap.entries()).map(
|
||||
([sessionId, sessionOverviews]) => {
|
||||
// Find the earliest timestamp in this session's overviews
|
||||
const timestamps = sessionOverviews
|
||||
.map((o) => o.timestamp)
|
||||
.filter((t): t is Date => t !== undefined)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const group: SessionOverviewGroup = {
|
||||
sessionId,
|
||||
overviews: sessionOverviews,
|
||||
};
|
||||
|
||||
// Add session-level timestamp if available
|
||||
if (timestamps.length > 0) {
|
||||
group.earliestTimestamp = timestamps[0];
|
||||
group.timeAgo = formatRelativeTime(timestamps[0]);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the complete session start template with provided data using Handlebars
|
||||
* Data processing is separated from presentation - template controls the format
|
||||
*/
|
||||
// Intentionally removed Handlebars-based renderer; console output is handled by
|
||||
// outputSessionStartContent() below.
|
||||
|
||||
/**
|
||||
* Outputs session start content using dual streams:
|
||||
* - stdout (console.log) -> Claude's context only (granular memories)
|
||||
* - stderr (console.error) -> User visible (clean overviews)
|
||||
*/
|
||||
export function outputSessionStartContent(params: {
|
||||
projectName: string;
|
||||
memoryCount: number;
|
||||
lastSessionTime?: string;
|
||||
recentObjects: any[];
|
||||
}): void {
|
||||
const { projectName, memoryCount, lastSessionTime, recentObjects } = params;
|
||||
const width = getWrapWidth();
|
||||
|
||||
// Start with current date and time at the top
|
||||
const now = new Date();
|
||||
const dateTimeFormatted = now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(wrapText(`📅 ${dateTimeFormatted}`, width));
|
||||
console.log(makeLine('─', width));
|
||||
|
||||
// Extract overviews for user display - get more to show session grouping
|
||||
const overviews = extractOverviews(recentObjects, 10, projectName);
|
||||
|
||||
// Debug: Log what we're getting
|
||||
console.error(`[DEBUG] recentObjects has ${recentObjects.length} items`);
|
||||
console.error(`[DEBUG] overviews extracted: ${overviews.length}`);
|
||||
|
||||
// Process memory entries for Claude context
|
||||
const memories = processMemoryEntries(recentObjects);
|
||||
// Helper to split and normalize keywords into a map (lowercased -> original)
|
||||
const splitKeywordsInto = (kw: string, dest: Map<string, string>) => {
|
||||
const tokens =
|
||||
kw.includes(',') || kw.includes('\n') ? kw.split(/[\n,]+/) : [kw];
|
||||
for (const t of tokens) {
|
||||
const trimmed = t.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (!dest.has(key)) dest.set(key, trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
// Output memories first, then overviews at bottom, all sorted oldest to newest
|
||||
if (memories.length > 0) {
|
||||
const sessionGroups = groupMemoriesBySession(memories);
|
||||
console.log('');
|
||||
console.log('');
|
||||
|
||||
console.log(wrapText('📚 Memories', width));
|
||||
sessionGroups.forEach((group) => {
|
||||
console.log(makeLine('─', width));
|
||||
|
||||
console.log('');
|
||||
|
||||
console.log(wrapText(`🔍 ${group.sessionId}`, width));
|
||||
|
||||
// Collect keywords for this session as we iterate its memories
|
||||
const groupKeywordMap = new Map<string, string>();
|
||||
|
||||
group.memories.forEach((memory) => {
|
||||
console.log('');
|
||||
console.log(wrapText(`${memory.number}. ${memory.summary}`, width));
|
||||
if (memory.keywords)
|
||||
splitKeywordsInto(memory.keywords, groupKeywordMap);
|
||||
});
|
||||
|
||||
// Print this session's aggregated keywords under the session block
|
||||
const groupKeywords = Array.from(groupKeywordMap.values());
|
||||
if (groupKeywords.length > 0) {
|
||||
console.log('');
|
||||
console.log(wrapText(`🏷️ ${groupKeywords.join(', ')}`, width));
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
// Overview section at bottom with session grouping
|
||||
if (overviews.length > 0) {
|
||||
const sessionGroups = groupOverviewsBySession(overviews);
|
||||
|
||||
// Sort groups by timestamp, oldest first for chronological reading order
|
||||
sessionGroups.sort((a, b) => {
|
||||
const timeA = a.earliestTimestamp?.getTime() || 0;
|
||||
const timeB = b.earliestTimestamp?.getTime() || 0;
|
||||
return timeA - timeB; // Ascending order (oldest first)
|
||||
});
|
||||
|
||||
console.log('');
|
||||
|
||||
console.log(wrapText('🧠 Overviews', width));
|
||||
console.log(makeLine('─', width));
|
||||
|
||||
// Match the memories section layout: session header, numbered items, per-session separator
|
||||
sessionGroups.forEach((group) => {
|
||||
console.log('');
|
||||
console.log(wrapText(`🔍 ${group.sessionId}`, width));
|
||||
|
||||
group.overviews.forEach((overview, index) => {
|
||||
console.log('');
|
||||
console.log(wrapText(`${index + 1}. ${overview.content}`, width));
|
||||
console.log('');
|
||||
|
||||
if (overview.timeAgo) {
|
||||
console.log(wrapText(`📅 ${overview.timeAgo}`, width));
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(makeLine('─', width));
|
||||
});
|
||||
} else if (memories.length === 0) {
|
||||
console.log(
|
||||
wrapText(`🧠 No recent context found for ${projectName}`, width)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { DiagnosticRow, DiagnosticInput, normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Data Access Object for diagnostic records
|
||||
*/
|
||||
export class DiagnosticsStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new diagnostic record
|
||||
*/
|
||||
create(input: DiagnosticInput): DiagnosticRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO diagnostics (
|
||||
session_id, message, severity, created_at, created_at_epoch, project, origin
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.session_id || null,
|
||||
input.message,
|
||||
input.severity || 'warn',
|
||||
isoString,
|
||||
epoch,
|
||||
input.project,
|
||||
input.origin || 'compressor'
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostic by primary key
|
||||
*/
|
||||
getById(id: number): DiagnosticRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM diagnostics WHERE id = ?');
|
||||
return stmt.get(id) as DiagnosticRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostics for a specific session
|
||||
*/
|
||||
getBySessionId(sessionId: string): DiagnosticRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM diagnostics
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
`);
|
||||
return stmt.all(sessionId) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent diagnostics for a project
|
||||
*/
|
||||
getRecentForProject(project: string, limit = 10): DiagnosticRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM diagnostics
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(project, limit) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent diagnostics across all projects
|
||||
*/
|
||||
getRecent(limit = 10): DiagnosticRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM diagnostics
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostics by severity level
|
||||
*/
|
||||
getBySeverity(severity: 'info' | 'warn' | 'error', limit?: number): DiagnosticRow[] {
|
||||
const query = limit
|
||||
? 'SELECT * FROM diagnostics WHERE severity = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM diagnostics WHERE severity = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
const stmt = this.db.query(query);
|
||||
const params = limit ? [severity, limit] : [severity];
|
||||
return stmt.all(...params) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostics by origin
|
||||
*/
|
||||
getByOrigin(origin: string, limit?: number): DiagnosticRow[] {
|
||||
const query = limit
|
||||
? 'SELECT * FROM diagnostics WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM diagnostics WHERE origin = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
const stmt = this.db.query(query);
|
||||
const params = limit ? [origin, limit] : [origin];
|
||||
return stmt.all(...params) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search diagnostics by message content
|
||||
*/
|
||||
searchByMessage(query: string, project?: string, limit = 20): DiagnosticRow[] {
|
||||
let sql = 'SELECT * FROM diagnostics WHERE message LIKE ?';
|
||||
const params: any[] = [`%${query}%`];
|
||||
|
||||
if (project) {
|
||||
sql += ' AND project = ?';
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const stmt = this.db.query(sql);
|
||||
return stmt.all(...params) as DiagnosticRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total diagnostics
|
||||
*/
|
||||
count(): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM diagnostics');
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count diagnostics by project
|
||||
*/
|
||||
countByProject(project: string): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM diagnostics WHERE project = ?');
|
||||
const result = stmt.get(project) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count diagnostics by severity
|
||||
*/
|
||||
countBySeverity(severity: 'info' | 'warn' | 'error'): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM diagnostics WHERE severity = ?');
|
||||
const result = stmt.get(severity) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a diagnostic record
|
||||
*/
|
||||
update(id: number, input: Partial<DiagnosticInput>): DiagnosticRow {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Diagnostic with id ${id} not found`);
|
||||
}
|
||||
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
UPDATE diagnostics SET
|
||||
message = ?, severity = ?, created_at = ?, created_at_epoch = ?, project = ?, origin = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
input.message || existing.message,
|
||||
input.severity || existing.severity,
|
||||
isoString,
|
||||
epoch,
|
||||
input.project || existing.project,
|
||||
input.origin || existing.origin,
|
||||
id
|
||||
);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a diagnostic by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.query('DELETE FROM diagnostics WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete diagnostics by session_id
|
||||
*/
|
||||
deleteBySessionId(sessionId: string): number {
|
||||
const stmt = this.db.query('DELETE FROM diagnostics WHERE session_id = ?');
|
||||
const info = stmt.run(sessionId);
|
||||
return info.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique projects from diagnostics
|
||||
*/
|
||||
getUniqueProjects(): string[] {
|
||||
const stmt = this.db.query('SELECT DISTINCT project FROM diagnostics ORDER BY project');
|
||||
const rows = stmt.all() as { project: string }[];
|
||||
return rows.map(row => row.project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostic summary stats
|
||||
*/
|
||||
getStats(): { total: number; info: number; warn: number; error: number } {
|
||||
const stmt = this.db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN severity = 'info' THEN 1 END) as info,
|
||||
COUNT(CASE WHEN severity = 'warn' THEN 1 END) as warn,
|
||||
COUNT(CASE WHEN severity = 'error' THEN 1 END) as error
|
||||
FROM diagnostics
|
||||
`);
|
||||
|
||||
return stmt.get() as { total: number; info: number; warn: number; error: number };
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { MemoryRow, MemoryInput, normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Data Access Object for memory records
|
||||
*/
|
||||
export class MemoryStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new memory record
|
||||
*/
|
||||
create(input: MemoryInput): MemoryRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO memories (
|
||||
session_id, text, document_id, keywords, created_at, created_at_epoch,
|
||||
project, archive_basename, origin, title, subtitle, facts, concepts, files_touched
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.session_id,
|
||||
input.text,
|
||||
input.document_id || null,
|
||||
input.keywords || null,
|
||||
isoString,
|
||||
epoch,
|
||||
input.project,
|
||||
input.archive_basename || null,
|
||||
input.origin || 'transcript',
|
||||
input.title || null,
|
||||
input.subtitle || null,
|
||||
input.facts || null,
|
||||
input.concepts || null,
|
||||
input.files_touched || null
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple memory records in a transaction
|
||||
*/
|
||||
createMany(inputs: MemoryInput[]): MemoryRow[] {
|
||||
const transaction = this.db.transaction((memories: MemoryInput[]) => {
|
||||
const results: MemoryRow[] = [];
|
||||
for (const memory of memories) {
|
||||
results.push(this.create(memory));
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
return transaction(inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory by primary key
|
||||
*/
|
||||
getById(id: number): MemoryRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM memories WHERE id = ?');
|
||||
return stmt.get(id) as MemoryRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory by document_id
|
||||
*/
|
||||
getByDocumentId(documentId: string): MemoryRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM memories WHERE document_id = ?');
|
||||
return stmt.get(documentId) as MemoryRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document_id already exists
|
||||
*/
|
||||
hasDocumentId(documentId: string): boolean {
|
||||
const stmt = this.db.query('SELECT 1 FROM memories WHERE document_id = ? LIMIT 1');
|
||||
return Boolean(stmt.get(documentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memories for a specific session
|
||||
*/
|
||||
getBySessionId(sessionId: string): MemoryRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM memories
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
`);
|
||||
return stmt.all(sessionId) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent memories for a project
|
||||
*/
|
||||
getRecentForProject(project: string, limit = 10): MemoryRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM memories
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(project, limit) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent memories across all projects
|
||||
*/
|
||||
getRecent(limit = 10): MemoryRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM memories
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memories by text content
|
||||
*/
|
||||
searchByText(query: string, project?: string, limit = 20): MemoryRow[] {
|
||||
let sql = 'SELECT * FROM memories WHERE text LIKE ?';
|
||||
const params: any[] = [`%${query}%`];
|
||||
|
||||
if (project) {
|
||||
sql += ' AND project = ?';
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const stmt = this.db.query(sql);
|
||||
return stmt.all(...params) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memories by keywords
|
||||
*/
|
||||
searchByKeywords(keywords: string, project?: string, limit = 20): MemoryRow[] {
|
||||
let sql = 'SELECT * FROM memories WHERE keywords LIKE ?';
|
||||
const params: any[] = [`%${keywords}%`];
|
||||
|
||||
if (project) {
|
||||
sql += ' AND project = ?';
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const stmt = this.db.query(sql);
|
||||
return stmt.all(...params) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memories by origin type
|
||||
*/
|
||||
getByOrigin(origin: string, limit?: number): MemoryRow[] {
|
||||
const query = limit
|
||||
? 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
const stmt = this.db.query(query);
|
||||
const params = limit ? [origin, limit] : [origin];
|
||||
return stmt.all(...params) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent memories for a project filtered by origin
|
||||
*/
|
||||
getRecentForProjectByOrigin(project: string, origin: string, limit = 10): MemoryRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM memories
|
||||
WHERE project = ? AND origin = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(project, origin, limit) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N memories for a project, sorted oldest to newest
|
||||
*/
|
||||
getLastNForProject(project: string, limit = 10): MemoryRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM (
|
||||
SELECT * FROM memories
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY created_at_epoch ASC
|
||||
`);
|
||||
return stmt.all(project, limit) as MemoryRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total memories
|
||||
*/
|
||||
count(): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM memories');
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count memories by project
|
||||
*/
|
||||
countByProject(project: string): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM memories WHERE project = ?');
|
||||
const result = stmt.get(project) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a memory record
|
||||
*/
|
||||
update(id: number, input: Partial<MemoryInput>): MemoryRow {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Memory with id ${id} not found`);
|
||||
}
|
||||
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
UPDATE memories SET
|
||||
text = ?, document_id = ?, keywords = ?, created_at = ?, created_at_epoch = ?,
|
||||
project = ?, archive_basename = ?, origin = ?, title = ?, subtitle = ?, facts = ?,
|
||||
concepts = ?, files_touched = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
input.text || existing.text,
|
||||
input.document_id !== undefined ? input.document_id : existing.document_id,
|
||||
input.keywords !== undefined ? input.keywords : existing.keywords,
|
||||
isoString,
|
||||
epoch,
|
||||
input.project || existing.project,
|
||||
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
|
||||
input.origin || existing.origin,
|
||||
input.title !== undefined ? input.title : existing.title,
|
||||
input.subtitle !== undefined ? input.subtitle : existing.subtitle,
|
||||
input.facts !== undefined ? input.facts : existing.facts,
|
||||
input.concepts !== undefined ? input.concepts : existing.concepts,
|
||||
input.files_touched !== undefined ? input.files_touched : existing.files_touched,
|
||||
id
|
||||
);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a memory by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.query('DELETE FROM memories WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete memories by session_id
|
||||
*/
|
||||
deleteBySessionId(sessionId: string): number {
|
||||
const stmt = this.db.query('DELETE FROM memories WHERE session_id = ?');
|
||||
const info = stmt.run(sessionId);
|
||||
return info.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique projects from memories
|
||||
*/
|
||||
getUniqueProjects(): string[] {
|
||||
const stmt = this.db.query('SELECT DISTINCT project FROM memories ORDER BY project');
|
||||
const rows = stmt.all() as { project: string }[];
|
||||
return rows.map(row => row.project);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { OverviewRow, OverviewInput, normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Data Access Object for overview records
|
||||
*/
|
||||
export class OverviewStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new overview record
|
||||
*/
|
||||
create(input: OverviewInput): OverviewRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO overviews (
|
||||
session_id, content, created_at, created_at_epoch, project, origin
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.session_id,
|
||||
input.content,
|
||||
isoString,
|
||||
epoch,
|
||||
input.project,
|
||||
input.origin || 'claude'
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace an overview for a session (since one session should have one overview)
|
||||
*/
|
||||
upsert(input: OverviewInput): OverviewRow {
|
||||
const existing = this.getBySessionId(input.session_id);
|
||||
if (existing) {
|
||||
return this.update(existing.id, input);
|
||||
}
|
||||
return this.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overview by primary key
|
||||
*/
|
||||
getById(id: number): OverviewRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM overviews WHERE id = ?');
|
||||
return stmt.get(id) as OverviewRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overview by session_id
|
||||
*/
|
||||
getBySessionId(sessionId: string): OverviewRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM overviews WHERE session_id = ?');
|
||||
return stmt.get(sessionId) as OverviewRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent overviews for a project
|
||||
*/
|
||||
getRecentForProject(project: string, limit = 5): OverviewRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM overviews
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(project, limit) as OverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all overviews for a project (oldest to newest)
|
||||
*/
|
||||
getAllForProject(project: string): OverviewRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM overviews
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch ASC
|
||||
`);
|
||||
return stmt.all(project) as OverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent overviews across all projects
|
||||
*/
|
||||
getRecent(limit = 5): OverviewRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM overviews
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as OverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search overviews by content
|
||||
*/
|
||||
searchByContent(query: string, project?: string, limit = 10): OverviewRow[] {
|
||||
let sql = 'SELECT * FROM overviews WHERE content LIKE ?';
|
||||
const params: any[] = [`%${query}%`];
|
||||
|
||||
if (project) {
|
||||
sql += ' AND project = ?';
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at_epoch DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const stmt = this.db.query(sql);
|
||||
return stmt.all(...params) as OverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overviews by origin type
|
||||
*/
|
||||
getByOrigin(origin: string, limit?: number): OverviewRow[] {
|
||||
const query = limit
|
||||
? 'SELECT * FROM overviews WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM overviews WHERE origin = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
const stmt = this.db.query(query);
|
||||
const params = limit ? [origin, limit] : [origin];
|
||||
return stmt.all(...params) as OverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total overviews
|
||||
*/
|
||||
count(): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM overviews');
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count overviews by project
|
||||
*/
|
||||
countByProject(project: string): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM overviews WHERE project = ?');
|
||||
const result = stmt.get(project) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an overview record
|
||||
*/
|
||||
update(id: number, input: Partial<OverviewInput>): OverviewRow {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Overview with id ${id} not found`);
|
||||
}
|
||||
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
UPDATE overviews SET
|
||||
content = ?, created_at = ?, created_at_epoch = ?, project = ?, origin = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
input.content || existing.content,
|
||||
isoString,
|
||||
epoch,
|
||||
input.project || existing.project,
|
||||
input.origin || existing.origin,
|
||||
id
|
||||
);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an overview by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.query('DELETE FROM overviews WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete overview by session_id
|
||||
*/
|
||||
deleteBySessionId(sessionId: string): boolean {
|
||||
const stmt = this.db.query('DELETE FROM overviews WHERE session_id = ?');
|
||||
const info = stmt.run(sessionId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique projects from overviews
|
||||
*/
|
||||
getUniqueProjects(): string[] {
|
||||
const stmt = this.db.query('SELECT DISTINCT project FROM overviews ORDER BY project');
|
||||
const rows = stmt.all() as { project: string }[];
|
||||
return rows.map(row => row.project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most recent overview for a specific project
|
||||
*/
|
||||
getByProject(project: string): OverviewRow | null {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM overviews
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
return stmt.get(project) as OverviewRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update overview for a project (keeps only most recent)
|
||||
*/
|
||||
upsertByProject(input: OverviewInput): OverviewRow {
|
||||
const existing = this.getByProject(input.project);
|
||||
if (existing) {
|
||||
return this.update(existing.id, input);
|
||||
}
|
||||
return this.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete overview by project name
|
||||
*/
|
||||
deleteByProject(project: string): boolean {
|
||||
const stmt = this.db.query('DELETE FROM overviews WHERE project = ?');
|
||||
const info = stmt.run(project);
|
||||
return info.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { SessionRow, SessionInput, normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Data Access Object for session records
|
||||
*/
|
||||
export class SessionStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session record
|
||||
*/
|
||||
create(input: SessionInput): SessionRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO sessions (
|
||||
session_id, project, created_at, created_at_epoch, source,
|
||||
archive_path, archive_bytes, archive_checksum, archived_at, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.session_id,
|
||||
input.project,
|
||||
isoString,
|
||||
epoch,
|
||||
input.source || 'compress',
|
||||
input.archive_path || null,
|
||||
input.archive_bytes || null,
|
||||
input.archive_checksum || null,
|
||||
input.archived_at || null,
|
||||
input.metadata_json || null
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a session record (insert or update if session_id exists)
|
||||
*/
|
||||
upsert(input: SessionInput): SessionRow {
|
||||
const existing = this.getBySessionId(input.session_id);
|
||||
if (existing) {
|
||||
return this.update(existing.id, input);
|
||||
}
|
||||
return this.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing session record
|
||||
*/
|
||||
update(id: number, input: Partial<SessionInput>): SessionRow {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Session with id ${id} not found`);
|
||||
}
|
||||
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
UPDATE sessions SET
|
||||
project = ?, created_at = ?, created_at_epoch = ?, source = ?,
|
||||
archive_path = ?, archive_bytes = ?, archive_checksum = ?, archived_at = ?, metadata_json = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
input.project || existing.project,
|
||||
isoString,
|
||||
epoch,
|
||||
input.source || existing.source,
|
||||
input.archive_path !== undefined ? input.archive_path : existing.archive_path,
|
||||
input.archive_bytes !== undefined ? input.archive_bytes : existing.archive_bytes,
|
||||
input.archive_checksum !== undefined ? input.archive_checksum : existing.archive_checksum,
|
||||
input.archived_at !== undefined ? input.archived_at : existing.archived_at,
|
||||
input.metadata_json !== undefined ? input.metadata_json : existing.metadata_json,
|
||||
id
|
||||
);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by primary key
|
||||
*/
|
||||
getById(id: number): SessionRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM sessions WHERE id = ?');
|
||||
return stmt.get(id) as SessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by session_id
|
||||
*/
|
||||
getBySessionId(sessionId: string): SessionRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM sessions WHERE session_id = ?');
|
||||
return stmt.get(sessionId) as SessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists by session_id
|
||||
*/
|
||||
has(sessionId: string): boolean {
|
||||
const stmt = this.db.query('SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1');
|
||||
return Boolean(stmt.get(sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session_ids as a Set (useful for import-history)
|
||||
*/
|
||||
getAllSessionIds(): Set<string> {
|
||||
const stmt = this.db.query('SELECT session_id FROM sessions');
|
||||
const rows = stmt.all() as { session_id: string }[];
|
||||
return new Set(rows.map(row => row.session_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent sessions for a project
|
||||
*/
|
||||
getRecentForProject(project: string, limit = 5): SessionRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM sessions
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(project, limit) as SessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent sessions across all projects
|
||||
*/
|
||||
getRecent(limit = 5): SessionRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM sessions
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as SessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions by source type
|
||||
*/
|
||||
getBySource(source: 'compress' | 'save' | 'legacy-jsonl', limit?: number): SessionRow[] {
|
||||
const query = limit
|
||||
? 'SELECT * FROM sessions WHERE source = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM sessions WHERE source = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
const stmt = this.db.query(query);
|
||||
const params = limit ? [source, limit] : [source];
|
||||
return stmt.all(...params) as SessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total sessions
|
||||
*/
|
||||
count(): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM sessions');
|
||||
const result = stmt.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count sessions by project
|
||||
*/
|
||||
countByProject(project: string): number {
|
||||
const stmt = this.db.query('SELECT COUNT(*) as count FROM sessions WHERE project = ?');
|
||||
const result = stmt.get(project) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by ID (cascades to related records)
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.query('DELETE FROM sessions WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by session_id (cascades to related records)
|
||||
*/
|
||||
deleteBySessionId(sessionId: string): boolean {
|
||||
const stmt = this.db.query('DELETE FROM sessions WHERE session_id = ?');
|
||||
const info = stmt.run(sessionId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Represents a streaming session row in the database
|
||||
*/
|
||||
export interface StreamingSessionRow {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
sdk_session_id?: string;
|
||||
project: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
user_prompt?: string;
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
updated_at?: string;
|
||||
updated_at_epoch?: number;
|
||||
completed_at?: string;
|
||||
completed_at_epoch?: number;
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for creating a new streaming session
|
||||
*/
|
||||
export interface StreamingSessionInput {
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
user_prompt?: string;
|
||||
started_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for updating a streaming session
|
||||
*/
|
||||
export interface StreamingSessionUpdate {
|
||||
sdk_session_id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
status?: 'active' | 'completed' | 'failed';
|
||||
completed_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Access Object for streaming session records
|
||||
* Handles real-time session tracking during SDK compression
|
||||
*/
|
||||
export class StreamingSessionStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new streaming session record
|
||||
* This should be called immediately when the hook receives a user prompt
|
||||
*/
|
||||
create(input: StreamingSessionInput): StreamingSessionRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.started_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO streaming_sessions (
|
||||
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.claude_session_id,
|
||||
input.project,
|
||||
input.user_prompt || null,
|
||||
isoString,
|
||||
epoch
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by internal ID
|
||||
* Uses atomic transaction to prevent race conditions
|
||||
*/
|
||||
update(id: number, updates: StreamingSessionUpdate): StreamingSessionRow {
|
||||
const { isoString: updatedAt, epoch: updatedEpoch } = normalizeTimestamp(new Date());
|
||||
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Streaming session with id ${id} not found`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.sdk_session_id !== undefined) {
|
||||
parts.push('sdk_session_id = ?');
|
||||
values.push(updates.sdk_session_id);
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
parts.push('title = ?');
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.subtitle !== undefined) {
|
||||
parts.push('subtitle = ?');
|
||||
values.push(updates.subtitle);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
parts.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.completed_at !== undefined) {
|
||||
const { isoString, epoch } = normalizeTimestamp(updates.completed_at);
|
||||
parts.push('completed_at = ?', 'completed_at_epoch = ?');
|
||||
values.push(isoString, epoch);
|
||||
}
|
||||
|
||||
// Always update the updated_at timestamp
|
||||
parts.push('updated_at = ?', 'updated_at_epoch = ?');
|
||||
values.push(updatedAt, updatedEpoch);
|
||||
|
||||
values.push(id);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
UPDATE streaming_sessions
|
||||
SET ${parts.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by Claude session ID
|
||||
* Convenience method for hooks that only have the Claude session ID
|
||||
*/
|
||||
updateByClaudeSessionId(claudeSessionId: string, updates: StreamingSessionUpdate): StreamingSessionRow | null {
|
||||
const session = this.getByClaudeSessionId(claudeSessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return this.update(session.id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by internal ID
|
||||
*/
|
||||
getById(id: number): StreamingSessionRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM streaming_sessions WHERE id = ?');
|
||||
return stmt.get(id) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by Claude session ID
|
||||
*/
|
||||
getByClaudeSessionId(claudeSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
return stmt.get(claudeSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by SDK session ID
|
||||
*/
|
||||
getBySdkSessionId(sdkSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.query('SELECT * FROM streaming_sessions WHERE sdk_session_id = ?');
|
||||
return stmt.get(sdkSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streaming session exists by Claude session ID
|
||||
*/
|
||||
has(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.query('SELECT 1 FROM streaming_sessions WHERE claude_session_id = ? LIMIT 1');
|
||||
return Boolean(stmt.get(claudeSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active streaming sessions for a project
|
||||
*/
|
||||
getActiveForProject(project: string): StreamingSessionRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE project = ? AND status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all(project) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active streaming sessions
|
||||
*/
|
||||
getAllActive(): StreamingSessionRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all() as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent streaming sessions (completed or failed)
|
||||
*/
|
||||
getRecent(limit = 10): StreamingSessionRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM streaming_sessions
|
||||
ORDER BY started_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as completed
|
||||
*/
|
||||
markCompleted(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'completed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as failed
|
||||
*/
|
||||
markFailed(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'failed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.query('DELETE FROM streaming_sessions WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by Claude session ID
|
||||
*/
|
||||
deleteByClaudeSessionId(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.query('DELETE FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
const info = stmt.run(claudeSessionId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed/failed sessions (older than N days)
|
||||
*/
|
||||
cleanupOldSessions(daysOld = 30): number {
|
||||
const cutoffEpoch = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
|
||||
const stmt = this.db.query(`
|
||||
DELETE FROM streaming_sessions
|
||||
WHERE status IN ('completed', 'failed')
|
||||
AND completed_at_epoch < ?
|
||||
`);
|
||||
const info = stmt.run(cutoffEpoch);
|
||||
return info.changes;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { getDatabase } from './Database.js';
|
||||
import {
|
||||
TranscriptEventInput,
|
||||
TranscriptEventRow,
|
||||
normalizeTimestamp
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Data access for transcript_events table
|
||||
*/
|
||||
export class TranscriptEventStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a transcript event
|
||||
*/
|
||||
upsert(event: TranscriptEventInput): TranscriptEventRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(event.captured_at);
|
||||
|
||||
const stmt = this.db.query(`
|
||||
INSERT INTO transcript_events (
|
||||
session_id,
|
||||
project,
|
||||
event_index,
|
||||
event_type,
|
||||
raw_json,
|
||||
captured_at,
|
||||
captured_at_epoch
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, event_index) DO UPDATE SET
|
||||
project = excluded.project,
|
||||
event_type = excluded.event_type,
|
||||
raw_json = excluded.raw_json,
|
||||
captured_at = excluded.captured_at,
|
||||
captured_at_epoch = excluded.captured_at_epoch
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
event.session_id,
|
||||
event.project || null,
|
||||
event.event_index,
|
||||
event.event_type || null,
|
||||
event.raw_json,
|
||||
isoString,
|
||||
epoch
|
||||
);
|
||||
|
||||
return this.getBySessionAndIndex(event.session_id, event.event_index)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk upsert events in a single transaction
|
||||
*/
|
||||
upsertMany(events: TranscriptEventInput[]): TranscriptEventRow[] {
|
||||
const transaction = this.db.transaction((rows: TranscriptEventInput[]) => {
|
||||
const results: TranscriptEventRow[] = [];
|
||||
for (const row of rows) {
|
||||
results.push(this.upsert(row));
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
return transaction(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by session and index
|
||||
*/
|
||||
getBySessionAndIndex(sessionId: string, eventIndex: number): TranscriptEventRow | null {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM transcript_events
|
||||
WHERE session_id = ? AND event_index = ?
|
||||
`);
|
||||
return stmt.get(sessionId, eventIndex) as TranscriptEventRow | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highest event_index stored for a session
|
||||
*/
|
||||
getMaxEventIndex(sessionId: string): number {
|
||||
const stmt = this.db.query(`
|
||||
SELECT MAX(event_index) as max_event_index
|
||||
FROM transcript_events
|
||||
WHERE session_id = ?
|
||||
`);
|
||||
const row = stmt.get(sessionId) as { max_event_index: number | null } | undefined;
|
||||
return row?.max_event_index ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent events for a session
|
||||
*/
|
||||
listBySession(sessionId: string, limit = 200, offset = 0): TranscriptEventRow[] {
|
||||
const stmt = this.db.query(`
|
||||
SELECT * FROM transcript_events
|
||||
WHERE session_id = ?
|
||||
ORDER BY event_index ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
return stmt.all(sessionId, limit, offset) as TranscriptEventRow[];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
// Export main components
|
||||
export { DatabaseManager, getDatabase, initializeDatabase } from './Database.js';
|
||||
|
||||
// Export store classes
|
||||
export { SessionStore } from './SessionStore.js';
|
||||
export { MemoryStore } from './MemoryStore.js';
|
||||
export { OverviewStore } from './OverviewStore.js';
|
||||
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
||||
export { TranscriptEventStore } from './TranscriptEventStore.js';
|
||||
export { StreamingSessionStore } from './StreamingSessionStore.js';
|
||||
|
||||
// Export hooks database
|
||||
export { HooksDatabase } from './HooksDatabase.js';
|
||||
|
||||
@@ -17,33 +9,3 @@ export * from './types.js';
|
||||
|
||||
// Export migrations
|
||||
export { migrations } from './migrations.js';
|
||||
|
||||
// Convenience function to get all stores
|
||||
export async function createStores() {
|
||||
const { DatabaseManager } = await import('./Database.js');
|
||||
const { migrations } = await import('./migrations.js');
|
||||
|
||||
// Register migrations before initialization
|
||||
const manager = DatabaseManager.getInstance();
|
||||
for (const migration of migrations) {
|
||||
manager.registerMigration(migration);
|
||||
}
|
||||
|
||||
const db = await manager.initialize();
|
||||
|
||||
const { SessionStore } = await import('./SessionStore.js');
|
||||
const { MemoryStore } = await import('./MemoryStore.js');
|
||||
const { OverviewStore } = await import('./OverviewStore.js');
|
||||
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
||||
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
|
||||
const { StreamingSessionStore } = await import('./StreamingSessionStore.js');
|
||||
|
||||
return {
|
||||
sessions: new SessionStore(db),
|
||||
memories: new MemoryStore(db),
|
||||
overviews: new OverviewStore(db),
|
||||
diagnostics: new DiagnosticsStore(db),
|
||||
transcriptEvents: new TranscriptEventStore(db),
|
||||
streamingSessions: new StreamingSessionStore(db)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Simple logging utility for claude-mem
|
||||
*/
|
||||
|
||||
export interface LogLevel {
|
||||
DEBUG: number;
|
||||
INFO: number;
|
||||
WARN: number;
|
||||
ERROR: number;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
};
|
||||
|
||||
class Logger {
|
||||
// <Block> 2.1 ====================================
|
||||
private level: number = LOG_LEVELS.INFO;
|
||||
|
||||
setLevel(level: keyof LogLevel): void {
|
||||
this.level = LOG_LEVELS[level];
|
||||
}
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 2.2 ====================================
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (this.level <= LOG_LEVELS.DEBUG) {
|
||||
console.debug(`[DEBUG] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 2.3 ====================================
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (this.level <= LOG_LEVELS.INFO) {
|
||||
console.info(`[INFO] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 2.4 ====================================
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.level <= LOG_LEVELS.WARN) {
|
||||
console.warn(`[WARN] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 2.5 ====================================
|
||||
error(message: string, error?: any, context?: any): void {
|
||||
if (this.level <= LOG_LEVELS.ERROR) {
|
||||
console.error(`[ERROR] ${message}`);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (context) {
|
||||
console.error('Context:', context);
|
||||
}
|
||||
}
|
||||
}
|
||||
// </Block> =======================================
|
||||
}
|
||||
|
||||
export const log = new Logger();
|
||||
@@ -1,42 +0,0 @@
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PathDiscovery } from '../services/path-discovery.js';
|
||||
|
||||
let logPath: string | null = null;
|
||||
|
||||
function ensureLogPath(): string {
|
||||
if (logPath) {
|
||||
return logPath;
|
||||
}
|
||||
|
||||
const discovery = PathDiscovery.getInstance();
|
||||
const logsDir = discovery.getLogsDirectory();
|
||||
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
logPath = join(logsDir, 'rolling-memory.log');
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export type RollingLogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export function rollingLog(
|
||||
level: RollingLogLevel,
|
||||
message: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): void {
|
||||
try {
|
||||
const file = ensureLogPath();
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...payload
|
||||
};
|
||||
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8');
|
||||
} catch {
|
||||
// Logging should never throw user-facing errors
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { readSettings } from './settings.js';
|
||||
|
||||
export interface RollingSettings {
|
||||
captureEnabled: boolean;
|
||||
summaryEnabled: boolean;
|
||||
sessionStartEnabled: boolean;
|
||||
chunkTokenLimit: number;
|
||||
chunkOverlapTokens: number;
|
||||
summaryTurnLimit: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: RollingSettings = {
|
||||
captureEnabled: true,
|
||||
summaryEnabled: true,
|
||||
sessionStartEnabled: true,
|
||||
chunkTokenLimit: 600,
|
||||
chunkOverlapTokens: 200,
|
||||
summaryTurnLimit: 20
|
||||
};
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const lowered = value.toLowerCase();
|
||||
if (lowered === 'true') return true;
|
||||
if (lowered === 'false') return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getRollingSettings(): RollingSettings {
|
||||
const settings = readSettings();
|
||||
|
||||
return {
|
||||
captureEnabled: normalizeBoolean(
|
||||
settings.rollingCaptureEnabled,
|
||||
DEFAULTS.captureEnabled
|
||||
),
|
||||
summaryEnabled: normalizeBoolean(
|
||||
settings.rollingSummaryEnabled,
|
||||
DEFAULTS.summaryEnabled
|
||||
),
|
||||
sessionStartEnabled: normalizeBoolean(
|
||||
settings.rollingSessionStartEnabled,
|
||||
DEFAULTS.sessionStartEnabled
|
||||
),
|
||||
chunkTokenLimit: normalizeNumber(
|
||||
settings.rollingChunkTokens,
|
||||
DEFAULTS.chunkTokenLimit
|
||||
),
|
||||
chunkOverlapTokens: normalizeNumber(
|
||||
settings.rollingChunkOverlapTokens,
|
||||
DEFAULTS.chunkOverlapTokens
|
||||
),
|
||||
summaryTurnLimit: normalizeNumber(
|
||||
settings.rollingSummaryTurnLimit,
|
||||
DEFAULTS.summaryTurnLimit
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function isRollingCaptureEnabled(): boolean {
|
||||
return getRollingSettings().captureEnabled;
|
||||
}
|
||||
|
||||
export function isRollingSummaryEnabled(): boolean {
|
||||
return getRollingSettings().summaryEnabled;
|
||||
}
|
||||
|
||||
export function isRollingSessionStartEnabled(): boolean {
|
||||
return getRollingSettings().sessionStartEnabled;
|
||||
}
|
||||
Reference in New Issue
Block a user