From 302047e25212850a13edd0f755bfa0a4e68d50df Mon Sep 17 00:00:00 2001 From: KeyVan Date: Fri, 9 Oct 2026 10:14:11 -0400 Subject: [PATCH] Read Continue and Copilot CLI sessions from their real storage formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both parsers were written against guessed formats and found nothing on a real install. - Continue: read ~/.continue/sessions/.json (history[].message with string or text-part content) plus the sessions.json index for creation dates; honours CONTINUE_GLOBAL_DIR. The old parser queried a sessions.db SQLite file Continue never creates. - Copilot CLI: read ~/.copilot/session-state//events.jsonl (session.start, user.message, assistant.message → data.content) and workspace.yaml for the title/created date/cwd. model.* side-channel events and tool-only assistant messages are skipped. - Copilot CLI writer now produces that layout (stable UUIDs for non-UUID ids so re-imports overwrite) instead of ~/.copilot/sessions/*.json, which Copilot never read. README notes `/chronicle reindex`. - Remove the now-unused SqliteParser. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- README.md | 8 +- movez/app/Parsers/ContinueParser.php | 177 +++++++++++++---- movez/app/Parsers/CopilotCliParser.php | 183 +++++++++++++----- movez/app/Parsers/SqliteParser.php | 35 ---- movez/app/Writers/CopilotCliWriter.php | 103 ++++++++-- movez/config/movez.php | 19 +- .../Commands/ExportImportCommandTest.php | 2 +- movez/tests/Unit/Parsers/OtherParsersTest.php | 101 ++++++++-- 9 files changed, 466 insertions(+), 166 deletions(-) delete mode 100644 movez/app/Parsers/SqliteParser.php diff --git a/AGENTS.md b/AGENTS.md index c7c7dad..230d4c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,9 @@ Required keys validated by `BundleSchema::validate()`: `version`, `sessions`, `s | windsurf | Encrypted protobuf (Cascade) | not supported — parser/writer throw | | claude-code | JSONL | ~/.claude/projects/ | | codex | JSONL rollout (session_meta/response_item) | ~/.codex/sessions/YYYY/MM/DD/ | -| copilot-cli | JSON | ~/.copilot/sessions/ | +| copilot-cli | JSONL events (session.start, user.message, assistant.message) + workspace.yaml | ~/.copilot/session-state// | | cline | JSON per task (read-only) | ~/.vscode/extensions/saoudrizwan.claude-dev-*/data/tasks// | -| continue | SQLite sessions.db (read-only) | ~/.continue/ | +| continue | JSON per session + sessions.json index (read-only) | ~/.continue/sessions/ | --- diff --git a/README.md b/README.md index 19b0352..985dc41 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ Run `movez --help` for details. | Cursor | ✅ | ✅ | `~/.cursor/projects` transcripts + `state.vscdb` | | Claude Code | ✅ | ✅ | `~/.claude/projects/*.jsonl` (+ Claude desktop app registry) | | Codex CLI | ✅ | ✅ | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | -| Copilot CLI | ✅ | ✅ | `~/.copilot/sessions/*.json` | +| Copilot CLI | ✅ | ✅ | `~/.copilot/session-state//events.jsonl` + `workspace.yaml` | | Cline | ✅ | — | VS Code extension `tasks//api_conversation_history.json` | -| Continue | ✅ | — | `~/.continue/sessions.db` | +| Continue | ✅ | — | `~/.continue/sessions/.json` (honours `CONTINUE_GLOBAL_DIR`) | | Windsurf | — | — | Not supported yet: Cascade stores conversations in an encrypted format | ### Importing into Cursor @@ -129,6 +129,10 @@ Cursor only shows sessions in its sidebar for workspaces it already knows about. MoveZ writes the transcript, the global `state.vscdb` entries and the workspace registration. The full conversation is visible, but Cursor's internal model context (`conversationState`) can't be recreated. To continue a conversation, start a new chat and reference the old one. +### Importing into Copilot CLI + +Sessions are written to `~/.copilot/session-state//` (non-UUID ids get a stable UUID, so re-importing overwrites rather than duplicates). Copilot CLI also keeps a search index of sessions; if imported sessions don't show up in its history, run `/chronicle reindex` inside Copilot CLI. + ### Importing into Claude Code Sessions are written under `~/.claude/projects//`. On Windows they are also registered with the Claude desktop app, so they appear in its history. Re-importing a session updates it rather than duplicating it. diff --git a/movez/app/Parsers/ContinueParser.php b/movez/app/Parsers/ContinueParser.php index a6fa3f9..8595c7c 100644 --- a/movez/app/Parsers/ContinueParser.php +++ b/movez/app/Parsers/ContinueParser.php @@ -10,9 +10,17 @@ use App\Support\PlatformPaths; use Carbon\Carbon; use Illuminate\Support\Collection; -use PDO; -class ContinueParser extends SqliteParser implements ParserInterface +/** + * Reads Continue (continue.dev) chat sessions. + * + * Layout (core/util/paths.ts in continuedev/continue): + * ~/.continue/sessions/.json — { sessionId, title, workspaceDirectory, history[] } + * ~/.continue/sessions/sessions.json — [{ sessionId, title, dateCreated, workspaceDirectory }] + * Each history item is { message: { role, content }, contextItems, ... } where content + * is a string or an array of { type: "text", text } parts. + */ +class ContinueParser implements ParserInterface { public function toolName(): string { @@ -21,15 +29,19 @@ public function toolName(): string public function getStoragePath(string $projectPath): string { - $os = PlatformPaths::configKey(); - $cfg = config("movez.tools.continue.storage.{$os}", ''); - return PlatformPaths::expand((string) $cfg); + // Continue honours CONTINUE_GLOBAL_DIR; mirror that so we read the same folder + $globalDir = getenv('CONTINUE_GLOBAL_DIR'); + if (is_string($globalDir) && $globalDir !== '') { + return rtrim($globalDir, '/\\') . DIRECTORY_SEPARATOR . 'sessions'; + } + + $os = PlatformPaths::configKey(); + return PlatformPaths::expand((string) config("movez.tools.continue.storage.{$os}", '')); } public function detect(string $projectPath): bool { - $dbPath = $this->getDbPath($projectPath); - return file_exists($dbPath); + return $this->sessionFiles($this->getStoragePath($projectPath)) !== []; } /** @return Collection */ @@ -38,49 +50,128 @@ public function parseMetadata(string $projectPath): Collection return $this->parse($projectPath); } + /** @return Collection */ public function parse(string $projectPath): Collection { - $dbPath = $this->getDbPath($projectPath); + $dir = $this->getStoragePath($projectPath); + $index = $this->readIndex($dir); + $sessions = collect(); - if (!file_exists($dbPath)) { - return collect(); + foreach ($this->sessionFiles($dir) as $file) { + $session = $this->parseFile($file, $index); + if ($session !== null) { + $sessions->push($session); + } } - $pdo = $this->openDb($dbPath); - $stmt = $pdo->query('SELECT * FROM sessions ORDER BY created_at ASC'); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - - return collect($rows)->map(function (array $row) use ($pdo) { - $turnsStmt = $pdo->prepare( - 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC' - ); - $turnsStmt->execute([$row['id']]); - $turnRows = $turnsStmt->fetchAll(PDO::FETCH_ASSOC); - - $turns = collect($turnRows)->map( - fn(array $t) => new TurnDTO( - role: $t['role'] ?? 'user', - content: ContentFlattener::flatten($t['content'] ?? ''), - timestamp: Carbon::parse($t['created_at'] ?? 'now'), - ) - ); - - return new SessionDTO( - id: (string) $row['id'], - title: (string) ($row['title'] ?? 'Untitled'), - sourceTool: $this->toolName(), - sourceMachineSha: substr(hash('sha256', (string) gethostname()), 0, 16), - createdAt: Carbon::parse($row['created_at'] ?? 'now'), - lastActiveAt: Carbon::parse($row['updated_at'] ?? $row['created_at'] ?? 'now'), - turns: $turns, - ); - }); + return $sessions; } - private function getDbPath(string $projectPath): string + /** @param array> $index */ + private function parseFile(string $file, array $index): ?SessionDTO { - $storagePath = $this->getStoragePath($projectPath); - $dbFile = config('movez.tools.continue.db_file', 'sessions.db'); - return $storagePath . DIRECTORY_SEPARATOR . $dbFile; + $data = json_decode((string) file_get_contents($file), true); + if (!is_array($data) || !is_array($data['history'] ?? null)) { + return null; + } + + $id = is_string($data['sessionId'] ?? null) ? $data['sessionId'] : pathinfo($file, PATHINFO_FILENAME); + $mtime = Carbon::createFromTimestamp((int) filemtime($file)); + $meta = $index[$id] ?? []; + + // Messages carry no timestamps; the index has the creation date + $created = $this->parseDate($meta['dateCreated'] ?? null) ?? $mtime; + + $turns = collect($data['history']) + ->map(fn($item) => is_array($item) ? ($item['message'] ?? null) : null) + ->filter(fn($msg) => is_array($msg) && in_array($msg['role'] ?? null, ['user', 'assistant'], true)) + ->map(fn(array $msg) => new TurnDTO( + role: $msg['role'], + content: trim(ContentFlattener::flatten($msg['content'] ?? '')), + timestamp: $created, + )) + ->filter(fn(TurnDTO $t) => $t->content !== '') + ->values(); + + if ($turns->isEmpty()) { + return null; + } + + $title = trim((string) ($data['title'] ?? '')); + if ($title === '' || $title === 'New Session') { + $title = mb_substr($turns->first()->content, 0, 80); + } + + return new SessionDTO( + id: $id, + title: $title, + sourceTool: $this->toolName(), + sourceMachineSha: substr(hash('sha256', (string) gethostname()), 0, 16), + createdAt: $created, + lastActiveAt: $mtime->greaterThan($created) ? $mtime : $created, + turns: $turns, + project: $this->projectName((string) ($data['workspaceDirectory'] ?? $meta['workspaceDirectory'] ?? '')), + ); + } + + /** workspaceDirectory is a path or a file:// URI (e.g. file:///c%3A/Work/app). */ + private function projectName(string $workspace): string + { + if ($workspace === '') { + return ''; + } + + $path = rawurldecode((string) preg_replace('#^file://#', '', $workspace)); + + return basename(str_replace('\\', '/', rtrim($path, '/\\'))); + } + + private function parseDate(mixed $value): ?Carbon + { + if (is_numeric($value)) { + // Continue stores Date.now() — milliseconds since the epoch + return Carbon::createFromTimestampMs((int) $value); + } + + if (is_string($value) && $value !== '') { + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + + return null; + } + + /** @return array> sessionId => metadata */ + private function readIndex(string $dir): array + { + $list = json_decode((string) @file_get_contents($dir . DIRECTORY_SEPARATOR . 'sessions.json'), true); + if (!is_array($list)) { + return []; + } + + $index = []; + foreach ($list as $entry) { + if (is_array($entry) && is_string($entry['sessionId'] ?? null)) { + $index[$entry['sessionId']] = $entry; + } + } + + return $index; + } + + /** @return string[] */ + private function sessionFiles(string $dir): array + { + if (!is_dir($dir)) { + return []; + } + + return array_values(array_filter( + glob($dir . DIRECTORY_SEPARATOR . '*.json') ?: [], + fn(string $f) => basename($f) !== 'sessions.json', + )); } } diff --git a/movez/app/Parsers/CopilotCliParser.php b/movez/app/Parsers/CopilotCliParser.php index 146e1f9..2e3ac1b 100644 --- a/movez/app/Parsers/CopilotCliParser.php +++ b/movez/app/Parsers/CopilotCliParser.php @@ -11,7 +11,19 @@ use Carbon\Carbon; use Illuminate\Support\Collection; -class CopilotCliParser implements ParserInterface +/** + * Reads GitHub Copilot CLI sessions. + * + * Layout: ~/.copilot/session-state// + * events.jsonl — one event per line: { type, data, id, timestamp, parentId } + * workspace.yaml — summary, created_at, cwd, repository + * + * Conversation turns are `user.message` / `assistant.message` events with the + * text in data.content. The format is undocumented upstream, so parsing is + * deliberately tolerant: unknown events are ignored and model.* side-channel + * events (utility-model calls) are never treated as conversation. + */ +class CopilotCliParser extends JsonlParser implements ParserInterface { public function toolName(): string { @@ -20,15 +32,13 @@ public function toolName(): string public function getStoragePath(string $projectPath): string { - $os = PlatformPaths::configKey(); - $cfg = config("movez.tools.copilot-cli.storage.{$os}", ''); - return PlatformPaths::expand((string) $cfg); + $os = PlatformPaths::configKey(); + return PlatformPaths::expand((string) config("movez.tools.copilot-cli.storage.{$os}", '')); } public function detect(string $projectPath): bool { - $storagePath = $this->getStoragePath($projectPath); - return is_dir($storagePath) && !empty(glob($storagePath . '/*.json')); + return $this->eventFiles($this->getStoragePath($projectPath)) !== []; } /** @return Collection */ @@ -37,55 +47,140 @@ public function parseMetadata(string $projectPath): Collection return $this->parse($projectPath); } + /** @return Collection */ public function parse(string $projectPath): Collection { - $storagePath = $this->getStoragePath($projectPath); + $sessions = collect(); - if (!is_dir($storagePath)) { - return collect(); + foreach ($this->eventFiles($this->getStoragePath($projectPath)) as $file) { + $session = $this->parseSession($file); + if ($session !== null) { + $sessions->push($session); + } } - $files = glob($storagePath . '/*.json') ?: []; - $sessions = collect(); + return $sessions; + } - foreach ($files as $file) { - $raw = file_get_contents($file); - $data = json_decode((string) $raw, true); + private function parseSession(string $eventsFile): ?SessionDTO + { + $dir = dirname($eventsFile); + $workspace = $this->readWorkspace($dir . DIRECTORY_SEPARATOR . 'workspace.yaml'); + $fallback = Carbon::createFromTimestamp((int) filemtime($eventsFile)); + + $sessionId = basename($dir); + $cwd = $workspace['cwd'] ?? null; + $turns = collect(); + + foreach ($this->readJsonlFile($eventsFile) as $event) { + $type = $event['type'] ?? null; + $data = is_array($event['data'] ?? null) ? $event['data'] : []; + + if ($type === 'session.start') { + $sessionId = is_string($data['sessionId'] ?? null) ? $data['sessionId'] : $sessionId; + $cwd ??= is_string($data['context']['cwd'] ?? null) ? $data['context']['cwd'] : null; + continue; + } - if (!is_array($data)) { - continue; // skip unreadable files rather than aborting the whole parse + $role = match ($type) { + 'user.message' => 'user', + 'assistant.message' => 'assistant', + default => null, + }; + + if ($role === null) { + continue; } - // Copilot-cli stores an array of sessions per file - $records = isset($data[0]) ? $data : [$data]; - - foreach ($records as $record) { - if (!is_array($record)) { - continue; - } - - $turns = collect($record['messages'] ?? []) - ->filter(fn($msg) => is_array($msg)) - ->map(fn(array $msg) => new TurnDTO( - role: ($msg['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user', - content: ContentFlattener::flatten($msg['content'] ?? ''), - timestamp: Carbon::parse($msg['timestamp'] ?? 'now'), - )) - ->values(); - - $sessions->push(new SessionDTO( - id: (string) ($record['id'] ?? pathinfo($file, PATHINFO_FILENAME)), - title: (string) ($record['title'] ?? basename($file)), - sourceTool: $this->toolName(), - sourceMachineSha: substr(hash('sha256', (string) gethostname()), 0, 16), - createdAt: Carbon::parse($record['created_at'] ?? 'now'), - lastActiveAt: Carbon::parse($record['updated_at'] ?? 'now'), - turns: $turns, - project: (string) ($record['project'] ?? ''), - )); + // Assistant messages that only request tools have empty content + $content = trim(ContentFlattener::flatten($data['content'] ?? '')); + if ($content === '') { + continue; } + + $turns->push(new TurnDTO( + role: $role, + content: $content, + timestamp: $this->parseTimestamp($event['timestamp'] ?? null) ?? $fallback, + )); } - return $sessions; + if ($turns->isEmpty()) { + return null; + } + + $title = trim((string) ($workspace['summary'] ?? '')); + if ($title === '') { + $title = $turns->first(fn(TurnDTO $t) => $t->role === 'user')?->content ?? $sessionId; + } + + $created = $this->parseTimestamp($workspace['created_at'] ?? null) ?? $turns->first()->timestamp; + + return new SessionDTO( + id: $sessionId, + title: mb_substr($title, 0, 80), + sourceTool: $this->toolName(), + sourceMachineSha: $this->machineSha(), + createdAt: $created, + lastActiveAt: $turns->last()->timestamp, + turns: $turns, + project: is_string($cwd) && $cwd !== '' ? basename(str_replace('\\', '/', rtrim($cwd, '/\\'))) : '', + ); + } + + /** + * Minimal reader for the flat `key: value` lines of workspace.yaml + * (no YAML dependency in the CLI). + * + * @return array + */ + private function readWorkspace(string $path): array + { + if (!is_file($path)) { + return []; + } + + $values = []; + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + if (!preg_match('/^([A-Za-z_]+):\s*(.*)$/', $line, $m) || trim($m[2]) === '') { + continue; + } + + $value = trim($m[2]); + if (str_starts_with($value, '"') && str_ends_with($value, '"')) { + // Double-quoted YAML scalars use JSON-compatible escapes (\" and \\) + $decoded = json_decode($value); + $value = is_string($decoded) ? $decoded : trim($value, '"'); + } elseif (str_starts_with($value, "'") && str_ends_with($value, "'")) { + $value = str_replace("''", "'", substr($value, 1, -1)); + } + + $values[$m[1]] = $value; + } + + return $values; + } + + private function parseTimestamp(mixed $value): ?Carbon + { + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + return Carbon::createFromTimestampMs((int) $value); // epoch milliseconds + } + + if (is_string($value) && $value !== '') { + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + + return null; + } + + /** @return string[] */ + private function eventFiles(string $root): array + { + return is_dir($root) ? (glob($root . '/*/events.jsonl') ?: []) : []; } } diff --git a/movez/app/Parsers/SqliteParser.php b/movez/app/Parsers/SqliteParser.php deleted file mode 100644 index b9c4e5c..0000000 --- a/movez/app/Parsers/SqliteParser.php +++ /dev/null @@ -1,35 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - return $pdo; - } catch (PDOException $e) { - throw new RuntimeException("Cannot open SQLite database: {$e->getMessage()}", 0, $e); - } - } - - protected function queryKey(PDO $db, string $key): ?string - { - $stmt = $db->prepare('SELECT value FROM ItemTable WHERE key = ?'); - $stmt->execute([$key]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - return $row !== false ? (string) $row['value'] : null; - } -} diff --git a/movez/app/Writers/CopilotCliWriter.php b/movez/app/Writers/CopilotCliWriter.php index f8f0c40..a052267 100644 --- a/movez/app/Writers/CopilotCliWriter.php +++ b/movez/app/Writers/CopilotCliWriter.php @@ -6,9 +6,17 @@ use App\Contracts\WriterInterface; use App\DTOs\SessionDTO; use App\Support\PlatformPaths; -use App\Support\SafePath; use Illuminate\Support\Collection; +/** + * Writes sessions in GitHub Copilot CLI's session-state layout: + * session-state//events.jsonl + workspace.yaml + * + * Copilot keys sessions by UUID, so non-UUID source ids are mapped to a + * stable name-based UUID (re-importing the same session overwrites it). + * Copilot also keeps a SQLite index of sessions; run `/chronicle reindex` + * in Copilot CLI if imported sessions don't appear in its history. + */ class CopilotCliWriter extends AbstractWriter implements WriterInterface { public function toolName(): string @@ -20,26 +28,53 @@ public function toolName(): string public function write(Collection $sessions, string $projectPath): void { $storagePath = $this->getStoragePath(); - if (!is_dir($storagePath)) { - mkdir($storagePath, 0755, true); - } foreach ($sessions as $session) { - $record = [ - 'id' => $session->id, - 'title' => $session->title, - 'project' => $session->project, - 'created_at' => $session->createdAt->toIso8601String(), - 'updated_at' => $session->lastActiveAt->toIso8601String(), - 'messages' => $session->turns->map(fn($turn) => [ - 'role' => $turn->role, - 'content' => $turn->content, - 'timestamp' => $turn->timestamp->toIso8601String(), - ])->values()->all(), - ]; - - $path = $storagePath . DIRECTORY_SEPARATOR . SafePath::id($session->id) . '.json'; - file_put_contents($path, json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $uuid = $this->sessionUuid($session->id); + $dir = $storagePath . DIRECTORY_SEPARATOR . $uuid; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $created = $session->createdAt->copy()->utc(); + $parentId = null; + $lines = []; + + $push = function (string $type, array $data, string $timestamp) use (&$lines, &$parentId): void { + $id = $this->uuid4(); + $lines[] = json_encode([ + 'type' => $type, + 'data' => $data, + 'id' => $id, + 'timestamp' => $timestamp, + 'parentId' => $parentId, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $parentId = $id; + }; + + $push('session.start', [ + 'sessionId' => $uuid, + 'producer' => 'movez-import:' . $session->sourceTool, + 'context' => ['cwd' => $projectPath], + ], $created->toIso8601ZuluString('millisecond')); + + foreach ($session->turns as $turn) { + $push( + $turn->role === 'user' ? 'user.message' : 'assistant.message', + ['content' => $turn->content], + $turn->timestamp->copy()->utc()->toIso8601ZuluString('millisecond'), + ); + } + + file_put_contents($dir . DIRECTORY_SEPARATOR . 'events.jsonl', implode("\n", $lines) . "\n"); + file_put_contents($dir . DIRECTORY_SEPARATOR . 'workspace.yaml', implode("\n", [ + 'id: ' . $uuid, + 'cwd: ' . $this->yamlString($projectPath), + 'summary: ' . $this->yamlString($session->title), + 'created_at: ' . $created->toIso8601ZuluString('millisecond'), + 'updated_at: ' . $session->lastActiveAt->copy()->utc()->toIso8601ZuluString('millisecond'), + ]) . "\n"); } } @@ -49,4 +84,34 @@ protected function getStoragePath(): string $cfg = config('movez.tools.copilot-cli.storage.' . $os, ''); return PlatformPaths::expand((string) $cfg); } + + /** Keep UUID ids; derive a deterministic UUID (v5-style) for anything else. */ + private function sessionUuid(string $id): string + { + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id)) { + return strtolower($id); + } + + $hash = substr(sha1('movez:' . $id), 0, 32); + $hash[12] = '5'; + $hash[16] = dechex((hexdec($hash[16]) & 0x3) | 0x8); + + return sprintf('%s-%s-%s-%s-%s', + substr($hash, 0, 8), substr($hash, 8, 4), substr($hash, 12, 4), substr($hash, 16, 4), substr($hash, 20, 12)); + } + + private function yamlString(string $value): string + { + // Single line, double-quoted, so titles with ':' or '#' stay valid YAML + $value = str_replace(["\r", "\n"], ' ', $value); + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"'; + } + + private function uuid4(): string + { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } } diff --git a/movez/config/movez.php b/movez/config/movez.php index 628f6a8..b1c00ec 100644 --- a/movez/config/movez.php +++ b/movez/config/movez.php @@ -79,12 +79,13 @@ 'format' => 'jsonl', ], 'copilot-cli' => [ + // One directory per session: session-state//events.jsonl + workspace.yaml 'storage' => [ - 'Darwin' => '~/.copilot/sessions', - 'Linux' => '~/.copilot/sessions', - 'Windows' => '%USERPROFILE%/.copilot/sessions', + 'Darwin' => '~/.copilot/session-state', + 'Linux' => '~/.copilot/session-state', + 'Windows' => '%USERPROFILE%/.copilot/session-state', ], - 'format' => 'json', + 'format' => 'jsonl-events', ], 'cline' => [ 'storage' => [ @@ -95,13 +96,13 @@ 'format' => 'json', ], 'continue' => [ + // sessions/.json plus a sessions.json index (CONTINUE_GLOBAL_DIR overrides ~/.continue) 'storage' => [ - 'Darwin' => '~/.continue', - 'Linux' => '~/.continue', - 'Windows' => '%USERPROFILE%/.continue', + 'Darwin' => '~/.continue/sessions', + 'Linux' => '~/.continue/sessions', + 'Windows' => '%USERPROFILE%/.continue/sessions', ], - 'db_file' => 'sessions.db', - 'format' => 'sqlite', + 'format' => 'json', ], ], diff --git a/movez/tests/Feature/Commands/ExportImportCommandTest.php b/movez/tests/Feature/Commands/ExportImportCommandTest.php index 3baaf68..2bd270f 100644 --- a/movez/tests/Feature/Commands/ExportImportCommandTest.php +++ b/movez/tests/Feature/Commands/ExportImportCommandTest.php @@ -82,7 +82,7 @@ function seedClaudeSession(string $storage, string $id, string $cwd, string $tex $this->artisan('import', ['--input' => $dir . '/secret.cbz', '--tool' => 'copilot-cli']) ->assertExitCode(0); - expect(file_exists($dir . '/copilot/sess-a.json'))->toBeTrue(); + expect(glob($dir . '/copilot/*/events.jsonl'))->toHaveCount(1); }); }); diff --git a/movez/tests/Unit/Parsers/OtherParsersTest.php b/movez/tests/Unit/Parsers/OtherParsersTest.php index e97b484..c731873 100644 --- a/movez/tests/Unit/Parsers/OtherParsersTest.php +++ b/movez/tests/Unit/Parsers/OtherParsersTest.php @@ -100,22 +100,101 @@ function storageKey(string $tool): string }); }); -it('Copilot CLI parser flattens block content and skips unreadable files', function (): void { +it('Copilot CLI parser reads session-state events.jsonl', function (): void { withTempDir(function (string $dir): void { - file_put_contents($dir . '/broken.json', '{not json'); - file_put_contents($dir . '/good.json', json_encode([ - 'id' => 'cp-1', 'title' => 'Copilot chat', 'created_at' => '2026-01-15T10:00:00Z', - 'messages' => [ - ['role' => 'user', 'content' => [['type' => 'text', 'text' => 'hi']], 'timestamp' => '2026-01-15T10:00:00Z'], - ['role' => 'assistant', 'content' => 'hello', 'timestamp' => '2026-01-15T10:00:01Z'], - ], + $session = $dir . '/0f8fad5b-d9cb-469f-a165-70867728950e'; + mkdir($session); + + $event = fn(string $type, array $data, string $ts) => json_encode([ + 'type' => $type, 'data' => $data, 'id' => uniqid(), 'timestamp' => $ts, 'parentId' => null, + ]); + + file_put_contents($session . '/events.jsonl', implode("\n", [ + $event('session.start', ['sessionId' => '0f8fad5b-d9cb-469f-a165-70867728950e', 'context' => ['cwd' => '/home/dev/api']], '2026-01-15T10:00:00.000Z'), + $event('user.message', ['content' => 'Add pagination', 'transformedContent' => 'Add pagination'], '2026-01-15T10:00:01.000Z'), + $event('model.message', ['role' => 'assistant', 'content' => 'utility-model side call'], '2026-01-15T10:00:02.000Z'), + $event('assistant.message', ['content' => '', 'toolRequests' => [['name' => 'view']]], '2026-01-15T10:00:03.000Z'), + $event('tool.execution_complete', ['result' => ['content' => 'file contents']], '2026-01-15T10:00:04.000Z'), + $event('assistant.message', ['content' => 'Added cursor pagination.'], '2026-01-15T10:00:05.000Z'), + '{"type":"user.message","data":{"content":"half-writ', ])); + file_put_contents($session . '/workspace.yaml', "summary: Paginate the API\ncreated_at: 2026-01-15T10:00:00Z\ncwd: /home/dev/api\n"); config([storageKey('copilot-cli') => $dir]); - $sessions = (new CopilotCliParser())->parse('/any'); + $parsed = (new CopilotCliParser())->parse('/any'); - expect($sessions)->toHaveCount(1) - ->and($sessions->first()->turns->pluck('content')->all())->toBe(['hi', 'hello']); + expect($parsed)->toHaveCount(1); + + $s = $parsed->first(); + expect($s->id)->toBe('0f8fad5b-d9cb-469f-a165-70867728950e') + ->and($s->title)->toBe('Paginate the API') + ->and($s->project)->toBe('api') + ->and($s->turns->pluck('content')->all())->toBe(['Add pagination', 'Added cursor pagination.']) + ->and($s->turns->pluck('role')->all())->toBe(['user', 'assistant']); + }); +}); + +it('Copilot CLI writer output round-trips through the parser', function (): void { + withTempDir(function (string $dir): void { + config([storageKey('copilot-cli') => $dir]); + + $session = new SessionDTO( + id: 'cursor-abc', title: 'Fix: login "redirect" bug', sourceTool: 'cursor', sourceMachineSha: 'x', + createdAt: Carbon::parse('2026-02-01T09:00:00Z'), lastActiveAt: Carbon::parse('2026-02-01T09:05:00Z'), + turns: collect([ + new TurnDTO('user', 'Why does login loop?', Carbon::parse('2026-02-01T09:00:00Z')), + new TurnDTO('assistant', 'The session cookie is not secure.', Carbon::parse('2026-02-01T09:01:00Z')), + ]), + ); + + (new App\Writers\CopilotCliWriter())->write(collect([$session, $session]), '/home/dev/web'); + + // Non-UUID ids map to one stable UUID directory, so re-imports overwrite + $dirs = glob($dir . '/*', GLOB_ONLYDIR); + expect($dirs)->toHaveCount(1) + ->and(basename($dirs[0]))->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/'); + + $parsed = (new CopilotCliParser())->parse('/any')->first(); + expect($parsed->title)->toBe('Fix: login "redirect" bug') + ->and($parsed->project)->toBe('web') + ->and($parsed->turns)->toHaveCount(2) + ->and($parsed->turns->last()->content)->toBe('The session cookie is not secure.'); + }); +}); + +it('Continue parser reads sessions/.json with the sessions.json index', function (): void { + withTempDir(function (string $dir): void { + file_put_contents($dir . '/sessions.json', json_encode([ + ['sessionId' => 'c-1', 'title' => 'Refactor auth', 'dateCreated' => '1736935200000', 'workspaceDirectory' => 'file:///c%3A/Work/My%20App'], + ])); + file_put_contents($dir . '/c-1.json', json_encode([ + 'sessionId' => 'c-1', + 'title' => 'Refactor auth', + 'workspaceDirectory' => 'file:///c%3A/Work/My%20App', + 'history' => [ + ['message' => ['role' => 'system', 'content' => 'You are helpful'], 'contextItems' => []], + ['message' => ['role' => 'user', 'content' => [['type' => 'text', 'text' => 'Split the auth module']]], 'contextItems' => []], + ['message' => ['role' => 'thinking', 'content' => 'hidden reasoning'], 'contextItems' => []], + ['message' => ['role' => 'assistant', 'content' => 'Here is a plan'], 'contextItems' => []], + ['message' => ['role' => 'tool', 'content' => 'tool output', 'toolCallId' => 't1'], 'contextItems' => []], + ], + ])); + // An untitled session falls back to its first message + file_put_contents($dir . '/c-2.json', json_encode([ + 'sessionId' => 'c-2', 'title' => 'New Session', 'workspaceDirectory' => '/home/dev/site', + 'history' => [['message' => ['role' => 'user', 'content' => 'Fix the footer'], 'contextItems' => []]], + ])); + + config([storageKey('continue') => $dir]); + $sessions = (new App\Parsers\ContinueParser())->parse('/any')->keyBy('id'); + + expect($sessions)->toHaveCount(2) + ->and($sessions['c-1']->title)->toBe('Refactor auth') + ->and($sessions['c-1']->project)->toBe('My App') + ->and($sessions['c-1']->createdAt->getTimestampMs())->toBe(1736935200000) + ->and($sessions['c-1']->turns->pluck('content')->all())->toBe(['Split the auth module', 'Here is a plan']) + ->and($sessions['c-2']->title)->toBe('Fix the footer') + ->and($sessions['c-2']->project)->toBe('site'); }); });