From 01c3347dac7604f7fdc51a8748d896b4c5405b4b Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Sun, 16 Aug 2026 01:38:23 +0100 Subject: [PATCH] Expose subagent kind and parent_id on the meta record. Claude Code already detects standalone sidechains; Cursor identity lives only in the agent-transcripts path, so listing plus an optional locator keeps the format token-lean without teaching every consumer the layout. Co-authored-by: Cursor --- README.md | 9 +- fixtures/claude-code/subagent/expected.json | 4 +- python/src/trajectory/_types.py | 3 + .../src/trajectory/_vendor/trajectory-cli.mjs | 194 +++++++++++++----- python/tests/test_wrapper.py | 1 + schema/trajectory-v1.schema.json | 4 +- src/adapters/claude-code/README.md | 4 +- src/adapters/claude-code/index.ts | 2 + src/adapters/cursor/README.md | 52 ++++- src/adapters/cursor/index.ts | 36 +++- src/adapters/cursor/list.ts | 60 ++++++ src/canonical.ts | 4 + src/core.ts | 2 + src/index.ts | 2 +- src/internal.ts | 7 +- src/listing.ts | 3 +- src/types.ts | 9 + src/validate.ts | 19 +- test/canonical.test.ts | 28 +++ test/listing.test.ts | 51 ++++- test/normalize.test.ts | 120 +++++++++++ 21 files changed, 539 insertions(+), 75 deletions(-) create mode 100644 src/adapters/cursor/list.ts diff --git a/README.md b/README.md index 68a44f3..f53c0f6 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,12 @@ what the adapter drops. `listTrajectories()` enumerates the sessions in a source's standard local store, newest first, with cursor pagination. It is a discovery layer beside normalization — `normalizeTranscript()` itself never touches the filesystem. -Copilot CLI, Cursor, Gemini CLI, and OpenCode are export-only input contracts -and intentionally return `listing_unavailable`; callers locate and read the -exports themselves. +Copilot CLI, Gemini CLI, and OpenCode are export-only input contracts and +intentionally return `listing_unavailable`; callers locate and read the +exports themselves. Cursor lists `~/.cursor/projects/*/agent-transcripts` +(parents and `subagents/` children); pass each item's `path` as +`sourceContext.locator` when normalizing so subagent identity can be parsed +from the path without reading it. ```ts import { listTrajectories } from "@letta-ai/trajectory"; diff --git a/fixtures/claude-code/subagent/expected.json b/fixtures/claude-code/subagent/expected.json index 8ac1fed..f0e999b 100644 --- a/fixtures/claude-code/subagent/expected.json +++ b/fixtures/claude-code/subagent/expected.json @@ -5,7 +5,9 @@ "source": "claude-code", "cwd": "/workspace/project", "git_branch": "main", - "model": "claude-sonnet" + "model": "claude-sonnet", + "kind": "subagent", + "parent_id": "parent-session-fixture" }, { "role": "user", diff --git a/python/src/trajectory/_types.py b/python/src/trajectory/_types.py index 8381d05..8ba1d0e 100644 --- a/python/src/trajectory/_types.py +++ b/python/src/trajectory/_types.py @@ -89,6 +89,7 @@ class SourceContext(TypedDict, total=False): groupId: str baseByteOffset: int partial: bool + locator: str class _NormalizeInputOptional(TypedDict, total=False): @@ -139,6 +140,8 @@ class _MetaOptional(TypedDict, total=False): cwd: str git_branch: str model: str + kind: Literal["subagent"] + parent_id: str class MetaRecord(_MetaOptional): diff --git a/python/src/trajectory/_vendor/trajectory-cli.mjs b/python/src/trajectory/_vendor/trajectory-cli.mjs index 2b314a1..596799d 100644 --- a/python/src/trajectory/_vendor/trajectory-cli.mjs +++ b/python/src/trajectory/_vendor/trajectory-cli.mjs @@ -229,7 +229,9 @@ var claudeCodeAdapter = { ...cwd ? { cwd } : {}, ...gitBranch ? { gitBranch } : {}, ...sourceGroupId ? { sourceGroupId } : {}, - ...sourceGroupAmbiguous ? { sourceGroupAmbiguous: true } : {} + ...sourceGroupAmbiguous ? { sourceGroupAmbiguous: true } : {}, + ...standaloneSidechain ? { kind: "subagent" } : {}, + ...standaloneSidechain && sessionId ? { parentId: sessionId } : {} }, diagnostics }; @@ -657,7 +659,7 @@ function invalidCopilotTranscript() { // src/adapters/cursor/index.ts var cursorAdapter = { source: "cursor", - decode(transcript) { + decode(transcript, sourceContext) { const diagnostics = []; const events = []; let recognizedRows = 0; @@ -729,13 +731,34 @@ var cursorAdapter = { } if (recognizedRows === 0) throw invalidCursorTranscript(); + const identity = parseCursorLocator(sourceContext?.locator); return { events, - context: { source: "cursor" }, + context: { + source: "cursor", + ...identity + }, diagnostics }; } }; +function parseCursorLocator(locator) { + if (!locator) + return {}; + const segments = locator.split(/[\\/]+/).filter(Boolean); + const filename = segments[segments.length - 1]; + if (!filename) + return {}; + const sourceGroupId = filename.replace(/\.jsonl$/i, ""); + const subagentsIndex = segments.findIndex((segment) => segment === "subagents"); + if (subagentsIndex > 0) { + const parentId = segments[subagentsIndex - 1]; + if (parentId) { + return { kind: "subagent", parentId, sourceGroupId }; + } + } + return { sourceGroupId }; +} function resultContent(value) { if (typeof value === "string") return value; @@ -1950,7 +1973,15 @@ function invalidFilters(message) { // src/validate.ts var TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; -var META_KEYS = new Set(["role", "source", "cwd", "git_branch", "model"]); +var META_KEYS = new Set([ + "role", + "source", + "cwd", + "git_branch", + "model", + "kind", + "parent_id" +]); var CONTENT_KEYS = new Set(["role", "content", "timestamp"]); var ASSISTANT_TOOL_KEYS = new Set(["role", "content", "timestamp", "tool_calls"]); var TOOL_RESULT_KEYS = new Set(["role", "tool_call_id", "content", "ok", "timestamp"]); @@ -1980,6 +2011,12 @@ function validateTranscript(value, options) { optionalString(record, "cwd", index); optionalString(record, "git_branch", index); optionalString(record, "model", index); + if ("kind" in record && record.kind !== "subagent") { + fail(`Record ${index}: meta.kind must be "subagent" when present.`); + } + if ("parent_id" in record && (typeof record.parent_id !== "string" || !record.parent_id)) { + fail(`Record ${index}: meta.parent_id must be a non-empty string when present.`); + } continue; } validateTimestamp(record.timestamp, index); @@ -2390,7 +2427,9 @@ function buildMeta(context, modelCounts) { source: context.source, ...context.cwd ? { cwd: context.cwd } : {}, ...context.gitBranch ? { git_branch: context.gitBranch } : {}, - ...model ? { model } : {} + ...model ? { model } : {}, + ...context.kind ? { kind: context.kind } : {}, + ...context.parentId ? { parent_id: context.parentId } : {} }; } function fillTimestamps(count, anchors, context, diagnostics) { @@ -3096,14 +3135,60 @@ async function listCodexTrajectories(root) { return sortListings(items); } -// src/adapters/droid/list.ts +// src/adapters/cursor/list.ts import { homedir as homedir4 } from "node:os"; import { basename as basename3, join as join5 } from "node:path"; +var JSONL_SUFFIX2 = ".jsonl"; +async function listCursorTrajectories(root) { + const base = root ?? join5(homedir4(), ".cursor", "projects"); + const items = []; + for (const project of safeReadDir(base)) { + if (!project.isDirectory) + continue; + const transcripts = join5(base, project.name, "agent-transcripts"); + for (const session of safeReadDir(transcripts)) { + if (!session.isDirectory) + continue; + const sessionDir = join5(transcripts, session.name); + const parent = listingFromFile(session.name, join5(sessionDir, `${session.name}${JSONL_SUFFIX2}`)); + if (parent) + items.push(parent); + const subagentsDir = join5(sessionDir, "subagents"); + for (const child of safeReadDir(subagentsDir)) { + if (!child.isFile || !child.name.endsWith(JSONL_SUFFIX2)) + continue; + const listing = listingFromFile(basename3(child.name, JSONL_SUFFIX2), join5(subagentsDir, child.name)); + if (listing) + items.push(listing); + } + } + } + return sortListings(collapseNewestById(items)); +} +function collapseNewestById(items) { + const newest = new Map; + for (const item of items) { + const current = newest.get(item.id); + if (!current) { + newest.set(item.id, item); + continue; + } + const currentTime = current.updatedAt ?? ""; + const nextTime = item.updatedAt ?? ""; + if (nextTime > currentTime) + newest.set(item.id, item); + } + return [...newest.values()]; +} + +// src/adapters/droid/list.ts +import { homedir as homedir5 } from "node:os"; +import { basename as basename4, join as join6 } from "node:path"; async function listDroidTrajectories(root) { - const base = root ?? join5(homedir4(), ".factory", "sessions"); + const base = root ?? join6(homedir5(), ".factory", "sessions"); const items = []; for (const path of collectFiles(base, ".jsonl", 12)) { - const listing = listingFromFile(basename3(path, ".jsonl"), path); + const listing = listingFromFile(basename4(path, ".jsonl"), path); if (listing) items.push(listing); } @@ -3111,8 +3196,8 @@ async function listDroidTrajectories(root) { } // src/adapters/deepagents/list.ts -import { homedir as homedir5 } from "node:os"; -import { join as join6 } from "node:path"; +import { homedir as homedir6 } from "node:os"; +import { join as join7 } from "node:path"; async function listDeepAgentsTrajectories(root) { const path = resolveStorePath(root); if (!safeStat(path)) @@ -3133,13 +3218,13 @@ async function listDeepAgentsTrajectories(root) { } function resolveStorePath(root) { if (root === undefined) - return join6(homedir5(), ".deepagents", "sessions.db"); - return root.endsWith(".db") ? root : join6(root, "sessions.db"); + return join7(homedir6(), ".deepagents", "sessions.db"); + return root.endsWith(".db") ? root : join7(root, "sessions.db"); } // src/adapters/hermes/list.ts -import { homedir as homedir6 } from "node:os"; -import { join as join7 } from "node:path"; +import { homedir as homedir7 } from "node:os"; +import { join as join8 } from "node:path"; async function listHermesTrajectories(root) { const path = resolveStorePath2(root); if (!safeStat(path)) @@ -3166,27 +3251,27 @@ async function listHermesTrajectories(root) { } function resolveStorePath2(root) { if (root === undefined) - return join7(homedir6(), ".hermes", "state.db"); - return root.endsWith(".db") ? root : join7(root, "state.db"); + return join8(homedir7(), ".hermes", "state.db"); + return root.endsWith(".db") ? root : join8(root, "state.db"); } function numeric(value) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } // src/adapters/letta-code/list.ts -import { homedir as homedir7 } from "node:os"; -import { join as join8 } from "node:path"; +import { homedir as homedir8 } from "node:os"; +import { join as join9 } from "node:path"; async function listLettaCodeTrajectories(root) { - const base = root ?? join8(homedir7(), ".letta", "transcripts"); + const base = root ?? join9(homedir8(), ".letta", "transcripts"); const items = []; for (const agent of safeReadDir(base)) { if (!agent.isDirectory) continue; - const agentPath = join8(base, agent.name); + const agentPath = join9(base, agent.name); for (const conversation of safeReadDir(agentPath)) { if (!conversation.isDirectory) continue; - const path = join8(agentPath, conversation.name, "transcript.jsonl"); + const path = join9(agentPath, conversation.name, "transcript.jsonl"); const listing = listingFromFile(`${agent.name}/${conversation.name}`, path); if (listing && (listing.sizeBytes ?? 0) > 0) items.push(listing); @@ -3197,21 +3282,21 @@ async function listLettaCodeTrajectories(root) { // src/adapters/openclaw/list.ts import { existsSync } from "node:fs"; -import { homedir as homedir8 } from "node:os"; -import { basename as basename4, join as join9 } from "node:path"; +import { homedir as homedir9 } from "node:os"; +import { basename as basename5, join as join10 } from "node:path"; async function listOpenClawTrajectories(root) { const base = root ?? defaultStateDir(); const items = []; - const agentsPath = join9(base, "agents"); + const agentsPath = join10(base, "agents"); for (const agent of safeReadDir(agentsPath)) { if (!agent.isDirectory) continue; - const sessionsPath = join9(agentsPath, agent.name, "sessions"); + const sessionsPath = join10(agentsPath, agent.name, "sessions"); for (const entry of safeReadDir(sessionsPath)) { if (!entry.isFile || !entry.name.endsWith(".jsonl")) continue; - const path = join9(sessionsPath, entry.name); - const listing = listingFromFile(basename4(entry.name, ".jsonl"), path); + const path = join10(sessionsPath, entry.name); + const listing = listingFromFile(basename5(entry.name, ".jsonl"), path); if (listing) items.push(listing); } @@ -3222,22 +3307,22 @@ function defaultStateDir() { const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim(); if (override) return override; - const current = join9(homedir8(), ".openclaw"); + const current = join10(homedir9(), ".openclaw"); if (existsSync(current)) return current; - return join9(homedir8(), ".clawdbot"); + return join10(homedir9(), ".clawdbot"); } // src/adapters/openhands/list.ts -import { homedir as homedir9 } from "node:os"; -import { join as join10 } from "node:path"; +import { homedir as homedir10 } from "node:os"; +import { join as join11 } from "node:path"; async function listOpenHandsTrajectories(root) { - const base = root ?? join10(homedir9(), ".openhands", "sessions"); + const base = root ?? join11(homedir10(), ".openhands", "sessions"); const items = []; for (const entry of safeReadDir(base)) { if (!entry.isDirectory) continue; - const path = join10(base, entry.name); + const path = join11(base, entry.name); const facts = safeStat(path); items.push({ id: entry.name, @@ -3249,21 +3334,21 @@ async function listOpenHandsTrajectories(root) { } // src/adapters/pi/list.ts -import { homedir as homedir10 } from "node:os"; -import { basename as basename5, join as join11 } from "node:path"; +import { homedir as homedir11 } from "node:os"; +import { basename as basename6, join as join12 } from "node:path"; async function listPiTrajectories(root) { const base = root ?? defaultAgentDir(); const items = []; - const sessionsPath = join11(base, "sessions"); + const sessionsPath = join12(base, "sessions"); for (const project of safeReadDir(sessionsPath)) { if (!project.isDirectory) continue; - const projectPath = join11(sessionsPath, project.name); + const projectPath = join12(sessionsPath, project.name); for (const entry of safeReadDir(projectPath)) { if (!entry.isFile || !entry.name.endsWith(".jsonl")) continue; - const path = join11(projectPath, entry.name); - const listing = listingFromFile(basename5(entry.name, ".jsonl"), path); + const path = join12(projectPath, entry.name); + const listing = listingFromFile(basename6(entry.name, ".jsonl"), path); if (listing) items.push(listing); } @@ -3274,17 +3359,17 @@ function defaultAgentDir() { const override = process.env.PI_CODING_AGENT_DIR?.trim(); if (override) return override; - return join11(homedir10(), ".pi", "agent"); + return join12(homedir11(), ".pi", "agent"); } // src/adapters/omp/list.ts import { existsSync as existsSync2 } from "node:fs"; -import { homedir as homedir11 } from "node:os"; -import { basename as basename6, join as join12 } from "node:path"; +import { homedir as homedir12 } from "node:os"; +import { basename as basename7, join as join13 } from "node:path"; async function listOmpTrajectories(root) { const items = []; - const sessionsPath = root ? join12(root, "sessions") : resolveOmpSessionsPath({ - home: homedir11(), + const sessionsPath = root ? join13(root, "sessions") : resolveOmpSessionsPath({ + home: homedir12(), platform: process.platform, env: process.env, exists: existsSync2 @@ -3292,12 +3377,12 @@ async function listOmpTrajectories(root) { for (const project of safeReadDir(sessionsPath)) { if (!project.isDirectory) continue; - const projectPath = join12(sessionsPath, project.name); + const projectPath = join13(sessionsPath, project.name); for (const entry of safeReadDir(projectPath)) { if (!entry.isFile || !entry.name.endsWith(".jsonl")) continue; - const path = join12(projectPath, entry.name); - const listing = listingFromFile(basename6(entry.name, ".jsonl"), path); + const path = join13(projectPath, entry.name); + const listing = listingFromFile(basename7(entry.name, ".jsonl"), path); if (listing) items.push(listing); } @@ -3306,18 +3391,18 @@ async function listOmpTrajectories(root) { } function resolveOmpSessionsPath(options) { const profile = resolveProfile(options.env.OMP_PROFILE, options.env.PI_PROFILE); - const configRoot = join12(options.home, options.env.PI_CONFIG_DIR || ".omp", ...profile ? ["profiles", profile] : []); + const configRoot = join13(options.home, options.env.PI_CONFIG_DIR || ".omp", ...profile ? ["profiles", profile] : []); const agentOverride = profile ? undefined : options.env.PI_CODING_AGENT_DIR?.trim() || undefined; - const agentDir = agentOverride ?? join12(configRoot, "agent"); + const agentDir = agentOverride ?? join13(configRoot, "agent"); if (agentOverride === undefined && (options.platform === "linux" || options.platform === "darwin")) { const xdgData = options.env.XDG_DATA_HOME?.trim(); if (xdgData) { - const xdgRoot = join12(xdgData, "omp", ...profile ? ["profiles", profile] : []); + const xdgRoot = join13(xdgData, "omp", ...profile ? ["profiles", profile] : []); if (options.exists(xdgRoot)) - return join12(xdgRoot, "sessions"); + return join13(xdgRoot, "sessions"); } } - return join12(agentDir, "sessions"); + return join13(agentDir, "sessions"); } function resolveProfile(ompProfile, piProfile) { const value = (ompProfile !== undefined ? ompProfile : piProfile)?.trim(); @@ -3335,6 +3420,7 @@ var MAX_LIMIT = 1000; var LISTERS = { "claude-code": listClaudeCodeTrajectories, codex: listCodexTrajectories, + cursor: listCursorTrajectories, droid: listDroidTrajectories, deepagents: listDeepAgentsTrajectories, hermes: listHermesTrajectories, @@ -3360,7 +3446,7 @@ async function listTrajectories(input) { return paginate(items, input.cursor, limit); } function isKnownNormalizationOnlySource(source) { - return source === "copilot-cli" || source === "cursor" || source === "gemini-cli" || source === "opencode"; + return source === "copilot-cli" || source === "gemini-cli" || source === "opencode"; } function resolveLimit2(limit) { if (limit === undefined) @@ -3435,7 +3521,7 @@ function decodeTranscript(input) { throw new NormalizationError("unknown_source", `Unknown trajectory source ${JSON.stringify(input.source)}. Supported sources: ${Object.keys(ADAPTERS).join(", ")}.`); } return { - decoded: adapter.decode(input.transcript), + decoded: adapter.decode(input.transcript, input.sourceContext), bounds: resolveBounds(input.bounds), filters: resolveFilters(input.filters) }; diff --git a/python/tests/test_wrapper.py b/python/tests/test_wrapper.py index 5eadf70..7a02d8e 100644 --- a/python/tests/test_wrapper.py +++ b/python/tests/test_wrapper.py @@ -25,6 +25,7 @@ FIXTURES = ( ("claude-code", "claude-code/tool-call", "input.jsonl"), ("claude-code", "claude-code/cleanup", "input.jsonl"), + ("claude-code", "claude-code/subagent", "input.jsonl"), ("codex", "codex/tool-calls", "input.jsonl"), ("codex", "codex/cleanup", "input.jsonl"), ("copilot-cli", "copilot-cli/tool-calls", "input.jsonl"), diff --git a/schema/trajectory-v1.schema.json b/schema/trajectory-v1.schema.json index c7216b7..df6ba41 100644 --- a/schema/trajectory-v1.schema.json +++ b/schema/trajectory-v1.schema.json @@ -28,7 +28,9 @@ "source": { "type": "string", "minLength": 1 }, "cwd": { "type": "string" }, "git_branch": { "type": "string" }, - "model": { "type": "string" } + "model": { "type": "string" }, + "kind": { "const": "subagent" }, + "parent_id": { "type": "string", "minLength": 1 } } }, "user": { diff --git a/src/adapters/claude-code/README.md b/src/adapters/claude-code/README.md index c3cc65b..66a2d9d 100644 --- a/src/adapters/claude-code/README.md +++ b/src/adapters/claude-code/README.md @@ -14,7 +14,9 @@ Standalone subagent JSONL is also supported. Claude Code marks every conversational row in those files with `isSidechain: true`; when no ordinary conversation rows are present, the adapter treats the sidechain as the primary conversation and uses its `agentId` as the source group rather than the parent -`sessionId`. +`sessionId`. The leading meta record then includes `kind: "subagent"` and, when +exactly one parent `sessionId` is present, `parent_id`. Missing or conflicting +parent session ids omit `parent_id` rather than failing. Resumed or concatenated exports may contain records carrying multiple parent `sessionId` values. They remain valid trajectory-v1 input. Canonical callers diff --git a/src/adapters/claude-code/index.ts b/src/adapters/claude-code/index.ts index b54ef75..762b06f 100644 --- a/src/adapters/claude-code/index.ts +++ b/src/adapters/claude-code/index.ts @@ -220,6 +220,8 @@ export const claudeCodeAdapter: SourceAdapter = { ...(gitBranch ? { gitBranch } : {}), ...(sourceGroupId ? { sourceGroupId } : {}), ...(sourceGroupAmbiguous ? { sourceGroupAmbiguous: true } : {}), + ...(standaloneSidechain ? { kind: "subagent" as const } : {}), + ...(standaloneSidechain && sessionId ? { parentId: sessionId } : {}), }, diagnostics, }; diff --git a/src/adapters/cursor/README.md b/src/adapters/cursor/README.md index f27cd81..a97cf71 100644 --- a/src/adapters/cursor/README.md +++ b/src/adapters/cursor/README.md @@ -17,11 +17,51 @@ synthesizes deterministic timestamps and call IDs, while canonical source identity anchors to each JSONL row's UTF-8 byte offset. The adapter also accepts the same content-block shape when IDs or results are present. -The transcript itself has no session identifier. Callers using -`normalizeToCanonical()` must therefore pass the corpus/session ID as -`sourceContext.groupId`; trajectory-v1 `normalizeTranscript()` needs no extra -context. +The transcript bytes have no session, kind, or parent identifier. SWE-chat +captures with no locator stay as today: no `kind` / `parent_id`, and callers +using `normalizeToCanonical()` must pass the corpus/session ID as +`sourceContext.groupId`. Trajectory-v1 `normalizeTranscript()` needs no extra +context for those exports. Malformed JSONL lines and unknown rows or content-block types are recoverable -diagnostics. The capture is an exported transcript rather than a documented -Cursor local store, so `listTrajectories()` is not supported. +diagnostics. + +## Local store + +Cursor writes agent transcripts under +`~/.cursor/projects//agent-transcripts/`: + +``` +/.jsonl +/subagents/.jsonl +``` + +Kind and parent live entirely in that path. `listTrajectories({ source: "cursor" })` +enumerates both parents and `subagents/` children. Listing `id` is the file +stem. The same uuid can appear under more than one project folder (a window +moved); listing collapses those to the newest file so pagination-by-id stays +well-defined. + +`normalizeTranscript()` never reads a path. Pass the listing `path` as +`sourceContext.locator` so the adapter can parse identity from path segments +(POSIX and Windows): + +```ts +import { readFile } from "node:fs/promises"; +import { listTrajectories, normalizeTranscript } from "@letta-ai/trajectory"; + +const page = await listTrajectories({ source: "cursor" }); +for (const item of page.items) { + const transcript = await readFile(item.path, "utf8"); + normalizeTranscript({ + source: "cursor", + transcript, + sourceContext: { locator: item.path }, + }); +} +``` + +A locator whose path contains a `subagents` segment sets `kind: "subagent"` and +`parent_id` to the directory immediately above it. The file stem becomes +`sourceGroupId`, so local canonical calls do not need a separate `groupId`. +A parent locator sets `sourceGroupId` from the stem and omits kind. diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index 9b755b9..17f1e21 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -3,7 +3,7 @@ import type { DecodedSession, SourceAdapter, } from "../../internal.js"; -import type { Diagnostic } from "../../types.js"; +import type { Diagnostic, SourceContext } from "../../types.js"; import { NormalizationError } from "../../types.js"; import { blocksText, @@ -16,7 +16,7 @@ import { export const cursorAdapter: SourceAdapter = { source: "cursor", - decode(transcript: string): DecodedSession { + decode(transcript: string, sourceContext?: SourceContext): DecodedSession { const diagnostics: Diagnostic[] = []; const events: DecodedEvent[] = []; let recognizedRows = 0; @@ -114,14 +114,44 @@ export const cursorAdapter: SourceAdapter = { } if (recognizedRows === 0) throw invalidCursorTranscript(); + const identity = parseCursorLocator(sourceContext?.locator); return { events, - context: { source: "cursor" }, + context: { + source: "cursor", + ...identity, + }, diagnostics, }; }, }; +/** + * Parse a store path for Cursor identity. The locator is never read; only its + * path segments matter. A `subagents` directory marks a child transcript. + */ +function parseCursorLocator( + locator: string | undefined, +): { + kind?: "subagent"; + parentId?: string; + sourceGroupId?: string; +} { + if (!locator) return {}; + const segments = locator.split(/[\\/]+/).filter(Boolean); + const filename = segments[segments.length - 1]; + if (!filename) return {}; + const sourceGroupId = filename.replace(/\.jsonl$/i, ""); + const subagentsIndex = segments.findIndex((segment) => segment === "subagents"); + if (subagentsIndex > 0) { + const parentId = segments[subagentsIndex - 1]; + if (parentId) { + return { kind: "subagent", parentId, sourceGroupId }; + } + } + return { sourceGroupId }; +} + function resultContent(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return blocksText(value); diff --git a/src/adapters/cursor/list.ts b/src/adapters/cursor/list.ts new file mode 100644 index 0000000..765ca8a --- /dev/null +++ b/src/adapters/cursor/list.ts @@ -0,0 +1,60 @@ +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import type { TrajectoryListing } from "../../listing.js"; +import { + listingFromFile, + safeReadDir, + sortListings, +} from "../listing-shared.js"; + +const JSONL_SUFFIX = ".jsonl"; + +/** + * Cursor parent sessions and standalone subagents under + * `~/.cursor/projects//agent-transcripts//`. + */ +export async function listCursorTrajectories( + root: string | undefined, +): Promise { + const base = root ?? join(homedir(), ".cursor", "projects"); + const items: TrajectoryListing[] = []; + for (const project of safeReadDir(base)) { + if (!project.isDirectory) continue; + const transcripts = join(base, project.name, "agent-transcripts"); + for (const session of safeReadDir(transcripts)) { + if (!session.isDirectory) continue; + const sessionDir = join(transcripts, session.name); + const parent = listingFromFile( + session.name, + join(sessionDir, `${session.name}${JSONL_SUFFIX}`), + ); + if (parent) items.push(parent); + const subagentsDir = join(sessionDir, "subagents"); + for (const child of safeReadDir(subagentsDir)) { + if (!child.isFile || !child.name.endsWith(JSONL_SUFFIX)) continue; + const listing = listingFromFile( + basename(child.name, JSONL_SUFFIX), + join(subagentsDir, child.name), + ); + if (listing) items.push(listing); + } + } + } + return sortListings(collapseNewestById(items)); +} + +/** The same uuid can appear under more than one project folder. */ +function collapseNewestById(items: TrajectoryListing[]): TrajectoryListing[] { + const newest = new Map(); + for (const item of items) { + const current = newest.get(item.id); + if (!current) { + newest.set(item.id, item); + continue; + } + const currentTime = current.updatedAt ?? ""; + const nextTime = item.updatedAt ?? ""; + if (nextTime > currentTime) newest.set(item.id, item); + } + return [...newest.values()]; +} diff --git a/src/canonical.ts b/src/canonical.ts index 8ff7fea..a8dcb50 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -233,6 +233,10 @@ function semanticContent( ? { git_branch: record.git_branch } : {}), ...(record.model !== undefined ? { model: record.model } : {}), + ...(record.kind !== undefined ? { kind: record.kind } : {}), + ...(record.parent_id !== undefined + ? { parent_id: record.parent_id } + : {}), } : {}; case "user": diff --git a/src/core.ts b/src/core.ts index e8abb47..1e40371 100644 --- a/src/core.ts +++ b/src/core.ts @@ -478,6 +478,8 @@ function buildMeta( ...(context.cwd ? { cwd: context.cwd } : {}), ...(context.gitBranch ? { git_branch: context.gitBranch } : {}), ...(model ? { model } : {}), + ...(context.kind ? { kind: context.kind } : {}), + ...(context.parentId ? { parent_id: context.parentId } : {}), }; } diff --git a/src/index.ts b/src/index.ts index 28ce245..3e63c2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ function decodeTranscript(input: NormalizeInput): { } return { - decoded: adapter.decode(input.transcript), + decoded: adapter.decode(input.transcript, input.sourceContext), bounds: resolveBounds(input.bounds), filters: resolveFilters(input.filters), }; diff --git a/src/internal.ts b/src/internal.ts index 57f382d..5784f16 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -3,6 +3,7 @@ import type { ResolvedNormalizationFilters } from "./filters.js"; import type { Diagnostic, NormalizedRecord, + SourceContext, TranscriptTrajectorySource, } from "./types.js"; @@ -95,6 +96,10 @@ export interface SessionContext { * Canonical callers must provide the authoritative group. */ sourceGroupRequired?: boolean; + /** Present only on standalone subagent transcripts. */ + kind?: "subagent"; + /** Spawning session id, when uniquely resolved. */ + parentId?: string; } export interface DecodedSession { @@ -105,7 +110,7 @@ export interface DecodedSession { export interface SourceAdapter { source: TranscriptTrajectorySource; - decode(transcript: string): DecodedSession; + decode(transcript: string, sourceContext?: SourceContext): DecodedSession; } /** diff --git a/src/listing.ts b/src/listing.ts index 8b51398..f74d255 100644 --- a/src/listing.ts +++ b/src/listing.ts @@ -12,6 +12,7 @@ import { listClaudeCodeTrajectories } from "./adapters/claude-code/list.js"; import { listCodexTrajectories } from "./adapters/codex/list.js"; +import { listCursorTrajectories } from "./adapters/cursor/list.js"; import { listDroidTrajectories } from "./adapters/droid/list.js"; import { listDeepAgentsTrajectories } from "./adapters/deepagents/list.js"; import { listHermesTrajectories } from "./adapters/hermes/list.js"; @@ -63,6 +64,7 @@ type SourceLister = (root: string | undefined) => Promise; const LISTERS: Partial> = { "claude-code": listClaudeCodeTrajectories, codex: listCodexTrajectories, + cursor: listCursorTrajectories, droid: listDroidTrajectories, deepagents: listDeepAgentsTrajectories, hermes: listHermesTrajectories, @@ -112,7 +114,6 @@ export async function listTrajectories( function isKnownNormalizationOnlySource(source: AnyTrajectorySource): boolean { return ( source === "copilot-cli" || - source === "cursor" || source === "gemini-cli" || source === "opencode" ); diff --git a/src/types.ts b/src/types.ts index 61238ba..4b34df5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,6 +79,11 @@ export interface SourceContext { * partial. Full-transcript callers omit this and stay strict. */ partial?: boolean; + /** + * Store path of this transcript. Adapters may parse it for identity (for + * example Cursor `kind` / `parent_id`); it is never read from disk. + */ + locator?: string; } export interface NormalizeInput { @@ -120,6 +125,10 @@ export interface MetaRecord { cwd?: string; git_branch?: string; model?: string; + /** Present only on standalone subagent transcripts. */ + kind?: "subagent"; + /** Spawning session id, when the adapter can resolve it uniquely. */ + parent_id?: string; } export interface UserRecord { diff --git a/src/validate.ts b/src/validate.ts index 4e847e6..4f8029c 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -4,7 +4,15 @@ import { NormalizationError } from "./types.js"; const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; -const META_KEYS = new Set(["role", "source", "cwd", "git_branch", "model"]); +const META_KEYS = new Set([ + "role", + "source", + "cwd", + "git_branch", + "model", + "kind", + "parent_id", +]); const CONTENT_KEYS = new Set(["role", "content", "timestamp"]); const ASSISTANT_TOOL_KEYS = new Set(["role", "content", "timestamp", "tool_calls"]); const TOOL_RESULT_KEYS = new Set(["role", "tool_call_id", "content", "ok", "timestamp"]); @@ -52,6 +60,15 @@ export function validateTranscript( optionalString(record, "cwd", index); optionalString(record, "git_branch", index); optionalString(record, "model", index); + if ("kind" in record && record.kind !== "subagent") { + fail(`Record ${index}: meta.kind must be "subagent" when present.`); + } + if ( + "parent_id" in record && + (typeof record.parent_id !== "string" || !record.parent_id) + ) { + fail(`Record ${index}: meta.parent_id must be a non-empty string when present.`); + } continue; } diff --git a/test/canonical.test.ts b/test/canonical.test.ts index 2907bfc..df0e09c 100644 --- a/test/canonical.test.ts +++ b/test/canonical.test.ts @@ -738,6 +738,10 @@ describe("meta determinism", () => { "assistant", ]); expect(result.diagnostics).toEqual([]); + expect(JSON.parse(result.records[0]?.record_json ?? "{}")).toMatchObject({ + kind: "subagent", + parent_id: "parent-session-fixture", + }); }); test("subagent identity survives parent session-id drift", () => { @@ -753,6 +757,30 @@ describe("meta determinism", () => { expect(new Set(result.records.map((record) => record.source_group_id))).toEqual( new Set(["subagent-fixture"]), ); + const meta = JSON.parse(result.records[0]?.record_json ?? "{}") as { + kind?: string; + parent_id?: string; + }; + expect(meta.kind).toBe("subagent"); + expect(meta.parent_id).toBeUndefined(); + }); + + test("Cursor locator supplies canonical group and subagent meta", () => { + const result = normalizeToCanonical({ + source: "cursor", + transcript: fixtureText("cursor/cleanup", "input.jsonl"), + sourceContext: { + locator: + "/Users/me/.cursor/projects/slug/agent-transcripts/parent-uuid/subagents/child-uuid.jsonl", + }, + }); + expect(new Set(result.records.map((record) => record.source_group_id))).toEqual( + new Set(["child-uuid"]), + ); + expect(JSON.parse(result.records[0]?.record_json ?? "{}")).toMatchObject({ + kind: "subagent", + parent_id: "parent-uuid", + }); }); }); diff --git a/test/listing.test.ts b/test/listing.test.ts index 9e8b5af..4d07d3f 100644 --- a/test/listing.test.ts +++ b/test/listing.test.ts @@ -119,6 +119,36 @@ beforeAll(() => { "('h-new', NULL, 1783100000.0, 1783100500.0)", ); hermes.close(); + + // cursor: parents + subagents, plus a duplicate id across project folders. + for (const [relativePath, at] of [ + [ + "slug-old/agent-transcripts/parent-aaa/parent-aaa.jsonl", + "2026-07-01T10:00:00Z", + ], + [ + "slug-old/agent-transcripts/parent-aaa/subagents/child-bbb.jsonl", + "2026-07-02T10:00:00Z", + ], + [ + "slug-new/agent-transcripts/parent-aaa/parent-aaa.jsonl", + "2026-07-04T10:00:00Z", + ], + [ + "slug-new/agent-transcripts/parent-ccc/parent-ccc.jsonl", + "2026-07-03T10:00:00Z", + ], + [ + "slug-new/agent-transcripts/parent-ccc/subagents/child-ddd.jsonl", + "2026-07-05T10:00:00Z", + ], + ] as const) { + const file = join(base, "cursor", relativePath); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `{"role":"user","message":{"content":"hi"}}\n`); + const time = new Date(at); + utimesSync(file, time, time); + } }); afterAll(() => { @@ -129,7 +159,6 @@ describe("listTrajectories", () => { test("reports normalization-only sources without pretending to discover a store", async () => { for (const source of [ "copilot-cli", - "cursor", "gemini-cli", "opencode", ] as const) { @@ -139,6 +168,24 @@ describe("listTrajectories", () => { } }); + test("lists cursor parents and subagents and collapses duplicate ids to newest", async () => { + const result = await listTrajectories({ + source: "cursor", + root: join(base, "cursor"), + }); + expect(result.items.map((item) => item.id)).toEqual([ + "child-ddd", + "parent-aaa", + "parent-ccc", + "child-bbb", + ]); + expect(result.items[1]?.path.includes("slug-new")).toBe(true); + expect(result.items[1]?.updatedAt).toBe("2026-07-04T10:00:00.000Z"); + expect(result.items.some((item) => item.path.includes("subagents"))).toBe( + true, + ); + }); + test("lists claude-code parent and subagent sessions newest first", async () => { const result = await listTrajectories({ source: "claude-code", @@ -263,7 +310,7 @@ describe("listTrajectories", () => { }); test("returns an empty listing for a missing store", async () => { - for (const source of ["claude-code", "hermes", "deepagents"] as const) { + for (const source of ["claude-code", "cursor", "hermes", "deepagents"] as const) { const result = await listTrajectories({ source, root: join(base, "does-not-exist"), diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 91dbda0..91223f1 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -844,6 +844,126 @@ describe("partial transcript fragments", () => { }); }); +describe("subagent meta identity", () => { + test("Claude Code parent sessions omit kind and parent_id", () => { + const result = normalizeTranscript({ + source: "claude-code", + transcript: fixtureText("claude-code/tool-call", "input.jsonl"), + }); + const meta = result.records[0]; + expect(meta?.role).toBe("meta"); + expect(meta).not.toHaveProperty("kind"); + expect(meta).not.toHaveProperty("parent_id"); + }); + + test("standalone Claude Code subagents set kind and parent_id", () => { + const result = normalizeTranscript({ + source: "claude-code", + transcript: fixtureText("claude-code/subagent", "input.jsonl"), + }); + expect(result.records[0]).toMatchObject({ + role: "meta", + kind: "subagent", + parent_id: "parent-session-fixture", + }); + }); + + test("standalone Claude Code subagents omit parent_id when session ids are missing", () => { + const transcript = [ + JSON.stringify({ + type: "user", + uuid: "u1", + isSidechain: true, + message: { role: "user", content: "Inspect the retry path." }, + }), + JSON.stringify({ + type: "assistant", + uuid: "a1", + isSidechain: true, + message: { + role: "assistant", + content: [{ type: "text", text: "Looking." }], + }, + }), + ].join("\n"); + const result = normalizeTranscript({ source: "claude-code", transcript }); + const meta = result.records[0]; + expect(meta).toMatchObject({ role: "meta", kind: "subagent" }); + expect(meta).not.toHaveProperty("parent_id"); + }); + + test("standalone Claude Code subagents omit parent_id when session ids are ambiguous", () => { + const transcript = [ + JSON.stringify({ + type: "user", + uuid: "u1", + isSidechain: true, + sessionId: "parent-a", + message: { role: "user", content: "Inspect the retry path." }, + }), + JSON.stringify({ + type: "assistant", + uuid: "a1", + isSidechain: true, + sessionId: "parent-b", + message: { + role: "assistant", + content: [{ type: "text", text: "Looking." }], + }, + }), + ].join("\n"); + const result = normalizeTranscript({ source: "claude-code", transcript }); + const meta = result.records[0]; + expect(meta).toMatchObject({ role: "meta", kind: "subagent" }); + expect(meta).not.toHaveProperty("parent_id"); + }); + + test("Cursor captures without a locator omit kind and parent_id", () => { + const result = normalizeTranscript({ + source: "cursor", + transcript: fixtureText("cursor/cleanup", "input.jsonl"), + }); + const meta = result.records[0]; + expect(meta?.role).toBe("meta"); + expect(meta).not.toHaveProperty("kind"); + expect(meta).not.toHaveProperty("parent_id"); + }); + + test("Cursor subagent locators set kind and parent_id from path segments", () => { + const transcript = fixtureText("cursor/cleanup", "input.jsonl"); + for (const locator of [ + "/Users/me/.cursor/projects/slug/agent-transcripts/parent-uuid/subagents/child-uuid.jsonl", + "C:\\Users\\me\\.cursor\\projects\\slug\\agent-transcripts\\parent-uuid\\subagents\\child-uuid.jsonl", + ]) { + const result = normalizeTranscript({ + source: "cursor", + transcript, + sourceContext: { locator }, + }); + expect(result.records[0]).toMatchObject({ + role: "meta", + kind: "subagent", + parent_id: "parent-uuid", + }); + } + }); + + test("Cursor parent locators do not set kind", () => { + const result = normalizeTranscript({ + source: "cursor", + transcript: fixtureText("cursor/cleanup", "input.jsonl"), + sourceContext: { + locator: + "/Users/me/.cursor/projects/slug/agent-transcripts/parent-uuid/parent-uuid.jsonl", + }, + }); + const meta = result.records[0]; + expect(meta?.role).toBe("meta"); + expect(meta).not.toHaveProperty("kind"); + expect(meta).not.toHaveProperty("parent_id"); + }); +}); + describe("validation", () => { test("rejects tool arguments that do not encode an object", () => { const invalid = [