Skip to content
Closed
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 supported agent 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
2 changes: 1 addition & 1 deletion docs/agent-install-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
19 changes: 18 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 openhandsSessionIdFromEventsPath(transcriptPath);
},
transcriptToMarkdown(transcript: string): string {
return openhandsTranscriptToMarkdown(transcript);
},
Expand Down Expand Up @@ -94,6 +98,12 @@ function loadOpenHandsEvents(eventsDir: string): string {
return lines.join("\n");
}

function openhandsSessionIdFromEventsPath(transcriptPath: string): string | undefined {
if (basename(transcriptPath) !== "events") return undefined;
const conversationId = basename(dirname(transcriptPath));
return conversationId.length > 0 && conversationId !== "." && conversationId !== ".." ? conversationId : undefined;
}

function openhandsTranscriptToMarkdown(jsonl: string): string {
const metadata: Record<string, string> = {};
const messages: SessionTranscriptMessage[] = [];
Expand All @@ -102,6 +112,13 @@ function openhandsTranscriptToMarkdown(jsonl: string): string {
const event = parseJsonLine(line);
if (!isRecord(event)) continue;

copyStringField(metadata, event, "session_id", "session_id");
copyStringField(metadata, event, "sessionId", "session_id");
copyStringField(metadata, event, "conversation_id", "session_id");
copyStringField(metadata, event, "conversationId", "session_id");
copyStringField(metadata, event, "cwd", "cwd");
copyStringField(metadata, event, "working_dir", "cwd");

const role = openhandsRole(event.source);
if (role === undefined) continue;

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
133 changes: 133 additions & 0 deletions scripts/check-transcript-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 droidOne = join(tmp, "factory-droid-one.jsonl");
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 droidOut = 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 +130,28 @@ writeFileSync(
"utf8",
);

writeFileSync(
droidOne,
[
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. <system_instruction>remove this</system_instruction>",
},
],
},
}),
].join("\n"),
"utf8",
);

const codexOutput = execFileSync(
process.execPath,
[
Expand Down Expand Up @@ -199,6 +225,27 @@ assert.match(copilotBundle, /Remember this durable Copilot insight/);
assert.match(copilotBundle, /A Copilot assistant fact/);
assert.doesNotMatch(copilotBundle, /remove this/);

const droidOutput = execFileSync(
process.execPath,
[
cliPath,
"transcript",
"bundle",
"--platform",
"factory-droid",
"--file",
droidOne,
"--out",
droidOut,
],
{ encoding: "utf8" },
);
const droidBundle = readFileSync(droidOut, "utf8");
assert.match(droidOutput, /factory-droid-session:factory-droid-session-one/);
assert.match(droidBundle, /session_ref: factory-droid-session:factory-droid-session-one/);
assert.match(droidBundle, /Remember this durable Factory Droid insight/);
assert.doesNotMatch(droidBundle, /remove this/);

assert.throws(
() =>
execFileSync(
Expand Down Expand Up @@ -269,4 +316,90 @@ assert.match(opencodeBundle, /Remember this durable OpenCode insight/);
assert.match(opencodeBundle, /An OpenCode assistant fact/);
assert.doesNotMatch(opencodeBundle, /remove this/);

const openhandsEventsDir = join(tmp, ".openhands", "conversations", "openhands-path-session", "events");
mkdirSync(openhandsEventsDir, { recursive: true });
writeFileSync(
join(openhandsEventsDir, "event-001.json"),
JSON.stringify({
kind: "MessageEvent",
source: "user",
timestamp: "2026-06-25T00:08:00.000Z",
llm_message: {
content: "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:09:00.000Z",
action: {
kind: "FinishAction",
message: "An OpenHands agent fact.",
},
}),
"utf8",
);

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:openhands-path-session/);
assert.match(openhandsBundle, /session_ref: openhands-session:openhands-path-session/);
assert.match(openhandsBundle, /Remember this durable OpenHands insight/);
assert.match(openhandsBundle, /An OpenHands agent fact/);
assert.doesNotMatch(openhandsBundle, /remove this/);

const openhandsMetadataEventsDir = join(tmp, ".openhands", "conversations", "openhands-path-fallback", "events");
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:10:00.000Z",
llm_message: {
content: "OpenHands explicit metadata should win.",
},
}),
"utf8",
);

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/);

console.log("Transcript bundle checks passed.");