From 39813f44bbb3b7d9b92db4e3b39773e7e62fc062 Mon Sep 17 00:00:00 2001 From: Timelovers <46080686+Timelovers@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:33:29 +0800 Subject: [PATCH] feat(agent-source): add Windsurf and Cline adapters Add agent-source adapters for Windsurf (Codeium) and Cline (VS Code extension) so Memmy can import conversation history from these tools. - Windsurf: reads from ~/.codeium/windsurf/conversations/*.json - Cline: reads from VS Code globalStorage tasks/*.json - Both follow the existing Claude Code / Opencode adapter pattern - Add path resolvers to agent-paths.ts - Register both in builtin-agent-source-registry - 6 unit tests (3 per adapter: descriptor, detect, custom config) Co-authored-by: Timelovers <46080686+Timelovers@users.noreply.github.com> --- .../src/adapters/outbound/agent-paths.ts | 23 ++++ .../outbound/agent-source/cline/adapter.ts | 130 ++++++++++++++++++ .../outbound/agent-source/cline/index.ts | 1 + .../agent-source/cline/session-discovery.ts | 59 ++++++++ .../agent-source/cline/tests/adapter.test.ts | 33 +++++ .../agent-source/cline/transcript-reader.ts | 81 +++++++++++ .../outbound/agent-source/windsurf/adapter.ts | 128 +++++++++++++++++ .../outbound/agent-source/windsurf/index.ts | 1 + .../windsurf/session-discovery.ts | 63 +++++++++ .../windsurf/tests/adapter.test.ts | 33 +++++ .../windsurf/transcript-reader.ts | 90 ++++++++++++ .../services/builtin-agent-source-registry.ts | 4 + 12 files changed, 646 insertions(+) create mode 100644 App/backend/src/adapters/outbound/agent-source/cline/adapter.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/cline/index.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/cline/session-discovery.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/cline/tests/adapter.test.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/cline/transcript-reader.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/windsurf/adapter.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/windsurf/index.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/windsurf/session-discovery.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/windsurf/tests/adapter.test.ts create mode 100644 App/backend/src/adapters/outbound/agent-source/windsurf/transcript-reader.ts diff --git a/App/backend/src/adapters/outbound/agent-paths.ts b/App/backend/src/adapters/outbound/agent-paths.ts index 6fd411752..de584305c 100644 --- a/App/backend/src/adapters/outbound/agent-paths.ts +++ b/App/backend/src/adapters/outbound/agent-paths.ts @@ -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" + ); +} + diff --git a/App/backend/src/adapters/outbound/agent-source/cline/adapter.ts b/App/backend/src/adapters/outbound/agent-source/cline/adapter.ts new file mode 100644 index 000000000..bbca2aab6 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/cline/adapter.ts @@ -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; +} diff --git a/App/backend/src/adapters/outbound/agent-source/cline/index.ts b/App/backend/src/adapters/outbound/agent-source/cline/index.ts new file mode 100644 index 000000000..7269016fa --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/cline/index.ts @@ -0,0 +1 @@ +export { createClineSourceAdapter } from "./adapter.js"; diff --git a/App/backend/src/adapters/outbound/agent-source/cline/session-discovery.ts b/App/backend/src/adapters/outbound/agent-source/cline/session-discovery.ts new file mode 100644 index 000000000..64fb4445b --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/cline/session-discovery.ts @@ -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 { + 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; +} diff --git a/App/backend/src/adapters/outbound/agent-source/cline/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/cline/tests/adapter.test.ts new file mode 100644 index 000000000..5cfce9692 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/cline/tests/adapter.test.ts @@ -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); + }); +}); diff --git a/App/backend/src/adapters/outbound/agent-source/cline/transcript-reader.ts b/App/backend/src/adapters/outbound/agent-source/cline/transcript-reader.ts new file mode 100644 index 000000000..c05db7071 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/cline/transcript-reader.ts @@ -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 { + 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; +} diff --git a/App/backend/src/adapters/outbound/agent-source/windsurf/adapter.ts b/App/backend/src/adapters/outbound/agent-source/windsurf/adapter.ts new file mode 100644 index 000000000..9b589a6c3 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/windsurf/adapter.ts @@ -0,0 +1,128 @@ +/** Windsurf source adapter. + * + * Windsurf (by Codeium) stores conversation history as JSON files + * under ``~/.codeium/windsurf/conversations/``. + */ + +import { access } from "node:fs/promises"; +import { resolveWindsurfDataDirectory } 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 { discoverWindsurfConversations } from "./session-discovery.js"; +import { readWindsurfConversation, type RawWindsurfMessage } from "./transcript-reader.js"; + +const WINDSURF_SOURCE_ID = "windsurf"; + +export interface CreateWindsurfSourceAdapterDeps { + dataDirectory?: string; + descriptor?: SourceDescriptor; +} + +export function createWindsurfSourceAdapter(deps: CreateWindsurfSourceAdapterDeps = {}): SourceAdapter { + const dataDirectory = deps.dataDirectory ?? resolveWindsurfDataDirectory(); + const descriptor = + deps.descriptor ?? + Object.freeze({ + sourceId: WINDSURF_SOURCE_ID, + displayName: "Windsurf", + 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 conversations = await discoverWindsurfConversations({ + root: dataDirectory, + order: options.order === "recent_first" ? "recent_first" : "path_asc", + maxSessions: options.maxScanTargets, + }); + + options.onProgress?.({ + sourceId: descriptor.sourceId, + phase: "discover", + current: conversations.length, + total: conversations.length, + }); + + let emitted = 0; + for (const [i, conv] of conversations.entries()) { + throwIfAborted(options.signal); + if (limitReached(emitted, options.maxMessages)) break; + + options.onProgress?.({ + sourceId: descriptor.sourceId, + phase: "read", + current: i, + total: conversations.length, + message: conv.filePath, + }); + + const messages = await collectConversationWindow( + readWindsurfConversation(conv.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, conv.workspacePath, conv.gitRoot); + } + } + + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "done", current: emitted, total: emitted }); + }, + }; +} + +function toConversationMessage( + sourceId: string, + raw: RawWindsurfMessage, + 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("Windsurf 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; +} diff --git a/App/backend/src/adapters/outbound/agent-source/windsurf/index.ts b/App/backend/src/adapters/outbound/agent-source/windsurf/index.ts new file mode 100644 index 000000000..03633f269 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/windsurf/index.ts @@ -0,0 +1 @@ +export { createWindsurfSourceAdapter } from "./adapter.js"; diff --git a/App/backend/src/adapters/outbound/agent-source/windsurf/session-discovery.ts b/App/backend/src/adapters/outbound/agent-source/windsurf/session-discovery.ts new file mode 100644 index 000000000..b4ee0b3b6 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/windsurf/session-discovery.ts @@ -0,0 +1,63 @@ +/** Discovers Windsurf conversation JSON files under the data directory. + * + * Windsurf stores each conversation as a ``{id}.json`` file under + * ``~/.codeium/windsurf/conversations/``. + */ + +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { readDirectoryEntries } from "../read-directory.js"; + +export interface DiscoveredWindsurfConversation { + filePath: string; + workspacePath: string | null; + gitRoot: string | null; + lastModified: number; +} + +export interface DiscoverWindsurfOptions { + root: string; + order?: "recent_first" | "path_asc"; + maxSessions?: number; +} + +export async function discoverWindsurfConversations( + options: DiscoverWindsurfOptions, +): Promise { + const conversationsDir = join(options.root, "conversations"); + + let entries: string[]; + try { + entries = await readdir(conversationsDir); + } catch { + return []; + } + + const conversations = await Promise.all( + entries + .filter((e) => e.endsWith(".json")) + .map(async (entry) => { + const filePath = join(conversationsDir, entry); + let lastModified = 0; + try { + const s = await stat(filePath); + lastModified = s.mtimeMs; + } catch { + // ignore stat errors + } + return { filePath, workspacePath: null, gitRoot: null, lastModified }; + }), + ); + + if (options.order === "recent_first") { + conversations.sort((a, b) => b.lastModified - a.lastModified); + } else { + conversations.sort((a, b) => a.filePath.localeCompare(b.filePath)); + } + + if (options.maxSessions !== undefined && conversations.length > options.maxSessions) { + return conversations.slice(0, options.maxSessions); + } + + return conversations; +} diff --git a/App/backend/src/adapters/outbound/agent-source/windsurf/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/windsurf/tests/adapter.test.ts new file mode 100644 index 000000000..c5db39a3d --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/windsurf/tests/adapter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createWindsurfSourceAdapter } from "../adapter.js"; + +describe("createWindsurfSourceAdapter", () => { + it("returns a source adapter with the expected descriptor", () => { + const adapter = createWindsurfSourceAdapter(); + expect(adapter.descriptor.sourceId).toBe("windsurf"); + expect(adapter.descriptor.displayName).toBe("Windsurf"); + expect(adapter.descriptor.builtin).toBe(true); + }); + + it("detect returns false when the data directory does not exist", async () => { + const adapter = createWindsurfSourceAdapter({ + dataDirectory: "/nonexistent/path/to/windsurf", + }); + const detected = await adapter.detect(); + expect(detected).toBe(false); + }); + + it("accepts custom descriptor and data directory", () => { + const adapter = createWindsurfSourceAdapter({ + dataDirectory: "/custom/windsurf", + descriptor: Object.freeze({ + sourceId: "windsurf-custom", + displayName: "Windsurf (Custom)", + builtin: false, + dataPath: "/custom/windsurf", + }), + }); + expect(adapter.descriptor.sourceId).toBe("windsurf-custom"); + expect(adapter.descriptor.builtin).toBe(false); + }); +}); diff --git a/App/backend/src/adapters/outbound/agent-source/windsurf/transcript-reader.ts b/App/backend/src/adapters/outbound/agent-source/windsurf/transcript-reader.ts new file mode 100644 index 000000000..b80063101 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/windsurf/transcript-reader.ts @@ -0,0 +1,90 @@ +/** Reads a Windsurf conversation JSON file. + * + * Windsurf stores each conversation as a JSON array of turn objects + * with ``role`` (``"user"`` / ``"assistant"``) and ``content`` fields. + */ + +import { readFile } from "node:fs/promises"; + +export interface RawWindsurfMessage { + messageId: string; + conversationId: string; + role: "user" | "assistant"; + content: string; + createdAt: string; + workspacePath: string | null; + gitRoot: string | null; +} + +interface WindsurfTurn { + id?: string; + role?: string; + content?: string | { type: string; text?: string }[]; + timestamp?: number | string; +} + +export async function* readWindsurfConversation( + filePath: string, + signal?: AbortSignal, +): AsyncIterable { + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch { + return; + } + + let turns: WindsurfTurn[]; + try { + const parsed = JSON.parse(raw); + turns = Array.isArray(parsed) ? parsed : parsed.messages ?? []; + } catch { + return; + } + + const conversationId = filePath.split("/").pop()?.replace(".json", "") ?? "unknown"; + + for (let i = 0; i < turns.length; i++) { + if (signal?.aborted) return; + const turn = turns[i]; + + const role = normalizeRole(turn.role); + if (!role) continue; + + const content = extractContent(turn.content); + if (!content || content.length === 0) continue; + + yield { + messageId: turn.id ?? `${conversationId}:${i}`, + conversationId, + role, + content, + createdAt: normalizeDate(turn.timestamp), + 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; +} + +function extractContent(content: WindsurfTurn["content"]): string | null { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c): c is { type: string; text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join("\n"); + } + return null; +} + +function normalizeDate(ts: number | string | undefined): string { + if (typeof ts === "number") return new Date(ts).toISOString(); + if (typeof ts === "string") return new Date(ts).toISOString(); + return new Date().toISOString(); +} diff --git a/App/backend/src/services/builtin-agent-source-registry.ts b/App/backend/src/services/builtin-agent-source-registry.ts index d88cc177b..ec3a8133b 100644 --- a/App/backend/src/services/builtin-agent-source-registry.ts +++ b/App/backend/src/services/builtin-agent-source-registry.ts @@ -1,10 +1,12 @@ import { createClaudeCodeSourceAdapter } from "../adapters/outbound/agent-source/claude-code/index.js"; +import { createClineSourceAdapter } from "../adapters/outbound/agent-source/cline/index.js"; import { createCodexSourceAdapter } from "../adapters/outbound/agent-source/codex/index.js"; import { createCursorSourceAdapter } from "../adapters/outbound/agent-source/cursor/index.js"; import { createHermesSourceAdapter } from "../adapters/outbound/agent-source/hermes/index.js"; import { createOpenclawSourceAdapter } from "../adapters/outbound/agent-source/openclaw/index.js"; import { createOpencodeSourceAdapter } from "../adapters/outbound/agent-source/opencode/index.js"; import { createSourceRegistry, type SourceRegistry } from "../adapters/outbound/agent-source/source-registry.js"; +import { createWindsurfSourceAdapter } from "../adapters/outbound/agent-source/windsurf/index.js"; import { createWorkbuddySourceAdapter } from "../adapters/outbound/agent-source/workbuddy/index.js"; export function createBuiltinAgentSourceRegistry(): SourceRegistry { @@ -15,6 +17,8 @@ export function createBuiltinAgentSourceRegistry(): SourceRegistry { createOpencodeSourceAdapter(), createOpenclawSourceAdapter(), createHermesSourceAdapter(), + createWindsurfSourceAdapter(), + createClineSourceAdapter(), createWorkbuddySourceAdapter() ]); }