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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>/ |
| cline | JSON per task (read-only) | ~/.vscode/extensions/saoudrizwan.claude-dev-*/data/tasks/<id>/ |
| continue | SQLite sessions.db (read-only) | ~/.continue/ |
| continue | JSON per session + sessions.json index (read-only) | ~/.continue/sessions/ |

---

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ Run `movez <command> --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/<id>/events.jsonl` + `workspace.yaml` |
| Cline | ✅ | — | VS Code extension `tasks/<id>/api_conversation_history.json` |
| Continue | ✅ | — | `~/.continue/sessions.db` |
| Continue | ✅ | — | `~/.continue/sessions/<id>.json` (honours `CONTINUE_GLOBAL_DIR`) |
| Windsurf | — | — | Not supported yet: Cascade stores conversations in an encrypted format |

### Importing into Cursor
Expand All @@ -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/<uuid>/` (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/<encoded-path>/`. 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.
Expand Down
177 changes: 134 additions & 43 deletions movez/app/Parsers/ContinueParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sessionId>.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
{
Expand All @@ -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<int, SessionDTO> */
Expand All @@ -38,49 +50,128 @@ public function parseMetadata(string $projectPath): Collection
return $this->parse($projectPath);
}

/** @return Collection<int, SessionDTO> */
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<string, array<string, mixed>> $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<string, array<string, mixed>> 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',
));
}
}
Loading
Loading