From 2f9637db13214772ead098cd9c7d60906d31966e Mon Sep 17 00:00:00 2001 From: Andrew Spell <38539919+AndrewSpell@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:44:51 -0400 Subject: [PATCH] fix: file subagent transcripts under their real project, not "subagents" Claude Code keeps subagent transcripts at //subagents/agent-*.jsonl. sync.ts (index and summary paths) and parseConversationFile took the parent directory as the project, so every subagent exchange was indexed under "subagents" and fell out of project-scoped searches. - projectFromTranscriptPath() in paths.ts skips /subagents/ when the file is an agent-*.jsonl; a real project named "subagents" keeps its name. - migrateSubagentProjects() repairs rows already indexed this way. It runs from migrateSchema and is a no-op once nothing needs fixing. Co-Authored-By: Claude Opus 5.5 --- dist/db.d.ts | 8 +++ dist/db.js | 24 ++++++- dist/mcp-server.js | 21 ++++++ dist/parser.js | 6 +- dist/paths.d.ts | 8 +++ dist/paths.js | 15 ++++ dist/sync.js | 6 +- src/db.ts | 26 ++++++- src/parser.ts | 7 +- src/paths.ts | 16 +++++ src/sync.ts | 6 +- test/subagent-project.test.ts | 124 ++++++++++++++++++++++++++++++++++ 12 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 test/subagent-project.test.ts diff --git a/dist/db.d.ts b/dist/db.d.ts index 3f590fff..3d96b333 100644 --- a/dist/db.d.ts +++ b/dist/db.d.ts @@ -1,6 +1,14 @@ import Database from 'better-sqlite3'; import { ConversationExchange } from './types.js'; export declare function migrateSchema(db: Database.Database): void; +/** + * Earlier versions took a transcript's parent directory as its project, so + * every subagent transcript (//subagents/agent-*.jsonl) was + * indexed under the project "subagents". Re-derive the project for those rows. + * Rows from a real project named "subagents" derive to the same name and are + * left as they are, so this is safe to run on every startup. + */ +export declare function migrateSubagentProjects(db: Database.Database): void; /** * Earlier versions created `tool_calls` with a plain * `FOREIGN KEY (exchange_id) REFERENCES exchanges(id)`. diff --git a/dist/db.js b/dist/db.js index 83f546ad..39ddda31 100644 --- a/dist/db.js +++ b/dist/db.js @@ -2,7 +2,7 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import * as sqliteVec from 'sqlite-vec'; -import { getDbPath } from './paths.js'; +import { getDbPath, projectFromTranscriptPath } from './paths.js'; import { EMBEDDING_VERSION } from './embedding-migration.js'; export function migrateSchema(db) { const columns = db.prepare(`SELECT name FROM pragma_table_info('exchanges')`).all(); @@ -36,6 +36,28 @@ export function migrateSchema(db) { console.log('Migration complete.'); } migrateToolCallsCascade(db); + migrateSubagentProjects(db); +} +/** + * Earlier versions took a transcript's parent directory as its project, so + * every subagent transcript (//subagents/agent-*.jsonl) was + * indexed under the project "subagents". Re-derive the project for those rows. + * Rows from a real project named "subagents" derive to the same name and are + * left as they are, so this is safe to run on every startup. + */ +export function migrateSubagentProjects(db) { + const rows = db.prepare(`SELECT DISTINCT archive_path FROM exchanges WHERE project = 'subagents'`).all(); + const update = db.prepare(`UPDATE exchanges SET project = ? WHERE archive_path = ?`); + const fixes = rows + .map(r => ({ path: r.archive_path, project: projectFromTranscriptPath(r.archive_path) })) + .filter(f => f.project !== 'subagents'); + if (fixes.length === 0) + return; + console.log(`Migrating ${fixes.length} subagent transcript(s) out of the "subagents" project...`); + db.transaction(() => { + for (const f of fixes) + update.run(f.project, f.path); + })(); } /** * Earlier versions created `tool_calls` with a plain diff --git a/dist/mcp-server.js b/dist/mcp-server.js index c8ca7b19..ee49e7dd 100644 --- a/dist/mcp-server.js +++ b/dist/mcp-server.js @@ -30264,6 +30264,14 @@ function ensureDir(dir) { } return dir; } +function projectFromTranscriptPath(filePath) { + let dir = path.dirname(filePath); + if (path.basename(dir) === "subagents" && path.basename(filePath).startsWith("agent-")) { + dir = path.dirname(path.dirname(dir)); + } + const name = path.basename(dir); + return name && name !== "." ? name : "unknown"; +} function getSuperpowersDir() { let dir; if (process.env.EPISODIC_MEMORY_CONFIG_DIR) { @@ -30337,6 +30345,19 @@ function migrateSchema(db) { console.log("Migration complete."); } migrateToolCallsCascade(db); + migrateSubagentProjects(db); +} +function migrateSubagentProjects(db) { + const rows = db.prepare( + `SELECT DISTINCT archive_path FROM exchanges WHERE project = 'subagents'` + ).all(); + const update = db.prepare(`UPDATE exchanges SET project = ? WHERE archive_path = ?`); + const fixes = rows.map((r) => ({ path: r.archive_path, project: projectFromTranscriptPath(r.archive_path) })).filter((f) => f.project !== "subagents"); + if (fixes.length === 0) return; + console.log(`Migrating ${fixes.length} subagent transcript(s) out of the "subagents" project...`); + db.transaction(() => { + for (const f of fixes) update.run(f.project, f.path); + })(); } function migrateToolCallsCascade(db) { const row = db.prepare( diff --git a/dist/parser.js b/dist/parser.js index 7a2108f8..a57e9e9a 100644 --- a/dist/parser.js +++ b/dist/parser.js @@ -1,6 +1,7 @@ import fs from 'fs'; import readline from 'readline'; import path from 'path'; +import { projectFromTranscriptPath } from './paths.js'; import crypto from 'crypto'; async function detectConversationHarness(filePath) { const fileStream = fs.createReadStream(filePath); @@ -1154,10 +1155,7 @@ async function parseCursorConversation(filePath, projectName, archivePath) { * Extracts project name from the file path and returns exchanges with metadata */ export async function parseConversationFile(filePath) { - // Extract project name from path (directory name before the .jsonl file). - // path.dirname uses the platform's separators, so Windows paths with `\` work too. - const parentDir = path.basename(path.dirname(filePath)); - const project = parentDir && parentDir !== '.' ? parentDir : 'unknown'; + const project = projectFromTranscriptPath(filePath); const exchanges = await parseConversation(filePath, project, filePath); return { project, diff --git a/dist/paths.d.ts b/dist/paths.d.ts index 2e83417f..a7266eee 100644 --- a/dist/paths.d.ts +++ b/dist/paths.d.ts @@ -61,6 +61,14 @@ export declare function getConversationSourceDirs(only?: ConversationSourceHarne * inside session UUIDs (#80). */ export declare function findJsonlFiles(dir: string, excludedDirNames?: ReadonlySet): string[]; +/** + * Project name for a transcript: the directory it sits in. Claude Code keeps + * subagent transcripts at //subagents/agent-*.jsonl, so for + * those the project is two directories further up. Both the directory and the + * agent- prefix must match, so a project that is itself named "subagents" + * keeps its name. + */ +export declare function projectFromTranscriptPath(filePath: string): string; /** * statSync that follows symlinks but returns null instead of throwing when * the entry cannot be stat'ed — a dangling symlink (e.g. left behind by a diff --git a/dist/paths.js b/dist/paths.js index d4d1df09..7169f4ba 100644 --- a/dist/paths.js +++ b/dist/paths.js @@ -138,6 +138,21 @@ export function findJsonlFiles(dir, excludedDirNames) { } return results; } +/** + * Project name for a transcript: the directory it sits in. Claude Code keeps + * subagent transcripts at //subagents/agent-*.jsonl, so for + * those the project is two directories further up. Both the directory and the + * agent- prefix must match, so a project that is itself named "subagents" + * keeps its name. + */ +export function projectFromTranscriptPath(filePath) { + let dir = path.dirname(filePath); + if (path.basename(dir) === 'subagents' && path.basename(filePath).startsWith('agent-')) { + dir = path.dirname(path.dirname(dir)); + } + const name = path.basename(dir); + return name && name !== '.' ? name : 'unknown'; +} /** * statSync that follows symlinks but returns null instead of throwing when * the entry cannot be stat'ed — a dangling symlink (e.g. left behind by a diff --git a/dist/sync.js b/dist/sync.js index 0629761d..b5c41bb6 100644 --- a/dist/sync.js +++ b/dist/sync.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { StringDecoder } from 'string_decoder'; import { SUMMARIZER_CONTEXT_MARKER } from './constants.js'; -import { getExcludedProjects, findJsonlFiles, statIfExists } from './paths.js'; +import { getExcludedProjects, findJsonlFiles, statIfExists, projectFromTranscriptPath } from './paths.js'; import { formatErrorSentinel, shouldQueueForSummary } from './summary-sentinel.js'; import { getMaxMessageBytes, isOversizeExchange } from './message-size.js'; const EXCLUSION_MARKERS = [ @@ -271,7 +271,7 @@ export async function syncConversations(sourceDir, destDir, options = {}) { // every exchange on every sync (#152). Ported from indexer.ts. const hw = db.prepare('SELECT COALESCE(MAX(line_end), 0) as maxLine FROM exchanges WHERE archive_path = ?').get(file); const maxIndexedLine = hw.maxLine; - const project = path.basename(path.dirname(file)); + const project = projectFromTranscriptPath(file); const exchanges = await parseConversation(file, project, file); const newExchanges = maxIndexedLine > 0 ? exchanges.filter(e => e.lineStart > maxIndexedLine) @@ -334,7 +334,7 @@ export async function syncConversations(sourceDir, destDir, options = {}) { continue; } try { - const project = path.basename(path.dirname(filePath)); + const project = projectFromTranscriptPath(filePath); const exchanges = await parseConversation(filePath, project, filePath); if (exchanges.length === 0) { // Skip empty conversations — write an empty -summary.txt sentinel so they aren't re-queued forever diff --git a/src/db.ts b/src/db.ts index 8c7a73ad..da3be813 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,7 +3,7 @@ import { ConversationExchange } from './types.js'; import path from 'path'; import fs from 'fs'; import * as sqliteVec from 'sqlite-vec'; -import { getDbPath } from './paths.js'; +import { getDbPath, projectFromTranscriptPath } from './paths.js'; import { EMBEDDING_VERSION } from './embedding-migration.js'; export function migrateSchema(db: Database.Database): void { @@ -42,6 +42,30 @@ export function migrateSchema(db: Database.Database): void { } migrateToolCallsCascade(db); + migrateSubagentProjects(db); +} + +/** + * Earlier versions took a transcript's parent directory as its project, so + * every subagent transcript (//subagents/agent-*.jsonl) was + * indexed under the project "subagents". Re-derive the project for those rows. + * Rows from a real project named "subagents" derive to the same name and are + * left as they are, so this is safe to run on every startup. + */ +export function migrateSubagentProjects(db: Database.Database): void { + const rows = db.prepare( + `SELECT DISTINCT archive_path FROM exchanges WHERE project = 'subagents'` + ).all() as Array<{ archive_path: string }>; + const update = db.prepare(`UPDATE exchanges SET project = ? WHERE archive_path = ?`); + const fixes = rows + .map(r => ({ path: r.archive_path, project: projectFromTranscriptPath(r.archive_path) })) + .filter(f => f.project !== 'subagents'); + if (fixes.length === 0) return; + + console.log(`Migrating ${fixes.length} subagent transcript(s) out of the "subagents" project...`); + db.transaction(() => { + for (const f of fixes) update.run(f.project, f.path); + })(); } /** diff --git a/src/parser.ts b/src/parser.ts index 5cc32e93..3a70b4ee 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import readline from 'readline'; import path from 'path'; import { ConversationExchange, ConversationHarness, ToolCall } from './types.js'; +import { projectFromTranscriptPath } from './paths.js'; import crypto from 'crypto'; interface JSONLMessage { @@ -1384,11 +1385,7 @@ export async function parseConversationFile(filePath: string): Promise<{ project: string; exchanges: ConversationExchange[]; }> { - // Extract project name from path (directory name before the .jsonl file). - // path.dirname uses the platform's separators, so Windows paths with `\` work too. - const parentDir = path.basename(path.dirname(filePath)); - const project = parentDir && parentDir !== '.' ? parentDir : 'unknown'; - + const project = projectFromTranscriptPath(filePath); const exchanges = await parseConversation(filePath, project, filePath); return { diff --git a/src/paths.ts b/src/paths.ts index 8a02a510..6624565a 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -150,6 +150,22 @@ export function findJsonlFiles(dir: string, excludedDirNames?: ReadonlySet//subagents/agent-*.jsonl, so for + * those the project is two directories further up. Both the directory and the + * agent- prefix must match, so a project that is itself named "subagents" + * keeps its name. + */ +export function projectFromTranscriptPath(filePath: string): string { + let dir = path.dirname(filePath); + if (path.basename(dir) === 'subagents' && path.basename(filePath).startsWith('agent-')) { + dir = path.dirname(path.dirname(dir)); + } + const name = path.basename(dir); + return name && name !== '.' ? name : 'unknown'; +} + /** * statSync that follows symlinks but returns null instead of throwing when * the entry cannot be stat'ed — a dangling symlink (e.g. left behind by a diff --git a/src/sync.ts b/src/sync.ts index d9ee6810..d4dbf963 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { StringDecoder } from 'string_decoder'; import { SUMMARIZER_CONTEXT_MARKER } from './constants.js'; -import { getExcludedProjects, findJsonlFiles, statIfExists } from './paths.js'; +import { getExcludedProjects, findJsonlFiles, statIfExists, projectFromTranscriptPath } from './paths.js'; import { formatErrorSentinel, shouldQueueForSummary } from './summary-sentinel.js'; import { getMaxMessageBytes, isOversizeExchange } from './message-size.js'; @@ -309,7 +309,7 @@ export async function syncConversations( ).get(file) as { maxLine: number }; const maxIndexedLine = hw.maxLine; - const project = path.basename(path.dirname(file)); + const project = projectFromTranscriptPath(file); const exchanges = await parseConversation(file, project, file); const newExchanges = maxIndexedLine > 0 ? exchanges.filter(e => e.lineStart > maxIndexedLine) @@ -384,7 +384,7 @@ export async function syncConversations( } try { - const project = path.basename(path.dirname(filePath)); + const project = projectFromTranscriptPath(filePath); const exchanges = await parseConversation(filePath, project, filePath); if (exchanges.length === 0) { diff --git a/test/subagent-project.test.ts b/test/subagent-project.test.ts new file mode 100644 index 00000000..997931c9 --- /dev/null +++ b/test/subagent-project.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import Database from 'better-sqlite3'; + +// Mock the embedding backend so sync can index without loading a model +// (same pattern as test/sync-incremental-and-marker.test.ts). +vi.mock('../src/embeddings.js', () => ({ + initEmbeddings: vi.fn(async () => {}), + generateExchangeEmbedding: vi.fn(async () => new Array(384).fill(0)), + generateQueryEmbedding: vi.fn(), + generateEmbedding: vi.fn(), + initEmbeddingsFailed: false, +})); + +import { syncConversations } from '../src/sync.js'; +import { parseConversationFile } from '../src/parser.js'; +import { projectFromTranscriptPath } from '../src/paths.js'; +import { initDatabase, migrateSubagentProjects } from '../src/db.js'; +import { suppressConsole } from './test-utils.js'; + +// Claude Code keeps subagent transcripts at //subagents/agent-*.jsonl. +// Taking the parent directory as the project name filed all of them under "subagents". + +function exchangeLines(sessionId: string): string { + const ts = new Date(2026, 0, 1).toISOString(); + return JSON.stringify({ + type: 'user', uuid: `u-${sessionId}`, parentUuid: null, sessionId, isSidechain: true, timestamp: ts, + message: { role: 'user', content: `question in ${sessionId}` }, + }) + '\n' + JSON.stringify({ + type: 'assistant', uuid: `a-${sessionId}`, parentUuid: `u-${sessionId}`, sessionId, isSidechain: true, timestamp: ts, + message: { role: 'assistant', content: [{ type: 'text', text: `answer in ${sessionId}` }] }, + }) + '\n'; +} + +describe('projectFromTranscriptPath', () => { + it('uses the parent directory for a top-level transcript', () => { + expect(projectFromTranscriptPath(join('archive', 'project-a', 'session.jsonl'))).toBe('project-a'); + }); + + it('skips /subagents/ for a subagent transcript', () => { + expect(projectFromTranscriptPath(join('archive', 'project-a', 'session-1', 'subagents', 'agent-1.jsonl'))).toBe('project-a'); + }); + + it('keeps the name of a real project called "subagents"', () => { + expect(projectFromTranscriptPath(join('archive', 'subagents', 'session.jsonl'))).toBe('subagents'); + }); + + it('falls back to "unknown" for a bare filename', () => { + expect(projectFromTranscriptPath('session.jsonl')).toBe('unknown'); + }); +}); + +describe('subagent transcripts are filed under their project', () => { + let testDir: string; + let restoreConsole: () => void; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'em-subagent-project-')); + process.env.TEST_DB_PATH = join(testDir, 'test.db'); + restoreConsole = suppressConsole(); + }); + + afterEach(() => { + restoreConsole(); + delete process.env.TEST_DB_PATH; + try { rmSync(testDir, { recursive: true, force: true }); } catch {} + }); + + it('parseConversationFile names the project, not "subagents"', async () => { + const dir = join(testDir, 'project-a', 'session-1', 'subagents'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'agent-1.jsonl'); + writeFileSync(file, exchangeLines('agent-1'), 'utf-8'); + + const result = await parseConversationFile(file); + expect(result.project).toBe('project-a'); + expect(result.exchanges.every(e => e.project === 'project-a')).toBe(true); + }); + + it('sync indexes main and subagent transcripts under the same project', async () => { + const sourceDir = join(testDir, 'source'); + const destDir = join(testDir, 'dest'); + mkdirSync(join(sourceDir, 'project-a', 'session-1', 'subagents'), { recursive: true }); + writeFileSync(join(sourceDir, 'project-a', 'session-1.jsonl'), exchangeLines('main'), 'utf-8'); + writeFileSync(join(sourceDir, 'project-a', 'session-1', 'subagents', 'agent-1.jsonl'), exchangeLines('agent-1'), 'utf-8'); + initDatabase().close(); + + await syncConversations(sourceDir, destDir, { skipSummaries: true }); + + const db = new Database(process.env.TEST_DB_PATH!); + const projects = db.prepare('SELECT DISTINCT project FROM exchanges').all() as Array<{ project: string }>; + const count = (db.prepare('SELECT COUNT(*) AS n FROM exchanges').get() as { n: number }).n; + db.close(); + expect(count).toBe(2); + expect(projects.map(p => p.project)).toEqual(['project-a']); + }); + + it('migrateSubagentProjects repairs rows already indexed as "subagents"', () => { + const db = initDatabase(); + const insert = db.prepare(` + INSERT INTO exchanges (id, project, timestamp, user_message, assistant_message, archive_path, line_start, line_end) + VALUES (?, ?, '2026-01-01', 'q', 'a', ?, 1, 2) + `); + const subagentPath = join('archive', 'project-a', 'session-1', 'subagents', 'agent-1.jsonl'); + // A real project that happens to be called "subagents" must be left alone. + const realProjectPath = join('archive', 'subagents', 'session-2.jsonl'); + insert.run('bad', 'subagents', subagentPath); + insert.run('real', 'subagents', realProjectPath); + insert.run('main', 'project-a', join('archive', 'project-a', 'session-1.jsonl')); + + migrateSubagentProjects(db); + migrateSubagentProjects(db); // idempotent + + const rows = db.prepare('SELECT id, project FROM exchanges ORDER BY id').all(); + db.close(); + expect(rows).toEqual([ + { id: 'bad', project: 'project-a' }, + { id: 'main', project: 'project-a' }, + { id: 'real', project: 'subagents' }, + ]); + }); +});