Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions dist/db.d.ts
Original file line number Diff line number Diff line change
@@ -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 (<project>/<session>/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)`.
Expand Down
24 changes: 23 additions & 1 deletion dist/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 (<project>/<session>/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
Expand Down
21 changes: 21 additions & 0 deletions dist/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 2 additions & 4 deletions dist/parser.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions dist/paths.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export declare function getConversationSourceDirs(only?: ConversationSourceHarne
* inside session UUIDs (#80).
*/
export declare function findJsonlFiles(dir: string, excludedDirNames?: ReadonlySet<string>): string[];
/**
* Project name for a transcript: the directory it sits in. Claude Code keeps
* subagent transcripts at <project>/<session>/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
Expand Down
15 changes: 15 additions & 0 deletions dist/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>/<session>/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
Expand Down
6 changes: 3 additions & 3 deletions dist/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (<project>/<session>/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);
})();
}

/**
Expand Down
7 changes: 2 additions & 5 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ export function findJsonlFiles(dir: string, excludedDirNames?: ReadonlySet<strin
return results;
}

/**
* Project name for a transcript: the directory it sits in. Claude Code keeps
* subagent transcripts at <project>/<session>/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
Expand Down
6 changes: 3 additions & 3 deletions src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading