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/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, diff --git a/libs/install/platforms/openhands.ts b/libs/install/platforms/openhands.ts index e20a5fb..f18f453 100644 --- a/libs/install/platforms/openhands.ts +++ b/libs/install/platforms/openhands.ts @@ -1,12 +1,13 @@ 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"; 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); }, @@ -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,21 @@ 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 !== "." && conversationId !== ".." ? conversationId : undefined; +} + +function copyOpenHandsMetadata(target: Record, event: Record): void { + 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 { if (source === "user") return "human"; if (source === "agent") return "agent"; 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 b16333f..cb3babb 100644 --- a/scripts/check-transcript-bundle.js +++ b/scripts/check-transcript-bundle.js @@ -13,10 +13,16 @@ 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 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, @@ -126,6 +132,76 @@ 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", +); + +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, [ @@ -199,6 +275,70 @@ 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/); + +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(