mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
feat: isolate Claude and Codex session sources
Persist platform_source across session creation, transcript ingestion, API query paths, and viewer state so Claude and Codex data can coexist without bleeding into each other. - add platform-source normalization helpers and persist platform_source in sdk_sessions via migration 24 with backfill and indexing - thread platformSource through CLI hooks, transcript processing, context generation, pagination, search routes, SSE payloads, and session management - expose source-aware project catalogs, viewer tabs, context preview selectors, and source badges for observations, prompts, and summaries - start the transcript watcher from the worker for transcript-based clients and preserve platform source during Codex ingestion - auto-start the worker from the MCP server for MCP-only clients and tighten stdio-driven cleanup during shutdown - keep createSDKSession backward compatible with existing custom-title callers while allowing explicit platform source forwarding
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -355,6 +355,14 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
@@ -549,6 +557,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-border-focus);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.source-tab.active {
|
||||
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
|
||||
border-color: var(--color-bg-button);
|
||||
color: var(--color-text-button);
|
||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
|
||||
}
|
||||
|
||||
.settings-btn,
|
||||
.theme-toggle-btn {
|
||||
background: var(--color-bg-card);
|
||||
@@ -887,6 +931,49 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.source-claude {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c25a00;
|
||||
border-color: rgba(255, 138, 61, 0.22);
|
||||
}
|
||||
|
||||
.source-codex {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #0f5ba7;
|
||||
border-color: rgba(33, 150, 243, 0.24);
|
||||
}
|
||||
|
||||
.source-cursor {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-claude {
|
||||
color: #ffb067;
|
||||
border-color: rgba(255, 176, 103, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-codex {
|
||||
color: #8fc7ff;
|
||||
border-color: rgba(143, 199, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-cursor {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1483,6 +1570,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1582,11 @@
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hide icon links (docs, github, twitter) on tablet */
|
||||
.icon-link {
|
||||
display: none;
|
||||
@@ -1544,6 +1640,27 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logomark {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -1732,6 +1849,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-selector select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-selector select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
@@ -2873,4 +2995,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const contextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -31,6 +32,7 @@ export const contextHandler: EventHandler = {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
const context = getProjectContext(cwd);
|
||||
const port = getWorkerPort();
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
// Check if terminal output should be shown (load settings early)
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
@@ -38,7 +40,7 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(platformSource)}`;
|
||||
const colorApiPath = `${apiPath}&colors=true`;
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const fileEditHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -20,6 +21,7 @@ export const fileEditHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, filePath, edits } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('fileEditHandler requires filePath');
|
||||
@@ -42,6 +44,7 @@ export const fileEditHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const observationHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -22,6 +23,7 @@ export const observationHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!toolName) {
|
||||
// No tool name provided - skip observation gracefully
|
||||
@@ -51,6 +53,7 @@ export const observationHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -23,6 +24,7 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
@@ -39,7 +41,8 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId
|
||||
contentSessionId: sessionId,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionInitHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -42,6 +43,7 @@ export const sessionInitHandler: EventHandler = {
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
||||
|
||||
@@ -52,7 +54,8 @@ export const sessionInitHandler: EventHandler = {
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
project,
|
||||
prompt
|
||||
prompt,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { getWorkerPort, workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { ensureWorkerStarted } from '../services/worker-service.js';
|
||||
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
|
||||
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -144,6 +145,26 @@ async function verifyWorkerConnection(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Worker is available for Codex and other MCP-only clients.
|
||||
* Claude hooks already start the worker; this path makes Codex turnkey.
|
||||
*/
|
||||
async function ensureWorkerConnection(): Promise<boolean> {
|
||||
if (await verifyWorkerConnection()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn('SYSTEM', 'Worker not available, attempting auto-start for MCP client');
|
||||
|
||||
try {
|
||||
const port = getWorkerPort();
|
||||
return await ensureWorkerStarted(port);
|
||||
} catch (error) {
|
||||
logger.error('SYSTEM', 'Worker auto-start failed', undefined, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definitions with HTTP-based handlers
|
||||
* Minimal descriptions - use help() tool with operation parameter for detailed docs
|
||||
@@ -392,6 +413,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
// Prevents orphaned MCP server processes when Claude Code exits unexpectedly
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let isCleaningUp = false;
|
||||
|
||||
function handleStdioClosed() {
|
||||
cleanup('stdio-closed');
|
||||
}
|
||||
|
||||
function handleStdioError(error: Error) {
|
||||
logger.warn('SYSTEM', 'MCP stdio stream errored, shutting down', {
|
||||
message: error.message
|
||||
});
|
||||
cleanup('stdio-error');
|
||||
}
|
||||
|
||||
function attachStdioLifecycle() {
|
||||
process.stdin.on('end', handleStdioClosed);
|
||||
process.stdin.on('close', handleStdioClosed);
|
||||
process.stdin.on('error', handleStdioError);
|
||||
}
|
||||
|
||||
function detachStdioLifecycle() {
|
||||
process.stdin.off('end', handleStdioClosed);
|
||||
process.stdin.off('close', handleStdioClosed);
|
||||
process.stdin.off('error', handleStdioError);
|
||||
}
|
||||
|
||||
function startParentHeartbeat() {
|
||||
// ppid-based orphan detection only works on Unix
|
||||
@@ -414,9 +459,13 @@ function startParentHeartbeat() {
|
||||
|
||||
// Cleanup function — synchronous to ensure consistent behavior whether called
|
||||
// from signal handlers, heartbeat interval, or awaited in async context
|
||||
function cleanup() {
|
||||
function cleanup(reason: string = 'shutdown') {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
logger.info('SYSTEM', 'MCP server shutting down');
|
||||
detachStdioLifecycle();
|
||||
logger.info('SYSTEM', 'MCP server shutting down', { reason });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -428,6 +477,7 @@ process.on('SIGINT', cleanup);
|
||||
async function main() {
|
||||
// Start the MCP server
|
||||
const transport = new StdioServerTransport();
|
||||
attachStdioLifecycle();
|
||||
await server.connect(transport);
|
||||
logger.info('SYSTEM', 'Claude-mem search server started');
|
||||
|
||||
@@ -436,7 +486,7 @@ async function main() {
|
||||
|
||||
// Check Worker availability in background
|
||||
setTimeout(async () => {
|
||||
const workerAvailable = await verifyWorkerConnection();
|
||||
const workerAvailable = await ensureWorkerConnection();
|
||||
if (!workerAvailable) {
|
||||
logger.error('SYSTEM', 'Worker not available', undefined, {});
|
||||
logger.error('SYSTEM', 'Tools will fail until Worker is started');
|
||||
|
||||
@@ -130,6 +130,7 @@ export async function generateContext(
|
||||
const config = loadContextConfig();
|
||||
const cwd = input?.cwd ?? process.cwd();
|
||||
const project = getProjectName(cwd);
|
||||
const platformSource = input?.platform_source;
|
||||
|
||||
// Use provided projects array (for worktree support) or fall back to single project
|
||||
const projects = input?.projects || [project];
|
||||
@@ -149,11 +150,11 @@ export async function generateContext(
|
||||
try {
|
||||
// Query data for all projects (supports worktree: parent + worktree combined)
|
||||
const observations = projects.length > 1
|
||||
? queryObservationsMulti(db, projects, config)
|
||||
: queryObservations(db, project, config);
|
||||
? queryObservationsMulti(db, projects, config, platformSource)
|
||||
: queryObservations(db, project, config, platformSource);
|
||||
const summaries = projects.length > 1
|
||||
? querySummariesMulti(db, projects, config)
|
||||
: querySummaries(db, project, config);
|
||||
? querySummariesMulti(db, projects, config, platformSource)
|
||||
: querySummaries(db, project, config, platformSource);
|
||||
|
||||
// Handle empty state
|
||||
if (observations.length === 0 && summaries.length === 0) {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { SUMMARY_LOOKAHEAD } from './types.js';
|
||||
export function queryObservations(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -34,19 +35,38 @@ export function queryObservations(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE project = ?
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE o.project = ?
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
project,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,15 +75,30 @@ export function queryObservations(
|
||||
export function querySummaries(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE ss.project = ?
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(
|
||||
...[project, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD]
|
||||
) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +110,8 @@ export function querySummaries(
|
||||
export function queryObservationsMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -87,19 +123,39 @@ export function queryObservationsMulti(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch, project
|
||||
FROM observations
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch,
|
||||
o.project
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE o.project IN (${projectPlaceholders})
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
...projects,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,18 +167,32 @@ export function queryObservationsMulti(
|
||||
export function querySummariesMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
// Build IN clause for projects
|
||||
const projectPlaceholders = projects.map(() => '?').join(',');
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch, project
|
||||
FROM session_summaries
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch,
|
||||
ss.project
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE ss.project IN (${projectPlaceholders})
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(...projects, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ContextInput {
|
||||
projects?: string[];
|
||||
/** When true, return ALL observations with no limit */
|
||||
full?: boolean;
|
||||
platform_source?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export interface ContextConfig {
|
||||
export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -70,6 +72,7 @@ export interface Observation {
|
||||
export interface SessionSummary {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TableNameRow } from '../../types/database.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { isDirectChild } from '../../shared/path-utils.js';
|
||||
import { AppError } from '../server/ErrorHandler.js';
|
||||
import {
|
||||
ObservationSearchResult,
|
||||
SessionSummarySearchResult,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
export class SessionSearch {
|
||||
private db: Database;
|
||||
|
||||
private static readonly MISSING_SEARCH_INPUT_MESSAGE = 'Either query or filters required for search';
|
||||
|
||||
constructor(dbPath?: string) {
|
||||
if (!dbPath) {
|
||||
ensureDir(DATA_DIR);
|
||||
@@ -280,7 +283,7 @@ export class SessionSearch {
|
||||
if (!query) {
|
||||
const filterClause = this.buildFilterClause(filters, params, 'o');
|
||||
if (!filterClause) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const orderClause = this.buildOrderClause(orderBy, false);
|
||||
@@ -317,7 +320,7 @@ export class SessionSearch {
|
||||
delete filterOptions.type;
|
||||
const filterClause = this.buildFilterClause(filterOptions, params, 's');
|
||||
if (!filterClause) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const orderClause = orderBy === 'date_asc'
|
||||
@@ -551,7 +554,7 @@ export class SessionSearch {
|
||||
// FILTER-ONLY PATH: When no query text, query user_prompts table directly
|
||||
if (!query) {
|
||||
if (baseConditions.length === 0) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
} from '../../types/database.js';
|
||||
import type { PendingMessageStore } from './PendingMessageStore.js';
|
||||
import { computeObservationContentHash, findDuplicateObservation } from './observations/store.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource, sortPlatformSources } from '../../shared/platform-source.js';
|
||||
|
||||
function resolveCreateSessionArgs(
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): { customTitle?: string; platformSource: string } {
|
||||
return {
|
||||
customTitle,
|
||||
platformSource: platformSource ?? DEFAULT_PLATFORM_SOURCE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session data store for SDK sessions, observations, and summaries
|
||||
@@ -51,6 +62,7 @@ export class SessionStore {
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
this.addObservationContentHashColumn();
|
||||
this.addSessionCustomTitleColumn();
|
||||
this.addSessionPlatformSourceColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +90,7 @@ export class SessionStore {
|
||||
content_session_id TEXT UNIQUE NOT NULL,
|
||||
memory_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
platform_source TEXT NOT NULL DEFAULT 'claude',
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
@@ -875,6 +888,31 @@ export class SessionStore {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
|
||||
*/
|
||||
private addSessionPlatformSourceColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run(`ALTER TABLE sdk_sessions ADD COLUMN platform_source TEXT NOT NULL DEFAULT '${DEFAULT_PLATFORM_SOURCE}'`);
|
||||
logger.debug('DB', 'Added platform_source column to sdk_sessions table');
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
UPDATE sdk_sessions
|
||||
SET platform_source = '${DEFAULT_PLATFORM_SOURCE}'
|
||||
WHERE platform_source IS NULL OR platform_source = ''
|
||||
`);
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
@@ -1002,14 +1040,26 @@ export class SessionStore {
|
||||
subtitle: string | null;
|
||||
text: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number | null;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
|
||||
FROM observations
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
o.id,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.text,
|
||||
o.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
o.prompt_number,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -1030,16 +1080,30 @@ export class SessionStore {
|
||||
files_edited: string | null;
|
||||
notes: string | null;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number | null;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, request, investigated, learned, completed, next_steps,
|
||||
files_read, files_edited, notes, project, prompt_number,
|
||||
created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.files_read,
|
||||
ss.files_edited,
|
||||
ss.notes,
|
||||
ss.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
ss.prompt_number,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -1053,6 +1117,7 @@ export class SessionStore {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at: string;
|
||||
@@ -1063,6 +1128,7 @@ export class SessionStore {
|
||||
up.id,
|
||||
up.content_session_id,
|
||||
s.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
up.prompt_number,
|
||||
up.prompt_text,
|
||||
up.created_at,
|
||||
@@ -1079,18 +1145,74 @@ export class SessionStore {
|
||||
/**
|
||||
* Get all unique projects from the database (for web UI project filter)
|
||||
*/
|
||||
getAllProjects(): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
getAllProjects(platformSource?: string): string[] {
|
||||
const normalizedPlatformSource = platformSource ? normalizePlatformSource(platformSource) : undefined;
|
||||
let query = `
|
||||
SELECT DISTINCT project
|
||||
FROM sdk_sessions
|
||||
WHERE project IS NOT NULL AND project != ''
|
||||
ORDER BY project ASC
|
||||
`);
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
|
||||
const rows = stmt.all() as Array<{ project: string }>;
|
||||
if (normalizedPlatformSource) {
|
||||
query += ' AND COALESCE(platform_source, ?) = ?';
|
||||
params.push(DEFAULT_PLATFORM_SOURCE, normalizedPlatformSource);
|
||||
}
|
||||
|
||||
query += ' ORDER BY project ASC';
|
||||
|
||||
const rows = this.db.prepare(query).all(...params) as Array<{ project: string }>;
|
||||
return rows.map(row => row.project);
|
||||
}
|
||||
|
||||
getProjectCatalog(): {
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
projectsBySource: Record<string, string[]>;
|
||||
} {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
project,
|
||||
MAX(started_at_epoch) as latest_epoch
|
||||
FROM sdk_sessions
|
||||
WHERE project IS NOT NULL AND project != ''
|
||||
GROUP BY COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}'), project
|
||||
ORDER BY latest_epoch DESC
|
||||
`).all() as Array<{ platform_source: string; project: string; latest_epoch: number }>;
|
||||
|
||||
const projects: string[] = [];
|
||||
const seenProjects = new Set<string>();
|
||||
const projectsBySource: Record<string, string[]> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const source = normalizePlatformSource(row.platform_source);
|
||||
|
||||
if (!projectsBySource[source]) {
|
||||
projectsBySource[source] = [];
|
||||
}
|
||||
|
||||
if (!projectsBySource[source].includes(row.project)) {
|
||||
projectsBySource[source].push(row.project);
|
||||
}
|
||||
|
||||
if (!seenProjects.has(row.project)) {
|
||||
seenProjects.add(row.project);
|
||||
projects.push(row.project);
|
||||
}
|
||||
}
|
||||
|
||||
const sources = sortPlatformSources(Object.keys(projectsBySource));
|
||||
|
||||
return {
|
||||
projects,
|
||||
sources,
|
||||
projectsBySource: Object.fromEntries(
|
||||
sources.map(source => [source, projectsBySource[source] || []])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest user prompt with session info for a Claude session
|
||||
* Used for syncing prompts to Chroma during session initialization
|
||||
@@ -1100,6 +1222,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
@@ -1108,7 +1231,8 @@ export class SessionStore {
|
||||
SELECT
|
||||
up.*,
|
||||
s.memory_session_id,
|
||||
s.project
|
||||
s.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source
|
||||
FROM user_prompts up
|
||||
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
||||
WHERE up.content_session_id = ?
|
||||
@@ -1339,11 +1463,14 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string | null;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
custom_title: string | null;
|
||||
} | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title
|
||||
SELECT id, content_session_id, memory_session_id, project,
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
user_prompt, custom_title
|
||||
FROM sdk_sessions
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
@@ -1361,6 +1488,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
custom_title: string | null;
|
||||
started_at: string;
|
||||
@@ -1373,7 +1501,9 @@ export class SessionStore {
|
||||
|
||||
const placeholders = memorySessionIds.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title,
|
||||
SELECT id, content_session_id, memory_session_id, project,
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
user_prompt, custom_title,
|
||||
started_at, started_at_epoch, completed_at, completed_at_epoch, status
|
||||
FROM sdk_sessions
|
||||
WHERE memory_session_id IN (${placeholders})
|
||||
@@ -1418,9 +1548,17 @@ export class SessionStore {
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
createSDKSession(contentSessionId: string, project: string, userPrompt: string, customTitle?: string): number {
|
||||
createSDKSession(
|
||||
contentSessionId: string,
|
||||
project: string,
|
||||
userPrompt: string,
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
|
||||
const normalizedPlatformSource = normalizePlatformSource(resolved.platformSource);
|
||||
|
||||
// Session reuse: Return existing session ID if already created for this contentSessionId.
|
||||
const existing = this.db.prepare(`
|
||||
@@ -1436,12 +1574,17 @@ export class SessionStore {
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
// Backfill custom_title if provided and not yet set
|
||||
if (customTitle) {
|
||||
if (resolved.customTitle) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET custom_title = ?
|
||||
WHERE content_session_id = ? AND custom_title IS NULL
|
||||
`).run(customTitle, contentSessionId);
|
||||
`).run(resolved.customTitle, contentSessionId);
|
||||
}
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET platform_source = ?
|
||||
WHERE content_session_id = ?
|
||||
AND COALESCE(platform_source, '') != ?
|
||||
`).run(normalizedPlatformSource, contentSessionId, normalizedPlatformSource);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
@@ -1451,9 +1594,9 @@ export class SessionStore {
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
this.db.prepare(`
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
|
||||
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return new ID
|
||||
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
@@ -2233,9 +2376,9 @@ export class SessionStore {
|
||||
// Create new manual session
|
||||
const now = new Date();
|
||||
this.db.prepare(`
|
||||
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`).run(memorySessionId, contentSessionId, project, now.toISOString(), now.getTime());
|
||||
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, platform_source, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(memorySessionId, contentSessionId, project, DEFAULT_PLATFORM_SOURCE, now.toISOString(), now.getTime());
|
||||
|
||||
logger.info('SESSION', 'Created manual session', { memorySessionId, project });
|
||||
|
||||
@@ -2261,6 +2404,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source?: string;
|
||||
user_prompt: string;
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
@@ -2279,15 +2423,16 @@ export class SessionStore {
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO sdk_sessions (
|
||||
content_session_id, memory_session_id, project, user_prompt,
|
||||
content_session_id, memory_session_id, project, platform_source, user_prompt,
|
||||
started_at, started_at_epoch, completed_at, completed_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
session.content_session_id,
|
||||
session.memory_session_id,
|
||||
session.project,
|
||||
normalizePlatformSource(session.platform_source),
|
||||
session.user_prompt,
|
||||
session.started_at,
|
||||
session.started_at_epoch,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TableNameRow,
|
||||
SchemaVersion
|
||||
} from '../../../types/database.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE } from '../../../shared/platform-source.js';
|
||||
|
||||
/**
|
||||
* MigrationRunner handles all database schema migrations
|
||||
@@ -34,6 +35,7 @@ export class MigrationRunner {
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
this.addObservationContentHashColumn();
|
||||
this.addSessionCustomTitleColumn();
|
||||
this.addSessionPlatformSourceColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +63,7 @@ export class MigrationRunner {
|
||||
content_session_id TEXT UNIQUE NOT NULL,
|
||||
memory_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
platform_source TEXT NOT NULL DEFAULT 'claude',
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
@@ -863,4 +866,29 @@ export class MigrationRunner {
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
|
||||
*/
|
||||
private addSessionPlatformSourceColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run(`ALTER TABLE sdk_sessions ADD COLUMN platform_source TEXT NOT NULL DEFAULT '${DEFAULT_PLATFORM_SOURCE}'`);
|
||||
logger.debug('DB', 'Added platform_source column to sdk_sessions table');
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
UPDATE sdk_sessions
|
||||
SET platform_source = '${DEFAULT_PLATFORM_SOURCE}'
|
||||
WHERE platform_source IS NULL OR platform_source = ''
|
||||
`);
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource } from '../../../shared/platform-source.js';
|
||||
|
||||
function resolveCreateSessionArgs(
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): { customTitle?: string; platformSource: string } {
|
||||
return {
|
||||
customTitle,
|
||||
platformSource: platformSource ?? DEFAULT_PLATFORM_SOURCE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SDK session (idempotent - returns existing session ID if already exists)
|
||||
@@ -22,10 +33,13 @@ export function createSDKSession(
|
||||
contentSessionId: string,
|
||||
project: string,
|
||||
userPrompt: string,
|
||||
customTitle?: string
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
|
||||
const normalizedPlatformSource = normalizePlatformSource(resolved.platformSource);
|
||||
|
||||
// Check for existing session
|
||||
const existing = db.prepare(`
|
||||
@@ -41,12 +55,17 @@ export function createSDKSession(
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
// Backfill custom_title if provided and not yet set
|
||||
if (customTitle) {
|
||||
if (resolved.customTitle) {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET custom_title = ?
|
||||
WHERE content_session_id = ? AND custom_title IS NULL
|
||||
`).run(customTitle, contentSessionId);
|
||||
`).run(resolved.customTitle, contentSessionId);
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET platform_source = ?
|
||||
WHERE content_session_id = ?
|
||||
AND COALESCE(platform_source, '') != ?
|
||||
`).run(normalizedPlatformSource, contentSessionId, normalizedPlatformSource);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
@@ -56,9 +75,9 @@ export function createSDKSession(
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
|
||||
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return new ID
|
||||
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
|
||||
@@ -9,9 +9,11 @@ import { writeAgentsMd } from '../../utils/agents-md-utils.js';
|
||||
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
platformSource: string;
|
||||
cwd?: string;
|
||||
project?: string;
|
||||
lastUserMessage?: string;
|
||||
@@ -51,6 +53,7 @@ export class TranscriptEventProcessor {
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId,
|
||||
platformSource: normalizePlatformSource(watch.name),
|
||||
pendingTools: new Map()
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
@@ -181,7 +184,7 @@ export class TranscriptEventProcessor {
|
||||
sessionId: session.sessionId,
|
||||
cwd,
|
||||
prompt,
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,7 +253,7 @@ export class TranscriptEventProcessor {
|
||||
toolName,
|
||||
toolInput: this.maybeParseJson(fields.toolInput),
|
||||
toolResponse: this.maybeParseJson(fields.toolResponse),
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,7 +266,7 @@ export class TranscriptEventProcessor {
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
filePath,
|
||||
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,7 +308,7 @@ export class TranscriptEventProcessor {
|
||||
await sessionCompleteHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
await this.updateContext(session, watch);
|
||||
session.pendingTools.clear();
|
||||
@@ -325,7 +328,8 @@ export class TranscriptEventProcessor {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: session.sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
platformSource: session.platformSource
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -350,7 +354,7 @@ export class TranscriptEventProcessor {
|
||||
|
||||
try {
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
|
||||
@@ -117,15 +117,15 @@ export class TranscriptWatcher {
|
||||
const files = this.resolveWatchFiles(resolvedPath);
|
||||
|
||||
for (const filePath of files) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
await this.addTailer(filePath, watch, schema, true);
|
||||
}
|
||||
|
||||
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
|
||||
const timer = setInterval(async () => {
|
||||
const timer = setInterval(async () => {
|
||||
const newFiles = this.resolveWatchFiles(resolvedPath);
|
||||
for (const filePath of newFiles) {
|
||||
if (!this.tailers.has(filePath)) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
await this.addTailer(filePath, watch, schema, false);
|
||||
}
|
||||
}
|
||||
}, rescanIntervalMs);
|
||||
@@ -164,13 +164,20 @@ export class TranscriptWatcher {
|
||||
return /[*?[\]{}()]/.test(inputPath);
|
||||
}
|
||||
|
||||
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
|
||||
private async addTailer(
|
||||
filePath: string,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
initialDiscovery: boolean
|
||||
): Promise<void> {
|
||||
if (this.tailers.has(filePath)) return;
|
||||
|
||||
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||
|
||||
let offset = this.state.offsets[filePath] ?? 0;
|
||||
if (offset === 0 && watch.startAtEnd) {
|
||||
// `startAtEnd` is useful on worker startup to avoid replaying the full backlog,
|
||||
// but new transcript files must be read from byte 0 or we lose session_meta/user_message.
|
||||
if (offset === 0 && watch.startAtEnd && initialDiscovery) {
|
||||
try {
|
||||
offset = statSync(filePath).size;
|
||||
} catch {
|
||||
|
||||
@@ -115,6 +115,8 @@ import { SearchManager } from './worker/SearchManager.js';
|
||||
import { FormattingService } from './worker/FormattingService.js';
|
||||
import { TimelineService } from './worker/TimelineService.js';
|
||||
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './transcripts/config.js';
|
||||
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||
|
||||
// HTTP route handlers
|
||||
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
|
||||
@@ -179,6 +181,9 @@ export class WorkerService {
|
||||
// Chroma MCP manager (lazy - connects on first use)
|
||||
private chromaMcpManager: ChromaMcpManager | null = null;
|
||||
|
||||
// Transcript watcher for Codex and other transcript-based clients
|
||||
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||
|
||||
// Initialization tracking
|
||||
private initializationComplete: Promise<void>;
|
||||
private resolveInitialization!: () => void;
|
||||
@@ -421,6 +426,8 @@ export class WorkerService {
|
||||
this.resolveInitialization();
|
||||
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
||||
|
||||
await this.startTranscriptWatcher(settings);
|
||||
|
||||
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
||||
if (this.chromaMcpManager) {
|
||||
ChromaSync.backfillAllProjects().then(() => {
|
||||
@@ -519,6 +526,48 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start transcript watcher for Codex and other transcript-based clients.
|
||||
* This is intentionally non-fatal so Claude hooks remain usable even if
|
||||
* transcript ingestion is misconfigured.
|
||||
*/
|
||||
private async startTranscriptWatcher(settings: ReturnType<typeof SettingsDefaultsManager.loadFromFile>): Promise<void> {
|
||||
const transcriptsEnabled = settings.CLAUDE_MEM_TRANSCRIPTS_ENABLED !== 'false';
|
||||
if (!transcriptsEnabled) {
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher disabled via CLAUDE_MEM_TRANSCRIPTS_ENABLED=false');
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = settings.CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||
const resolvedConfigPath = expandHomePath(configPath);
|
||||
|
||||
try {
|
||||
if (!existsSync(resolvedConfigPath)) {
|
||||
writeSampleConfig(configPath);
|
||||
logger.info('TRANSCRIPT', 'Created default transcript watch config', {
|
||||
configPath: resolvedConfigPath
|
||||
});
|
||||
}
|
||||
|
||||
const transcriptConfig = loadTranscriptWatchConfig(configPath);
|
||||
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
|
||||
|
||||
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
|
||||
await this.transcriptWatcher.start();
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher started', {
|
||||
configPath: resolvedConfigPath,
|
||||
statePath,
|
||||
watches: transcriptConfig.watches.length
|
||||
});
|
||||
} catch (error) {
|
||||
this.transcriptWatcher?.stop();
|
||||
this.transcriptWatcher = null;
|
||||
logger.error('TRANSCRIPT', 'Failed to start transcript watcher (continuing without Codex ingestion)', {
|
||||
configPath: resolvedConfigPath
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate agent based on provider settings.
|
||||
* Same logic as SessionRoutes.getActiveAgent() for consistency.
|
||||
@@ -903,6 +952,12 @@ export class WorkerService {
|
||||
* Shutdown the worker service
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.transcriptWatcher) {
|
||||
this.transcriptWatcher.stop();
|
||||
this.transcriptWatcher = null;
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher stopped');
|
||||
}
|
||||
|
||||
// Stop orphan reaper before shutdown (Issue #737)
|
||||
if (this.stopOrphanReaper) {
|
||||
this.stopOrphanReaper();
|
||||
@@ -957,7 +1012,7 @@ export class WorkerService {
|
||||
* @param port - The TCP port (used for port-in-use checks and daemon spawn)
|
||||
* @returns true if worker is healthy (existing or newly started), false on failure
|
||||
*/
|
||||
async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
export async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
// Clean stale PID file first (cheap: 1 fs read + 1 signal-0 check)
|
||||
const pidFileStatus = cleanStalePidFile();
|
||||
if (pidFileStatus === 'alive') {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ActiveSession {
|
||||
contentSessionId: string; // User's Claude Code session being observed
|
||||
memorySessionId: string | null; // Memory agent's session ID for resume
|
||||
project: string;
|
||||
platformSource: string;
|
||||
userPrompt: string;
|
||||
pendingMessages: PendingMessage[]; // Deprecated: now using persistent store, kept for compatibility
|
||||
abortController: AbortController;
|
||||
@@ -97,6 +98,7 @@ export interface PaginationParams {
|
||||
offset: number;
|
||||
limit: number;
|
||||
project?: string;
|
||||
platformSource?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -117,6 +119,7 @@ export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string; // Renamed from sdk_session_id
|
||||
project: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
@@ -135,6 +138,7 @@ export interface Summary {
|
||||
id: number;
|
||||
session_id: string; // content_session_id (from JOIN)
|
||||
project: string;
|
||||
platform_source: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
@@ -149,6 +153,7 @@ export interface UserPrompt {
|
||||
id: number;
|
||||
content_session_id: string; // Renamed from claude_session_id
|
||||
project: string; // From JOIN with sdk_sessions
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at: string;
|
||||
@@ -159,6 +164,7 @@ export interface DBSession {
|
||||
id: number;
|
||||
content_session_id: string; // Renamed from claude_session_id
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
memory_session_id: string | null; // Renamed from sdk_session_id
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
|
||||
@@ -71,14 +71,54 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated observations
|
||||
*/
|
||||
getObservations(offset: number, limit: number, project?: string): PaginatedResult<Observation> {
|
||||
const result = this.paginate<Observation>(
|
||||
'observations',
|
||||
'id, memory_session_id, project, type, title, subtitle, narrative, text, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch',
|
||||
getObservations(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Observation> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
let query = `
|
||||
SELECT
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
o.project,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.text,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.prompt_number,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
conditions.push('o.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
const results = db.prepare(query).all(...params) as Observation[];
|
||||
const result: PaginatedResult<Observation> = {
|
||||
items: results.slice(0, limit),
|
||||
hasMore: results.length > limit,
|
||||
offset,
|
||||
limit,
|
||||
project
|
||||
);
|
||||
limit
|
||||
};
|
||||
|
||||
// Strip project paths from file paths before returning
|
||||
return {
|
||||
@@ -90,13 +130,14 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated summaries
|
||||
*/
|
||||
getSummaries(offset: number, limit: number, project?: string): PaginatedResult<Summary> {
|
||||
getSummaries(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Summary> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
ss.id,
|
||||
s.content_session_id as session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
@@ -110,11 +151,22 @@ export class PaginationHelper {
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
query += ' WHERE ss.project = ?';
|
||||
conditions.push('ss.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
@@ -132,21 +184,40 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
getPrompts(offset: number, limit: number, project?: string): PaginatedResult<UserPrompt> {
|
||||
getPrompts(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<UserPrompt> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
let query = `
|
||||
SELECT up.id, up.content_session_id, s.project, up.prompt_number, up.prompt_text, up.created_at, up.created_at_epoch
|
||||
SELECT
|
||||
up.id,
|
||||
up.content_session_id,
|
||||
s.project,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
up.prompt_number,
|
||||
up.prompt_text,
|
||||
up.created_at,
|
||||
up.created_at_epoch
|
||||
FROM user_prompts up
|
||||
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
query += ' WHERE s.project = ?';
|
||||
conditions.push('s.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY up.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ export class SessionManager {
|
||||
});
|
||||
session.project = dbSession.project;
|
||||
}
|
||||
if (dbSession.platform_source && dbSession.platform_source !== session.platformSource) {
|
||||
session.platformSource = dbSession.platform_source;
|
||||
}
|
||||
|
||||
// Update userPrompt for continuation prompts
|
||||
if (currentUserPrompt) {
|
||||
@@ -144,6 +147,7 @@ export class SessionManager {
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: null, // Always start fresh - SDK will capture new ID
|
||||
project: dbSession.project,
|
||||
platformSource: dbSession.platform_source,
|
||||
userPrompt,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
|
||||
@@ -223,6 +223,7 @@ async function syncAndBroadcastObservations(
|
||||
id: obsId,
|
||||
memory_session_id: session.memorySessionId,
|
||||
session_id: session.contentSessionId,
|
||||
platform_source: session.platformSource,
|
||||
type: obs.type,
|
||||
title: obs.title,
|
||||
subtitle: obs.subtitle,
|
||||
@@ -312,6 +313,7 @@ async function syncAndBroadcastSummary(
|
||||
broadcastSummary(worker, {
|
||||
id: result.summaryId,
|
||||
session_id: session.contentSessionId,
|
||||
platform_source: session.platformSource,
|
||||
request: summary!.request,
|
||||
investigated: summary!.investigated,
|
||||
learned: summary!.learned,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ObservationSSEPayload {
|
||||
id: number;
|
||||
memory_session_id: string | null;
|
||||
session_id: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -50,6 +51,7 @@ export interface ObservationSSEPayload {
|
||||
export interface SummarySSEPayload {
|
||||
id: number;
|
||||
session_id: string;
|
||||
platform_source: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ export class SessionEventBroadcaster {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { AppError } from '../../server/ErrorHandler.js';
|
||||
|
||||
export abstract class BaseRouteHandler {
|
||||
/**
|
||||
@@ -80,7 +81,18 @@ export abstract class BaseRouteHandler {
|
||||
protected handleError(res: Response, error: Error, context?: string): void {
|
||||
logger.failure('WORKER', context || 'Request failed', {}, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message });
|
||||
const statusCode = error instanceof AppError ? error.statusCode : 500;
|
||||
const response: Record<string, unknown> = { error: error.message };
|
||||
|
||||
if (error instanceof AppError && error.code) {
|
||||
response.code = error.code;
|
||||
}
|
||||
|
||||
if (error instanceof AppError && error.details !== undefined) {
|
||||
response.details = error.details;
|
||||
}
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated observations
|
||||
*/
|
||||
private handleGetObservations = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getObservations(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getObservations(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -75,8 +75,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated summaries
|
||||
*/
|
||||
private handleGetSummaries = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getSummaries(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getSummaries(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -84,8 +84,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
private handleGetPrompts = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getPrompts(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getPrompts(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -256,19 +256,19 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* GET /api/projects
|
||||
*/
|
||||
private handleGetProjects = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
const store = this.dbManager.getSessionStore();
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT project
|
||||
FROM observations
|
||||
WHERE project IS NOT NULL
|
||||
GROUP BY project
|
||||
ORDER BY MAX(created_at_epoch) DESC
|
||||
`).all() as Array<{ project: string }>;
|
||||
if (platformSource) {
|
||||
res.json({
|
||||
projects: store.getAllProjects(platformSource),
|
||||
sources: [platformSource],
|
||||
projectsBySource: { [platformSource]: store.getAllProjects(platformSource) }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = rows.map(row => row.project);
|
||||
|
||||
res.json({ projects });
|
||||
res.json(store.getProjectCatalog());
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -299,12 +299,13 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
/**
|
||||
* Parse pagination parameters from request query
|
||||
*/
|
||||
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
|
||||
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string; platformSource?: string } {
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
|
||||
const project = req.query.project as string | undefined;
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
return { offset, limit, project };
|
||||
return { offset, limit, project, platformSource };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,6 +167,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleContextPreview = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const projectName = req.query.project as string;
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
if (!projectName) {
|
||||
this.badRequest(res, 'Project parameter is required');
|
||||
@@ -183,7 +184,9 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
const contextText = await generateContext(
|
||||
{
|
||||
session_id: 'preview-' + Date.now(),
|
||||
cwd: cwd
|
||||
cwd: cwd,
|
||||
projects: [projectName],
|
||||
platform_source: platformSource
|
||||
},
|
||||
true // useColors=true for ANSI terminal output
|
||||
);
|
||||
@@ -209,6 +212,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
const projectsParam = (req.query.projects as string) || (req.query.project as string);
|
||||
const useColors = req.query.colors === 'true';
|
||||
const full = req.query.full === 'true';
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
if (!projectsParam) {
|
||||
this.badRequest(res, 'Project(s) parameter is required');
|
||||
@@ -236,7 +240,8 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
session_id: 'context-inject-' + Date.now(),
|
||||
cwd: cwd,
|
||||
projects: projects,
|
||||
full
|
||||
full,
|
||||
platform_source: platformSource
|
||||
},
|
||||
useColors
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import { PrivacyCheckValidator } from '../../validation/PrivacyCheckValidator.js
|
||||
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
|
||||
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
|
||||
import { getProjectName } from '../../../../utils/project-name.js';
|
||||
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
|
||||
|
||||
export class SessionRoutes extends BaseRouteHandler {
|
||||
private completionHandler: SessionCompletionHandler;
|
||||
@@ -348,6 +350,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
id: latestPrompt.id,
|
||||
content_session_id: latestPrompt.content_session_id,
|
||||
project: latestPrompt.project,
|
||||
platform_source: latestPrompt.platform_source,
|
||||
prompt_number: latestPrompt.prompt_number,
|
||||
prompt_text: latestPrompt.prompt_text,
|
||||
created_at_epoch: latestPrompt.created_at_epoch
|
||||
@@ -497,6 +500,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
const project = typeof cwd === 'string' && cwd.trim() ? getProjectName(cwd) : '';
|
||||
|
||||
if (!contentSessionId) {
|
||||
return this.badRequest(res, 'Missing contentSessionId');
|
||||
@@ -531,7 +536,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Get or create session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, '', undefined, platformSource);
|
||||
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
|
||||
|
||||
// Privacy check: skip if user prompt was entirely private
|
||||
@@ -595,6 +600,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { contentSessionId, last_assistant_message } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
|
||||
if (!contentSessionId) {
|
||||
return this.badRequest(res, 'Missing contentSessionId');
|
||||
@@ -603,7 +609,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Get or create session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
|
||||
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
|
||||
|
||||
// Privacy check: skip if user prompt was entirely private
|
||||
@@ -643,6 +649,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { contentSessionId } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
|
||||
logger.info('HTTP', '→ POST /api/sessions/complete', { contentSessionId });
|
||||
|
||||
@@ -654,7 +661,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
|
||||
// Look up sessionDbId from contentSessionId (createSDKSession is idempotent)
|
||||
// Pass empty strings - we only need the ID lookup, not to create a new session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
|
||||
|
||||
// Check if session is in the active sessions map
|
||||
const activeSession = this.sessionManager.getSession(sessionDbId);
|
||||
@@ -698,11 +705,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
// may omit prompt/project in their payload (#838, #1049)
|
||||
const project = req.body.project || 'unknown';
|
||||
const prompt = req.body.prompt || '[media prompt]';
|
||||
const platformSource = req.body.platformSource || 'claude';
|
||||
const customTitle = req.body.customTitle || undefined;
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
|
||||
contentSessionId,
|
||||
project,
|
||||
platformSource,
|
||||
prompt_length: prompt?.length,
|
||||
customTitle
|
||||
});
|
||||
@@ -715,7 +724,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Step 1: Create/get SDK session (idempotent INSERT OR IGNORE)
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle);
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle, platformSource);
|
||||
|
||||
// Verify session creation with DB lookup
|
||||
const dbSession = store.getSessionById(sessionDbId);
|
||||
|
||||
@@ -76,11 +76,13 @@ export class ViewerRoutes extends BaseRouteHandler {
|
||||
// Add client to broadcaster
|
||||
this.sseBroadcaster.addClient(res);
|
||||
|
||||
// Send initial_load event with projects list
|
||||
const allProjects = this.dbManager.getSessionStore().getAllProjects();
|
||||
// Send initial_load event with project/source catalog
|
||||
const projectCatalog = this.dbManager.getSessionStore().getProjectCatalog();
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'initial_load',
|
||||
projects: allProjects,
|
||||
projects: projectCatalog.projects,
|
||||
sources: projectCatalog.sources,
|
||||
projectsBySource: projectCatalog.projectsBySource,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string; // 'true' | 'false' - enable transcript watcher ingestion for Codex and other transcript-based clients
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; // Path to transcript watcher config JSON
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
|
||||
// Exclusion Settings
|
||||
@@ -108,6 +110,8 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
|
||||
// Exclusion Settings
|
||||
|
||||
36
src/shared/platform-source.ts
Normal file
36
src/shared/platform-source.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const DEFAULT_PLATFORM_SOURCE = 'claude';
|
||||
|
||||
function sanitizeRawSource(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
export function normalizePlatformSource(value?: string | null): string {
|
||||
if (!value) return DEFAULT_PLATFORM_SOURCE;
|
||||
|
||||
const source = sanitizeRawSource(value);
|
||||
if (!source) return DEFAULT_PLATFORM_SOURCE;
|
||||
|
||||
if (source === 'transcript') return 'codex';
|
||||
if (source.includes('codex')) return 'codex';
|
||||
if (source.includes('cursor')) return 'cursor';
|
||||
if (source.includes('claude')) return 'claude';
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export function sortPlatformSources(sources: string[]): string[] {
|
||||
const priority = ['claude', 'codex', 'cursor'];
|
||||
|
||||
return [...sources].sort((a, b) => {
|
||||
const aPriority = priority.indexOf(a);
|
||||
const bPriority = priority.indexOf(b);
|
||||
|
||||
if (aPriority !== -1 || bPriority !== -1) {
|
||||
if (aPriority === -1) return 1;
|
||||
if (bPriority === -1) return -1;
|
||||
return aPriority - bPriority;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export interface UserPromptRecord {
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
project?: string; // From JOIN with sdk_sessions
|
||||
platform_source?: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
@@ -115,6 +116,7 @@ export interface LatestPromptResult {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
|
||||
@@ -355,6 +355,14 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
@@ -549,6 +557,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-border-focus);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.source-tab.active {
|
||||
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
|
||||
border-color: var(--color-bg-button);
|
||||
color: var(--color-text-button);
|
||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
|
||||
}
|
||||
|
||||
.settings-btn,
|
||||
.theme-toggle-btn {
|
||||
background: var(--color-bg-card);
|
||||
@@ -887,6 +931,49 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.source-claude {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c25a00;
|
||||
border-color: rgba(255, 138, 61, 0.22);
|
||||
}
|
||||
|
||||
.source-codex {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #0f5ba7;
|
||||
border-color: rgba(33, 150, 243, 0.24);
|
||||
}
|
||||
|
||||
.source-cursor {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-claude {
|
||||
color: #ffb067;
|
||||
border-color: rgba(255, 176, 103, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-codex {
|
||||
color: #8fc7ff;
|
||||
border-color: rgba(143, 199, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-cursor {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1483,6 +1570,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1582,11 @@
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hide icon links (docs, github, twitter) on tablet */
|
||||
.icon-link {
|
||||
display: none;
|
||||
@@ -1544,6 +1640,27 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logomark {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -1732,6 +1849,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-selector select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-selector select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
@@ -2873,4 +2995,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -13,39 +13,57 @@ import { mergeAndDeduplicateByProject } from './utils/data';
|
||||
|
||||
export function App() {
|
||||
const [currentFilter, setCurrentFilter] = useState('');
|
||||
const [currentSource, setCurrentSource] = useState('all');
|
||||
const [contextPreviewOpen, setContextPreviewOpen] = useState(false);
|
||||
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
||||
const [paginatedObservations, setPaginatedObservations] = useState<Observation[]>([]);
|
||||
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
|
||||
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
|
||||
|
||||
const { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected } = useSSE();
|
||||
const { observations, summaries, prompts, projects, sources, projectsBySource, isProcessing, queueDepth, isConnected } = useSSE();
|
||||
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
|
||||
const { stats, refreshStats } = useStats();
|
||||
const { preference, resolvedTheme, setThemePreference } = useTheme();
|
||||
const pagination = usePagination(currentFilter);
|
||||
const pagination = usePagination(currentFilter, currentSource);
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (currentSource === 'all') {
|
||||
return projects;
|
||||
}
|
||||
|
||||
return projectsBySource[currentSource] || [];
|
||||
}, [currentSource, projects, projectsBySource]);
|
||||
|
||||
const matchesSelection = useCallback((item: { project: string; platform_source: string }) => {
|
||||
const matchesProject = !currentFilter || item.project === currentFilter;
|
||||
const matchesSource = currentSource === 'all' || (item.platform_source || 'claude') === currentSource;
|
||||
return matchesProject && matchesSource;
|
||||
}, [currentFilter, currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFilter && !availableProjects.includes(currentFilter)) {
|
||||
setCurrentFilter('');
|
||||
}
|
||||
}, [availableProjects, currentFilter]);
|
||||
|
||||
// Merge SSE live data with paginated data, filtering by project when active
|
||||
const allObservations = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? observations.filter(o => o.project === currentFilter)
|
||||
: observations;
|
||||
return mergeAndDeduplicateByProject(live, paginatedObservations);
|
||||
}, [observations, paginatedObservations, currentFilter]);
|
||||
const live = observations.filter(matchesSelection);
|
||||
const paginated = paginatedObservations.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [observations, paginatedObservations, matchesSelection]);
|
||||
|
||||
const allSummaries = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? summaries.filter(s => s.project === currentFilter)
|
||||
: summaries;
|
||||
return mergeAndDeduplicateByProject(live, paginatedSummaries);
|
||||
}, [summaries, paginatedSummaries, currentFilter]);
|
||||
const live = summaries.filter(matchesSelection);
|
||||
const paginated = paginatedSummaries.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [summaries, paginatedSummaries, matchesSelection]);
|
||||
|
||||
const allPrompts = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? prompts.filter(p => p.project === currentFilter)
|
||||
: prompts;
|
||||
return mergeAndDeduplicateByProject(live, paginatedPrompts);
|
||||
}, [prompts, paginatedPrompts, currentFilter]);
|
||||
const live = prompts.filter(matchesSelection);
|
||||
const paginated = paginatedPrompts.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [prompts, paginatedPrompts, matchesSelection]);
|
||||
|
||||
// Toggle context preview modal
|
||||
const toggleContextPreview = useCallback(() => {
|
||||
@@ -78,24 +96,27 @@ export function App() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load more data:', error);
|
||||
}
|
||||
}, [currentFilter, pagination.observations, pagination.summaries, pagination.prompts]);
|
||||
}, [pagination.observations, pagination.summaries, pagination.prompts]);
|
||||
|
||||
// Reset paginated data and load first page when filter changes
|
||||
// Reset paginated data and load first page when project/source changes
|
||||
useEffect(() => {
|
||||
setPaginatedObservations([]);
|
||||
setPaginatedSummaries([]);
|
||||
setPaginatedPrompts([]);
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFilter]);
|
||||
}, [currentFilter, currentSource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
isConnected={isConnected}
|
||||
projects={projects}
|
||||
projects={availableProjects}
|
||||
sources={sources}
|
||||
currentFilter={currentFilter}
|
||||
currentSource={currentSource}
|
||||
onFilterChange={setCurrentFilter}
|
||||
onSourceChange={setCurrentSource}
|
||||
isProcessing={isProcessing}
|
||||
queueDepth={queueDepth}
|
||||
themePreference={preference}
|
||||
|
||||
@@ -136,7 +136,17 @@ export function ContextSettingsModal({
|
||||
}, [settings]);
|
||||
|
||||
// Get context preview based on current form state
|
||||
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
|
||||
const {
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
projects,
|
||||
sources,
|
||||
selectedSource,
|
||||
setSelectedSource,
|
||||
selectedProject,
|
||||
setSelectedProject
|
||||
} = useContextPreview(formState);
|
||||
|
||||
const updateSetting = useCallback((key: keyof Settings, value: string) => {
|
||||
const newState = { ...formState, [key]: value };
|
||||
@@ -174,10 +184,23 @@ export function ContextSettingsModal({
|
||||
<h2>Settings</h2>
|
||||
<div className="header-controls">
|
||||
<label className="preview-selector">
|
||||
Preview for:
|
||||
Source:
|
||||
<select
|
||||
value={selectedSource || ''}
|
||||
onChange={(e) => setSelectedSource(e.target.value)}
|
||||
disabled={sources.length === 0}
|
||||
>
|
||||
{sources.map(source => (
|
||||
<option key={source} value={source}>{source}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="preview-selector">
|
||||
Project:
|
||||
<select
|
||||
value={selectedProject || ''}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map(project => (
|
||||
<option key={project} value={project}>{project}</option>
|
||||
|
||||
@@ -7,8 +7,11 @@ import { useSpinningFavicon } from '../hooks/useSpinningFavicon';
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
currentFilter: string;
|
||||
currentSource: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
onSourceChange: (source: string) => void;
|
||||
isProcessing: boolean;
|
||||
queueDepth: number;
|
||||
themePreference: ThemePreference;
|
||||
@@ -16,11 +19,26 @@ interface HeaderProps {
|
||||
onContextPreviewToggle: () => void;
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: string): string {
|
||||
if (source === 'all') return 'All';
|
||||
if (source === 'claude') return 'Claude';
|
||||
if (source === 'codex') return 'Codex';
|
||||
return source.charAt(0).toUpperCase() + source.slice(1);
|
||||
}
|
||||
|
||||
function buildSourceTabs(sources: string[]): string[] {
|
||||
const merged = ['all', 'claude', 'codex', ...sources];
|
||||
return Array.from(new Set(merged.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function Header({
|
||||
isConnected,
|
||||
projects,
|
||||
sources,
|
||||
currentFilter,
|
||||
currentSource,
|
||||
onFilterChange,
|
||||
onSourceChange,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
themePreference,
|
||||
@@ -28,20 +46,36 @@ export function Header({
|
||||
onContextPreviewToggle
|
||||
}: HeaderProps) {
|
||||
useSpinningFavicon(isProcessing);
|
||||
const availableSources = buildSourceTabs(sources);
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<h1>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
||||
{queueDepth > 0 && (
|
||||
<div className="queue-bubble">
|
||||
{queueDepth}
|
||||
</div>
|
||||
)}
|
||||
<div className="header-main">
|
||||
<h1>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
||||
{queueDepth > 0 && (
|
||||
<div className="queue-bubble">
|
||||
{queueDepth}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="logo-text">claude-mem</span>
|
||||
</h1>
|
||||
<div className="source-tabs" role="tablist" aria-label="Context source tabs">
|
||||
{availableSources.map(source => (
|
||||
<button
|
||||
key={source}
|
||||
type="button"
|
||||
className={`source-tab ${currentSource === source ? 'active' : ''}`}
|
||||
onClick={() => onSourceChange(source)}
|
||||
aria-pressed={currentSource === source}
|
||||
>
|
||||
{formatSourceLabel(source)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="logo-text">claude-mem</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="status">
|
||||
<a
|
||||
href="https://docs.claude-mem.ai"
|
||||
|
||||
@@ -52,6 +52,9 @@ export function ObservationCard({ observation }: ObservationCardProps) {
|
||||
<span className={`card-type type-${observation.type}`}>
|
||||
{observation.type}
|
||||
</span>
|
||||
<span className={`card-source source-${observation.platform_source || 'claude'}`}>
|
||||
{observation.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="card-project">{observation.project}</span>
|
||||
</div>
|
||||
<div className="view-mode-toggles">
|
||||
|
||||
@@ -14,6 +14,9 @@ export function PromptCard({ prompt }: PromptCardProps) {
|
||||
<div className="card-header">
|
||||
<div className="card-header-left">
|
||||
<span className="card-type">Prompt</span>
|
||||
<span className={`card-source source-${prompt.platform_source || 'claude'}`}>
|
||||
{prompt.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="card-project">{prompt.project}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ export function SummaryCard({ summary }: SummaryCardProps) {
|
||||
<header className="summary-card-header">
|
||||
<div className="summary-badge-row">
|
||||
<span className="card-type summary-badge">Session Summary</span>
|
||||
<span className={`card-source source-${summary.platform_source || 'claude'}`}>
|
||||
{summary.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="summary-project-badge">{summary.project}</span>
|
||||
</div>
|
||||
{summary.request && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Settings } from '../types';
|
||||
import type { ProjectCatalog, Settings } from '../types';
|
||||
|
||||
interface UseContextPreviewResult {
|
||||
preview: string;
|
||||
@@ -7,15 +7,31 @@ interface UseContextPreviewResult {
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
selectedSource: string | null;
|
||||
setSelectedSource: (source: string) => void;
|
||||
selectedProject: string | null;
|
||||
setSelectedProject: (project: string) => void;
|
||||
}
|
||||
|
||||
function getPreferredSource(sources: string[]): string | null {
|
||||
if (sources.includes('claude')) return 'claude';
|
||||
if (sources.includes('codex')) return 'codex';
|
||||
return sources[0] || null;
|
||||
}
|
||||
|
||||
function withDefaultSources(sources: string[]): string[] {
|
||||
const merged = ['claude', 'codex', ...sources];
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
const [preview, setPreview] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({ projects: [], sources: [], projectsBySource: {} });
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
|
||||
// Fetch projects on mount
|
||||
@@ -23,11 +39,27 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const data = await response.json();
|
||||
if (data.projects && data.projects.length > 0) {
|
||||
setProjects(data.projects);
|
||||
setSelectedProject(data.projects[0]); // Default to first project
|
||||
const data = await response.json() as ProjectCatalog;
|
||||
const nextCatalog: ProjectCatalog = {
|
||||
projects: data.projects || [],
|
||||
sources: withDefaultSources(data.sources || []),
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
};
|
||||
|
||||
setCatalog(nextCatalog);
|
||||
|
||||
const preferredSource = getPreferredSource(nextCatalog.sources);
|
||||
setSelectedSource(preferredSource);
|
||||
|
||||
if (preferredSource) {
|
||||
const sourceProjects = nextCatalog.projectsBySource[preferredSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(sourceProjects[0] || null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects(nextCatalog.projects);
|
||||
setSelectedProject(nextCatalog.projects[0] || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch projects:', err);
|
||||
}
|
||||
@@ -35,6 +67,18 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setProjects(catalog.projects);
|
||||
setSelectedProject(prev => (prev && catalog.projects.includes(prev) ? prev : catalog.projects[0] || null));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceProjects = catalog.projectsBySource[selectedSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(prev => (prev && sourceProjects.includes(prev) ? prev : sourceProjects[0] || null));
|
||||
}, [catalog, selectedSource]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
setPreview('No project selected');
|
||||
@@ -48,6 +92,10 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
project: selectedProject
|
||||
});
|
||||
|
||||
if (selectedSource) {
|
||||
params.append('platformSource', selectedSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/context/preview?${params}`);
|
||||
const text = await response.text();
|
||||
|
||||
@@ -58,7 +106,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [selectedProject]);
|
||||
}, [selectedProject, selectedSource]);
|
||||
|
||||
// Debounced refresh when settings or selectedProject change
|
||||
useEffect(() => {
|
||||
@@ -68,5 +116,16 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [settings, refresh]);
|
||||
|
||||
return { preview, isLoading, error, refresh, projects, selectedProject, setSelectedProject };
|
||||
return {
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
projects,
|
||||
sources: catalog.sources,
|
||||
selectedSource,
|
||||
setSelectedSource,
|
||||
selectedProject,
|
||||
setSelectedProject
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type DataItem = Observation | Summary | UserPrompt;
|
||||
/**
|
||||
* Generic pagination hook for observations, summaries, and prompts
|
||||
*/
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string) {
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string, currentSource: string) {
|
||||
const [state, setState] = useState<PaginationState>({
|
||||
isLoading: false,
|
||||
hasMore: true
|
||||
@@ -22,7 +22,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
// Track offset and filter in refs to handle synchronous resets
|
||||
const offsetRef = useRef(0);
|
||||
const lastFilterRef = useRef(currentFilter);
|
||||
const lastSelectionRef = useRef(`${currentSource}::${currentFilter}`);
|
||||
const stateRef = useRef(state);
|
||||
|
||||
/**
|
||||
@@ -31,16 +31,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
||||
// Check if filter changed - if so, reset pagination synchronously
|
||||
const filterChanged = lastFilterRef.current !== currentFilter;
|
||||
const selectionKey = `${currentSource}::${currentFilter}`;
|
||||
const filterChanged = lastSelectionRef.current !== selectionKey;
|
||||
|
||||
if (filterChanged) {
|
||||
offsetRef.current = 0;
|
||||
lastFilterRef.current = currentFilter;
|
||||
lastSelectionRef.current = selectionKey;
|
||||
|
||||
// Reset state both in React state and ref synchronously
|
||||
const newState = { isLoading: false, hasMore: true };
|
||||
setState(newState);
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
}
|
||||
|
||||
// Prevent concurrent requests using ref (always current)
|
||||
@@ -49,6 +50,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
return [];
|
||||
}
|
||||
|
||||
stateRef.current = { ...stateRef.current, isLoading: true };
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Build query params using current offset from ref
|
||||
@@ -62,6 +64,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
params.append('project', currentFilter);
|
||||
}
|
||||
|
||||
if (currentSource && currentSource !== 'all') {
|
||||
params.append('platformSource', currentSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -70,6 +76,13 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
const data = await response.json() as { items: DataItem[], hasMore: boolean };
|
||||
|
||||
const nextState = {
|
||||
...stateRef.current,
|
||||
isLoading: false,
|
||||
hasMore: data.hasMore
|
||||
};
|
||||
stateRef.current = nextState;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
@@ -80,7 +93,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
offsetRef.current += UI.PAGINATION_PAGE_SIZE;
|
||||
|
||||
return data.items;
|
||||
}, [currentFilter, endpoint, dataType]);
|
||||
}, [currentFilter, currentSource, endpoint, dataType]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -91,10 +104,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
/**
|
||||
* Hook for paginating observations
|
||||
*/
|
||||
export function usePagination(currentFilter: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter);
|
||||
export function usePagination(currentFilter: string, currentSource: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter, currentSource);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter, currentSource);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter, currentSource);
|
||||
|
||||
return {
|
||||
observations,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent } from '../types';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent, ProjectCatalog } from '../types';
|
||||
import { API_ENDPOINTS } from '../constants/api';
|
||||
import { TIMING } from '../constants/timing';
|
||||
|
||||
@@ -7,16 +7,42 @@ export function useSSE() {
|
||||
const [observations, setObservations] = useState<Observation[]>([]);
|
||||
const [summaries, setSummaries] = useState<Summary[]>([]);
|
||||
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({
|
||||
projects: [],
|
||||
sources: [],
|
||||
projectsBySource: {}
|
||||
});
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [queueDepth, setQueueDepth] = useState(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const updateCatalogForItem = (project: string, platformSource: string) => {
|
||||
setCatalog(prev => {
|
||||
const nextProjects = prev.projects.includes(project)
|
||||
? prev.projects
|
||||
: [...prev.projects, project];
|
||||
const nextSources = prev.sources.includes(platformSource)
|
||||
? prev.sources
|
||||
: [...prev.sources, platformSource];
|
||||
const sourceProjects = prev.projectsBySource[platformSource] || [];
|
||||
|
||||
return {
|
||||
projects: nextProjects,
|
||||
sources: nextSources,
|
||||
projectsBySource: {
|
||||
...prev.projectsBySource,
|
||||
[platformSource]: sourceProjects.includes(project)
|
||||
? sourceProjects
|
||||
: [...sourceProjects, project]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
// Clean up existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
@@ -27,7 +53,6 @@ export function useSSE() {
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connected');
|
||||
setIsConnected(true);
|
||||
// Clear any pending reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
@@ -38,9 +63,8 @@ export function useSSE() {
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = undefined; // Clear before reconnecting
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
console.log('[SSE] Attempting to reconnect...');
|
||||
connect();
|
||||
}, TIMING.SSE_RECONNECT_DELAY_MS);
|
||||
@@ -52,32 +76,37 @@ export function useSSE() {
|
||||
switch (data.type) {
|
||||
case 'initial_load':
|
||||
console.log('[SSE] Initial load:', {
|
||||
projects: data.projects?.length || 0
|
||||
projects: data.projects?.length || 0,
|
||||
sources: data.sources?.length || 0
|
||||
});
|
||||
setCatalog({
|
||||
projects: data.projects || [],
|
||||
sources: data.sources || [],
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
});
|
||||
// Only load projects list - data will come via pagination
|
||||
setProjects(data.projects || []);
|
||||
break;
|
||||
|
||||
case 'new_observation':
|
||||
if (data.observation) {
|
||||
console.log('[SSE] New observation:', data.observation.id);
|
||||
setObservations(prev => [data.observation, ...prev]);
|
||||
updateCatalogForItem(data.observation.project, data.observation.platform_source || 'claude');
|
||||
setObservations(prev => [data.observation!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_summary':
|
||||
if (data.summary) {
|
||||
const summary = data.summary;
|
||||
console.log('[SSE] New summary:', summary.id);
|
||||
setSummaries(prev => [summary, ...prev]);
|
||||
console.log('[SSE] New summary:', data.summary.id);
|
||||
updateCatalogForItem(data.summary.project, data.summary.platform_source || 'claude');
|
||||
setSummaries(prev => [data.summary!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_prompt':
|
||||
if (data.prompt) {
|
||||
const prompt = data.prompt;
|
||||
console.log('[SSE] New prompt:', prompt.id);
|
||||
setPrompts(prev => [prompt, ...prev]);
|
||||
console.log('[SSE] New prompt:', data.prompt.id);
|
||||
updateCatalogForItem(data.prompt.project, data.prompt.platform_source || 'claude');
|
||||
setPrompts(prev => [data.prompt!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -94,7 +123,6 @@ export function useSSE() {
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
@@ -105,5 +133,15 @@ export function useSSE() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected };
|
||||
return {
|
||||
observations,
|
||||
summaries,
|
||||
prompts,
|
||||
projects: catalog.projects,
|
||||
sources: catalog.sources,
|
||||
projectsBySource: catalog.projectsBySource,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -20,6 +21,7 @@ export interface Summary {
|
||||
id: number;
|
||||
session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
request?: string;
|
||||
investigated?: string;
|
||||
learned?: string;
|
||||
@@ -32,6 +34,7 @@ export interface UserPrompt {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
@@ -48,10 +51,19 @@ export interface StreamEvent {
|
||||
summaries?: Summary[];
|
||||
prompts?: UserPrompt[];
|
||||
projects?: string[];
|
||||
sources?: string[];
|
||||
projectsBySource?: Record<string, string[]>;
|
||||
observation?: Observation;
|
||||
summary?: Summary;
|
||||
prompt?: UserPrompt;
|
||||
isProcessing?: boolean;
|
||||
queueDepth?: number;
|
||||
}
|
||||
|
||||
export interface ProjectCatalog {
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
projectsBySource: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
|
||||
Reference in New Issue
Block a user