mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-10 06:28:18 +00:00
Fix/websocket streaming issues (#748)
This commit is contained in:
@@ -257,8 +257,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
|
||||
if (!shouldRebuild) {
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
|
||||
db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)');
|
||||
db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
|
||||
db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
|
||||
return;
|
||||
@@ -284,6 +286,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
? 'jsonl_path'
|
||||
: 'NULL';
|
||||
|
||||
const isArchivedExpression = columnNames.includes('isArchived')
|
||||
? 'COALESCE(isArchived, 0)'
|
||||
: '0';
|
||||
|
||||
const createdAtExpression = columnNames.includes('created_at')
|
||||
? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
|
||||
: 'CURRENT_TIMESTAMP';
|
||||
@@ -303,6 +309,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
isArchived BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id),
|
||||
@@ -319,6 +326,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
${customNameExpression} AS custom_name,
|
||||
${projectPathExpression} AS project_path,
|
||||
${jsonlPathExpression} AS jsonl_path,
|
||||
${isArchivedExpression} AS isArchived,
|
||||
${createdAtExpression} AS created_at,
|
||||
${updatedAtExpression} AS updated_at,
|
||||
rowid AS source_rowid
|
||||
@@ -332,6 +340,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
isArchived,
|
||||
created_at,
|
||||
updated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
@@ -346,6 +355,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
isArchived,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
@@ -355,6 +365,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
isArchived,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM ranked_rows
|
||||
@@ -421,6 +432,7 @@ export const runMigrations = (db: Database) => {
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
|
||||
|
||||
|
||||
@@ -95,6 +95,19 @@ export const projectsDb = {
|
||||
`).all() as ProjectRepositoryRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Archived rows are queried separately so archive-focused UIs can present
|
||||
* hidden workspaces without reintroducing them into the active sidebar list.
|
||||
*/
|
||||
getArchivedProjectPaths(): ProjectRepositoryRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(`
|
||||
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||
FROM projects
|
||||
WHERE isArchived = 1
|
||||
`).all() as ProjectRepositoryRow[];
|
||||
},
|
||||
|
||||
getCustomProjectName(projectPath: string): string | null {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection } from '@/modules/database/connection.js';
|
||||
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-db-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('session archive queries hide archived rows from active project views', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session');
|
||||
sessionsDb.createSession('session-archived', 'claude', '/workspace/demo-project', 'Archived Session');
|
||||
sessionsDb.updateSessionIsArchived('session-archived', true);
|
||||
|
||||
const activeSessions = sessionsDb.getAllSessions();
|
||||
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||
const activeProjectSessions = sessionsDb.getSessionsByProjectPath('/workspace/demo-project');
|
||||
const allProjectSessions = sessionsDb.getSessionsByProjectPathIncludingArchived('/workspace/demo-project');
|
||||
|
||||
assert.deepEqual(activeSessions.map((session) => session.session_id), ['session-active']);
|
||||
assert.deepEqual(archivedSessions.map((session) => session.session_id), ['session-archived']);
|
||||
assert.deepEqual(activeProjectSessions.map((session) => session.session_id), ['session-active']);
|
||||
assert.deepEqual(
|
||||
allProjectSessions.map((session) => session.session_id).sort(),
|
||||
['session-active', 'session-archived'],
|
||||
);
|
||||
assert.equal(sessionsDb.countSessionsByProjectPath('/workspace/demo-project'), 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('createSession reactivates archived rows when the session becomes active again', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'First Name');
|
||||
sessionsDb.updateSessionIsArchived('session-reused', true);
|
||||
|
||||
sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'Updated Name');
|
||||
|
||||
const activeSessions = sessionsDb.getAllSessions();
|
||||
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||
const restoredSession = sessionsDb.getSessionById('session-reused');
|
||||
|
||||
assert.equal(activeSessions.length, 1);
|
||||
assert.equal(activeSessions[0]?.session_id, 'session-reused');
|
||||
assert.equal(activeSessions[0]?.custom_name, 'Updated Name');
|
||||
assert.equal(archivedSessions.length, 0);
|
||||
assert.equal(restoredSession?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,14 @@ type SessionRow = {
|
||||
project_path: string | null;
|
||||
jsonl_path: string | null;
|
||||
custom_name: string | null;
|
||||
isArchived: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type SessionMetadataLookupRow = Pick<
|
||||
SessionRow,
|
||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
|
||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
||||
>;
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
@@ -53,13 +54,14 @@ export const sessionsDb = {
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
updated_at = excluded.updated_at,
|
||||
project_path = excluded.project_path,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
isArchived = 0,
|
||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||
).run(
|
||||
sessionId,
|
||||
@@ -87,7 +89,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
@@ -102,8 +104,25 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions`
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE isArchived = 0`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Archived rows are intentionally queried separately so the caller can render
|
||||
* them in a dedicated view without reintroducing them into active session lists.
|
||||
*/
|
||||
getArchivedSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE isArchived = 1
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
},
|
||||
@@ -113,7 +132,24 @@ export const sessionsDb = {
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Permanent project deletion must see every session row for the path,
|
||||
* including archived ones, so their transcript files can be cleaned up.
|
||||
*/
|
||||
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
@@ -125,9 +161,10 @@ export const sessionsDb = {
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
@@ -141,7 +178,8 @@ export const sessionsDb = {
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0`
|
||||
)
|
||||
.get(normalizedProjectPath) as { count: number } | undefined;
|
||||
|
||||
@@ -167,6 +205,19 @@ export const sessionsDb = {
|
||||
return row?.custom_name ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete and restore both use the same flag update so callers keep the
|
||||
* row, metadata, and file path intact while toggling visibility.
|
||||
*/
|
||||
updateSessionIsArchived(sessionId: string, isArchived: boolean): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`UPDATE sessions
|
||||
SET isArchived = ?
|
||||
WHERE session_id = ?`
|
||||
).run(isArchived ? 1 : 0, sessionId);
|
||||
},
|
||||
|
||||
deleteSessionById(sessionId: string): boolean {
|
||||
const db = getConnection();
|
||||
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
|
||||
|
||||
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
isArchived BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id),
|
||||
|
||||
@@ -3,9 +3,9 @@ import express from 'express';
|
||||
import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
|
||||
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||
import { AppError, asyncHandler } from '@/shared/utils.js';
|
||||
import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
||||
import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
||||
import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
|
||||
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -73,6 +73,14 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/archived',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const projects = await getArchivedProjectsWithSessions();
|
||||
res.json(createApiSuccessResponse({ projects }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:projectId/sessions',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -230,6 +238,15 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:projectId/restore',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
restoreArchivedProject(projectId);
|
||||
res.json(createApiSuccessResponse({ projectId, isArchived: false }));
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list).
|
||||
* - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir.
|
||||
|
||||
@@ -42,7 +42,7 @@ async function unlinkJsonlIfExists(filePath: string): Promise<void> {
|
||||
* Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk.
|
||||
*/
|
||||
export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise<void> {
|
||||
const sessions = sessionsDb.getSessionsByProjectPath(projectPath);
|
||||
const sessions = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath);
|
||||
const paths = uniqueJsonlPathsFromSessions(sessions);
|
||||
|
||||
for (const filePath of paths) {
|
||||
@@ -73,3 +73,18 @@ export async function deleteOrArchiveProject(projectId: string, force: boolean):
|
||||
sessionsDb.deleteSessionsByProjectPath(row.project_path);
|
||||
projectsDb.deleteProjectById(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores one archived project row back into the active project list.
|
||||
*/
|
||||
export function restoreArchivedProject(projectId: string): void {
|
||||
const row = projectsDb.getProjectById(projectId);
|
||||
if (!row) {
|
||||
throw new AppError(`Unknown projectId: ${projectId}`, {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
projectsDb.updateProjectIsArchivedById(projectId, false);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ export type ProjectListItem = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ArchivedProjectListItem = ProjectListItem & {
|
||||
isArchived: true;
|
||||
};
|
||||
|
||||
type ProgressUpdate = {
|
||||
phase: 'loading' | 'complete';
|
||||
current: number;
|
||||
@@ -150,6 +154,16 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
||||
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
||||
|
||||
return {
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
total: rows.length,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one paginated project session slice from the DB and groups rows by provider.
|
||||
*/
|
||||
@@ -255,6 +269,56 @@ export async function getProjectsWithSessions(
|
||||
return projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads archived projects from DB and includes every session row for each
|
||||
* project path, because an archived workspace should surface all preserved
|
||||
* conversation history in the archive view regardless of each session's flag.
|
||||
*/
|
||||
export async function getArchivedProjectsWithSessions(
|
||||
options: Pick<GetProjectsWithSessionsOptions, 'skipSynchronization'> = {},
|
||||
): Promise<ArchivedProjectListItem[]> {
|
||||
if (!options.skipSynchronization) {
|
||||
await sessionSynchronizerService.synchronizeSessions();
|
||||
}
|
||||
|
||||
const projectRows = projectsDb.getArchivedProjectPaths() as Array<{
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
custom_project_name?: string | null;
|
||||
isStarred?: number;
|
||||
}>;
|
||||
|
||||
const archivedProjects: ArchivedProjectListItem[] = [];
|
||||
|
||||
for (const row of projectRows) {
|
||||
const displayName =
|
||||
row.custom_project_name && row.custom_project_name.trim().length > 0
|
||||
? row.custom_project_name
|
||||
: await generateDisplayName(path.basename(row.project_path) || row.project_path, row.project_path);
|
||||
|
||||
const sessionsPage = readProjectSessionsIncludingArchived(row.project_path);
|
||||
|
||||
archivedProjects.push({
|
||||
projectId: row.project_id,
|
||||
path: row.project_path,
|
||||
displayName,
|
||||
fullPath: row.project_path,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
isArchived: true,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return archivedProjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one paginated session slice for a specific project id.
|
||||
*/
|
||||
|
||||
@@ -157,9 +157,14 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
|
||||
const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
|
||||
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
|
||||
const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;
|
||||
|
||||
if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim())) {
|
||||
return aiTitle || lastPrompt;
|
||||
if (
|
||||
(eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) ||
|
||||
(eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) ||
|
||||
(eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())
|
||||
) {
|
||||
return aiTitle || lastPrompt || claudeRenamedTitle;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -200,17 +200,18 @@ async function getSessionMessages(
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes internal command and system reminder entries into history.
|
||||
* Those are useful for the CLI but should not appear in the user-facing chat.
|
||||
* Claude writes a mix of truly internal transcript rows and "UI-hidden" local
|
||||
* command artifacts into the same JSONL stream.
|
||||
*
|
||||
* Important distinction:
|
||||
* - system reminders / caveats / interruption banners should stay hidden
|
||||
* - local command payloads (`<command-name>...`) and stdout wrappers
|
||||
* (`<local-command-stdout>...`) should be remapped into normal chat messages
|
||||
* instead of being discarded as internal content
|
||||
*/
|
||||
const INTERNAL_CONTENT_PREFIXES = [
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
] as const;
|
||||
|
||||
@@ -218,6 +219,73 @@ function isInternalContent(content: string): boolean {
|
||||
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude wraps local slash-command metadata in lightweight XML-like tags inside
|
||||
* a plain string payload. We intentionally parse only the small tag surface we
|
||||
* care about instead of introducing a generic XML parser for untrusted history.
|
||||
*/
|
||||
function extractTaggedContent(content: string, tagName: string): string | null {
|
||||
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
type ClaudeLocalCommandPayload = {
|
||||
commandName: string;
|
||||
commandMessage: string;
|
||||
commandArgs: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Claude's hidden local command wrapper into structured metadata.
|
||||
*
|
||||
* The three tags often coexist in one string payload. Returning `null` lets the
|
||||
* normal text path continue untouched for unrelated messages.
|
||||
*/
|
||||
function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null {
|
||||
const commandName = extractTaggedContent(content, 'command-name');
|
||||
const commandMessage = extractTaggedContent(content, 'command-message');
|
||||
const commandArgs = extractTaggedContent(content, 'command-args');
|
||||
|
||||
if (commandName === null && commandMessage === null && commandArgs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
commandName: commandName ?? '',
|
||||
commandMessage: commandMessage ?? '',
|
||||
commandArgs: commandArgs ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the short user-visible command string that should appear in chat.
|
||||
*
|
||||
* We prefer the slash-prefixed command name because that most closely matches
|
||||
* what the user actually typed, and only fall back to the message body when the
|
||||
* command name is unavailable in older transcript variants.
|
||||
*/
|
||||
function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string {
|
||||
const commandName = payload.commandName.trim();
|
||||
const commandMessage = payload.commandMessage.trim();
|
||||
const commandArgs = payload.commandArgs.trim();
|
||||
const baseCommand = commandName || commandMessage;
|
||||
|
||||
if (!baseCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude local-command stdout may contain ANSI styling codes because it was
|
||||
* captured from the terminal. The web chat should receive readable plain text.
|
||||
*/
|
||||
function stripAnsiFormatting(text: string): string {
|
||||
return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
||||
@@ -240,7 +308,7 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
||||
const part = raw.message.content[partIndex];
|
||||
@@ -293,6 +361,80 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
|
||||
/**
|
||||
* Claude stores compact summaries as synthetic "user" rows so the CLI
|
||||
* can resume the next session turn with the summary in-context.
|
||||
*
|
||||
* For the web UI this is much more useful as assistant-authored summary
|
||||
* text; otherwise it is both filtered by the generic internal-prefix
|
||||
* check and visually mislabeled as a user message.
|
||||
*/
|
||||
if (raw.isCompactSummary === true && text.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
isCompactSummary: true,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local slash commands are serialized as tagged text even though they
|
||||
* are semantically a user action. Expose the parsed fields to the
|
||||
* frontend and emit a plain user-visible command string so the command
|
||||
* no longer disappears from history.
|
||||
*/
|
||||
const localCommandPayload = parseLocalCommandPayload(text);
|
||||
if (localCommandPayload) {
|
||||
const displayText = buildLocalCommandDisplayText(localCommandPayload);
|
||||
if (displayText) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: displayText,
|
||||
commandName: localCommandPayload.commandName,
|
||||
commandMessage: localCommandPayload.commandMessage,
|
||||
commandArgs: localCommandPayload.commandArgs,
|
||||
isLocalCommand: true,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local command stdout is also written as a "user" row in Claude's
|
||||
* transcript, but it is terminal output produced in response to the
|
||||
* command. Re-label it as assistant text so the chat transcript matches
|
||||
* the actual conversational flow seen by the user.
|
||||
*/
|
||||
const localCommandStdout = extractTaggedContent(text, 'local-command-stdout');
|
||||
if (localCommandStdout !== null) {
|
||||
const stdoutText = stripAnsiFormatting(localCommandStdout).trim();
|
||||
if (stdoutText) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: stdoutText,
|
||||
isLocalCommandStdout: true,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
@@ -414,7 +556,9 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
result = await getSessionMessages(sessionId, limit, offset);
|
||||
// Load full history first so `total` reflects frontend-normalized messages,
|
||||
// not raw JSONL records.
|
||||
result = await getSessionMessages(sessionId, null, 0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
@@ -422,8 +566,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
const toolResultMap = new Map<string, ClaudeToolResult>();
|
||||
for (const raw of rawMessages) {
|
||||
@@ -464,12 +606,31 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
messages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +520,9 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
|
||||
let result: CodexHistoryResult;
|
||||
try {
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
// Load full history first so `total` reflects frontend-normalized messages,
|
||||
// not raw JSONL records.
|
||||
result = await getCodexSessionMessages(sessionId, null, 0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
||||
@@ -528,8 +530,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
@@ -552,12 +552,31 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
messages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
tokenUsage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,44 +45,28 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectsDir = path.join(this.cursorHome, 'projects');
|
||||
const projectEntries = await listDirectoryEntriesSafe(projectsDir);
|
||||
const seenProjectPaths = new Set<string>();
|
||||
|
||||
let processed = 0;
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
|
||||
const files = await findFilesRecursivelyCreatedAfter(projectsDir, '.jsonl', since ?? null);
|
||||
|
||||
for (const filePath of files) {
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workerLogPath = path.join(projectsDir, entry.name, 'worker.log');
|
||||
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
|
||||
if (!projectPath || seenProjectPaths.has(projectPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenProjectPaths.add(projectPath);
|
||||
const projectHash = this.md5(projectPath);
|
||||
const chatsDir = path.join(this.cursorHome, 'chats', projectHash);
|
||||
const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', since ?? null);
|
||||
|
||||
for (const filePath of files) {
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
@@ -113,13 +97,6 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the same project hash Cursor uses in chat directory names.
|
||||
*/
|
||||
private md5(input: string): string {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts project path from Cursor worker.log.
|
||||
*/
|
||||
@@ -149,7 +126,7 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
*/
|
||||
private async processSessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||
const sessionId = path.basename(filePath, '.jsonl');
|
||||
const grandparentDir = path.dirname(path.dirname(filePath));
|
||||
const grandparentDir = path.dirname(path.dirname(path.dirname(filePath)));
|
||||
const workerLogPath = path.join(grandparentDir, 'worker.log');
|
||||
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
|
||||
|
||||
|
||||
@@ -25,6 +25,167 @@ type CursorMessageBlob = {
|
||||
content: AnyRecord;
|
||||
};
|
||||
|
||||
function isInternalCursorText(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.startsWith('<user_info>') || normalized.startsWith('<system_reminder>');
|
||||
}
|
||||
|
||||
function isInternalCursorPart(part: unknown): boolean {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = part as AnyRecord;
|
||||
const type = typeof record.type === 'string' ? record.type : '';
|
||||
if (type === 'user_info' || type === 'system_reminder') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isInternalCursorText(record.text);
|
||||
}
|
||||
|
||||
function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string {
|
||||
if (role !== 'user') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = value.trimStart();
|
||||
const openTag = '<user_query>';
|
||||
const closeTag = '</user_query>';
|
||||
if (!normalized.startsWith(openTag)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const afterOpen = normalized.slice(openTag.length);
|
||||
const closeIndex = afterOpen.lastIndexOf(closeTag);
|
||||
const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen;
|
||||
return inner.trim();
|
||||
}
|
||||
|
||||
function normalizeToolId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function extractCursorToolResultContent(item: AnyRecord): string {
|
||||
if (typeof item.result === 'string' && item.result.trim()) {
|
||||
return item.result;
|
||||
}
|
||||
|
||||
if (typeof item.output === 'string' && item.output.trim()) {
|
||||
return item.output;
|
||||
}
|
||||
|
||||
if (Array.isArray(item.experimental_content)) {
|
||||
const experimentalText = item.experimental_content
|
||||
.map((part: unknown) => {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === 'object') {
|
||||
const record = part as AnyRecord;
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (experimentalText.trim()) {
|
||||
return experimentalText;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof item.result === 'string' ? item.result : '';
|
||||
}
|
||||
|
||||
function parseCursorToolInput(rawInput: unknown): unknown {
|
||||
if (typeof rawInput !== 'string') {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
const trimmed = rawInput.trim();
|
||||
if (!trimmed) {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return rawInput;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown {
|
||||
const parsed = parseCursorToolInput(rawInput);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const input = parsed as AnyRecord;
|
||||
const normalized: AnyRecord = { ...input };
|
||||
|
||||
const filePath = input.file_path
|
||||
?? input.filePath
|
||||
?? input.path
|
||||
?? input.file
|
||||
?? input.filename;
|
||||
if (typeof filePath === 'string' && filePath.trim()) {
|
||||
normalized.file_path = filePath;
|
||||
}
|
||||
|
||||
if (toolName === 'Write') {
|
||||
const content = input.content
|
||||
?? input.text
|
||||
?? input.value
|
||||
?? input.contents
|
||||
?? input.fileContent
|
||||
?? input.new_string
|
||||
?? input.newString;
|
||||
if (typeof content === 'string') {
|
||||
normalized.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'Edit') {
|
||||
const oldString = input.old_string
|
||||
?? input.oldString
|
||||
?? input.old
|
||||
?? '';
|
||||
const newString = input.new_string
|
||||
?? input.newString
|
||||
?? input.new
|
||||
?? input.content
|
||||
?? '';
|
||||
|
||||
if (typeof oldString === 'string') {
|
||||
normalized.old_string = oldString;
|
||||
}
|
||||
if (typeof newString === 'string') {
|
||||
normalized.new_string = newString;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'ApplyPatch') {
|
||||
const patch = input.patch ?? input.diff ?? input.content;
|
||||
if (typeof patch === 'string' && !normalized.patch) {
|
||||
normalized.patch = patch;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeCursorSessionId(sessionId: string): string {
|
||||
const normalized = sessionId.trim();
|
||||
if (!normalized) {
|
||||
@@ -225,13 +386,14 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
try {
|
||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||
const total = allNormalized.length;
|
||||
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||
const total = renderableMessages.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const start = offset;
|
||||
const page = limit === 0
|
||||
? []
|
||||
: allNormalized.slice(start, start + limit);
|
||||
: renderableMessages.slice(start, start + limit);
|
||||
const hasMore = limit === 0
|
||||
? start < total
|
||||
: start + limit < total;
|
||||
@@ -245,7 +407,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
messages: renderableMessages,
|
||||
total,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
@@ -283,11 +445,24 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
|
||||
.map((part: string | AnyRecord) => {
|
||||
if (typeof part === 'string') {
|
||||
if (isInternalCursorText(part)) {
|
||||
return '';
|
||||
}
|
||||
return unwrapUserQueryText(part, role);
|
||||
}
|
||||
if (isInternalCursorPart(part)) {
|
||||
return '';
|
||||
}
|
||||
return unwrapUserQueryText(part?.text || '', role);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
if (!isInternalCursorText(content.message.content)) {
|
||||
text = unwrapUserQueryText(content.message.content, role);
|
||||
}
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
@@ -316,7 +491,14 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
const cursorOptions = content.providerOptions?.cursor as AnyRecord | undefined;
|
||||
const highLevelToolCallResult = cursorOptions?.highLevelToolCallResult;
|
||||
const toolCallId = normalizeToolId(item.toolCallId)
|
||||
|| normalizeToolId(item.tool_call_id)
|
||||
|| normalizeToolId(highLevelToolCallResult?.toolCallId)
|
||||
|| normalizeToolId(highLevelToolCallResult?.tool_call_id)
|
||||
|| normalizeToolId(content.id)
|
||||
|| '';
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
@@ -324,8 +506,9 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
content: extractCursorToolResultContent(item),
|
||||
isError: Boolean(item.isError || item.is_error),
|
||||
toolUseResult: highLevelToolCallResult,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
@@ -336,8 +519,15 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
if (isInternalCursorPart(part)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
const normalizedPartText = unwrapUserQueryText(part.text, role);
|
||||
if (!normalizedPartText) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
@@ -345,7 +535,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
content: normalizedPartText,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -361,7 +551,11 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const rawToolName = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
const toolId = normalizeToolId(part.toolCallId)
|
||||
|| normalizeToolId(part.tool_call_id)
|
||||
|| normalizeToolId(part.id)
|
||||
|| `tool_${i}_${partIdx}`;
|
||||
const normalizedToolInput = normalizeCursorToolInput(rawToolName, part.args ?? part.input);
|
||||
const message = createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
@@ -369,14 +563,22 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolInput: normalizedToolInput,
|
||||
toolId,
|
||||
});
|
||||
messages.push(message);
|
||||
toolUseMap.set(toolId, message);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
} else if (
|
||||
typeof content.content === 'string'
|
||||
&& content.content.trim()
|
||||
&& !isInternalCursorText(content.content)
|
||||
) {
|
||||
const normalizedText = unwrapUserQueryText(content.content, role);
|
||||
if (!normalizedText) {
|
||||
continue;
|
||||
}
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
@@ -384,7 +586,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
content: normalizedText,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
@@ -401,6 +603,7 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
toolUseResult: msg.toolUseResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,24 @@ type GeminiCredentialsStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GeminiAuthType =
|
||||
| 'oauth-personal'
|
||||
| 'gemini-api-key'
|
||||
| 'vertex-ai'
|
||||
| 'compute-default-credentials'
|
||||
| 'gateway'
|
||||
| 'cloud-shell'
|
||||
| null;
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||
* Use the same resolution so status checks match runtime behavior.
|
||||
*/
|
||||
private getGeminiCliHome(): string {
|
||||
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
@@ -58,6 +75,88 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses dotenv-style key/value pairs.
|
||||
*/
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedLine = line.startsWith('export ')
|
||||
? line.slice('export '.length).trim()
|
||||
: line;
|
||||
const separatorIndex = normalizedLine.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||
if (quoted) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
value = value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user-level auth env in Gemini's "first file found" order.
|
||||
*/
|
||||
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||
const geminiCliHome = this.getGeminiCliHome();
|
||||
const envCandidates = [
|
||||
path.join(geminiCliHome, '.gemini', '.env'),
|
||||
path.join(geminiCliHome, '.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envCandidates) {
|
||||
try {
|
||||
const content = await readFile(envPath, 'utf8');
|
||||
return this.parseEnvFile(content);
|
||||
} catch {
|
||||
// Continue to the next fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's selected auth type from settings.json when available.
|
||||
*/
|
||||
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||
try {
|
||||
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
const security = readObjectRecord(settings?.security);
|
||||
const auth = readObjectRecord(security?.auth);
|
||||
const selectedType = readOptionalString(auth?.selectedType);
|
||||
if (!selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedType as GeminiAuthType;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
@@ -66,8 +165,46 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const userEnv = await this.loadUserLevelAuthEnv();
|
||||
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const selectedType = await this.readSelectedAuthType();
|
||||
if (selectedType === 'vertex-ai') {
|
||||
const hasGoogleApiKey = Boolean(
|
||||
process.env.GOOGLE_API_KEY?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||
);
|
||||
const hasProject = Boolean(
|
||||
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||
);
|
||||
const hasLocation = Boolean(
|
||||
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||
);
|
||||
const hasServiceAccount = Boolean(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||
);
|
||||
|
||||
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'vertex_ai',
|
||||
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
@@ -106,6 +243,25 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
if (selectedType === 'gemini-api-key') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'api_key',
|
||||
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedType === 'oauth-personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
@@ -140,7 +296,7 @@ export class GeminiProviderAuth implements IProviderAuth {
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
|
||||
@@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectHashLookup = this.buildProjectHashLookup();
|
||||
|
||||
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'tmp'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.jsonl',
|
||||
// since ?? null
|
||||
// );
|
||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
// Process legacy JSON first, then JSONL. If both exist for a session id,
|
||||
// the JSONL artifact becomes the canonical jsonl_path via upsert.
|
||||
// Current strategy: index only temp chat JSONL artifacts.
|
||||
const files = [
|
||||
...legacySessionFiles,
|
||||
...legacyTempFiles,
|
||||
...jsonlSessionFiles,
|
||||
// ...legacySessionFiles,
|
||||
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||
// ...legacyTempFiles,
|
||||
// ...jsonlSessionFiles,
|
||||
...jsonlTempFiles,
|
||||
];
|
||||
|
||||
|
||||
@@ -528,10 +528,16 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
const messages = pageLimit === null
|
||||
? normalized.slice(start)
|
||||
: normalized.slice(start, start + pageLimit);
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
total: normalized.length,
|
||||
total,
|
||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
|
||||
@@ -311,12 +311,33 @@ router.post(
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
router.get(
|
||||
'/sessions/archived',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
const sessions = sessionsService.listArchivedSessions();
|
||||
res.json(createApiSuccessResponse({ sessions }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/sessions/:sessionId',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false;
|
||||
const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk);
|
||||
const force = parseOptionalBooleanQuery(req.query.force, 'force') ?? false;
|
||||
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? force;
|
||||
const result = await sessionsService.deleteOrArchiveSessionById(sessionId, {
|
||||
force,
|
||||
deletedFromDisk,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/sessions/:sessionId/restore',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const result = sessionsService.restoreSessionById(sessionId);
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -89,13 +89,8 @@ const RIPGREP_CHUNK_CONCURRENCY = 6;
|
||||
const UNKNOWN_PROJECT_KEY = '__unknown_project__';
|
||||
|
||||
const INTERNAL_CONTENT_PREFIXES = [
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'Invalid API key',
|
||||
'[Request interrupted',
|
||||
] as const;
|
||||
@@ -302,6 +297,135 @@ function extractClaudeText(content: unknown): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function extractTaggedContent(content: string, tagName: string): string | null {
|
||||
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
type ClaudeLocalCommandPayload = {
|
||||
commandName: string;
|
||||
commandMessage: string;
|
||||
commandArgs: string;
|
||||
};
|
||||
|
||||
function parseClaudeLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null {
|
||||
const commandName = extractTaggedContent(content, 'command-name');
|
||||
const commandMessage = extractTaggedContent(content, 'command-message');
|
||||
const commandArgs = extractTaggedContent(content, 'command-args');
|
||||
|
||||
if (commandName === null && commandMessage === null && commandArgs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
commandName: commandName ?? '',
|
||||
commandMessage: commandMessage ?? '',
|
||||
commandArgs: commandArgs ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string {
|
||||
const commandName = payload.commandName.trim();
|
||||
const commandMessage = payload.commandMessage.trim();
|
||||
const commandArgs = payload.commandArgs.trim();
|
||||
const baseCommand = commandName || commandMessage;
|
||||
|
||||
if (!baseCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand;
|
||||
}
|
||||
|
||||
function stripAnsiFormatting(text: string): string {
|
||||
return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
type ClaudeSearchableMessage = {
|
||||
text: string;
|
||||
role: 'user' | 'assistant';
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude mixes visible chat, compact summaries, and local command wrappers into
|
||||
* the same transcript stream. Search should operate on the user-visible meaning
|
||||
* of those rows rather than the raw wrapper syntax.
|
||||
*/
|
||||
function extractClaudeSearchableMessage(entry: AnyRecord): ClaudeSearchableMessage | null {
|
||||
if (!entry.message?.content || entry.isApiErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawRole = entry.message.role;
|
||||
if (rawRole !== 'user' && rawRole !== 'assistant') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const content = String(entry.message.content);
|
||||
|
||||
if (entry.isCompactSummary === true && content.trim()) {
|
||||
return {
|
||||
text: content,
|
||||
role: 'assistant',
|
||||
};
|
||||
}
|
||||
|
||||
const localCommand = parseClaudeLocalCommandPayload(content);
|
||||
if (localCommand) {
|
||||
const displayText = buildClaudeLocalCommandDisplayText(localCommand);
|
||||
return displayText
|
||||
? {
|
||||
text: displayText,
|
||||
role: 'user',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
const localCommandStdout = extractTaggedContent(content, 'local-command-stdout');
|
||||
if (localCommandStdout !== null) {
|
||||
const stdoutText = stripAnsiFormatting(localCommandStdout).trim();
|
||||
return stdoutText
|
||||
? {
|
||||
text: stdoutText,
|
||||
role: 'assistant',
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!content || isInternalContent(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text: content,
|
||||
role: rawRole,
|
||||
};
|
||||
}
|
||||
|
||||
const text = extractClaudeText(entry.message.content);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.isCompactSummary === true) {
|
||||
return {
|
||||
text,
|
||||
role: 'assistant',
|
||||
};
|
||||
}
|
||||
|
||||
if (isInternalContent(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
role: rawRole,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCodexText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
@@ -348,6 +472,7 @@ function extractGeminiText(content: unknown): string {
|
||||
|
||||
function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] {
|
||||
const normalizedRows: SearchableSessionRow[] = [];
|
||||
const projectArchiveStateByPath = new Map<string, boolean>();
|
||||
|
||||
for (const row of rows) {
|
||||
const provider = row.provider as SearchableProvider;
|
||||
@@ -365,6 +490,27 @@ function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSe
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active session rows can still belong to an archived project because
|
||||
* project archiving intentionally preserves the underlying session data.
|
||||
* Global conversation search should follow the visible workspace model,
|
||||
* which means excluding any session whose owning project is archived.
|
||||
*
|
||||
* Cache the archive lookup per normalized project path so one search pass
|
||||
* does not re-query the same project row for every session in that folder.
|
||||
*/
|
||||
const normalizedProjectPath = typeof row.project_path === 'string' ? row.project_path.trim() : '';
|
||||
if (normalizedProjectPath) {
|
||||
if (!projectArchiveStateByPath.has(normalizedProjectPath)) {
|
||||
const projectRow = projectsDb.getProjectPath(normalizedProjectPath);
|
||||
projectArchiveStateByPath.set(normalizedProjectPath, Boolean(projectRow?.isArchived));
|
||||
}
|
||||
|
||||
if (projectArchiveStateByPath.get(normalizedProjectPath) === true) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
normalizedRows.push({
|
||||
...row,
|
||||
provider,
|
||||
@@ -733,18 +879,21 @@ async function parseClaudeSessionMatches(
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.message?.content || entry.isApiErrorMessage) {
|
||||
const searchableMessage = extractClaudeSearchableMessage(entry);
|
||||
if (!searchableMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = entry.message.role;
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
const { text, role } = searchableMessage;
|
||||
|
||||
const text = extractClaudeText(entry.message.content);
|
||||
if (!text || isInternalContent(text)) {
|
||||
continue;
|
||||
/**
|
||||
* Claude compact summaries are the most faithful session-summary source
|
||||
* after a `/compact` because they describe the post-compaction state that
|
||||
* the resumed session actually continues from. Prefer them over generic
|
||||
* fallback user text when present.
|
||||
*/
|
||||
if (entry.isCompactSummary === true) {
|
||||
state.resolvedSummary = text;
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
|
||||
@@ -18,16 +18,18 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
},
|
||||
{
|
||||
provider: 'cursor',
|
||||
rootPath: path.join(os.homedir(), '.cursor', 'chats'),
|
||||
rootPath: path.join(os.homedir(), '.cursor', 'projects'),
|
||||
},
|
||||
{
|
||||
provider: 'codex',
|
||||
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
},
|
||||
// {
|
||||
// provider: 'gemini',
|
||||
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
// },
|
||||
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
||||
// which causes duplicate synchronization events.
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
@@ -10,6 +11,19 @@ import type {
|
||||
} from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type ArchivedSessionListItem = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
projectId: string | null;
|
||||
projectPath: string | null;
|
||||
projectDisplayName: string;
|
||||
sessionTitle: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
lastActivity: string | null;
|
||||
isProjectArchived: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes one file if it exists.
|
||||
*/
|
||||
@@ -26,6 +40,28 @@ async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive rows need a stable project label even when the owning project is not
|
||||
* part of the active sidebar payload. This lightweight resolver keeps the
|
||||
* archive API self-contained while still matching the project's stored display
|
||||
* name when one exists.
|
||||
*/
|
||||
function resolveProjectDisplayName(
|
||||
projectPath: string | null,
|
||||
customProjectName: string | null | undefined,
|
||||
): string {
|
||||
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
||||
if (trimmedCustomName.length > 0) {
|
||||
return trimmedCustomName;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
return 'Unknown Project';
|
||||
}
|
||||
|
||||
return path.basename(projectPath) || projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application service for provider-backed session message operations.
|
||||
*
|
||||
@@ -79,15 +115,53 @@ export const sessionsService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes one persisted session row by id.
|
||||
*
|
||||
* When `deletedFromDisk` is true and a session `jsonl_path` exists, the path
|
||||
* is deleted from disk before the DB row is removed.
|
||||
* Returns archived sessions with enough project metadata for the sidebar to
|
||||
* group, filter, open, and restore them without a per-row follow-up query.
|
||||
*/
|
||||
async deleteSessionById(
|
||||
listArchivedSessions(): ArchivedSessionListItem[] {
|
||||
const archivedSessions = sessionsDb.getArchivedSessions();
|
||||
const projectCache = new Map<string, ReturnType<typeof projectsDb.getProjectPath>>();
|
||||
|
||||
return archivedSessions.map((session) => {
|
||||
const projectPath = session.project_path?.trim() ? session.project_path : null;
|
||||
let project = null;
|
||||
|
||||
if (projectPath) {
|
||||
if (!projectCache.has(projectPath)) {
|
||||
projectCache.set(projectPath, projectsDb.getProjectPath(projectPath));
|
||||
}
|
||||
project = projectCache.get(projectPath) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.session_id,
|
||||
provider: session.provider as LLMProvider,
|
||||
projectId: project?.project_id ?? null,
|
||||
projectPath,
|
||||
projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name),
|
||||
sessionTitle: session.custom_name?.trim() || session.session_id,
|
||||
createdAt: session.created_at ?? null,
|
||||
updatedAt: session.updated_at ?? null,
|
||||
lastActivity: session.updated_at ?? session.created_at ?? null,
|
||||
isProjectArchived: Boolean(project?.isArchived),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Archives or permanently deletes one persisted session row by id.
|
||||
*
|
||||
* Soft-delete mirrors the project behavior by toggling `isArchived` so the
|
||||
* row disappears from active lists but remains restorable. Force-delete
|
||||
* optionally removes the transcript file before deleting the database row.
|
||||
*/
|
||||
async deleteOrArchiveSessionById(
|
||||
sessionId: string,
|
||||
deletedFromDisk = false,
|
||||
): Promise<{ sessionId: string; deletedFromDisk: boolean }> {
|
||||
options: {
|
||||
force?: boolean;
|
||||
deletedFromDisk?: boolean;
|
||||
} = {},
|
||||
): Promise<{ sessionId: string; action: 'archived' | 'deleted'; deletedFromDisk: boolean }> {
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
@@ -96,8 +170,17 @@ export const sessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
sessionsDb.updateSessionIsArchived(sessionId, true);
|
||||
return {
|
||||
sessionId,
|
||||
action: 'archived',
|
||||
deletedFromDisk: false,
|
||||
};
|
||||
}
|
||||
|
||||
let removedFromDisk = false;
|
||||
if (deletedFromDisk && session.jsonl_path) {
|
||||
if (options.deletedFromDisk && session.jsonl_path) {
|
||||
removedFromDisk = await removeFileIfExists(session.jsonl_path);
|
||||
}
|
||||
|
||||
@@ -109,7 +192,27 @@ export const sessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
return { sessionId, deletedFromDisk: removedFromDisk };
|
||||
return {
|
||||
sessionId,
|
||||
action: 'deleted',
|
||||
deletedFromDisk: removedFromDisk,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Restores one archived session back into the active sidebar lists.
|
||||
*/
|
||||
restoreSessionById(sessionId: string): { sessionId: string; isArchived: false } {
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
code: 'SESSION_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
sessionsDb.updateSessionIsArchived(sessionId, false);
|
||||
return { sessionId, isArchived: false };
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user