Skip to content
Open
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
23 changes: 23 additions & 0 deletions App/backend/src/adapters/outbound/agent-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,26 @@ function resolveAgentPathWithRuntime(value: string, runtime: AgentPathRuntime):
? runtime.pathApi.normalize(expanded)
: runtime.pathApi.resolve(expanded);
}


export function resolveWindsurfDataDirectory(options: ResolveAgentPathOptions = {}): string {
const runtime = createAgentPathRuntime(options);
return runtime.pathApi.join(runtime.homeDirectory, ".codeium", "windsurf");
}

export function resolveClineDataDirectory(options: ResolveAgentPathOptions = {}): string {
const runtime = createAgentPathRuntime(options);
const configBase =
runtime.environment.APPDATA ??
(runtime.platform() === "darwin"
? runtime.pathApi.join(runtime.homeDirectory, "Library", "Application Support")
: runtime.pathApi.join(runtime.homeDirectory, ".config"));
return runtime.pathApi.join(
configBase,
"Code",
"User",
"globalStorage",
"saoudrizwan.claude-dev"
);
}

130 changes: 130 additions & 0 deletions App/backend/src/adapters/outbound/agent-source/cline/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/** Cline source adapter.
*
* Cline (VS Code extension) stores conversations as JSON files under
* the VS Code globalStorage directory.
*
* Path (macOS): ``~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/``
* Path (Linux): ``~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/``
* Path (Windows): ``%APPDATA%/Code/User/globalStorage/saoudrizwan.claude-dev/``
*/

import { access } from "node:fs/promises";
import { resolveClineDataDirectory } from "../../agent-paths.js";
import { collectConversationWindow, remainingMessageCapacity } from "../conversation-window.js";
import { redactSecrets } from "../secret-redactor.js";
import type { ConversationMessage, ScanOptions, SourceAdapter, SourceDescriptor } from "../types.js";
import { discoverClineSessions } from "./session-discovery.js";
import { readClineConversation, type RawClineMessage } from "./transcript-reader.js";

const CLINE_SOURCE_ID = "cline";

export interface CreateClineSourceAdapterDeps {
dataDirectory?: string;
descriptor?: SourceDescriptor;
}

export function createClineSourceAdapter(deps: CreateClineSourceAdapterDeps = {}): SourceAdapter {
const dataDirectory = deps.dataDirectory ?? resolveClineDataDirectory();
const descriptor =
deps.descriptor ??
Object.freeze({
sourceId: CLINE_SOURCE_ID,
displayName: "Cline",
builtin: true,
dataPath: dataDirectory,
});

return {
descriptor,

async detect() {
try {
await access(dataDirectory);
return true;
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return false;
throw error;
}
},

async *scan(options: ScanOptions) {
throwIfAborted(options.signal);
options.onProgress?.({ sourceId: descriptor.sourceId, phase: "discover", current: 0, total: 1 });

const sessions = await discoverClineSessions({
root: dataDirectory,
order: options.order === "recent_first" ? "recent_first" : "path_asc",
maxSessions: options.maxScanTargets,
});

options.onProgress?.({
sourceId: descriptor.sourceId,
phase: "discover",
current: sessions.length,
total: sessions.length,
});

let emitted = 0;
for (const [i, session] of sessions.entries()) {
throwIfAborted(options.signal);
if (limitReached(emitted, options.maxMessages)) break;

options.onProgress?.({
sourceId: descriptor.sourceId,
phase: "read",
current: i,
total: sessions.length,
message: session.filePath,
});

const messages = await collectConversationWindow(
readClineConversation(session.filePath, options.signal),
options.since,
options.signal,
remainingMessageCapacity(options.maxMessages, emitted),
);

for (const raw of messages) {
throwIfAborted(options.signal);
options.onProgress?.({ sourceId: descriptor.sourceId, phase: "redact", current: emitted, total: emitted + 1 });
emitted += 1;
options.onProgress?.({ sourceId: descriptor.sourceId, phase: "emit", current: emitted, total: emitted });
yield toConversationMessage(descriptor.sourceId, raw, session.workspacePath, session.gitRoot);
}
}

options.onProgress?.({ sourceId: descriptor.sourceId, phase: "done", current: emitted, total: emitted });
},
};
}

function toConversationMessage(
sourceId: string,
raw: RawClineMessage,
discoveredWorkspacePath: string | null,
discoveredGitRoot: string | null,
): ConversationMessage {
return {
messageId: raw.messageId,
sourceId,
conversationId: raw.conversationId,
role: raw.role,
content: redactSecrets(raw.content),
createdAt: raw.createdAt,
workspacePath: raw.workspacePath ?? discoveredWorkspacePath,
gitRoot: raw.gitRoot ?? discoveredGitRoot,
rawMeta: Object.freeze({}),
};
}

function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw new DOMException("Cline source scan aborted", "AbortError");
}

function limitReached(count: number, max: number | undefined): boolean {
return max !== undefined && count >= max;
}

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createClineSourceAdapter } from "./adapter.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Discovers Cline conversation JSON files.
*
* Cline stores task histories as individual JSON task files.
*/

import { readdir, stat } from "node:fs/promises";
import { extname, join } from "node:path";

export interface DiscoveredClineSession {
filePath: string;
workspacePath: string | null;
gitRoot: string | null;
lastModified: number;
}

export interface DiscoverClineOptions {
root: string;
order?: "recent_first" | "path_asc";
maxSessions?: number;
}

export async function discoverClineSessions(options: DiscoverClineOptions): Promise<DiscoveredClineSession[]> {
const tasksDir = join(options.root, "tasks");

let entries: string[];
try {
entries = await readdir(tasksDir);
} catch {
return [];
}

const sessions = await Promise.all(
entries
.filter((e) => extname(e) === ".json")
.map(async (entry) => {
const filePath = join(tasksDir, entry);
let lastModified = 0;
try {
const s = await stat(filePath);
lastModified = s.mtimeMs;
} catch {
// ignore
}
return { filePath, workspacePath: null, gitRoot: null, lastModified };
}),
);

if (options.order === "recent_first") {
sessions.sort((a, b) => b.lastModified - a.lastModified);
} else {
sessions.sort((a, b) => a.filePath.localeCompare(b.filePath));
}

if (options.maxSessions !== undefined && sessions.length > options.maxSessions) {
return sessions.slice(0, options.maxSessions);
}

return sessions;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { createClineSourceAdapter } from "../adapter.js";

describe("createClineSourceAdapter", () => {
it("returns a source adapter with the expected descriptor", () => {
const adapter = createClineSourceAdapter();
expect(adapter.descriptor.sourceId).toBe("cline");
expect(adapter.descriptor.displayName).toBe("Cline");
expect(adapter.descriptor.builtin).toBe(true);
});

it("detect returns false when the data directory does not exist", async () => {
const adapter = createClineSourceAdapter({
dataDirectory: "/nonexistent/path/to/cline",
});
const detected = await adapter.detect();
expect(detected).toBe(false);
});

it("accepts custom descriptor and data directory", () => {
const adapter = createClineSourceAdapter({
dataDirectory: "/custom/cline",
descriptor: Object.freeze({
sourceId: "cline-custom",
displayName: "Cline (Custom)",
builtin: false,
dataPath: "/custom/cline",
}),
});
expect(adapter.descriptor.sourceId).toBe("cline-custom");
expect(adapter.descriptor.builtin).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/** Reads a Cline task JSON file.
*
* Cline stores each task as a JSON object with a ``messages`` array
* containing turns with ``role`` and ``content`` fields.
*/

import { readFile } from "node:fs/promises";

export interface RawClineMessage {
messageId: string;
conversationId: string;
role: "user" | "assistant";
content: string;
createdAt: string;
workspacePath: string | null;
gitRoot: string | null;
}

interface ClineTaskFile {
taskId?: string;
messages?: ClineTurn[];
history?: ClineTurn[];
}

interface ClineTurn {
id?: string | number;
ts?: number;
role?: string;
say?: string;
text?: string;
content?: string;
}

export async function* readClineConversation(
filePath: string,
signal?: AbortSignal,
): AsyncIterable<RawClineMessage> {
let raw: string;
try {
raw = await readFile(filePath, "utf-8");
} catch {
return;
}

let task: ClineTaskFile;
try {
task = JSON.parse(raw);
} catch {
return;
}

const messages = task.messages ?? task.history ?? [];
const conversationId = task.taskId ?? filePath.split("/").pop()?.replace(".json", "") ?? "unknown";

for (let i = 0; i < messages.length; i++) {
if (signal?.aborted) return;
const msg = messages[i];

const role = normalizeRole(msg.role);
if (!role) continue;

const content = msg.say ?? msg.text ?? msg.content ?? "";
if (!content) continue;

yield {
messageId: typeof msg.id === "number" ? String(msg.id) : (msg.id as string) ?? `${conversationId}:${i}`,
conversationId,
role,
content,
createdAt: msg.ts ? new Date(msg.ts).toISOString() : new Date().toISOString(),
workspacePath: null,
gitRoot: null,
};
}
}

function normalizeRole(role: string | undefined): "user" | "assistant" | null {
if (role === "user" || role === "human") return "user";
if (role === "assistant" || role === "ai" || role === "bot") return "assistant";
return null;
}
Loading