Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b07347f
Add support for compacting full.jsonl
computermode Mar 23, 2026
ea56d5c
Add tests to include cursor logging
computermode Mar 23, 2026
886b17e
Add support for FactoryDroid; strip ide tags
computermode Mar 23, 2026
0593dab
Add gemini tests
computermode Mar 23, 2026
5ee9626
Add test fixtures and failing test
computermode Mar 24, 2026
ba96074
Update fixtures to testdata folder
computermode Mar 25, 2026
5eb8c6c
Run fmt for linter
computermode Mar 25, 2026
81c0cf0
Include user-provided metadata in expected fixture
computermode Mar 25, 2026
3eed09f
Include the tool output
computermode Mar 25, 2026
4a2589d
Add compact subpackage with shared core helpers
computermode Mar 26, 2026
fb90e3c
Add OpenCode format converter to compact package
computermode Mar 26, 2026
9acec00
Add Factory AI Droid envelope unwrapping to compact package
computermode Mar 26, 2026
202d27d
Add JSONL conversion pipeline to compact package
computermode Mar 26, 2026
4fcec5d
Add Gemini format converter to compact package and transcript
computermode Mar 26, 2026
bf925e1
Refactor transcript compaction for agent-specific handling
computermode Mar 26, 2026
44f646e
Merge branch 'main' of https://github.com/entireio/cli into convert-f…
computermode Mar 26, 2026
d0f2b5f
Merge branch 'main' of https://github.com/entireio/cli into convert-f…
computermode Mar 26, 2026
47fd58b
Merge branch 'main' of https://github.com/entireio/cli into convert-f…
computermode Mar 26, 2026
b42e4ab
Continue extracting shared agent parsing logic
computermode Mar 26, 2026
ee806a7
Run simplify
computermode Mar 26, 2026
e257fea
Add claude full and compacted jsonl examples
computermode Mar 26, 2026
8d506b2
Add shared helpers
computermode Mar 26, 2026
5ba730b
Update compact and droid tests
computermode Mar 26, 2026
8b2f016
Merge branch 'convert-full-transcript-agent-support' of https://githu…
computermode Mar 26, 2026
c5bda2c
Include copilot
computermode Mar 26, 2026
433343b
Fix linter error
computermode Mar 26, 2026
d969af1
ensure content has text for claude
computermode Mar 26, 2026
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
546 changes: 546 additions & 0 deletions cmd/entire/cli/transcript/compact/compact.go

Large diffs are not rendered by default.

495 changes: 495 additions & 0 deletions cmd/entire/cli/transcript/compact/compact_test.go

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions cmd/entire/cli/transcript/compact/copilot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package compact

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"

"github.com/entireio/cli/cmd/entire/cli/textutil"
"github.com/entireio/cli/cmd/entire/cli/transcript"
)

// Copilot CLI uses events.jsonl with event types like "user.message" and
// "assistant.message". This is distinct from Claude/Cursor JSONL shapes.
var copilotEventTypes = map[string]bool{
"user.message": true,
"assistant.message": true,
"assistant.turn_start": true,
"assistant.turn_end": true,
"tool.execution_complete": true,
"session.start": true,
"session.shutdown": true,
"session.model_change": true,
}

// isCopilotFormat checks whether JSONL content looks like Copilot CLI events.
func isCopilotFormat(content []byte) bool {
reader := bufio.NewReader(bytes.NewReader(content))
for {
lineBytes, err := reader.ReadBytes('\n')
if err != nil && err != io.EOF {
return false
}

line := bytes.TrimSpace(lineBytes)
if len(line) > 0 {
var raw map[string]json.RawMessage
if json.Unmarshal(line, &raw) == nil {
eventType := unquote(raw["type"])
if copilotEventTypes[eventType] {
return true
}
// If the first parseable non-empty line has a conventional JSONL
// transcript type ("user"/"assistant"), this is not Copilot.
if userAliases[eventType] || eventType == transcript.TypeAssistant {
return false
}
}
}

if err == io.EOF {
break
}
}
return false
}

func compactCopilot(content []byte, opts Options) ([]byte, error) {
reader := bufio.NewReader(bytes.NewReader(content))
meta := newCompactMeta(opts)
var result []byte

for {
lineBytes, err := reader.ReadBytes('\n')
if err != nil && err != io.EOF {
return nil, fmt.Errorf("reading copilot jsonl line: %w", err)
}

line := bytes.TrimSpace(lineBytes)
if len(line) > 0 {
b := compactCopilotLine(line, meta)
if b != nil {
result = append(result, b...)
result = append(result, '\n')
}
}

if err == io.EOF {
break
}
}

return result, nil
}

func compactCopilotLine(line []byte, meta compactMeta) []byte {
var raw map[string]json.RawMessage
if json.Unmarshal(line, &raw) != nil {
return nil
}

eventType := unquote(raw["type"])
ts := raw["timestamp"]
id := raw["id"]

dataRaw, ok := raw["data"]
if !ok {
return nil
}
var data map[string]json.RawMessage
if json.Unmarshal(dataRaw, &data) != nil {
return nil
}

content := unquote(data["content"])
if content == "" {
return nil
}

switch eventType {
case "user.message":
return marshalOrdered(
"v", meta.v,
"agent", meta.agent,
"cli_version", meta.cliVersion,
"type", mustMarshal(transcript.TypeUser),
"ts", ts,
"content", mustMarshal(textutil.StripIDEContextTags(content)),
)
case "assistant.message":
return marshalOrdered(
"v", meta.v,
"agent", meta.agent,
"cli_version", meta.cliVersion,
"type", mustMarshal(transcript.TypeAssistant),
"ts", ts,
"id", id,
"content", mustMarshal(content),
)
default:
return nil
}
}
94 changes: 94 additions & 0 deletions cmd/entire/cli/transcript/compact/droid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package compact

import (
"bufio"
"bytes"
"encoding/json"

"github.com/entireio/cli/cmd/entire/cli/transcript"
)

// isDroidFormat checks whether JSONL content uses Factory AI Droid's envelope
// format. It scans lines looking for one with a recognizable "type" field — if
// the first such line has type "message" with a nested "message" object, it's
// Droid format. Unrecognized types (session_start, session_event) are skipped.
func isDroidFormat(content []byte) bool {
scanner := bufio.NewScanner(bytes.NewReader(content))
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
var probe struct {
Type string `json:"type"`
Message *json.RawMessage `json:"message"`
}
if json.Unmarshal(line, &probe) != nil {
continue
}
if probe.Type == "message" && probe.Message != nil {
return true
}
// If we hit a known Claude Code/Cursor type, it's not Droid.
if userAliases[probe.Type] || probe.Type == transcript.TypeAssistant || droppedTypes[probe.Type] {
return false
}
}
return false
}

// compactDroid converts Factory AI Droid JSONL transcripts into the compact
// format. Droid uses the same Anthropic Messages API structure as Claude Code
// and Cursor, but wraps each message in an envelope that must be unwrapped first.
func compactDroid(content []byte, opts Options) ([]byte, error) {
return compactJSONLWith(content, opts, unwrapDroidEnvelope)
}

// unwrapDroidEnvelope handles Factory AI Droid's envelope format where the
// actual message is nested:
//
// {"type":"message","message":{"role":"user","content":...}}
//
// It promotes inner fields (role, content) to the top level and carries over
// outer fields (timestamp, id) so the shared converters see a flat structure.
// Returns raw unchanged if the line is not a Droid envelope.
func unwrapDroidEnvelope(raw map[string]json.RawMessage) map[string]json.RawMessage {
if unquote(raw["type"]) != "message" {
return raw
}

msgRaw, ok := raw["message"]
if !ok {
return raw
}

var inner map[string]json.RawMessage
if json.Unmarshal(msgRaw, &inner) != nil {
return raw
}

innerRole := unquote(inner["role"])
if !userAliases[innerRole] && innerRole != transcript.TypeAssistant {
return raw
}

// Merge outer → inner: outer timestamp/id as defaults, inner fields override.
merged := make(map[string]json.RawMessage, len(inner)+3)
if v, has := raw["timestamp"]; has {
merged["timestamp"] = v
}
if v, has := raw["id"]; has {
merged["id"] = v
}
for k, v := range inner {
merged[k] = v
}
// Promote "role" to "type" so normalizeKind resolves it.
if _, hasType := merged["type"]; !hasType {
merged["type"] = inner["role"]
}
// Keep "message" so converters can extract nested content.
merged["message"] = msgRaw

return merged
}
63 changes: 63 additions & 0 deletions cmd/entire/cli/transcript/compact/droid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package compact

import "testing"

// --- Factory AI Droid tests ---

func TestCompact_FactoryDroidInlineCases(t *testing.T) {
t.Parallel()

droidOpts := agentOpts("factoryai-droid")

tests := []struct {
name string
input []byte
expected []string
}{
{
name: "envelope",
// Factory AI Droid wraps messages in a type:"message" envelope with role inside.
input: []byte(`{"type":"message","id":"m1","timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"create a file"}]}}
{"type":"message","id":"m2","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"Done!"},{"type":"tool_use","id":"tu-1","name":"Write","input":{"file_path":"hello.txt","content":"hi"}}]}}
`),
expected: []string{
`{"v":1,"agent":"factoryai-droid","cli_version":"0.5.1","type":"user","ts":"t1","content":[{"text":"create a file"}]}`,
`{"v":1,"agent":"factoryai-droid","cli_version":"0.5.1","type":"assistant","ts":"t2","content":[{"type":"text","text":"Done!"},{"type":"tool_use","id":"tu-1","name":"Write","input":{"file_path":"hello.txt","content":"hi"}}]}`,
},
},
{
name: "tool result",
// Droid user message with tool_result blocks inside the envelope.
input: []byte(`{"type":"message","id":"m1","timestamp":"t1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-1","content":"success"},{"type":"text","text":"next step"}]}}
`),
expected: []string{
`{"v":1,"agent":"factoryai-droid","cli_version":"0.5.1","type":"user","ts":"t1","content":[{"text":"next step"}]}`,
},
},
{
name: "drops non-message entries",
// Non-message Droid entries (session_start, etc.) should be dropped.
input: []byte(`{"type":"session_start","id":"sess-1","title":"test"}
{"type":"message","id":"m1","timestamp":"t1","message":{"role":"user","content":"hello"}}
{"type":"session_event","data":"something"}
{"type":"message","id":"m2","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}
`),
expected: []string{
`{"v":1,"agent":"factoryai-droid","cli_version":"0.5.1","type":"user","ts":"t1","content":[{"text":"hello"}]}`,
`{"v":1,"agent":"factoryai-droid","cli_version":"0.5.1","type":"assistant","ts":"t2","content":[{"type":"text","text":"hi"}]}`,
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

result, err := Compact(tc.input, droidOpts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertJSONLines(t, result, tc.expected)
})
}
}
Loading
Loading