Release v3.9.11

Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
Alex Newman
2025-10-03 20:55:36 -04:00
parent 5b30764fa8
commit 5244a12422
13 changed files with 1136 additions and 1027 deletions
+8 -3
View File
@@ -224,9 +224,8 @@ program
.description('Generate a session title and subtitle from a prompt')
.option('--json', 'Output as JSON')
.option('--oneline', 'Output as single line (title - subtitle)')
.option('--save', 'Save title and subtitle to session metadata')
.option('--project <name>', 'Project name (required with --save)')
.option('--session <id>', 'Session ID (required with --save)')
.option('--session-id <id>', 'Claude session ID to update')
.option('--save', 'Save the generated title to the database (requires --session-id)')
.action(generateTitle);
// </Block> =======================================
@@ -268,3 +267,9 @@ try {
// Parse arguments and execute
program.parse();
// </Block> =======================================
// <Block> 1.12 ===================================
// 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';
// </Block> =======================================
+46 -44
View File
@@ -1,14 +1,17 @@
import { OptionValues } from 'commander';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { getClaudePath } from '../shared/settings.js';
import path from 'path';
import fs from 'fs';
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
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) {
@@ -19,6 +22,36 @@ export async function generateTitle(prompt: string, options: OptionValues): Prom
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:
@@ -107,49 +140,17 @@ Now generate the title and subtitle (two lines exactly):`;
const title = lines[0].trim();
const subtitle = lines[1].trim();
// Save to session metadata if --save flag is provided
if (options.save) {
if (!options.project || !options.session) {
console.error(JSON.stringify({
success: false,
error: '--project and --session are required when using --save'
}));
process.exit(1);
}
// If --save and we have a session, update the database
if (options.save && streamingStore && sessionRecord) {
try {
const sessionFile = path.join(SESSION_DIR, `${options.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;
sessionData.promptSubtitle = subtitle;
sessionData.updatedAt = new Date().toISOString();
// Write back to file
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
streamingStore.update(sessionRecord.id, {
title,
subtitle
});
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: `Failed to save metadata: ${error.message}`
error: `Failed to save title: ${error.message}`
}));
process.exit(1);
}
@@ -160,7 +161,8 @@ Now generate the title and subtitle (two lines exactly):`;
console.log(JSON.stringify({
success: true,
title,
subtitle
subtitle,
sessionId: sessionRecord?.claude_session_id
}, null, 2));
} else if (options.oneline) {
console.log(`${title} - ${subtitle}`);
+176 -764
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
import { Database } from 'better-sqlite3';
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.prepare(`
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.prepare(`
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.prepare('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.prepare('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.prepare('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.prepare('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.prepare(`
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.prepare(`
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.prepare(`
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.prepare('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.prepare('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.prepare(`
DELETE FROM streaming_sessions
WHERE status IN ('completed', 'failed')
AND completed_at_epoch < ?
`);
const info = stmt.run(cutoffEpoch);
return info.changes;
}
}
+5 -2
View File
@@ -7,6 +7,7 @@ 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 types
export * from './types.js';
@@ -26,18 +27,20 @@ export async function createStores() {
}
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)
transcriptEvents: new TranscriptEventStore(db),
streamingSessions: new StreamingSessionStore(db)
};
}
+44 -1
View File
@@ -160,10 +160,53 @@ export const migration002: Migration = {
}
};
/**
* Migration 003 - Add streaming_sessions table for real-time session tracking
*/
export const migration003: Migration = {
version: 3,
up: (db: Database.Database) => {
// Streaming sessions table - tracks active SDK compression sessions
db.exec(`
CREATE TABLE IF NOT EXISTS streaming_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT,
project TEXT NOT NULL,
title TEXT,
subtitle TEXT,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
updated_at TEXT,
updated_at_epoch INTEGER,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id ON streaming_sessions(claude_session_id);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id ON streaming_sessions(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project ON streaming_sessions(project);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_status ON streaming_sessions(status);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_started ON streaming_sessions(started_at_epoch DESC);
`);
console.log('✅ Created streaming_sessions table for real-time session tracking');
},
down: (db: Database.Database) => {
db.exec(`
DROP TABLE IF EXISTS streaming_sessions;
`);
}
};
/**
* All migrations in order
*/
export const migrations: Migration[] = [
migration001,
migration002
migration002,
migration003
];
+112
View File
@@ -0,0 +1,112 @@
import { platform, homedir } from 'os';
import { execSync } from 'child_process';
import { chmodSync } from 'fs';
import { join } from 'path';
const isWindows = platform() === 'win32';
/**
* Platform-specific utilities for cross-platform compatibility
* Handles differences between Windows and Unix-like systems
*/
export const Platform = {
/**
* Returns the appropriate shell for the current platform
*/
getShell: (): string => {
return isWindows ? 'powershell' : '/bin/sh';
},
/**
* Returns the file extension for hook scripts
*/
getHookExtension: (): string => {
return '.js'; // Both platforms can execute Node.js scripts
},
/**
* Finds the path to an executable command
* @param name - Name of the executable to find
* @returns Full path to the executable
*/
findExecutable: (name: string): string => {
const cmd = isWindows ? `where ${name}` : `which ${name}`;
return execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim();
},
/**
* Makes a file executable (Unix only - no-op on Windows)
* @param path - Path to the file to make executable
*/
makeExecutable: (path: string): void => {
if (!isWindows) {
chmodSync(path, 0o755);
}
},
/**
* Installs uv package manager using platform-specific method
*/
installUv: (): void => {
if (isWindows) {
execSync('powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"', {
stdio: 'pipe'
});
} else {
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
stdio: 'pipe',
shell: '/bin/sh'
});
}
},
/**
* Returns shell configuration file paths for the current platform
* @returns Array of shell config file paths
*/
getShellConfigPaths: (): string[] => {
const home = homedir();
if (isWindows) {
return [
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1')
];
}
return [
join(home, '.bashrc'),
join(home, '.zshrc'),
join(home, '.bash_profile')
];
},
/**
* Gets the appropriate alias syntax for the current platform's shell
* @param aliasName - Name of the alias
* @param command - Command to alias
* @returns Alias definition string
*/
getAliasDefinition: (aliasName: string, command: string): string => {
if (isWindows) {
// PowerShell function syntax
return `function ${aliasName} { ${command} $args }`;
}
// Bash/Zsh alias syntax
return `alias ${aliasName}='${command}'`;
},
/**
* Returns whether the current platform is Windows
*/
isWindows: (): boolean => isWindows,
/**
* Returns whether the current platform is Unix-like (macOS/Linux)
*/
isUnix: (): boolean => !isWindows
};