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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ greplica graph context "<query>" [--debug]
greplica graph audit anchors
greplica graph view [--out <file>] [--no-open]
greplica graph export <dir>
greplica transcript bundle --platform codex|claude|copilot --file <path> [--file <path>...] --out <bundle.md>
greplica transcript bundle --platform codex|claude|copilot|opencode|openhands|factory-droid --file <path> [--file <path>...] --out <bundle.md>
greplica proposal validate <proposal.json>
greplica proposal apply <proposal.json>
```

- `greplica graph context "<query>"` - 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.

Expand Down
4 changes: 2 additions & 2 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const cliCommands = [
{
key: "transcriptBundle",
path: ["transcript", "bundle"],
usage: "transcript bundle --platform codex|claude|copilot|opencode --file <path> [--file <path>...] --out <bundle.md>",
usage: "transcript bundle --platform codex|claude|copilot|opencode|openhands|factory-droid --file <path> [--file <path>...] --out <bundle.md>",
handler: runTranscriptBundle,
showInTopLevelHelp: true,
},
Expand Down Expand Up @@ -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")}`);
}

Expand Down
4 changes: 2 additions & 2 deletions docs/agent-install-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ 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/<sessionId>/`.
- 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/<sessionId>/`; OpenHands `~/.openhands/conversations/<conversationId>/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.
- Show me the selected transcripts before bundling them: title if available, date/time, path, size/turn count if available, and why each matched this repo.
- Do not ask for confirmation. Continue with a temporary bundle path:

```bash
greplica transcript bundle --platform <codex-or-claude-or-copilot-or-opencode> --file <path-1> [--file <path-2>] [--file <path-3>] --out <greplica-transcript-backfill.md>
greplica transcript bundle --platform <codex-or-claude-or-copilot-or-opencode-or-openhands-or-factory-droid> --file <path-1> [--file <path-2>] [--file <path-3>] --out <greplica-transcript-backfill.md>
```

- Then use the `greplica-fast-session-bootstrap` skill on `<greplica-transcript-backfill.md>` and include its final value summary naturally in the final answer.
Expand Down
3 changes: 2 additions & 1 deletion libs/install/platforms/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion libs/install/platforms/openhands.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -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;

Expand All @@ -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<string, string>, event: Record<string, unknown>): 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";
Expand Down
2 changes: 2 additions & 0 deletions libs/install/platforms/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion libs/session-transcript/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions scripts/check-transcript-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. <developer_instruction>remove this</developer_instruction>",
},
],
},
}),
].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. <system_instruction>remove this</system_instruction>",
},
],
},
}),
"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,
[
Expand Down Expand Up @@ -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(
Expand Down