From 3991aa24afb035573dca297d6c3f5d1461cfbfe2 Mon Sep 17 00:00:00 2001 From: volfiros <99183441+volfiros@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:21:22 +0530 Subject: [PATCH 1/3] fix: support more transcript bundle platforms --- README.md | 4 +- apps/cli/main.ts | 4 +- docs/agent-install-prompt.md | 4 +- libs/install/platforms/openhands.ts | 26 ++++++- scripts/check-transcript-bundle.js | 103 ++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8fe8e5b..e37a7a5 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ greplica graph context "" [--debug] greplica graph audit anchors greplica graph view [--out ] [--no-open] greplica graph export -greplica transcript bundle --platform codex|claude|copilot --file [--file ...] --out +greplica transcript bundle --platform codex|claude|copilot|opencode|openhands|factory-droid --file [--file ...] --out greplica proposal validate greplica proposal apply ``` @@ -134,7 +134,7 @@ greplica proposal apply - `greplica graph context ""` - returns Markdown for agent use. Add `--debug` for the full retrieval payload with ranking signals. - `greplica graph read` - prints the current graph view: all components, flows, claims, sources, and edges in scope. - `greplica graph view` to visualise the current memory in a local HTML, opens in your default browser. Use `--out` to choose where the file is written; by default it goes to a temp path. -- `greplica transcript bundle` - converts one or more Codex, Claude Code, or GitHub Copilot CLI JSONL transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`. +- `greplica transcript bundle` - converts one or more Codex, Claude Code, GitHub Copilot CLI, OpenCode, OpenHands, or Factory Droid transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`. - `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command. - `greplica install` prepares repo state, local storage, and agent integration; normal repo commands require install first. diff --git a/apps/cli/main.ts b/apps/cli/main.ts index e18df82..f167780 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -142,7 +142,7 @@ const cliCommands = [ { key: "transcriptBundle", path: ["transcript", "bundle"], - usage: "transcript bundle --platform codex|claude|copilot|opencode --file [--file ...] --out ", + usage: "transcript bundle --platform codex|claude|copilot|opencode|openhands|factory-droid --file [--file ...] --out ", handler: runTranscriptBundle, showInTopLevelHelp: true, }, @@ -755,7 +755,7 @@ function parseTranscriptBundleArgs(args: string[]): TranscriptBundleOptions { } function parseTranscriptBundlePlatform(value: string): InstallPlatform { - if (value === "codex" || value === "claude" || value === "copilot" || value === "opencode") return value; + if (value === "codex" || value === "claude" || value === "copilot" || value === "opencode" || value === "openhands" || value === "factory-droid") return value; throw new Error(`Invalid --platform ${value}.\n${usage("transcriptBundle")}`); } diff --git a/docs/agent-install-prompt.md b/docs/agent-install-prompt.md index 02f9de3..d75d492 100644 --- a/docs/agent-install-prompt.md +++ b/docs/agent-install-prompt.md @@ -51,7 +51,7 @@ Then bootstrap shallow memory for this repo: If I chose "Yes, recent sessions", analyze prior sessions: - Find recent prior sessions for this same repo and platform, preferring work from the last 1-2 days. -- Candidate locations: Codex `~/.codex/sessions/**/*.jsonl`; Claude Code `~/.claude/projects/**/*.jsonl`; GitHub Copilot CLI paths from `Stop` hook `transcript_path` values or `$COPILOT_HOME/session-state`; OpenCode `~/.local/share/opencode/storage/session/*.json` with messages under `storage/message//`. +- Candidate locations: Codex `~/.codex/sessions/**/*.jsonl`; Claude Code `~/.claude/projects/**/*.jsonl`; GitHub Copilot CLI paths from `Stop` hook `transcript_path` values or `$COPILOT_HOME/session-state`; OpenCode `~/.local/share/opencode/storage/session/*.json` with messages under `storage/message//`; OpenHands `~/.openhands/conversations//events`; Factory Droid Claude-style JSONL transcripts. - Do not require transcript metadata `cwd` to equal the current checkout path. Users may use worktrees, renamed folders, or multiple checkouts of the same repo. - Treat a transcript as same-repo when its metadata `cwd` is the current path, or when that `cwd` still exists and Git reports the same `remote.origin.url` or same normalized repo identity as the current repo. If the old path no longer exists, use transcript cwd text, repo name, branch, and recent session content as weaker matching evidence. - Select 1-3 transcripts. Use one if there is a large high-signal session, two by default when multiple sessions are useful, and three only when sessions are smaller or cover distinct work. @@ -59,7 +59,7 @@ If I chose "Yes, recent sessions", analyze prior sessions: - Do not ask for confirmation. Continue with a temporary bundle path: ```bash -greplica transcript bundle --platform --file [--file ] [--file ] --out +greplica transcript bundle --platform --file [--file ] [--file ] --out ``` - Then use the `greplica-fast-session-bootstrap` skill on `` and include its final value summary naturally in the final answer. diff --git a/libs/install/platforms/openhands.ts b/libs/install/platforms/openhands.ts index e20a5fb..dc04a67 100644 --- a/libs/install/platforms/openhands.ts +++ b/libs/install/platforms/openhands.ts @@ -1,6 +1,6 @@ import { readFileSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { hookCommand, hookEvents, mergeHookConfig, readJsonObject, writeJson } from "../hook-config.js"; import { copyBundledSkills } from "../skills.js"; import { runOpenHandsAgent } from "../../agent-runner/openhands.js"; @@ -82,6 +82,10 @@ function loadOpenHandsEvents(eventsDir: string): string { } const lines: string[] = []; + const sessionId = sessionIdFromEventsDir(eventsDir); + if (sessionId !== undefined) { + lines.push(JSON.stringify({ type: "session.header", session_id: sessionId })); + } for (const name of entries.filter((entry) => entry.startsWith("event-") && entry.endsWith(".json")).sort()) { let parsed: unknown; try { @@ -102,6 +106,8 @@ function openhandsTranscriptToMarkdown(jsonl: string): string { const event = parseJsonLine(line); if (!isRecord(event)) continue; + copyOpenHandsMetadata(metadata, event); + const role = openhandsRole(event.source); if (role === undefined) continue; @@ -121,6 +127,20 @@ function openhandsTranscriptToMarkdown(jsonl: string): string { return renderSessionTranscriptMarkdown({ metadata, messages }); } +function sessionIdFromEventsDir(eventsDir: string): string | undefined { + if (basename(eventsDir) !== "events") return undefined; + const conversationId = basename(dirname(eventsDir)); + return conversationId.length > 0 ? conversationId : undefined; +} + +function copyOpenHandsMetadata(target: Record, event: Record): void { + const sessionId = stringValue(event.session_id) ?? stringValue(event.sessionId) ?? stringValue(event.conversation_id) ?? stringValue(event.conversationId); + if (target.session_id === undefined && sessionId !== undefined) target.session_id = sessionId; + + const cwd = stringValue(event.cwd) ?? stringValue(event.working_dir); + if (target.cwd === undefined && cwd !== undefined) target.cwd = cwd; +} + function openhandsRole(source: unknown): SessionTranscriptMessage["role"] | undefined { if (source === "user") return "human"; if (source === "agent") return "agent"; @@ -151,3 +171,7 @@ function extractTextContent(value: unknown): string { } return parts.join("\n\n").trim(); } + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/scripts/check-transcript-bundle.js b/scripts/check-transcript-bundle.js index b16333f..7f78071 100644 --- a/scripts/check-transcript-bundle.js +++ b/scripts/check-transcript-bundle.js @@ -13,10 +13,14 @@ const codexOne = join(tmp, "codex-one.jsonl"); const codexTwo = join(tmp, "codex-two.jsonl"); const claudeOne = join(tmp, "claude-one.jsonl"); const copilotOne = join(tmp, "copilot-one.jsonl"); +const factoryDroidOne = join(tmp, "factory-droid-one.jsonl"); +const openhandsEventsDir = join(tmp, "openhands", "conversations", "openhandssessionone", "events"); const codexOut = join(tmp, "codex-bundle.md"); const claudeOut = join(tmp, "claude-bundle.md"); const copilotOut = join(tmp, "copilot-bundle.md"); const opencodeOut = join(tmp, "opencode-bundle.md"); +const factoryDroidOut = join(tmp, "factory-droid-bundle.md"); +const openhandsOut = join(tmp, "openhands-bundle.md"); writeFileSync( codexOne, @@ -126,6 +130,61 @@ writeFileSync( "utf8", ); +writeFileSync( + factoryDroidOne, + [ + JSON.stringify({ + type: "user", + sessionId: "factory-droid-session-one", + cwd: "/repo/example", + timestamp: "2026-06-25T00:05:30.000Z", + message: { + role: "user", + content: [ + { + type: "text", + text: "Remember this durable Factory Droid insight. remove this", + }, + ], + }, + }), + ].join("\n"), + "utf8", +); + +mkdirSync(openhandsEventsDir, { recursive: true }); +writeFileSync( + join(openhandsEventsDir, "event-001.json"), + JSON.stringify({ + kind: "MessageEvent", + source: "user", + timestamp: "2026-06-25T00:05:45.000Z", + working_dir: "/repo/example", + llm_message: { + content: [ + { + type: "text", + text: "Remember this durable OpenHands insight. remove this", + }, + ], + }, + }), + "utf8", +); +writeFileSync( + join(openhandsEventsDir, "event-002.json"), + JSON.stringify({ + kind: "ActionEvent", + source: "agent", + timestamp: "2026-06-25T00:05:50.000Z", + action: { + kind: "FinishAction", + message: "An OpenHands finish fact.", + }, + }), + "utf8", +); + const codexOutput = execFileSync( process.execPath, [ @@ -199,6 +258,50 @@ assert.match(copilotBundle, /Remember this durable Copilot insight/); assert.match(copilotBundle, /A Copilot assistant fact/); assert.doesNotMatch(copilotBundle, /remove this/); +const factoryDroidOutput = execFileSync( + process.execPath, + [ + cliPath, + "transcript", + "bundle", + "--platform", + "factory-droid", + "--file", + factoryDroidOne, + "--out", + factoryDroidOut, + ], + { encoding: "utf8" }, +); +const factoryDroidBundle = readFileSync(factoryDroidOut, "utf8"); +assert.match(factoryDroidOutput, /factory-droid-session:factory-droid-session-one/); +assert.match(factoryDroidBundle, /session_ref: factory-droid-session:factory-droid-session-one/); +assert.match(factoryDroidBundle, /Remember this durable Factory Droid insight/); +assert.doesNotMatch(factoryDroidBundle, /remove this/); + +const openhandsOutput = execFileSync( + process.execPath, + [ + cliPath, + "transcript", + "bundle", + "--platform", + "openhands", + "--file", + openhandsEventsDir, + "--out", + openhandsOut, + ], + { encoding: "utf8" }, +); +const openhandsBundle = readFileSync(openhandsOut, "utf8"); +assert.match(openhandsOutput, /openhands-session:openhandssessionone/); +assert.match(openhandsBundle, /session_ref: openhands-session:openhandssessionone/); +assert.match(openhandsBundle, /cwd: \/repo\/example/); +assert.match(openhandsBundle, /Remember this durable OpenHands insight/); +assert.match(openhandsBundle, /An OpenHands finish fact/); +assert.doesNotMatch(openhandsBundle, /remove this/); + assert.throws( () => execFileSync( From 36cf4ebf5957be04e9d110b4bbacaa17720dff53 Mon Sep 17 00:00:00 2001 From: volfiros <99183441+volfiros@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:22:19 +0530 Subject: [PATCH 2/3] fix: import opencode hook input type --- libs/install/platforms/opencode.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/install/platforms/opencode.ts b/libs/install/platforms/opencode.ts index 85c0b8d..3623029 100644 --- a/libs/install/platforms/opencode.ts +++ b/libs/install/platforms/opencode.ts @@ -5,7 +5,8 @@ import Database from "better-sqlite3"; import { hookCommand, hookEvents, mergeHookConfig, readJsonObject, writeJson } from "../hook-config.js"; import { copyBundledSkills } from "../skills.js"; import { runOpenCodeAgent } from "../../agent-runner/opencode.js"; -import { hookSessionId, type HookInput } from "../../hooks/hook-input.js"; +import { hookSessionId } from "../../hooks/hook-input.js"; +import type { HookInput } from "../../hooks/types.js"; import { isRecord, parseJsonLine, From 62a992ddf8fb513ef5d36145ae9e2043c9033f56 Mon Sep 17 00:00:00 2001 From: volfiros <99183441+volfiros@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:35:02 +0530 Subject: [PATCH 3/3] refactor: derive transcript session ids from paths --- libs/install/platforms/openhands.ts | 25 +++++++++---------- libs/install/platforms/types.ts | 2 ++ libs/session-transcript/bundle.ts | 2 +- scripts/check-transcript-bundle.js | 37 +++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/libs/install/platforms/openhands.ts b/libs/install/platforms/openhands.ts index dc04a67..f18f453 100644 --- a/libs/install/platforms/openhands.ts +++ b/libs/install/platforms/openhands.ts @@ -7,6 +7,7 @@ import { runOpenHandsAgent } from "../../agent-runner/openhands.js"; import { hookSessionId } from "../../hooks/hook-input.js"; import type { HookInput } from "../../hooks/types.js"; import { + copyStringField, isRecord, parseJsonLine, renderSessionTranscriptMarkdown, @@ -60,6 +61,9 @@ export const openhandsInstaller: PlatformInstaller = { loadTranscript(transcriptPath: string): string { return loadOpenHandsEvents(transcriptPath); }, + sessionIdFromTranscriptPath(transcriptPath: string): string | undefined { + return sessionIdFromEventsDir(transcriptPath); + }, transcriptToMarkdown(transcript: string): string { return openhandsTranscriptToMarkdown(transcript); }, @@ -82,10 +86,6 @@ function loadOpenHandsEvents(eventsDir: string): string { } const lines: string[] = []; - const sessionId = sessionIdFromEventsDir(eventsDir); - if (sessionId !== undefined) { - lines.push(JSON.stringify({ type: "session.header", session_id: sessionId })); - } for (const name of entries.filter((entry) => entry.startsWith("event-") && entry.endsWith(".json")).sort()) { let parsed: unknown; try { @@ -130,15 +130,16 @@ function openhandsTranscriptToMarkdown(jsonl: string): string { function sessionIdFromEventsDir(eventsDir: string): string | undefined { if (basename(eventsDir) !== "events") return undefined; const conversationId = basename(dirname(eventsDir)); - return conversationId.length > 0 ? conversationId : undefined; + return conversationId.length > 0 && conversationId !== "." && conversationId !== ".." ? conversationId : undefined; } function copyOpenHandsMetadata(target: Record, event: Record): void { - const sessionId = stringValue(event.session_id) ?? stringValue(event.sessionId) ?? stringValue(event.conversation_id) ?? stringValue(event.conversationId); - if (target.session_id === undefined && sessionId !== undefined) target.session_id = sessionId; - - const cwd = stringValue(event.cwd) ?? stringValue(event.working_dir); - if (target.cwd === undefined && cwd !== undefined) target.cwd = cwd; + copyStringField(target, event, "session_id", "session_id"); + copyStringField(target, event, "sessionId", "session_id"); + copyStringField(target, event, "conversation_id", "session_id"); + copyStringField(target, event, "conversationId", "session_id"); + copyStringField(target, event, "cwd", "cwd"); + copyStringField(target, event, "working_dir", "cwd"); } function openhandsRole(source: unknown): SessionTranscriptMessage["role"] | undefined { @@ -171,7 +172,3 @@ function extractTextContent(value: unknown): string { } return parts.join("\n\n").trim(); } - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} diff --git a/libs/install/platforms/types.ts b/libs/install/platforms/types.ts index 952db9b..36b8de7 100644 --- a/libs/install/platforms/types.ts +++ b/libs/install/platforms/types.ts @@ -37,4 +37,6 @@ export interface PlatformInstaller { transcriptPathFromHook?(hook: HookInput): string | undefined; // Override when the transcript is not a single readable file. Default: readFileSync(path). loadTranscript?(transcriptPath: string): string; + // Optional fallback when the filtered transcript omits session metadata. + sessionIdFromTranscriptPath?(transcriptPath: string): string | undefined; } diff --git a/libs/session-transcript/bundle.ts b/libs/session-transcript/bundle.ts index 8b89c89..0f80ec3 100644 --- a/libs/session-transcript/bundle.ts +++ b/libs/session-transcript/bundle.ts @@ -50,7 +50,7 @@ export function buildTranscriptBundle(input: TranscriptBundleInput): TranscriptB const rawTranscript = installer.loadTranscript ? installer.loadTranscript(file) : readFileSync(file, "utf8"); const filteredMarkdown = installer.transcriptToMarkdown(rawTranscript); const metadata = parseFilteredTranscriptMetadata(filteredMarkdown); - const sessionId = metadata.session_id; + const sessionId = metadata.session_id ?? installer.sessionIdFromTranscriptPath?.(file); const sessionRef = sessionId === undefined ? undefined : installer.sessionSourceRef(sessionId); const entry: TranscriptBundleEntry = { file, diff --git a/scripts/check-transcript-bundle.js b/scripts/check-transcript-bundle.js index 7f78071..cb3babb 100644 --- a/scripts/check-transcript-bundle.js +++ b/scripts/check-transcript-bundle.js @@ -15,12 +15,14 @@ const claudeOne = join(tmp, "claude-one.jsonl"); const copilotOne = join(tmp, "copilot-one.jsonl"); const factoryDroidOne = join(tmp, "factory-droid-one.jsonl"); const openhandsEventsDir = join(tmp, "openhands", "conversations", "openhandssessionone", "events"); +const openhandsMetadataEventsDir = join(tmp, "openhands", "conversations", "openhands-path-fallback", "events"); const codexOut = join(tmp, "codex-bundle.md"); const claudeOut = join(tmp, "claude-bundle.md"); const copilotOut = join(tmp, "copilot-bundle.md"); const opencodeOut = join(tmp, "opencode-bundle.md"); const factoryDroidOut = join(tmp, "factory-droid-bundle.md"); const openhandsOut = join(tmp, "openhands-bundle.md"); +const openhandsMetadataOut = join(tmp, "openhands-metadata-bundle.md"); writeFileSync( codexOne, @@ -185,6 +187,21 @@ writeFileSync( "utf8", ); +mkdirSync(openhandsMetadataEventsDir, { recursive: true }); +writeFileSync( + join(openhandsMetadataEventsDir, "event-001.json"), + JSON.stringify({ + kind: "MessageEvent", + source: "user", + conversation_id: "openhands-event-session", + timestamp: "2026-06-25T00:05:55.000Z", + llm_message: { + content: "OpenHands explicit metadata should win.", + }, + }), + "utf8", +); + const codexOutput = execFileSync( process.execPath, [ @@ -302,6 +319,26 @@ assert.match(openhandsBundle, /Remember this durable OpenHands insight/); assert.match(openhandsBundle, /An OpenHands finish fact/); assert.doesNotMatch(openhandsBundle, /remove this/); +const openhandsMetadataOutput = execFileSync( + process.execPath, + [ + cliPath, + "transcript", + "bundle", + "--platform", + "openhands", + "--file", + openhandsMetadataEventsDir, + "--out", + openhandsMetadataOut, + ], + { encoding: "utf8" }, +); +const openhandsMetadataBundle = readFileSync(openhandsMetadataOut, "utf8"); +assert.match(openhandsMetadataOutput, /openhands-session:openhands-event-session/); +assert.match(openhandsMetadataBundle, /session_ref: openhands-session:openhands-event-session/); +assert.doesNotMatch(openhandsMetadataBundle, /openhands-session:openhands-path-fallback/); + assert.throws( () => execFileSync(