From 57c40d541ec7e7a4dbefa4df38269b27444fa5d8 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 9 Oct 2026 18:52:40 +0200 Subject: [PATCH] Migrate chat log to JSONL format Server changes: - format/block.go: Block struct with JSONL marshaling, RenderBlock for text - Deleted format/event.go, format.go, format_test.go (old delimiter format) - agent/chat.go: Dual logs (rawLog JSONL + textLog rendered text) - agent/chatlog.go: Streaming via flushPartial/closeBlock, skip internal events - fs/spec.go: New files log.raw (JSONL), log (text), block (ID lookup) - Removed chat/chat.raw/chat.search from namespace GUI changes: - chatblockmodel: JSONL parsing via QJsonDocument, removed regex state machine - ollie9pclient: Read from log.raw for replay, removed old streaming code - Added Context type to block enum, filter context/state/usage from display Docs updated: AGENTS.md, system_prompt.md, architecture-*.md, scripts/o --- AGENTS.md | 6 +- cmd/olliesrv/internal/agent/agent.go | 14 +- cmd/olliesrv/internal/agent/chat.go | 136 ++-- cmd/olliesrv/internal/agent/chat_test.go | 286 +++---- cmd/olliesrv/internal/agent/chatlog.go | 232 +++--- cmd/olliesrv/internal/agent/turn.go | 13 +- cmd/olliesrv/internal/fs/blockid_e2e_test.sh | 39 +- cmd/olliesrv/internal/fs/spec.go | 211 +++-- .../internal/prompts/system_prompt.md | 5 +- data/plumbing | 4 +- data/scripts/o | 10 +- doc/architecture-9p.md | 8 +- doc/architecture-ide.md | 2 +- doc/architecture-kde.md | 12 +- format/block.go | 68 ++ format/event.go | 67 -- format/format.go | 107 --- format/format_test.go | 188 ----- kde/gui/ChatBlockDelegate.qml | 10 +- kde/gui/chatblockmodel.cpp | 746 +++++------------- kde/gui/chatblockmodel.h | 44 +- kde/gui/main.cpp | 24 - kde/gui/main.qml | 24 +- kde/gui/ollie9pclient.cpp | 18 +- kde/gui/ollie9pclient.h | 2 +- 25 files changed, 798 insertions(+), 1478 deletions(-) create mode 100644 format/block.go delete mode 100644 format/event.go delete mode 100644 format/format.go delete mode 100644 format/format_test.go diff --git a/AGENTS.md b/AGENTS.md index 2490b31..5748438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,11 @@ This is a standing preference, not a per-task instruction. Apply it without aski 11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface. 12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`. 13. **Working directory (default + override)**: Session cwd is **required** at session creation and is the inheritance root for every agent — the sane default so many agents can work in one directory with zero per-agent config. Agent cwd is an **optional** per-agent override (empty = inherit the session cwd). Because the per-session `toolsrv` sets `cmd.Dir` per tool call, per-agent cwd needs no extra process: `toolsrv` keeps an in-memory `agentCWD` map resolved per call from the `agent=` field (`cmd/toolsrv/internal/server/proc.go`), falling back to the session-level global cwd. The override is set only over the controlled `olliesrv`→`toolsrv` ctl channel (`agentcwd `), never embedded in a tool-call payload, so the model cannot influence where its own tools run. The map is process-local, so `Agent.SyncCwdToToolServer` re-pushes the override on every (re)connect — profile switch, resume, restore — matching how env and tools are resynced. Set/clear via the agent `cfg` (`cwd=...`) or ctl (`cwd [|-]`); the GUI Agent Settings dialog shows `(inherit: )` as the placeholder. -14. **Chat log files**: Each agent exposes multiple views of its conversation history. `chat` and `chat.raw` are **blocking streams** — reads wait for new data, making them suitable for live tailing but not for one-shot inspection. Use `log` for non-blocking reads (returns last 64KB). `chat.search` is an rdwr file: write a block ID, read the matching block. Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`. Block format: `[[[role:name#blockID]]]` header, content, `[[[end]]]` terminator. +14. **Chat log files**: Each agent exposes three views of its conversation history via the 9P namespace: + - `log.raw` — JSONL stream (one JSON object per line). Blocking; suitable for live tailing. Each line is `{"role":"...","id":"...","content":"..."}` with optional `name`, `format`, and `partial` fields. Partial blocks (`"partial":true`) represent in-progress streaming; they are replaced by the final block when complete. + - `log` — Rendered text (last 64KB). Non-blocking; suitable for one-shot inspection. Filters out reasoning, call, and tool blocks; shows only user/assistant/context content. + - `block` — Rdwr lookup: write a block ID (8-char hex), read the matching block as JSON. Returns the full block object or an error if not found. + Block IDs are 8-char hex strings generated deterministically from `sha256(sessionID + agentID + counter)`. ## Where to Start diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index 144e1da..9fc3f59 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -68,10 +68,12 @@ type Agent struct { onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start onClear func(agentID string) // optional callback for clear - // Chat log — the rendered output stream, exposed via 9P. + // Chat logs — dual format: JSONL for programmatic access, plain text for humans. chatMu sync.RWMutex - chatLog []byte // append-only chat log buffer - chatStart int // byte offset of first byte in chatLog (for truncation accounting) + rawLog []byte // JSONL format (one Block per line) + rawStart int // byte offset of first byte in rawLog (for truncation accounting) + textLog []byte // Rendered plain text + textStart int // byte offset of first byte in textLog chatVers uint32 // incremented on each append for change detection chatCond *sync.Cond // signaled on new chat data chatSignalMu sync.Mutex @@ -313,8 +315,10 @@ func (ag *Agent) Clear() error { } ag.history = nil ag.chatMu.Lock() - ag.chatLog = nil - ag.chatStart = 0 + ag.rawLog = nil + ag.rawStart = 0 + ag.textLog = nil + ag.textStart = 0 ag.chatVers++ ag.chatMu.Unlock() ag.chatCond.Broadcast() diff --git a/cmd/olliesrv/internal/agent/chat.go b/cmd/olliesrv/internal/agent/chat.go index 4d45f63..c75969b 100644 --- a/cmd/olliesrv/internal/agent/chat.go +++ b/cmd/olliesrv/internal/agent/chat.go @@ -1,8 +1,10 @@ // chat.go — Chat log storage and streaming. // -// The chat log is an append-only buffer of formatted output. Readers can -// stream from any offset; StreamChat returns a read function that blocks -// until data is available. +// Two logs are maintained: +// - rawLog: JSONL format (one Block per line) for programmatic access +// - textLog: Rendered plain text for human reading +// +// Both are append-only buffers with overflow handling. package agent @@ -13,16 +15,14 @@ import ( "fmt" "sync" "sync/atomic" + + "ollie/format" ) -// --- Chat log methods --- - -const maxChatLogBytes = 16 * 1024 * 1024 +const maxLogBytes = 16 * 1024 * 1024 // NextBlockID returns the next deterministic block ID and increments the counter. -// Block IDs are sha256(sessionID + agentID + counter)[:8] — deterministic given -// the same session, agent, and sequence. Counter starts at 0 for fresh agents, -// or at the replayed block count after session restore. +// Block IDs are sha256(sessionID + agentID + counter)[:8]. func (ag *Agent) NextBlockID() string { n := atomic.AddUint64(&ag.blockCounter, 1) - 1 h := sha256.New() @@ -34,20 +34,17 @@ func (ag *Agent) NextBlockID() string { return hex.EncodeToString(h.Sum(nil))[:8] } -// AppendChat appends data to the chat log and notifies stream readers. -func (ag *Agent) AppendChat(data []byte) { - if len(data) == 0 { - return - } +// AppendBlock writes a block to both logs and notifies stream readers. +func (ag *Agent) AppendBlock(b format.Block) { + raw := format.MarshalBlock(b) + text := []byte(format.RenderBlock(b)) + ag.chatMu.Lock() - ag.chatLog = append(ag.chatLog, data...) - if len(ag.chatLog) > maxChatLogBytes { - drop := len(ag.chatLog) - maxChatLogBytes - ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...) - ag.chatStart += drop - } + ag.rawLog = appendWithOverflow(ag.rawLog, raw, maxLogBytes, &ag.rawStart) + ag.textLog = appendWithOverflow(ag.textLog, text, maxLogBytes, &ag.textStart) ag.chatVers++ ag.chatMu.Unlock() + ag.chatCond.Broadcast() ag.chatSignalMu.Lock() close(ag.chatSignalCh) @@ -55,14 +52,14 @@ func (ag *Agent) AppendChat(data []byte) { ag.chatSignalMu.Unlock() } -// EnsureTrailingNewline appends a newline if the log doesn't end with one. -func (ag *Agent) EnsureTrailingNewline() { - ag.chatMu.Lock() - if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' { - ag.chatLog = append(ag.chatLog, '\n') +func appendWithOverflow(log, data []byte, max int, start *int) []byte { + log = append(log, data...) + if len(log) > max { + drop := len(log) - max + log = append([]byte(nil), log[drop:]...) + *start += drop } - ag.chatVers++ - ag.chatMu.Unlock() + return log } // ChatMu returns the chat log mutex for external locking (streaming). @@ -79,72 +76,71 @@ func (ag *Agent) ChatSignal() <-chan struct{} { return ch } -// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock). -func (ag *Agent) ChatLog() []byte { return ag.chatLog } +// RawLog returns the JSONL log bytes (caller must hold ChatMu.RLock). +func (ag *Agent) RawLog() []byte { return ag.rawLog } -// ChatRead returns new chat data since the given offset (base). -// Returns (data, nextBase, error). If no new data, returns empty data. -func (ag *Agent) ChatRead(base string) ([]byte, string, error) { +// TextLog returns the rendered text log bytes (caller must hold ChatMu.RLock). +func (ag *Agent) TextLog() []byte { return ag.textLog } + +// RawLogRead returns new JSONL data since the given offset. +func (ag *Agent) RawLogRead(base string) ([]byte, string, error) { + return ag.logRead(base, &ag.rawLog, &ag.rawStart) +} + +// TextLogRead returns new rendered text since the given offset. +func (ag *Agent) TextLogRead(base string) ([]byte, string, error) { + return ag.logRead(base, &ag.textLog, &ag.textStart) +} + +func (ag *Agent) logRead(base string, log *[]byte, start *int) ([]byte, string, error) { var offset int if base != "" { fmt.Sscanf(base, "%d", &offset) } else { + // Empty base = start from beginning (for replay) ag.chatMu.RLock() - offset = len(ag.chatLog) + ag.chatStart + offset = *start ag.chatMu.RUnlock() } ag.chatMu.RLock() - if offset < ag.chatStart { - offset = ag.chatStart + if offset < *start { + offset = *start } - localOffset := offset - ag.chatStart - log := ag.chatLog - if len(log) <= localOffset { + localOffset := offset - *start + if len(*log) <= localOffset { ag.chatMu.RUnlock() return nil, fmt.Sprintf("%d", offset), nil } - data := make([]byte, len(log)-localOffset) - copy(data, log[localOffset:]) - newOffset := ag.chatStart + len(log) + data := make([]byte, len(*log)-localOffset) + copy(data, (*log)[localOffset:]) + newOffset := *start + len(*log) ag.chatMu.RUnlock() return data, fmt.Sprintf("%d", newOffset), nil } -// ChatSearchByID returns the block content for the given block ID. -// Returns the block text (without markers) and true if found, empty and false otherwise. -func (ag *Agent) ChatSearchByID(blockID string) ([]byte, bool) { +// BlockByID returns the block with the given ID from the raw log. +func (ag *Agent) BlockByID(blockID string) (format.Block, bool) { if blockID == "" { - return nil, false + return format.Block{}, false } ag.chatMu.RLock() - log := ag.chatLog + raw := ag.rawLog ag.chatMu.RUnlock() - // Parse blocks looking for the target ID - // Format: [[[role:name#id]]] ... [[[end]]] - target := "#" + blockID + "]]]" - idx := bytes.Index(log, []byte(target)) - if idx < 0 { - return nil, false + // Scan JSONL lines for matching ID + for _, line := range bytes.Split(raw, []byte("\n")) { + if len(line) == 0 { + continue + } + b, err := format.UnmarshalBlock(line) + if err != nil { + continue + } + if b.ID == blockID && !b.Partial { + return b, true + } } - - // Find the start of this block header - headerStart := bytes.LastIndex(log[:idx], []byte("[[[")) - if headerStart < 0 { - return nil, false - } - - // Find [[[end]]] after the header - endMarker := []byte("[[[end]]]") - endIdx := bytes.Index(log[idx:], endMarker) - if endIdx < 0 { - return nil, false - } - blockEnd := idx + endIdx + len(endMarker) - - // Extract block including header and end marker - block := log[headerStart:blockEnd] - return block, true + return format.Block{}, false } // --- Plan methods --- diff --git a/cmd/olliesrv/internal/agent/chat_test.go b/cmd/olliesrv/internal/agent/chat_test.go index 3efced1..21178df 100644 --- a/cmd/olliesrv/internal/agent/chat_test.go +++ b/cmd/olliesrv/internal/agent/chat_test.go @@ -1,9 +1,10 @@ package agent import ( - "strings" "sync" "testing" + + "ollie/format" ) // testAgent creates a minimal Agent for chat/block testing. @@ -81,192 +82,94 @@ func TestNextBlockID_Length(t *testing.T) { } } -func TestChatSearchByID_Found(t *testing.T) { +func TestAppendBlock_JSONL(t *testing.T) { ag := testAgent("session1", "agent1") - // Simulate chat log with blocks - chatData := `[[[user#abc12345]]] -Hello -[[[end]]] -[[[assistant#def67890]]] -Hi there -[[[end]]] -[[[tool:shell#11223344]]] -` + "```" + ` -output here -` + "```" + ` -[[[end]]] -` - ag.AppendChat([]byte(chatData)) + ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"}) + ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"}) + ag.AppendBlock(format.Block{Role: "tool", ID: "11223344", Content: "output"}) - tests := []struct { - blockID string - wantFound bool - wantPart string - }{ - {"abc12345", true, "[[[user#abc12345]]]"}, - {"def67890", true, "[[[assistant#def67890]]]"}, - {"11223344", true, "[[[tool:shell#11223344]]]"}, - {"notfound", false, ""}, - {"", false, ""}, + ag.ChatMu().RLock() + raw := ag.RawLog() + ag.ChatMu().RUnlock() + + // Should contain valid JSONL + if len(raw) == 0 { + t.Fatal("raw log should not be empty") } - for _, tt := range tests { - block, found := ag.ChatSearchByID(tt.blockID) - if found != tt.wantFound { - t.Errorf("ChatSearchByID(%q): got found=%v, want %v", tt.blockID, found, tt.wantFound) + // Parse and verify + lines := 0 + for _, line := range splitLines(raw) { + if len(line) == 0 { continue } - if found && !strings.Contains(string(block), tt.wantPart) { - t.Errorf("ChatSearchByID(%q): block missing %q, got:\n%s", tt.blockID, tt.wantPart, block) + lines++ + b, err := format.UnmarshalBlock(line) + if err != nil { + t.Errorf("failed to parse JSONL line: %v", err) } + if b.ID == "" { + t.Error("block should have ID") + } + } + if lines != 3 { + t.Errorf("expected 3 blocks, got %d", lines) } } -func TestChatSearchByID_ReturnsFullBlock(t *testing.T) { +func TestBlockByID(t *testing.T) { ag := testAgent("session1", "agent1") - chatData := `[[[user#aabbccdd]]] -test content -more lines -[[[end]]] -` - ag.AppendChat([]byte(chatData)) + ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"}) + ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"}) - block, found := ag.ChatSearchByID("aabbccdd") + // Find existing block + block, found := ag.BlockByID("def67890") if !found { t.Fatal("block should be found") } - - blockStr := string(block) - if !strings.HasPrefix(blockStr, "[[[user#aabbccdd]]]") { - t.Errorf("block should start with header, got: %s", blockStr) + if block.Role != "assistant" || block.Content != "Hi there" { + t.Errorf("wrong block content: %+v", block) } - if !strings.HasSuffix(blockStr, "[[[end]]]") { - t.Errorf("block should end with end marker, got: %s", blockStr) - } - if !strings.Contains(blockStr, "test content") { - t.Errorf("block should contain content") - } -} -func TestChatSearchByID_PartialMatch(t *testing.T) { - ag := testAgent("session1", "agent1") - - // Block ID is full 8 chars - chatData := `[[[user#12345678]]] -content -[[[end]]] -` - ag.AppendChat([]byte(chatData)) - - // Should not find partial match - _, found := ag.ChatSearchByID("1234") + // Not found + _, found = ag.BlockByID("notfound") if found { - t.Error("partial block ID should not match") + t.Error("nonexistent block should not be found") } - // Should find exact match - _, found = ag.ChatSearchByID("12345678") - if !found { - t.Error("exact block ID should match") - } -} - -func TestChatSearchByID_MultipleBlocks(t *testing.T) { - ag := testAgent("session1", "agent1") - - // Multiple blocks with different IDs - chatData := `[[[user#aaaaaaaa]]] -first -[[[end]]] -[[[assistant#bbbbbbbb]]] -second -[[[end]]] -[[[user#cccccccc]]] -third -[[[end]]] -` - ag.AppendChat([]byte(chatData)) - - // Search for middle block - block, found := ag.ChatSearchByID("bbbbbbbb") - if !found { - t.Fatal("middle block should be found") - } - - blockStr := string(block) - if !strings.Contains(blockStr, "[[[assistant#bbbbbbbb]]]") { - t.Errorf("should find correct block, got: %s", blockStr) - } - if strings.Contains(blockStr, "first") || strings.Contains(blockStr, "third") { - t.Errorf("should not include content from other blocks") - } -} - -func TestChatSearchByID_NoEndMarker(t *testing.T) { - ag := testAgent("session1", "agent1") - - // Block without end marker (incomplete/streaming) - chatData := `[[[user#12345678]]] -content -` - ag.AppendChat([]byte(chatData)) - - // Should not find block without end marker - _, found := ag.ChatSearchByID("12345678") + // Empty ID + _, found = ag.BlockByID("") if found { - t.Error("block without end marker should not be found") + t.Error("empty block ID should not be found") } } -func TestChatSearchByID_NamedBlock(t *testing.T) { +func TestBlockByID_SkipsPartial(t *testing.T) { ag := testAgent("session1", "agent1") - // Block with name component (role:name#id format) - chatData := `[[[tool:file_read#abcd1234]]] -` + "```" + ` -file contents -` + "```" + ` -[[[end]]] -` - ag.AppendChat([]byte(chatData)) + // Add partial block first + ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "H", Partial: true}) + // Then final + ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "Hello"}) - block, found := ag.ChatSearchByID("abcd1234") + block, found := ag.BlockByID("abc12345") if !found { - t.Fatal("named block should be found") + t.Fatal("block should be found") } - if !strings.Contains(string(block), "tool:file_read") { - t.Errorf("block should include role:name, got: %s", block) + if block.Content != "Hello" { + t.Errorf("should find final block, not partial: %+v", block) } } -func TestChatSearchByID_ResponseIDBlock(t *testing.T) { - ag := testAgent("session1", "agent1") - - // Assistant block with response ID (assistant:responseID#blockID format) - chatData := `[[[assistant:resp_123#aabbccdd]]] -response content -[[[end]]] -` - ag.AppendChat([]byte(chatData)) - - block, found := ag.ChatSearchByID("aabbccdd") - if !found { - t.Fatal("assistant block with response ID should be found") - } - if !strings.Contains(string(block), "assistant:resp_123#aabbccdd") { - t.Errorf("block should include response ID, got: %s", block) - } -} - -func TestAppendChat_SignalsBroadcast(t *testing.T) { +func TestAppendBlock_SignalsBroadcast(t *testing.T) { ag := testAgent("session1", "agent1") signalCh := ag.ChatSignal() - // Append data - ag.AppendChat([]byte("test")) + // Append block + ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hi"}) // Signal channel should be closed select { @@ -286,36 +189,99 @@ func TestAppendChat_SignalsBroadcast(t *testing.T) { } } -func TestChatRead_Streaming(t *testing.T) { +func TestRawLogRead_Streaming(t *testing.T) { ag := testAgent("session1", "agent1") // Initial read with no base - should return empty and current offset - data, base, err := ag.ChatRead("") + data, base, err := ag.RawLogRead("") if err != nil { - t.Fatalf("ChatRead error: %v", err) + t.Fatalf("RawLogRead error: %v", err) } if len(data) != 0 { t.Errorf("initial read should be empty, got: %s", data) } - // Append some data - ag.AppendChat([]byte("hello")) + // Append a block + ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hello"}) // Read with previous base - should get new data - data, newBase, err := ag.ChatRead(base) + data, newBase, err := ag.RawLogRead(base) if err != nil { - t.Fatalf("ChatRead error: %v", err) + t.Fatalf("RawLogRead error: %v", err) } - if string(data) != "hello" { - t.Errorf("expected 'hello', got: %s", data) + if len(data) == 0 { + t.Error("should have new data") + } + + // Verify it's valid JSON + b, err := format.UnmarshalBlock(data[:len(data)-1]) // strip trailing newline + if err != nil { + t.Errorf("data should be valid JSONL: %v", err) + } + if b.Content != "hello" { + t.Errorf("wrong content: %s", b.Content) } // Read again with new base - should be empty - data, _, err = ag.ChatRead(newBase) + data, _, err = ag.RawLogRead(newBase) if err != nil { - t.Fatalf("ChatRead error: %v", err) + t.Fatalf("RawLogRead error: %v", err) } if len(data) != 0 { t.Errorf("no new data, should be empty, got: %s", data) } } + +func TestTextLog_RendersHumanReadable(t *testing.T) { + ag := testAgent("session1", "agent1") + + ag.AppendBlock(format.Block{Role: "user", ID: "a", Content: "Hello"}) + ag.AppendBlock(format.Block{Role: "assistant", ID: "b", Content: "Hi there"}) + ag.AppendBlock(format.Block{Role: "call", ID: "c", Name: "shell", Content: `{"cmd":"ls"}`}) + + ag.ChatMu().RLock() + text := string(ag.TextLog()) + ag.ChatMu().RUnlock() + + // Should have user and assistant content + if !contains(text, "Hello") { + t.Error("should contain user message") + } + if !contains(text, "Hi there") { + t.Error("should contain assistant message") + } + // Should NOT have call content (hidden from rendered log) + if contains(text, "shell") || contains(text, "cmd") { + t.Error("call content should be hidden") + } +} + +// Helper to split JSONL +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range data { + if b == '\n' { + lines = append(lines, data[start:i]) + start = i + 1 + } + } + if start < len(data) { + lines = append(lines, data[start:]) + } + return lines +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/olliesrv/internal/agent/chatlog.go b/cmd/olliesrv/internal/agent/chatlog.go index 8bf3bee..9e6e6e4 100644 --- a/cmd/olliesrv/internal/agent/chatlog.go +++ b/cmd/olliesrv/internal/agent/chatlog.go @@ -1,138 +1,158 @@ // chatlog.go — Chat output formatting and event handling. // // Converts agent events (streaming text, tool calls, tool results) into -// formatted output written to the chat log. The output handler is set up -// once at agent construction. +// JSONL blocks written to the chat log. Handles streaming by emitting +// partial blocks that are replaced by final blocks. package agent import ( - "fmt" + "strings" "ollie/cmd/olliesrv/internal/backend" "ollie/format" ) // initChatHandler sets up the agent's output handler to write to its own chat log. -// Called once at construction. func (ag *Agent) initChatHandler() { var streamingRole string + var streamingID string + var streamingContent strings.Builder var streamingResponseID string - var streamingFence string - var streamingBlockID string + var streamingFormat string + + // flushPartial emits a partial block with current content. + flushPartial := func() { + if streamingID == "" { + return + } + ag.AppendBlock(format.Block{ + Role: streamingRole, + ID: streamingID, + Content: streamingContent.String(), + Format: streamingFormat, + Partial: true, + }) + } + + // closeBlock emits the final block and resets streaming state. + closeBlock := func() { + if streamingID == "" { + return + } + ag.AppendBlock(format.Block{ + Role: streamingRole, + ID: streamingID, + Content: streamingContent.String(), + Format: streamingFormat, + Partial: false, + }) + streamingRole = "" + streamingID = "" + streamingContent.Reset() + streamingFormat = "" + } ag.output = func(ev Event) { switch ev.Role { case "assistant": - if streamingRole != ev.Role || (ev.Role == "assistant" && ev.ResponseID != streamingResponseID) { - if streamingRole != "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - } - blockID := ag.NextBlockID() - if ev.Role == "assistant" && ev.ResponseID != "" { - // Include both responseID and blockID: [[[assistant:responseID#blockID]]] - ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", ev.ResponseID, blockID))) - streamingResponseID = ev.ResponseID - } else { - ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID))) + // New response or different response ID = new block + if streamingRole != ev.Role || ev.ResponseID != streamingResponseID { + if streamingID != "" { + closeBlock() } streamingRole = ev.Role - streamingBlockID = blockID + streamingID = ag.NextBlockID() + streamingResponseID = ev.ResponseID + streamingContent.Reset() } - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) + streamingContent.WriteString(ev.Content) + flushPartial() case "reasoning": if streamingRole != ev.Role { - if streamingRole != "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) + if streamingID != "" { + closeBlock() } - blockID := ag.NextBlockID() - ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n", blockID))) streamingRole = ev.Role - streamingBlockID = blockID + streamingID = ag.NextBlockID() + streamingContent.Reset() } - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) + streamingContent.WriteString(ev.Content) + flushPartial() case "tool": if streamingRole == "tool" { if ev.Content == "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - streamingRole = "" + // Empty content = end of streaming tool output + closeBlock() } else { - ag.AppendChat([]byte(ev.Content)) + // Append to streaming tool content + streamingContent.WriteString(ev.Content) + flushPartial() } return } - if streamingRole != "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - streamingRole = "" + if streamingID != "" { + closeBlock() } if ev.Content == "" { - // Streaming tool output: write header with blockID - blockID := ag.NextBlockID() - ag.AppendChat([]byte(fmt.Sprintf("[[[tool:%s#%s]]]\n```%s\n", ev.Name, blockID, ev.OutputFormat))) - streamingFence = format.ToolFence + // Start streaming tool output streamingRole = "tool" - streamingBlockID = blockID + streamingID = ag.NextBlockID() + streamingFormat = ev.OutputFormat + streamingContent.Reset() } else { // Complete tool output in one event - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: "tool", + ID: ag.NextBlockID(), + Content: ev.Content, + Format: ev.OutputFormat, + }) } case "retry": if streamingRole == "retry" { - ag.AppendChat([]byte("\n" + ev.Content)) + streamingContent.WriteString("\n" + ev.Content) + flushPartial() return } - if streamingRole != "" { - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - streamingRole = "" + if streamingID != "" { + closeBlock() } - blockID := ag.NextBlockID() - ag.AppendChat([]byte(fmt.Sprintf("[[[retry#%s]]]\n", blockID))) streamingRole = "retry" - streamingBlockID = blockID - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, "")) + streamingID = ag.NextBlockID() + streamingContent.Reset() + streamingContent.WriteString(ev.Content) + flushPartial() case "call": - if streamingRole != "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - streamingRole = "" + if streamingID != "" { + closeBlock() } - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: "call", + ID: ag.NextBlockID(), + Name: ev.Name, + Content: ev.Content, + }) + + case "state", "usage", "limitretry": + // Internal events for status/stats, not chat display — skip + return default: - if streamingRole != "" { - if streamingFence != "" { - ag.AppendChat([]byte("\n" + streamingFence + "\n")) - streamingFence = "" - } - ag.AppendChat([]byte("\n" + format.EndTag + "\n")) - streamingRole = "" + // error, info, stalled, etc. + if streamingID != "" { + closeBlock() } - ag.AppendChat(format.FormatEvent(ev.Role, ev.Name, ev.Content, ev.OutputFormat, ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: ev.Role, + ID: ag.NextBlockID(), + Content: ev.Content, + }) } - _ = streamingBlockID // suppress unused warning (available for future use) } } @@ -147,45 +167,43 @@ func (ag *Agent) ReplayMessages(messages []backend.Message) { start++ } - // Build a lookup from ToolCallID → tool name. - callNames := make(map[string]string) - for _, m := range messages[start:] { - for _, tc := range m.ToolCalls { - if tc.ID != "" { - callNames[tc.ID] = tc.Name - } - } - } - for _, m := range messages[start:] { switch m.Role { case "system": + // Skip case "user": - ag.AppendChat(format.FormatEvent("user", "", m.Content, "", ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: "user", + ID: ag.NextBlockID(), + Content: m.Content, + }) case "assistant": if m.Reasoning != "" { - blockID := ag.NextBlockID() - ag.AppendChat([]byte(fmt.Sprintf("[[[reasoning#%s]]]\n%s\n%s\n", blockID, m.Reasoning, format.EndTag))) + ag.AppendBlock(format.Block{ + Role: "reasoning", + ID: ag.NextBlockID(), + Content: m.Reasoning, + }) } - blockID := ag.NextBlockID() - if m.ID != "" { - ag.AppendChat([]byte(fmt.Sprintf("[[[assistant:%s#%s]]]\n", m.ID, blockID))) - } else { - ag.AppendChat([]byte(fmt.Sprintf("[[[assistant#%s]]]\n", blockID))) - } - if m.Content != "" { - ag.AppendChat([]byte(m.Content + "\n")) - } - ag.AppendChat([]byte(format.EndTag + "\n")) + ag.AppendBlock(format.Block{ + Role: "assistant", + ID: ag.NextBlockID(), + Content: m.Content, + }) for _, tc := range m.ToolCalls { - ag.AppendChat(format.FormatEvent("call", tc.Name, string(tc.Arguments), "", ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: "call", + ID: ag.NextBlockID(), + Name: tc.Name, + Content: string(tc.Arguments), + }) } case "tool": - name := callNames[m.ToolCallID] - if name == "" { - name = m.ToolCallID - } - ag.AppendChat(format.FormatEvent("tool", name, m.Content, "", ag.NextBlockID())) + ag.AppendBlock(format.Block{ + Role: "tool", + ID: ag.NextBlockID(), + Content: m.Content, + }) } } } diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index 2e1eb9f..31814a4 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -147,11 +147,16 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { contextParts = append(contextParts, toolHints) } } - if len(contextParts) > 0 { - input = "\n" + strings.Join(contextParts, "\n") + "\n\n\n" + input - } - ag.emit(Event{Role: "user", Content: input}) + // Emit context as separate block (filtered by GUI), then user message. + // History still gets the combined input for LLM context. + userInput := input + if len(contextParts) > 0 { + contextBlock := strings.Join(contextParts, "\n") + ag.emit(Event{Role: "context", Content: contextBlock}) + input = "\n" + contextBlock + "\n\n\n" + userInput + } + ag.emit(Event{Role: "user", Content: userInput}) // Snapshot messages before this turn modifies them. Restored on context // overflow so the retry starts from a clean state. diff --git a/cmd/olliesrv/internal/fs/blockid_e2e_test.sh b/cmd/olliesrv/internal/fs/blockid_e2e_test.sh index 4630e2f..eba47ac 100755 --- a/cmd/olliesrv/internal/fs/blockid_e2e_test.sh +++ b/cmd/olliesrv/internal/fs/blockid_e2e_test.sh @@ -3,11 +3,11 @@ # Requires: running olliesrv, existing agent with chat history # # This test verifies: -# 1. Block IDs can be extracted from chat log -# 2. chat.search finds blocks by exact ID +# 1. Block IDs can be extracted from log.raw (JSONL) +# 2. block file returns JSON for exact ID # 3. Partial IDs are rejected # 4. Non-existent IDs return proper error -# 5. Different block types (user, assistant, call, tool, error) work +# 5. Different block types (user, assistant, call, tool) work set -e @@ -16,25 +16,26 @@ AGENT="src:ollie" echo "=== Block ID E2E Test ===" -# Get some block IDs from the log -echo "Extracting block IDs from log..." -BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log 2>&1 | strings | grep -oE '\[\[\[[a-z]+[:#][^]]+#([a-f0-9]{8})\]\]\]' | sed 's/.*#\([a-f0-9]\{8\}\)\]\]\]/\1/' | tail -5) +# Get some block IDs from the JSONL log +echo "Extracting block IDs from log.raw..." +BLOCK_IDS=$(o read session/$SESSION/agent/$AGENT/log.raw 2>&1 | grep -oE '"id":"[a-f0-9]{8}"' | sed 's/"id":"//;s/"//' | tail -5) if [ -z "$BLOCK_IDS" ]; then - echo "FAIL: No block IDs found in log" + echo "FAIL: No block IDs found in log.raw" exit 1 fi echo "Found block IDs: $(echo $BLOCK_IDS | tr '\n' ' ')" -# Test 1: Search for existing blocks +# Test 1: Lookup existing blocks echo "" -echo "Test 1: Search for existing blocks..." +echo "Test 1: Lookup existing blocks via block file..." for bid in $BLOCK_IDS; do - result=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$bid" 2>&1) - if echo "$result" | grep -q "\[\[\[.*#$bid\]\]\]"; then + result=$(o rdwr session/$SESSION/agent/$AGENT/block "$bid" 2>&1) + if echo "$result" | grep -q '"id":"'$bid'"'; then echo " PASS: Found block $bid" else echo " FAIL: Block $bid not found or malformed" + echo " Got: $result" exit 1 fi done @@ -44,7 +45,7 @@ echo "" echo "Test 2: Partial ID rejection..." FIRST_ID=$(echo "$BLOCK_IDS" | head -1) PARTIAL=${FIRST_ID:0:4} -if o rdwr session/$SESSION/agent/$AGENT/chat.search "$PARTIAL" 2>&1 | grep -q "block not found"; then +if o rdwr session/$SESSION/agent/$AGENT/block "$PARTIAL" 2>&1 | grep -q "block not found"; then echo " PASS: Partial ID '$PARTIAL' correctly rejected" else echo " FAIL: Partial ID '$PARTIAL' should be rejected" @@ -54,22 +55,22 @@ fi # Test 3: Non-existent ID echo "" echo "Test 3: Non-existent ID..." -if o rdwr session/$SESSION/agent/$AGENT/chat.search "deadbeef" 2>&1 | grep -q "block not found"; then +if o rdwr session/$SESSION/agent/$AGENT/block "deadbeef" 2>&1 | grep -q "block not found"; then echo " PASS: Non-existent ID 'deadbeef' correctly rejected" else echo " FAIL: Non-existent ID should return error" exit 1 fi -# Test 4: Block content includes header and end marker +# Test 4: Block JSON structure validation echo "" -echo "Test 4: Block structure validation..." +echo "Test 4: Block JSON structure validation..." FIRST_ID=$(echo "$BLOCK_IDS" | head -1) -BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/chat.search "$FIRST_ID" 2>&1) -if echo "$BLOCK" | grep -q "^\[\[\[" && echo "$BLOCK" | grep -q "\[\[\[end\]\]\]$"; then - echo " PASS: Block has proper header and end marker" +BLOCK=$(o rdwr session/$SESSION/agent/$AGENT/block "$FIRST_ID" 2>&1) +if echo "$BLOCK" | grep -qE '^\{"role":"[^"]+","id":"[a-f0-9]{8}","content":'; then + echo " PASS: Block has proper JSON structure with role, id, content" else - echo " FAIL: Block missing header or end marker" + echo " FAIL: Block missing required JSON fields" echo " Got: $BLOCK" exit 1 fi diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index 9d512f2..4739a9b 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -20,6 +20,7 @@ import ( "ollie/cmd/olliesrv/internal/backend" "ollie/cmd/olliesrv/internal/metrics" "ollie/cmd/olliesrv/internal/session" + "ollie/format" "ollie/util" "ollie/virtfs" ) @@ -687,15 +688,14 @@ func buildSessionChildren( defer cancel() session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".new", "") - subPrompt := "You are a sub-agent. Your task is below. " + - "Execute it completely using tools — investigate, implement, verify. " + - "Do NOT respond with a plan or intentions. Do NOT say what you will do. " + - "Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt - ag.Submit(subCtx, subPrompt) - ag.EnsureTrailingNewline() - reply := ag.Reply() - s.RemoveAgent(ag.ID()) - session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "") + subPrompt := "You are a sub-agent. Your task is below. " + + "Execute it completely using tools — investigate, implement, verify. " + + "Do NOT respond with a plan or intentions. Do NOT say what you will do. " + + "Call tools. Do the work. Your final text response must summarize what you ACCOMPLISHED.\n\n" + req.Prompt + ag.Submit(subCtx, subPrompt) + reply := ag.Reply() + s.RemoveAgent(ag.ID()) + session.PublishEvent("session."+s.ID+".agent."+ag.ID()+".kill", "") if reply == "" { return []byte("(no reply)\n"), nil @@ -767,7 +767,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl } else { startAsync(s.Ctx, func() { a.Submit(s.Ctx, text) - a.EnsureTrailingNewline() }) } return []byte("ok\n"), nil @@ -786,7 +785,6 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl } startAsync(s.Ctx, func() { a.Submit(s.Ctx, input) - a.EnsureTrailingNewline() }) return nil }), @@ -798,106 +796,90 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return nil } if err := a.Queue(input); err != nil { - return err - } - // If the agent is idle, trigger processing. - if !a.IsRunning() { - if !startAsync(s.Ctx, func() { - if next, ok := a.PopQueue(); ok { - a.Submit(s.Ctx, next) - a.EnsureTrailingNewline() - } - }) { - return fmt.Errorf("async work limit reached") + return err } - } - return nil - }), - virtfs.Read(func() ([]byte, error) { - item, ok := a.PopQueue() - if !ok { - return nil, nil - } - return []byte(item), nil - }), - ), - virtfs.FileNode("chat", 0440, // read-only, owner + group - virtfs.StatOverride(chatStat), - virtfs.Read(func() ([]byte, error) { - a.ChatMu().RLock() - defer a.ChatMu().RUnlock() - return stripMarkers(a.ChatLog()), nil - }), - virtfs.Stream(func(base string) ([]byte, string, error) { - data, nextBase, err := a.ChatRead(base) - if err != nil || len(data) == 0 { - return data, nextBase, err - } - stripped := stripMarkers(data) - if len(stripped) > 0 { - return stripped, nextBase, nil - } - return nil, nextBase, nil - }, a.ChatSignal), - ), - virtfs.FileNode("chat.raw", 0440, // read-only, owner + group - virtfs.StatOverride(chatStat), - virtfs.Read(func() ([]byte, error) { - a.ChatMu().RLock() - defer a.ChatMu().RUnlock() - log := a.ChatLog() - data := make([]byte, len(log)) - copy(data, log) - return data, nil - }), - virtfs.Stream(a.ChatRead, a.ChatSignal), + // If the agent is idle, trigger processing. + if !a.IsRunning() { + if !startAsync(s.Ctx, func() { + if next, ok := a.PopQueue(); ok { + a.Submit(s.Ctx, next) + } + }) { + return fmt.Errorf("async work limit reached") + } + } + return nil + }), + virtfs.Read(func() ([]byte, error) { + item, ok := a.PopQueue() + if !ok { + return nil, nil + } + return []byte(item), nil + }), ), - virtfs.FileNode("chat.search", 0660, // rdwr - write blockId, read block - virtfs.Doc("Search chat by block ID. Write ID, read block content."), + // log.raw — JSONL (one Block per line), for GUI/programmatic access + virtfs.FileNode("log.raw", 0440, + virtfs.Doc("JSONL chat log. One JSON block per line."), + virtfs.StatOverride(chatStat), + virtfs.Read(func() ([]byte, error) { + a.ChatMu().RLock() + defer a.ChatMu().RUnlock() + raw := a.RawLog() + data := make([]byte, len(raw)) + copy(data, raw) + return data, nil + }), + ), + // log — Rendered plain text for TUI/humans (non-blocking read) + virtfs.FileNode("log", 0440, + virtfs.Doc("Rendered chat log (plain text). Last 64KB."), + virtfs.Read(func() ([]byte, error) { + const maxWindow = 64 * 1024 + a.ChatMu().RLock() + text := a.TextLog() + start := 0 + if len(text) > maxWindow { + start = len(text) - maxWindow + } + data := make([]byte, len(text)-start) + copy(data, text[start:]) + a.ChatMu().RUnlock() + return data, nil + }), + ), + // block — Read block by ID (rdwr: write ID, read JSON block) + virtfs.FileNode("block", 0660, + virtfs.Doc("Get block by ID. Write block ID, read JSON block."), virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { blockID := strings.TrimSpace(string(data)) - block, found := a.ChatSearchByID(blockID) + block, found := a.BlockByID(blockID) if !found { return nil, fmt.Errorf("block not found: %s", blockID) } - return block, nil + return format.MarshalBlock(block), nil }), ), virtfs.FileNode("state", 0444, // world-readable (non-sensitive) - virtfs.Doc("Current agent state (idle, calling, thinking, paused)"), - virtfs.Read(func() ([]byte, error) { - return []byte(a.State() + "\n"), nil - }), - ), - virtfs.FileNode("status", 0444, - virtfs.Doc("Human-readable status: current activity and elapsed time"), - virtfs.Read(func() ([]byte, error) { - return []byte(a.Status() + "\n"), nil - }), - ), - virtfs.FileNode("log", 0440, // read-only, owner + group - virtfs.Read(func() ([]byte, error) { - const maxWindow = 64 * 1024 - a.ChatMu().RLock() - log := a.ChatLog() - start := 0 - if len(log) > maxWindow { - start = len(log) - maxWindow - } - data := make([]byte, len(log)-start) - copy(data, log[start:]) - a.ChatMu().RUnlock() - return data, nil - }), - ), - virtfs.FileNode("plan", 0660, // owner + group (frontend needs access) - virtfs.Read(func() ([]byte, error) { - return a.Plan(), nil - }), - virtfs.Write(func(data []byte) error { - a.SetPlan(data) - return nil - }), + virtfs.Doc("Current agent state (idle, calling, thinking, paused)"), + virtfs.Read(func() ([]byte, error) { + return []byte(a.State() + "\n"), nil + }), + ), + virtfs.FileNode("status", 0444, + virtfs.Doc("Human-readable status: current activity and elapsed time"), + virtfs.Read(func() ([]byte, error) { + return []byte(a.Status() + "\n"), nil + }), + ), + virtfs.FileNode("plan", 0660, // owner + group (frontend needs access) + virtfs.Read(func() ([]byte, error) { + return a.Plan(), nil + }), + virtfs.Write(func(data []byte) error { + a.SetPlan(data) + return nil + }), ), virtfs.FileNode("cfg", 0640, // owner rw, group r virtfs.Read(func() ([]byte, error) { @@ -965,21 +947,20 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return fmt.Errorf("peer %q not found", name) } input := strings.TrimSpace(string(data)) - if input == "" { + if input == "" { + return nil + } + startAsync(s.Ctx, func() { + target.Submit(s.Ctx, input) + }) return nil - } - startAsync(s.Ctx, func() { - target.Submit(s.Ctx, input) - target.EnsureTrailingNewline() - }) - return nil - }, - }) - } - return out, nil - }), - virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access) - virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { + }, + }) + } + return out, nil + }), + virtfs.FileNode("ctl", 0660, // owner + group (frontend needs access) + virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { return dispatch([]ctlCmd{ {"kill", "destroy this agent", func(_ []string) ([]byte, error) { s.RemoveAgent(a.ID()) diff --git a/cmd/olliesrv/internal/prompts/system_prompt.md b/cmd/olliesrv/internal/prompts/system_prompt.md index 847b28b..f2f50d4 100644 --- a/cmd/olliesrv/internal/prompts/system_prompt.md +++ b/cmd/olliesrv/internal/prompts/system_prompt.md @@ -205,8 +205,9 @@ Use `client_9p` for all 9P operations. Your session ID is `${OLLIE_SESSION_ID}`. | `plan` | r/w | Markdown checklist; survives compaction | | `prompt` | write | Submit a prompt to this agent | | `fifo` | r/w | Prompt queue. Write: enqueue. Read: dequeue. | -| `chat` | read | Conversation (filtered text, streamable) | -| `chat.raw` | read | Full conversation with block markers | +| `log` | read | Conversation (filtered text, last 64KB) | +| `log.raw` | read | Full conversation as JSONL stream | +| `block` | rdwr | Lookup block by ID (write ID, read JSON) | | `state` | read | Current agent state (idle, calling, thinking, paused) | | `cfg` | r/w | Agent config (key=value: backend, model, cwd, temperature, etc.) | | `ctl` | rdwr | Control: stop, compact, clear, inject, agent, model, tools, tool_load, tool_unload, cwd, name, peeradd, peerdel, peers, proc | diff --git a/data/plumbing b/data/plumbing index 6e28923..fe20dcd 100644 --- a/data/plumbing +++ b/data/plumbing @@ -4,7 +4,7 @@ # Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules # ollie:// URLs go to the ollie port -# Format: ollie://session/agent#block +# Format: ollie://session/agent#blockId (blockId is 8-char hex) type is text -data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?' +data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?' plumb to ollie diff --git a/data/scripts/o b/data/scripts/o index 0eb0dfa..d33ca9e 100755 --- a/data/scripts/o +++ b/data/scripts/o @@ -27,7 +27,7 @@ # o myproj ls List session contents # o myproj/coding new Create session+agent (cwd=$PWD) # o myproj/coding prompt Interactive prompt -# o myproj/coding read chat Read chat output +# o myproj/coding read log Read text log output set -euo pipefail @@ -512,13 +512,13 @@ cmd_tui() { tmux kill-session -t "${tmux_session}" 2>/dev/null || true - # Layout: chat (top 80%), prompt (bottom 20%) + # Layout: log (top 80%), prompt (bottom 20%) # Use slash separator - session names don't contain slashes, agent names might contain colons local ctx="$s/$a" tmux new-session -d -s "$tmux_session" -c "$HOME" tmux set-option -t "$tmux_session" mouse on sleep 0.3 # Wait for shell to initialize - tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read chat" Enter + tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read log.raw" Enter tmux split-window -t "$tmux_session" -v -l 20% sleep 0.2 @@ -631,7 +631,7 @@ Commands (availability depends on context): bypass Show pending bypass request (session-level) approve [id] Approve pending bypass request (session-level) deny [id] Deny pending bypass request (session-level) - read/write Access agent files (chat, cfg, etc.) + read/write Access agent files (log, cfg, etc.) Common: read [-l] Read a file (-l: loop) @@ -648,7 +648,7 @@ Examples: o myproj/coding new ~/src # create with specific cwd o myproj ls # list session contents o myproj/coding prompt # interactive prompt - o myproj/coding read chat # read chat stream + o myproj/coding read log # read text log o myproj bypass # show pending bypass details o myproj approve # approve pending bypass o myproj approve 42 # approve specific request by id diff --git a/doc/architecture-9p.md b/doc/architecture-9p.md index 10949b9..30740d4 100644 --- a/doc/architecture-9p.md +++ b/doc/architecture-9p.md @@ -87,10 +87,10 @@ echo "name=coding cwd=$PWD" | ollie-9p write session/myproj/agent/new | `agent/idx` | r | Agent index: `session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate`. | | `agent/{aname}/prompt` | w | Queue a user turn. | | `agent/{aname}/fifo` | r/w | Prompt queue. | -| `agent/{aname}/chat` | r | Filtered streaming chat output. | -| `agent/{aname}/chat.raw` | r | Full streaming output with markers. | +| `agent/{aname}/log` | r | Filtered text conversation (last 64KB). | +| `agent/{aname}/log.raw` | r | Full JSONL streaming output. | +| `agent/{aname}/block` | rdwr | Lookup block by ID (write ID, read JSON). | | `agent/{aname}/state` | r | Current agent state (idle, calling, thinking, paused). | -| `agent/{aname}/log` | r | Conversation snapshot. | | `agent/{aname}/plan` | r/w | Persistent planning scratch space. | | `agent/{aname}/cfg` | r/w | Agent configuration. | | `agent/{aname}/ctl` | rdwr | Agent controls and queries. | @@ -106,7 +106,7 @@ Submit and observe work: ```sh echo 'fix the bug in main.go' \ | ollie-9p write session/myproj/agent/coding/prompt -ollie-9p read session/myproj/agent/coding/chat +ollie-9p read session/myproj/agent/coding/log.raw # Wait for any agent state to change ollie-9p read event | grep "\.state" diff --git a/doc/architecture-ide.md b/doc/architecture-ide.md index c7b89a0..9c643af 100644 --- a/doc/architecture-ide.md +++ b/doc/architecture-ide.md @@ -8,7 +8,7 @@ An integration reads editor state, writes a prompt to an agent, and reads the re editor or IDE ├─ file, selection, cursor, project, or diff └─ 9P client → agent/.../prompt - ├─ chat / chat.raw + ├─ log / log.raw ├─ state ├─ status └─ ctl diff --git a/doc/architecture-kde.md b/doc/architecture-kde.md index 3d2c6dd..da78659 100644 --- a/doc/architecture-kde.md +++ b/doc/architecture-kde.md @@ -94,7 +94,7 @@ For the public protocol and namespace, see [`architecture-9p.md`](architecture-9 ### URL Scheme ``` -ollie://session/agent#block +ollie://session/agent#blockId ollie://session/agent ollie://session ``` @@ -103,10 +103,12 @@ ollie://session |---|---| | `session` | Session name (required) | | `agent` | Agent name (optional) | -| `#block` | Block index to scroll to (optional) | +| `#blockId` | 8-character hex block ID to fetch (optional) | + +Block IDs are deterministic SHA256-derived identifiers. Use `log.raw` (JSONL) to see block IDs in the `"id"` field. Examples: -- `ollie://default/main#42` — switch to session "default", agent "main", scroll to block 42 +- `ollie://default/main#a1b2c3d4` — fetch block "a1b2c3d4" from agent "main" in session "default" - `ollie://myproject/src:kate-custom` — switch to agent "src:kate-custom" in session "myproject" - `ollie://default` — switch to session "default" @@ -117,7 +119,7 @@ Examples: ``` # ollie:// URLs go to the ollie port type is text -data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[0-9]+)?' +data matches 'ollie://[a-zA-Z0-9_\-.:]+(/[a-zA-Z0-9_\-.:]+)?(#[a-f0-9]+)?' plumb to ollie ``` @@ -140,7 +142,7 @@ cat $HOME/lib/plumbing | 9p write plumb/rules From any Plan 9 application (acme, sam, rc, etc.): ```sh -plumb 'ollie://default/main#100' +plumb 'ollie://default/main#a1b2c3d4' ``` Or right-click an `ollie://` URL in acme/sam and plumb it. diff --git a/format/block.go b/format/block.go new file mode 100644 index 0000000..9c3dbae --- /dev/null +++ b/format/block.go @@ -0,0 +1,68 @@ +// Package format provides chat log serialization. +// +// The chat log uses JSON Lines format: one JSON object per line. +// Each line is a complete, self-contained block. +// +// For streaming, blocks can be partial (Partial=true). The GUI shows +// partial blocks immediately; when the final block arrives (same ID, +// Partial=false), it replaces the partial version. +package format + +import ( + "encoding/json" +) + +// Block represents a single chat log entry. +type Block struct { + Role string `json:"role"` // user, assistant, context, reasoning, call, tool, error, info, retry, stalled + ID string `json:"id"` // 8-char hex block ID + Content string `json:"content,omitempty"` // Block content (text, args for call, output for tool) + Name string `json:"name,omitempty"` // Tool/function name (for call blocks) + Format string `json:"format,omitempty"` // Output format hint (for tool blocks) + Partial bool `json:"partial,omitempty"` // True if streaming (not final) +} + +// MarshalBlock returns a JSONL line for the block (with trailing newline). +func MarshalBlock(b Block) []byte { + data, _ := json.Marshal(b) + return append(data, '\n') +} + +// UnmarshalBlock parses a JSONL line into a Block. +func UnmarshalBlock(line []byte) (Block, error) { + var b Block + err := json.Unmarshal(line, &b) + return b, err +} + +// RenderBlock returns human-readable text for a block (for log file). +// Partial blocks are skipped (only final blocks render). +func RenderBlock(b Block) string { + if b.Partial { + return "" + } + switch b.Role { + case "user": + return "You: " + b.Content + "\n\n" + case "assistant": + return b.Content + "\n\n" + case "context": + return "" // Hidden from rendered log + case "reasoning": + return "" // Hidden from rendered log + case "call": + return "" // Hidden from rendered log + case "tool": + return "" // Hidden from rendered log + case "error": + return "Error: " + b.Content + "\n\n" + case "info": + return "Info: " + b.Content + "\n\n" + case "retry": + return "Retry: " + b.Content + "\n\n" + case "stalled": + return "[Stalled]\n\n" + default: + return b.Content + "\n\n" + } +} diff --git a/format/event.go b/format/event.go deleted file mode 100644 index 9200c5e..0000000 --- a/format/event.go +++ /dev/null @@ -1,67 +0,0 @@ -package format - -import ( - "fmt" - "strings" -) - -// FormatEvent converts event data to bytes for appending to a chat log. -// Streaming roles (assistant, reasoning, retry) return only the content chunk; -// the caller is responsible for writing the [[[role]]] header for those. -// If blockID is non-empty, it is included in the block header. -func FormatEvent(role, name, content, outputFormat, blockID string) []byte { - switch role { - case "user": - if blockID != "" { - return []byte(fmt.Sprintf("[[[user#%s]]]\n", blockID) + content + "\n" + EndTag + "\n") - } - return []byte(UserTag + "\n" + content + "\n" + EndTag + "\n") - case "assistant": - return []byte(content) - case "reasoning": - return []byte(content) - case "call": - args := strings.Join(strings.Fields(content), " ") - if blockID != "" { - return []byte(fmt.Sprintf("[[[call:%s#%s]]]\n", name, blockID) + "```\n" + args + "\n```\n" + EndTag + "\n") - } - return []byte(CallDelim(name) + "```\n" + args + "\n```\n" + EndTag + "\n") - case "tool": - fence := "```" - if outputFormat != "" { - fence += outputFormat - } - if blockID != "" { - return []byte(fmt.Sprintf("[[[tool:%s#%s]]]\n", name, blockID) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n") - } - return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n") - case "error": - r := "error" - if name != "" { - r = "error:" + name - } - if blockID != "" { - return []byte(fmt.Sprintf("[[[%s#%s]]]\n", r, blockID) + content + "\n" + EndTag + "\n") - } - return []byte(RoleDelim(r) + "\n" + content + "\n" + EndTag + "\n") - case "info": - if blockID != "" { - return []byte(fmt.Sprintf("[[[info#%s]]]\n", blockID) + content + "\n" + EndTag + "\n") - } - return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n") - case "retry": - return []byte(content) - case "stalled": - if blockID != "" { - return []byte(fmt.Sprintf("[[[stalled#%s]]]\n", blockID) + EndTag + "\n") - } - return []byte(RoleDelim("stalled") + "\n" + EndTag + "\n") - case "maxsteps": - if blockID != "" { - return []byte(fmt.Sprintf("[[[maxsteps#%s]]]\n", blockID) + content + "\n" + EndTag + "\n") - } - return []byte(RoleDelim("maxsteps") + "\n" + content + "\n" + EndTag + "\n") - default: - return nil - } -} diff --git a/format/format.go b/format/format.go deleted file mode 100644 index 38385d4..0000000 --- a/format/format.go +++ /dev/null @@ -1,107 +0,0 @@ -package format - -import ( - "fmt" - "strings" -) - -// Formatting constants for the 9P chat log protocol. -// Used by fs.NewEventHandler and fs.replayMessagesToLog. -// -// Block format: [[[role:name#blockId]]] or [[[role#blockId]]] -// The blockId is a hex string that uniquely identifies the block. - -const ( - // EndTag marks the end of a message block. - EndTag = "[[[end]]]" - - // RoleTag formats a role delimiter, e.g. "[[[user]]]". - RoleTag = "[[[%s]]]" - - // ToolFence is the opening fence for tool output blocks. - ToolFence = "```\n" - - // AssistantHeaderPrefix is prepended to assistant headers when a response ID is present. - AssistantHeaderPrefix = "[[[assistant:" - - // AssistantTag is the bare assistant header (no response ID). - AssistantTag = "[[[assistant]]]" - - // UserTag is the user role delimiter. - UserTag = "[[[user]]]" - - // RetryTag is the retry role delimiter. - RetryTag = "[[[retry]]]" - - // ToolDelimPrefix is the prefix for tool delimiters, e.g. "[[[tool:shell]]]". - ToolDelimPrefix = "[[[tool:" - - // ToolTag is the bare tool role delimiter (no name). - ToolTag = "[[[tool]]]" - - // CallDelimPrefix is the prefix for call delimiters, e.g. "[[[call:file_read]]]". - CallDelimPrefix = "[[[call:" -) - -// RoleDelim returns a formatted role delimiter. -func RoleDelim(role string) string { - return fmt.Sprintf(RoleTag, role) -} - -// ToolDelim returns a formatted tool delimiter. -func ToolDelim(name string) string { - return fmt.Sprintf(ToolDelimPrefix+"%s]]]\n", name) -} - -// CallDelim returns a formatted call delimiter. -func CallDelim(name string) string { - return fmt.Sprintf(CallDelimPrefix+"%s]]]\n", name) -} - -// BlockDelim returns a formatted block delimiter with a block ID. -// Format: [[[role#blockId]]] or [[[role:name#blockId]]] -func BlockDelim(role, name, blockID string) string { - if name != "" { - return fmt.Sprintf("[[[%s:%s#%s]]]\n", role, name, blockID) - } - return fmt.Sprintf("[[[%s#%s]]]\n", role, blockID) -} - -// BlockHeader is a single-line [[[...]]] marker that opens a log block. -type BlockHeader struct { - Role string // e.g. "user", "assistant", "tool", "retry", or "tool:shell" - Name string // for tool/call: the sub-name (e.g. "shell"), empty for bare roles - BlockID string // unique block identifier (hex), empty if not present -} - -// ParseBlockHeader extracts a BlockHeader from a line like "[[[tool:shell#a1b2]]]" -// or "[[[assistant]]]". Returns nil if the line is not a valid block header. -func ParseBlockHeader(line string) *BlockHeader { - const prefix, suffix = "[[[", "]]]" - if !strings.HasPrefix(line, prefix) || !strings.HasSuffix(line, suffix) { - return nil - } - inner := strings.TrimPrefix(line, prefix) - inner = strings.TrimSuffix(inner, suffix) - if inner == "" { - return nil - } - bh := &BlockHeader{Role: inner} - // Extract blockId if present (after #) - if idx := strings.LastIndex(inner, "#"); idx >= 0 { - bh.BlockID = inner[idx+1:] - inner = inner[:idx] - bh.Role = inner - } - // Extract name if present (after :) - if idx := strings.Index(inner, ":"); idx >= 0 { - bh.Role = inner[:idx] - bh.Name = inner[idx+1:] - } - return bh -} - -// IsEndMarker reports whether the line is exactly the [[[end]]] closing marker. -func IsEndMarker(line string) bool { - return line == EndTag -} diff --git a/format/format_test.go b/format/format_test.go deleted file mode 100644 index 578a1e9..0000000 --- a/format/format_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package format - -import ( - "strings" - "testing" -) - -func TestParseBlockHeader(t *testing.T) { - tests := []struct { - line string - wantNil bool - wantRole string - wantName string - wantID string - }{ - // Valid headers - {"[[[user]]]", false, "user", "", ""}, - {"[[[user#abc12345]]]", false, "user", "", "abc12345"}, - {"[[[assistant]]]", false, "assistant", "", ""}, - {"[[[assistant#def67890]]]", false, "assistant", "", "def67890"}, - {"[[[assistant:resp_123#aabbccdd]]]", false, "assistant", "resp_123", "aabbccdd"}, - {"[[[tool:shell]]]", false, "tool", "shell", ""}, - {"[[[tool:shell#11223344]]]", false, "tool", "shell", "11223344"}, - {"[[[call:file_read]]]", false, "call", "file_read", ""}, - {"[[[call:file_read#99887766]]]", false, "call", "file_read", "99887766"}, - {"[[[reasoning]]]", false, "reasoning", "", ""}, - {"[[[reasoning#aabbcc00]]]", false, "reasoning", "", "aabbcc00"}, - {"[[[retry]]]", false, "retry", "", ""}, - {"[[[end]]]", false, "end", "", ""}, - {"[[[error]]]", false, "error", "", ""}, - {"[[[error:file_read#12345678]]]", false, "error", "file_read", "12345678"}, - {"[[[info]]]", false, "info", "", ""}, - {"[[[stalled]]]", false, "stalled", "", ""}, - - // Invalid headers - {"", true, "", "", ""}, - {"[[[]]]", true, "", "", ""}, - {"not a header", true, "", "", ""}, - {"[[user]]", true, "", "", ""}, - {"[[[user", true, "", "", ""}, - {"user]]]", true, "", "", ""}, - } - - for _, tt := range tests { - bh := ParseBlockHeader(tt.line) - if tt.wantNil { - if bh != nil { - t.Errorf("ParseBlockHeader(%q): expected nil, got %+v", tt.line, bh) - } - continue - } - if bh == nil { - t.Errorf("ParseBlockHeader(%q): expected non-nil", tt.line) - continue - } - if bh.Role != tt.wantRole { - t.Errorf("ParseBlockHeader(%q): role = %q, want %q", tt.line, bh.Role, tt.wantRole) - } - if bh.Name != tt.wantName { - t.Errorf("ParseBlockHeader(%q): name = %q, want %q", tt.line, bh.Name, tt.wantName) - } - if bh.BlockID != tt.wantID { - t.Errorf("ParseBlockHeader(%q): blockID = %q, want %q", tt.line, bh.BlockID, tt.wantID) - } - } -} - -func TestBlockDelim(t *testing.T) { - tests := []struct { - role, name, blockID string - want string - }{ - {"user", "", "abc12345", "[[[user#abc12345]]]\n"}, - {"assistant", "", "def67890", "[[[assistant#def67890]]]\n"}, - {"tool", "shell", "11223344", "[[[tool:shell#11223344]]]\n"}, - {"call", "file_read", "99887766", "[[[call:file_read#99887766]]]\n"}, - {"error", "test", "aabbccdd", "[[[error:test#aabbccdd]]]\n"}, - } - - for _, tt := range tests { - got := BlockDelim(tt.role, tt.name, tt.blockID) - if got != tt.want { - t.Errorf("BlockDelim(%q, %q, %q) = %q, want %q", tt.role, tt.name, tt.blockID, got, tt.want) - } - } -} - -func TestFormatEvent_WithBlockID(t *testing.T) { - tests := []struct { - role, name, content, outputFormat, blockID string - wantContains []string - }{ - { - "user", "", "hello", "", "abc12345", - []string{"[[[user#abc12345]]]", "hello", "[[[end]]]"}, - }, - { - "call", "shell", `{"cmd": "ls"}`, "", "def67890", - []string{"[[[call:shell#def67890]]]", `{"cmd": "ls"}`, "[[[end]]]"}, - }, - { - "tool", "shell", "output", "text", "11223344", - []string{"[[[tool:shell#11223344]]]", "output", "[[[end]]]"}, - }, - { - "error", "", "something failed", "", "aabbccdd", - []string{"[[[error#aabbccdd]]]", "something failed", "[[[end]]]"}, - }, - { - "error", "test", "something failed", "", "eeff0011", - []string{"[[[error:test#eeff0011]]]", "something failed", "[[[end]]]"}, - }, - { - "info", "", "status update", "", "22334455", - []string{"[[[info#22334455]]]", "status update", "[[[end]]]"}, - }, - { - "stalled", "", "", "", "66778899", - []string{"[[[stalled#66778899]]]", "[[[end]]]"}, - }, - { - "maxsteps", "", "limit reached", "", "aabbcc00", - []string{"[[[maxsteps#aabbcc00]]]", "limit reached", "[[[end]]]"}, - }, - } - - for _, tt := range tests { - got := string(FormatEvent(tt.role, tt.name, tt.content, tt.outputFormat, tt.blockID)) - for _, want := range tt.wantContains { - if !strings.Contains(got, want) { - t.Errorf("FormatEvent(%q, %q, ...): missing %q in:\n%s", tt.role, tt.name, want, got) - } - } - } -} - -func TestIsEndMarker(t *testing.T) { - tests := []struct { - line string - want bool - }{ - {"[[[end]]]", true}, - {"[[[end]]", false}, - {"[[end]]]", false}, - {"[[[END]]]", false}, - {"[[[end]]] ", false}, - {" [[[end]]]", false}, - {"", false}, - } - - for _, tt := range tests { - got := IsEndMarker(tt.line) - if got != tt.want { - t.Errorf("IsEndMarker(%q) = %v, want %v", tt.line, got, tt.want) - } - } -} - -func TestRoundtrip_ParseBlockHeader(t *testing.T) { - // Test that BlockDelim output can be parsed by ParseBlockHeader - tests := []struct { - role, name, blockID string - }{ - {"user", "", "abc12345"}, - {"tool", "shell", "def67890"}, - {"call", "file_read", "11223344"}, - } - - for _, tt := range tests { - delim := BlockDelim(tt.role, tt.name, tt.blockID) - // Trim the trailing newline for parsing - delim = strings.TrimSuffix(delim, "\n") - bh := ParseBlockHeader(delim) - if bh == nil { - t.Errorf("ParseBlockHeader(BlockDelim(%q, %q, %q)) returned nil", tt.role, tt.name, tt.blockID) - continue - } - if bh.Role != tt.role { - t.Errorf("roundtrip role: got %q, want %q", bh.Role, tt.role) - } - if bh.Name != tt.name { - t.Errorf("roundtrip name: got %q, want %q", bh.Name, tt.name) - } - if bh.BlockID != tt.blockID { - t.Errorf("roundtrip blockID: got %q, want %q", bh.BlockID, tt.blockID) - } - } -} diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index cfd2adf..f9d6aad 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -14,14 +14,13 @@ Column { // Required properties from model required property string blockType - required property string blockName required property var blockSegments required property string blockRenderedHtml required property int index required property string blockId // Bookmark functions (connected from parent) - property var addBookmark: function(sid, sn, aid, an, bid, bt, bn, p) { return false } + property var addBookmark: function(sid, sn, aid, an, bid, bt, p) { return false } property var removeBookmark: function(sid, aid, bid) { return false } property var checkBookmarked: function(sid, aid, bid) { return false } property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId) @@ -82,8 +81,8 @@ Column { case "user": return "\uD83E\uDDD1 You" case "assistant": return "\uD83E\uDD16 Ollie" case "reasoning": return "Reasoning" - case "call": return "\u25B6\uFE0F " + blockName - case "tool": return "\uD83D\uDDA5\uFE0F " + blockName + case "call": return "\u25B6\uFE0F Call" + case "tool": return "\uD83D\uDDA5\uFE0F Tool" case "error": return "\u274C Error" case "info": return "\u2139\uFE0F Info" case "retry": return "Retry" @@ -200,7 +199,7 @@ Column { } else { var sessionName = ollie.activeSessionName || ollie.activeSessionId var agentName = ollie.activeAgentName || ollie.activeAgentId - var preview = getBlockPlainText() || blockName + var preview = getBlockPlainText() || blockType root.addBookmark( ollie.activeSessionId, sessionName, @@ -208,7 +207,6 @@ Column { agentName, root.blockId, blockType, - blockName, preview ) } diff --git a/kde/gui/chatblockmodel.cpp b/kde/gui/chatblockmodel.cpp index 499b346..fcd6888 100644 --- a/kde/gui/chatblockmodel.cpp +++ b/kde/gui/chatblockmodel.cpp @@ -1,7 +1,6 @@ #include "chatblockmodel.h" #include -#include #include #include #include @@ -10,6 +9,9 @@ #include #include #include +#include +#include +#include #include #include @@ -81,34 +83,24 @@ private: // --- Static helpers --- -static ChatBlock::Type blockTypeFromString(const QString &typeStr) +ChatBlock::Type ChatBlockModel::blockTypeFromString(const QString &role) { - if (typeStr == "user") return ChatBlock::User; - if (typeStr == "assistant") return ChatBlock::Assistant; - if (typeStr == "reasoning") return ChatBlock::Reasoning; - if (typeStr == "call") return ChatBlock::Call; - if (typeStr == "tool") return ChatBlock::Tool; - if (typeStr == "error") return ChatBlock::Error; - if (typeStr == "info") return ChatBlock::Info; - if (typeStr == "retry") return ChatBlock::Retry; + if (role == "user") return ChatBlock::User; + if (role == "assistant") return ChatBlock::Assistant; + if (role == "context") return ChatBlock::Context; + if (role == "reasoning") return ChatBlock::Reasoning; + if (role == "call") return ChatBlock::Call; + if (role == "tool") return ChatBlock::Tool; + if (role == "error") return ChatBlock::Error; + if (role == "info") return ChatBlock::Info; + if (role == "retry") return ChatBlock::Retry; + if (role == "interrupt") return ChatBlock::Interrupt; return ChatBlock::Default; } -// --- Block Parsing --- - -const QRegularExpression ChatBlockModel::s_headerRe( - R"(^\[\[\[([^\]]+)\]\]\]$)"); - -const QRegularExpression ChatBlockModel::s_endRe( - R"(^\[\[\[end\]\]\]$)"); - -// Matches opening fence: ```lang or just ``` -const QRegularExpression ChatBlockModel::s_fenceOpenRe( - R"(^```([A-Za-z0-9_+.-]*).*$)"); - -// Matches closing fence: ``` on its own line -const QRegularExpression ChatBlockModel::s_fenceCloseRe( - R"(^```$)"); +// Code fence regex (used for content rendering, not block parsing) +static const QRegularExpression s_fenceOpenRe(R"(^```([A-Za-z0-9_+.-]*).*$)"); +static const QRegularExpression s_fenceCloseRe(R"(^```$)"); ChatBlockModel::ChatBlockModel(QObject *parent) : QAbstractListModel(parent) @@ -141,6 +133,7 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const switch (b.type) { case ChatBlock::User: return QStringLiteral("user"); case ChatBlock::Assistant: return QStringLiteral("assistant"); + case ChatBlock::Context: return QStringLiteral("context"); case ChatBlock::Reasoning: return QStringLiteral("reasoning"); case ChatBlock::Call: return QStringLiteral("call"); case ChatBlock::Tool: return QStringLiteral("tool"); @@ -151,8 +144,6 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const case ChatBlock::Default: return QStringLiteral("default"); } return {}; - case BlockNameRole: - return b.name; case ContentRole: return b.content; case RenderedHtmlRole: @@ -161,6 +152,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const return segmentsToVariant(b.segments); case BlockIdRole: return b.blockId; + case PartialRole: + return b.partial; } return {}; } @@ -189,44 +182,88 @@ QHash ChatBlockModel::roleNames() const { return { {BlockTypeRole, "blockType"}, - {BlockNameRole, "blockName"}, {ContentRole, "blockContent"}, {RenderedHtmlRole, "blockRenderedHtml"}, {SegmentsRole, "blockSegments"}, {BlockIdRole, "blockId"}, + {PartialRole, "blockPartial"}, }; } void ChatBlockModel::appendData(const QString &text) { - m_lineBuffer += text; + qDebug() << "ChatBlockModel::appendData len=" << text.length() << "text=" << text.left(200); + m_lineBuffer += text.toUtf8(); + // Process complete JSONL lines int idx; while ((idx = m_lineBuffer.indexOf('\n')) >= 0) { - QString line = m_lineBuffer.left(idx); + QByteArray line = m_lineBuffer.left(idx); m_lineBuffer = m_lineBuffer.mid(idx + 1); - processLine(line); + if (!line.isEmpty()) { + qDebug() << "ChatBlockModel: processing line:" << line.left(100); + processJsonLine(line); + } + } +} + +void ChatBlockModel::processJsonLine(const QByteArray &line) +{ + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(line, &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning() << "ChatBlockModel: invalid JSON line:" << error.errorString(); + return; } - // For streaming: update current block with partial line buffer content - // so the UI shows text as it arrives, not just after newlines. - if (!m_lineBuffer.isEmpty() && !m_blocks.isEmpty() && m_state == State::BlockEnd) { - int blockIndex = m_blocks.size() - 1; - ChatBlock &last = m_blocks[blockIndex]; + QJsonObject obj = doc.object(); + QString role = obj["role"].toString(); + QString id = obj["id"].toString(); + QString content = obj["content"].toString(); + QString name = obj["name"].toString(); + QString format = obj["format"].toString(); + bool partial = obj["partial"].toBool(false); - // Save current segment state, render with partial line, then restore - QList savedSegments = last.segments; - QString savedHtml = last.renderedHtml; + qDebug() << "ChatBlockModel::processJsonLine role=" << role << "id=" << id << "content.len=" << content.length(); - renderLastSegmentPartial(blockIndex, m_lineBuffer); + // Skip context blocks from rendering (context is for LLM, not display) + if (role == "context") { + return; + } - // Emit the partial-updated view - QModelIndex idx = index(blockIndex); - emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole}); + // Find existing block with same ID (for streaming updates) + int existingIdx = indexForBlockId(id); - // Restore — partial line not committed yet - last.segments = savedSegments; - last.renderedHtml = savedHtml; + if (existingIdx >= 0) { + // Update existing block + ChatBlock &block = m_blocks[existingIdx]; + block.content = content; + block.partial = partial; + rebuildBlockHtml(existingIdx); + + QModelIndex idx = index(existingIdx); + emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole, PartialRole}); + } else { + // New block + int newIdx = m_blocks.size(); + beginInsertRows(QModelIndex(), newIdx, newIdx); + + ChatBlock block; + block.type = blockTypeFromString(role); + block.blockId = id; + block.content = content; + block.name = name; + block.format = format; + block.partial = partial; + + m_blocks.append(block); + endInsertRows(); + emit countChanged(); + + // Render after insertion so rebuildBlockHtml can find it + rebuildBlockHtml(newIdx); + QModelIndex idx = index(newIdx); + emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole}); } } @@ -235,14 +272,8 @@ void ChatBlockModel::reset() beginResetModel(); m_blocks.clear(); m_lineBuffer.clear(); - m_inContext = false; - m_state = State::BlockStart; - m_inCodeFence = false; - m_fenceLanguage.clear(); - m_fenceContent.clear(); m_pendingDiagrams.clear(); m_inFlightRequests.clear(); - m_blockIdCounter = 0; // Reset so same chat produces same IDs endResetModel(); emit countChanged(); } @@ -418,6 +449,37 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) return; ChatBlock &block = m_blocks[blockIndex]; + + // For call blocks, show tool name and JSON formatted args + if (block.type == ChatBlock::Call) { + QString html = QStringLiteral("
"); + if (!block.name.isEmpty()) { + html += QStringLiteral("") + block.name.toHtmlEscaped() + QStringLiteral("
"); + } + html += highlightCode(block.content, QStringLiteral("json")); + html += QStringLiteral("
"); + block.renderedHtml = html; + + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = html; + block.segments = {seg}; + return; + } + + // For tool blocks, highlight with format hint + if (block.type == ChatBlock::Tool) { + QString lang = block.format.isEmpty() ? QStringLiteral("text") : block.format; + block.renderedHtml = highlightCode(block.content, lang); + + BlockSegment seg; + seg.type = BlockSegment::Text; + seg.html = block.renderedHtml; + block.segments = {seg}; + return; + } + + // For other blocks, render content with code fence detection QString html; QList segments; @@ -504,9 +566,9 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) } } - // Handle unclosed fence (streaming) or remaining prose + // Handle any remaining content if (inFence) { - // Still inside a fence — render what we have (incomplete) + // Unclosed fence (streaming) flushFence(false); } else { flushProse(); @@ -514,209 +576,6 @@ void ChatBlockModel::rebuildBlockHtml(int blockIndex) block.renderedHtml = html; block.segments = segments; - // Reset incremental state to match the full rebuild - block.renderState = BlockRenderState(); -} - -void ChatBlockModel::appendContent(const QString &line) -{ - if (m_blocks.isEmpty()) - return; - - int blockIndex = m_blocks.size() - 1; - ChatBlock &last = m_blocks[blockIndex]; - if (!last.content.isEmpty()) - last.content += '\n'; - last.content += line; - - incrementalAppend(blockIndex, line); - - QModelIndex idx = index(blockIndex); - emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole}); -} - -void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block) -{ - QString html; - for (const BlockSegment &seg : block.segments) - html += seg.html; - block.renderedHtml = html; -} - -void ChatBlockModel::incrementalAppend(int blockIndex, const QString &line) -{ - ChatBlock &block = m_blocks[blockIndex]; - BlockRenderState &rs = block.renderState; - rs.committedLineCount++; - - if (!rs.inFence) { - // Check for fence open - auto match = s_fenceOpenRe.match(line); - if (match.hasMatch()) { - // Flush any accumulated table into a finalized segment - if (rs.inTable && !rs.tableLines.isEmpty()) { - if (!block.segments.isEmpty()) - block.segments.last().html = renderTable(rs.tableLines); - rs.tableLines.clear(); - rs.inTable = false; - } - // Flush any accumulated prose into a finalized text segment - if (!rs.proseLines.isEmpty()) { - // The last segment should already be Text with these prose lines; - // it was being built up incrementally. Mark it done. - rs.proseLines.clear(); - } - rs.inFence = true; - rs.fenceLang = match.captured(1); - rs.fenceLines.clear(); - - // Add a new Text segment for the code fence (will be rebuilt each line) - BlockSegment seg; - seg.type = BlockSegment::Text; - seg.html = highlightCode(QString(), rs.fenceLang); - block.segments.append(seg); - block.renderedHtml += seg.html; - return; - } - - if (isTableLine(line)) { - // Transitioning from prose to table — flush prose, start table segment - if (!rs.inTable) { - if (!rs.proseLines.isEmpty()) - rs.proseLines.clear(); - rs.inTable = true; - rs.tableLines.clear(); - rs.tableLines.append(line); - - // Start a new segment for the table - BlockSegment seg; - seg.type = BlockSegment::Text; - seg.html = renderTable(rs.tableLines); - block.segments.append(seg); - } else { - // Continue accumulating table lines - rs.tableLines.append(line); - if (!block.segments.isEmpty()) - block.segments.last().html = renderTable(rs.tableLines); - } - rebuildRenderedHtmlFromSegments(block); - return; - } - - // Non-table prose line - if (rs.inTable) { - // Table just ended — finalize the table segment, start new prose - rs.tableLines.clear(); - rs.inTable = false; - } - - // Normal prose line — extend or create last text segment - rs.proseLines.append(line); - - // Incremental: escape only the new line and append to existing segment - const QString defaultColor = getDefaultTextColor(); - const QString escapedLine = line.toHtmlEscaped(); - - // Only extend the last segment if it is an in-progress prose segment - // (identified by its trailing ). A finalized code fence segment - // ends in and must not be overwritten. - bool canExtend = !block.segments.isEmpty() - && block.segments.last().type == BlockSegment::Text - && block.segments.last().html.endsWith(QStringLiteral("")); - - if (canExtend) { - QString &segHtml = block.segments.last().html; - segHtml.chop(6); // remove trailing - segHtml += '\n' + escapedLine + QStringLiteral(""); - } else { - // Start a new prose segment - rs.proseLines.clear(); - rs.proseLines.append(line); - BlockSegment seg; - seg.type = BlockSegment::Text; - seg.html = QStringLiteral("
") - + escapedLine + QStringLiteral("
"); - block.segments.append(seg); - } - // Rebuild renderedHtml from segments (concatenation is cheap) - rebuildRenderedHtmlFromSegments(block); - } else { - // Inside a fence — check for close - if (s_fenceCloseRe.match(line).hasMatch()) { - // Close the fence — finalize the segment - rs.inFence = false; - const QString code = rs.fenceLines.join('\n'); - - if (isDiagramLanguage(rs.fenceLang)) { - // Replace last segment with a diagram segment - BlockSegment seg; - seg.type = BlockSegment::Diagram; - seg.diagramLang = rs.fenceLang; - seg.diagramSource = code; - seg.html = highlightCode(code, rs.fenceLang); - seg.diagramLoading = true; - requestDiagram(rs.fenceLang, code, blockIndex); - - // Replace the in-progress fence segment - if (!block.segments.isEmpty()) - block.segments.last() = seg; - else - block.segments.append(seg); - } else { - // Normal code: finalize highlighted segment - QString codeHtml = highlightCode(code, rs.fenceLang); - if (!block.segments.isEmpty()) - block.segments.last().html = codeHtml; - } - - rs.fenceLang.clear(); - rs.fenceLines.clear(); - rebuildRenderedHtmlFromSegments(block); - } else { - // Append line to fence — incrementally highlight only the new line - rs.fenceLines.append(line); - - // Get highlighter definition + theme - auto definition = m_repo->definitionForName(rs.fenceLang); - if (!definition.isValid()) - definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang); - - if (definition.isValid()) { - const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; - const auto hlTheme = dark - ? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) - : m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme); - - HtmlHighlighter highlighter; - highlighter.setDefinition(definition); - highlighter.setTheme(hlTheme); - highlighter.defaultColor = getDefaultTextColor(); - - // Add line break before new line (if not the first) - if (!rs.hlHtml.isEmpty()) - rs.hlHtml += QStringLiteral("
"); - - // Process only the new line, continuing from saved state - rs.hlState = highlighter.processLine(line, rs.hlState); - highlighter.endLine(); - rs.hlHtml += highlighter.html; - - if (!block.segments.isEmpty()) - block.segments.last().html = highlightCodeWrapped(rs.hlHtml); - } else { - // No syntax def — just escape - if (!rs.hlHtml.isEmpty()) - rs.hlHtml += '\n'; - rs.hlHtml += line.toHtmlEscaped(); - - const QString defaultColor = getDefaultTextColor(); - if (!block.segments.isEmpty()) - block.segments.last().html = QStringLiteral("
")
-                        + rs.hlHtml + QStringLiteral("
"); - } - rebuildRenderedHtmlFromSegments(block); - } - } } void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &partialLine) @@ -725,345 +584,156 @@ void ChatBlockModel::renderLastSegmentPartial(int blockIndex, const QString &par return; ChatBlock &block = m_blocks[blockIndex]; - BlockRenderState &rs = block.renderState; - - if (rs.inFence) { - // Show partial line appended to current fence — use cached state - auto definition = m_repo->definitionForName(rs.fenceLang); - if (!definition.isValid()) - definition = m_repo->definitionForFileName(QStringLiteral("file.") + rs.fenceLang); - - if (definition.isValid()) { - const bool dark = QApplication::palette().color(QPalette::Window).lightnessF() < 0.5; - const auto hlTheme = dark - ? m_repo->defaultTheme(KSyntaxHighlighting::Repository::DarkTheme) - : m_repo->defaultTheme(KSyntaxHighlighting::Repository::LightTheme); - - HtmlHighlighter highlighter; - highlighter.setDefinition(definition); - highlighter.setTheme(hlTheme); - highlighter.defaultColor = getDefaultTextColor(); - - // Process partial line from saved state (don't save back) - QString partialHtml = rs.hlHtml; - if (!partialHtml.isEmpty()) - partialHtml += QStringLiteral("
"); - highlighter.processLine(partialLine, rs.hlState); - highlighter.endLine(); - partialHtml += highlighter.html; - - if (!block.segments.isEmpty()) - block.segments.last().html = highlightCodeWrapped(partialHtml); - } else { - // No definition — simple escape - QString inner = rs.hlHtml; - if (!inner.isEmpty()) - inner += '\n'; - inner += partialLine.toHtmlEscaped(); - const QString defaultColor = getDefaultTextColor(); - if (!block.segments.isEmpty()) - block.segments.last().html = QStringLiteral("
")
-                    + inner + QStringLiteral("
"); - } + + // Append partial line to content and re-render + QString tempContent = block.content; + if (!tempContent.isEmpty() && !tempContent.endsWith('\n')) { + tempContent += partialLine; } else { - // Show partial line appended to current prose/table - if (rs.inTable) { - // Currently building a table — show partial line as part of the table - if (isTableLine(partialLine)) { - QStringList preview = rs.tableLines; - preview.append(partialLine); - if (!block.segments.isEmpty()) - block.segments.last().html = renderTable(preview); + tempContent += partialLine; + } + + block.content = tempContent; + rebuildBlockHtml(blockIndex); +} + +void ChatBlockModel::rebuildRenderedHtmlFromSegments(ChatBlock &block) +{ + QString html; + for (const BlockSegment &seg : block.segments) { + if (seg.type == BlockSegment::Diagram) { + if (!seg.diagramSvg.isEmpty()) { + html += seg.diagramSvg; + } else if (!seg.diagramError.isEmpty()) { + html += QStringLiteral("
") + seg.diagramError.toHtmlEscaped() + QStringLiteral("
"); } else { - // Partial line breaks the table — just show it as escaped text after - const QString defaultColor = getDefaultTextColor(); - const QString escapedPartial = partialLine.toHtmlEscaped(); - BlockSegment seg; - seg.type = BlockSegment::Text; - seg.html = QStringLiteral("
") - + escapedPartial + QStringLiteral("
"); - block.segments.append(seg); + html += QStringLiteral("
[Loading diagram...]
"); } } else { - const QString defaultColor = getDefaultTextColor(); - const QString escapedPartial = partialLine.toHtmlEscaped(); - - if (!block.segments.isEmpty() && block.segments.last().type == BlockSegment::Text) { - // Temporarily append to the segment (caller will restore) - QString &segHtml = block.segments.last().html; - if (segHtml.endsWith(QStringLiteral(""))) { - segHtml.chop(6); - segHtml += '\n' + escapedPartial + QStringLiteral(""); - } else { - segHtml = escapeForHtml((rs.proseLines + QStringList{partialLine}).join('\n')); - } - } else { - BlockSegment seg; - seg.type = BlockSegment::Text; - seg.html = QStringLiteral("
") - + escapedPartial + QStringLiteral("
"); - block.segments.append(seg); - } + html += seg.html; } } - rebuildRenderedHtmlFromSegments(block); + block.renderedHtml = html; } -void ChatBlockModel::processLine(const QString &line) -{ - switch (m_state) { - case State::BlockStart: { - // [[[blockname]]] or [[[blockname#blockId]]] starts a new block - QRegularExpressionMatch match = s_headerRe.match(line); - if (match.hasMatch()) { - QString blockName = match.captured(1); - beginInsertRows(QModelIndex(), m_blocks.size(), m_blocks.size()); - ChatBlock newBlock; - - // Extract blockId if present (after #) - int hashIdx = blockName.lastIndexOf('#'); - if (hashIdx >= 0) { - newBlock.blockId = blockName.mid(hashIdx + 1); - blockName = blockName.left(hashIdx); - } else { - // Fallback: generate client-side ID for legacy blocks without server IDs - QString idSource = QString::number(m_blockIdCounter++) + blockName; - newBlock.blockId = QString::fromLatin1( - QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12)); - } - - newBlock.type = blockTypeFromString(blockName.section(':', 0, 0)); - newBlock.name = blockName.section(':', 1); - m_blocks.append(newBlock); - endInsertRows(); - emit countChanged(); - m_state = State::BlockEnd; - } - // Lines outside blocks are ignored - return; - } - - case State::BlockEnd: - // Hide prompt context blocks from the rendered chat, matching olliesrv. - if (line.trimmed() == QStringLiteral("")) { - m_inContext = true; - return; - } - if (line.trimmed() == QStringLiteral("")) { - m_inContext = false; - return; - } - if (m_inContext) - return; - - // [[[end]]] closes the current block - if (s_endRe.match(line).hasMatch()) { - // Emit final update so QML picks up the completed segment state. - if (!m_blocks.isEmpty()) { - int blockIndex = m_blocks.size() - 1; - QModelIndex idx = index(blockIndex); - emit dataChanged(idx, idx, {ContentRole, RenderedHtmlRole, SegmentsRole}); - } - m_state = State::BlockStart; - return; - } - appendContent(line); - return; - } -} - -// --- Diagram rendering --- +// --- Diagram support --- bool ChatBlockModel::isDiagramLanguage(const QString &lang) const { - const QString l = lang.toLower(); - return l == QStringLiteral("plantuml") || - l == QStringLiteral("mermaid") || - l == QStringLiteral("puml"); + return lang == QStringLiteral("plantuml") || + lang == QStringLiteral("mermaid") || + lang == QStringLiteral("dot") || + lang == QStringLiteral("graphviz"); } QString ChatBlockModel::diagramPlaceholder(const QString &lang) const { - const QString defaultColor = getDefaultTextColor(); - return QStringLiteral("
Loading ") + - lang.toHtmlEscaped() + QStringLiteral(" diagram...
"); + return QStringLiteral("
") + + QStringLiteral("[Rendering ") + lang.toHtmlEscaped() + QStringLiteral(" diagram...]
"); } QString ChatBlockModel::diagramErrorHtml(const QString &error) const { - return QStringLiteral("
") + - QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + - QStringLiteral("
"); + return QStringLiteral("
") + + QStringLiteral("Diagram error: ") + error.toHtmlEscaped() + QStringLiteral("
"); } QString ChatBlockModel::diagramSvgHtml(const QByteArray &svg) const { - // Embed SVG inline as data URI - const QString base64 = QString::fromLatin1(svg.toBase64()); - return QStringLiteral(""); + return QStringLiteral(""); } QString ChatBlockModel::diagramPngHtml(const QByteArray &png) const { - // Embed PNG inline as data URI - const QString base64 = QString::fromLatin1(png.toBase64()); - return QStringLiteral(""); + return QStringLiteral(""); } QString ChatBlockModel::diagramJpegHtml(const QByteArray &jpeg) const { - // Embed JPEG inline as data URI - const QString base64 = QString::fromLatin1(jpeg.toBase64()); - return QStringLiteral(""); + return QStringLiteral(""); } void ChatBlockModel::requestDiagram(const QString &lang, const QString &code, int blockIndex) { - const QString requestKey = lang + QStringLiteral(":") + - QString::fromLatin1(QCryptographicHash::hash( - code.toUtf8(), QCryptographicHash::Md5).toHex()); + // Hash the request for deduplication + const QString hashInput = lang + QStringLiteral(":") + code; + const QString hash = QString::fromLatin1( + QCryptographicHash::hash(hashInput.toUtf8(), QCryptographicHash::Sha256).toHex().left(16)); - // Track duplicate requests only while this local render is in flight. - if (!m_pendingDiagrams.contains(requestKey)) - m_pendingDiagrams[requestKey] = QList(); - if (!m_pendingDiagrams[requestKey].contains(blockIndex)) - m_pendingDiagrams[requestKey].append(blockIndex); - if (m_inFlightRequests.contains(requestKey)) + // Track which blocks want this diagram + m_pendingDiagrams[hash].append(blockIndex); + + // Don't re-request if already in flight + if (m_inFlightRequests.contains(hash)) return; + m_inFlightRequests.insert(hash); - m_inFlightRequests.insert(requestKey); + // Request from local diagram renderer (ollie-diagram service) + QUrl url(QStringLiteral("http://localhost:18019/render")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("lang"), lang); + url.setQuery(query); - QNetworkRequest request; - request.setAttribute(QNetworkRequest::User, requestKey); - request.setTransferTimeout(10000); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain")); + request.setAttribute(QNetworkRequest::User, hash); - const QString langLower = lang.toLower(); - - if (langLower == QStringLiteral("plantuml") || langLower == QStringLiteral("puml")) { - // PlantUML server accepts POST with raw source - request.setUrl(QUrl(QStringLiteral("http://localhost:8080/svg"))); - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain")); - m_netManager->post(request, code.toUtf8()); - } else if (langLower == QStringLiteral("mermaid")) { - // Mermaid-ink expects base64 in URL path, /img/ for PNG output - // (SVG uses foreignObject which Qt's SVG renderer doesn't support) - const QByteArray encoded = code.toUtf8().toBase64(); - request.setUrl(QUrl(QStringLiteral("http://localhost:8081/img/") + QString::fromLatin1(encoded))); - m_netManager->get(request); - } else { - m_inFlightRequests.remove(requestKey); - return; - } + m_netManager->post(request, code.toUtf8()); } void ChatBlockModel::onDiagramReply(QNetworkReply *reply) { reply->deleteLater(); - const QString cacheKey = reply->request().attribute(QNetworkRequest::User).toString(); - m_inFlightRequests.remove(cacheKey); + const QString hash = reply->request().attribute(QNetworkRequest::User).toString(); + m_inFlightRequests.remove(hash); - QByteArray imageData; - bool success = false; - QString errorMsg; + QList indices = m_pendingDiagrams.take(hash); + if (indices.isEmpty()) + return; - if (reply->error() == QNetworkReply::NoError) { - imageData = reply->readAll(); + QString resultHtml; + if (reply->error() != QNetworkReply::NoError) { + resultHtml = diagramErrorHtml(reply->errorString()); + } else { + const QByteArray data = reply->readAll(); + const QString contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString(); - // Check for JPEG/PNG (mermaid) or SVG (plantuml) - if (imageData.startsWith("\xff\xd8\xff")) { - // JPEG image - success = true; - imageData.prepend("JPEG:"); - } else if (imageData.startsWith("\x89PNG")) { - // PNG image - success = true; - imageData.prepend("PNG:"); - } else if (imageData.contains("errorString(); } - if (success) { - // Keep the result only on the waiting segments; do not cache diagrams. - } else { - // The error is applied directly to waiting segments below. - } - - // Apply the response directly to waiting segments. Nothing is retained after - // this request completes; a later occurrence renders through the local server. - const QList blocks = m_pendingDiagrams.take(cacheKey); - for (int blockIndex : blocks) { - if (blockIndex < 0 || blockIndex >= m_blocks.size()) + // Update all blocks waiting for this diagram + for (int idx : indices) { + if (idx < 0 || idx >= m_blocks.size()) continue; - ChatBlock &block = m_blocks[blockIndex]; - BlockSegment *matchingSegment = nullptr; - for (BlockSegment &segment : block.segments) { - if (segment.type != BlockSegment::Diagram || !segment.diagramLoading) - continue; - const QString segmentKey = segment.diagramLang + QStringLiteral(":") + - QString::fromLatin1(QCryptographicHash::hash( - segment.diagramSource.toUtf8(), QCryptographicHash::Md5).toHex()); - if (segmentKey == cacheKey) { - matchingSegment = &segment; - break; - } - } - - // A block can be rebuilt while a request is in flight. In that case - // the segment still represents this request, but its source may have - // been normalized by the parser. Use the sole pending diagram rather - // than leaving the UI loading forever. - if (!matchingSegment) { - for (BlockSegment &segment : block.segments) { - if (segment.type == BlockSegment::Diagram && segment.diagramLoading) { - if (matchingSegment) { - matchingSegment = nullptr; - break; - } - matchingSegment = &segment; + ChatBlock &block = m_blocks[idx]; + for (BlockSegment &seg : block.segments) { + if (seg.type == BlockSegment::Diagram && seg.diagramLoading) { + seg.diagramSvg = resultHtml; + seg.diagramLoading = false; + if (reply->error() != QNetworkReply::NoError) { + seg.diagramError = reply->errorString(); } } } - - if (!matchingSegment) - continue; - - BlockSegment &segment = *matchingSegment; - segment.diagramLoading = false; - if (!success) { - segment.diagramError = errorMsg; - segment.html = diagramErrorHtml(errorMsg); - } else if (imageData.startsWith("JPEG:")) { - const QByteArray data = imageData.mid(5); - segment.diagramSvg = QStringLiteral("data:image/jpeg;base64,") + - QString::fromLatin1(data.toBase64()); - segment.html = diagramJpegHtml(data); - } else if (imageData.startsWith("PNG:")) { - const QByteArray data = imageData.mid(4); - segment.diagramSvg = QStringLiteral("data:image/png;base64,") + - QString::fromLatin1(data.toBase64()); - segment.html = diagramPngHtml(data); - } else { - const QByteArray data = imageData.startsWith("SVG:") ? imageData.mid(4) : imageData; - segment.diagramSvg = QStringLiteral("data:image/svg+xml;base64,") + - QString::fromLatin1(data.toBase64()); - segment.html = diagramSvgHtml(data); - } rebuildRenderedHtmlFromSegments(block); - QModelIndex idx = index(blockIndex); - emit dataChanged(idx, idx, {RenderedHtmlRole, SegmentsRole}); + + QModelIndex modelIdx = index(idx); + emit dataChanged(modelIdx, modelIdx, {RenderedHtmlRole, SegmentsRole}); } } diff --git a/kde/gui/chatblockmodel.h b/kde/gui/chatblockmodel.h index dc2f702..c797a5f 100644 --- a/kde/gui/chatblockmodel.h +++ b/kde/gui/chatblockmodel.h @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -31,7 +30,7 @@ struct BlockSegment { }; // Incremental rendering state for a block — tracks where we left off so -// appendContent() can extend the last segment without reparsing from scratch. +// content updates can extend the last segment without reparsing from scratch. struct BlockRenderState { bool inFence = false; // Currently inside a code fence QString fenceLang; // Language of the open fence @@ -49,6 +48,7 @@ struct ChatBlock { enum Type { User, Assistant, + Context, Reasoning, Call, Tool, @@ -60,12 +60,14 @@ struct ChatBlock { }; Type type = Default; - QString name; QString content; - QString renderedHtml; // Legacy: full HTML (kept for compatibility) + QString renderedHtml; // Full HTML render of content QList segments; // Structured segments for interactive rendering BlockRenderState renderState; // Incremental rendering state - QString blockId; // Stable identifier (hash of type+name+first N chars of content) + QString blockId; // Stable identifier (8-char hex from server) + QString name; // For call blocks: tool name + QString format; // For tool blocks: output format hint + bool partial = false; // True if streaming (not final) }; class ChatBlockModel : public QAbstractListModel @@ -74,19 +76,13 @@ class ChatBlockModel : public QAbstractListModel Q_PROPERTY(int count READ rowCount NOTIFY countChanged) public: - // Two-state parser for [[[blockname]]] ... [[[end]]] blocks. - enum class State { - BlockStart, // Waiting for a block header - BlockEnd // Collecting content until [[[end]]] - }; - enum Roles { BlockTypeRole = Qt::UserRole + 1, - BlockNameRole, ContentRole, RenderedHtmlRole, SegmentsRole, // QVariantList of segment maps BlockIdRole, // Stable block identifier + PartialRole, // True if streaming (not final) }; explicit ChatBlockModel(QObject *parent = nullptr); @@ -108,10 +104,8 @@ private slots: void onDiagramReply(QNetworkReply *reply); private: - void processLine(const QString &line); - void appendContent(const QString &line); + void processJsonLine(const QByteArray &line); void rebuildBlockHtml(int blockIndex); - void incrementalAppend(int blockIndex, const QString &line); void renderLastSegmentPartial(int blockIndex, const QString &partialLine); static void rebuildRenderedHtmlFromSegments(ChatBlock &block); @@ -135,31 +129,19 @@ private: // Convert segments to QVariantList for QML QVariantList segmentsToVariant(const QList &segments) const; - State m_state = State::BlockStart; + // Map block type string to enum + static ChatBlock::Type blockTypeFromString(const QString &role); + KSyntaxHighlighting::Repository *m_repo = nullptr; QNetworkAccessManager *m_netManager = nullptr; QList m_blocks; - QString m_lineBuffer; - quint64 m_blockIdCounter = 0; // Monotonic counter for stable block IDs - - // Context blocks are injected into prompts but hidden from chat rendering. - bool m_inContext = false; - - // Code fence state (for streaming) - bool m_inCodeFence = false; - QString m_fenceLanguage; - QString m_fenceContent; + QByteArray m_lineBuffer; // Buffer for incomplete JSON lines // Pending requests: hash -> block indices waiting for this diagram QHash> m_pendingDiagrams; // Track which requests are in flight QSet m_inFlightRequests; - - static const QRegularExpression s_headerRe; - static const QRegularExpression s_endRe; - static const QRegularExpression s_fenceOpenRe; - static const QRegularExpression s_fenceCloseRe; }; #endif // CHATBLOCKMODEL_H diff --git a/kde/gui/main.cpp b/kde/gui/main.cpp index f0fc468..17c7d0a 100644 --- a/kde/gui/main.cpp +++ b/kde/gui/main.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include "ollie9pclient.h" #include "sessionmodel.h" @@ -55,29 +54,6 @@ int main(int argc, char *argv[]) QObject::connect(ollie, &Ollie9pClient::chatReceived, chatModel, &ChatBlockModel::appendData); - // Handle ollie:// URLs from plumber — fetch block and display in chat - QObject::connect(plumber, &Plumber::ollieMessage, [ollie, chatModel](const QString &url) { - // Parse ollie://session/agent#blockid - QUrl parsed(url); - if (parsed.scheme() != QStringLiteral("ollie")) - return; - QString path = parsed.path(); - if (path.startsWith('/')) - path = path.mid(1); - QStringList parts = path.split('/'); - if (parts.size() < 2) - return; - QString sessionId = parts.value(0); - QString agentId = parts.value(1); - QString blockId = parsed.fragment(); - if (blockId.isEmpty()) - return; - - QString block = ollie->fetchBlock(sessionId, agentId, blockId); - if (!block.isEmpty()) - chatModel->appendData(block); - }); - engine.rootContext()->setContextProperty("ollie", ollie); engine.rootContext()->setContextProperty("sessionModel", sessionModel); engine.rootContext()->setContextProperty("chatModel", chatModel); diff --git a/kde/gui/main.qml b/kde/gui/main.qml index 4e6b7a2..313fcd0 100644 --- a/kde/gui/main.qml +++ b/kde/gui/main.qml @@ -14,12 +14,12 @@ ApplicationWindow { font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize // Bookmarks model (non-persistent, GUI state only) - // Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview } + // Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, preview } ListModel { id: bookmarksModel } - function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview) { + function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, preview) { // Check for duplicate for (var i = 0; i < bookmarksModel.count; i++) { var b = bookmarksModel.get(i) @@ -34,7 +34,6 @@ ApplicationWindow { agentName: agentName, blockId: blockId, blockType: blockType, - blockName: blockName, preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "") }) return true @@ -74,7 +73,17 @@ ApplicationWindow { var agentName = match[2] ? decodeURIComponent(match[2]) : "" var blockId = match[3] || "" - // Find session by name and switch to it + // If blockId is present, fetch and append the block content to current chat + // Don't navigate — user wants to see the block in their current context + if (blockId) { + var block = ollie.fetchBlock(sessionName, agentName, blockId) + if (block) { + chatModel.appendData(block) + } + return + } + + // No blockId — navigate to session/agent var sessions = ollie.sessions for (var i = 0; i < sessions.length; i++) { var s = sessions[i] @@ -92,13 +101,6 @@ ApplicationWindow { } } - // If blockId specified, scroll to it after a short delay - if (blockId) { - Qt.callLater(function() { - chatPane.scrollToBlockId(blockId) - }) - } - // Raise window root.show() root.raise() diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp index 7be9e31..0434634 100644 --- a/kde/gui/ollie9pclient.cpp +++ b/kde/gui/ollie9pclient.cpp @@ -468,7 +468,8 @@ QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString if (sessionId.isEmpty() || agentId.isEmpty()) return {}; if (!m_9p || !m_9p->isConnected()) return {}; // Use immutable IDs — the 9P namespace resolves them via aliases. - QString path = "session/" + sessionId + "/agent/" + agentId + "/log"; + // Read JSONL from log.raw for the chat model (not plain text log). + QString path = "session/" + sessionId + "/agent/" + agentId + "/log.raw"; return QString::fromUtf8(m_9p->read(path)); } @@ -1075,13 +1076,20 @@ QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId) QString Ollie9pClient::fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId) { - if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) + qDebug("fetchBlock: session=%s agent=%s block=%s", qPrintable(sessionId), qPrintable(agentId), qPrintable(blockId)); + if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) { + qDebug("fetchBlock: empty parameter"); return QString(); - if (!m_9p || !m_9p->isConnected()) + } + if (!m_9p || !m_9p->isConnected()) { + qDebug("fetchBlock: not connected (m_9p=%p connected=%d)", (void*)m_9p, m_9p ? m_9p->isConnected() : 0); return QString(); + } - QString path = QStringLiteral("session/%1/agent/%2/chat.search").arg(sessionId, agentId); + QString path = QStringLiteral("session/%1/agent/%2/block").arg(sessionId, agentId); + qDebug("fetchBlock: rdwr path=%s", qPrintable(path)); QByteArray out = m_9p->rdwr(path, blockId.toUtf8()); + qDebug("fetchBlock: got %lld bytes", (long long)out.size()); return QString::fromUtf8(out); } @@ -1124,7 +1132,7 @@ void Ollie9pClient::startActiveAgentStreams() if (!m_daemonConnected) return; if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return; - m_chat->start(agentPath() + "/chat.raw"); + m_chat->start(agentPath() + "/log.raw"); // Check for pending bypass request on this session checkPendingBypass(); diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h index a25d6aa..f58a94d 100644 --- a/kde/gui/ollie9pclient.h +++ b/kde/gui/ollie9pclient.h @@ -113,7 +113,7 @@ public: Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName); Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId); - // Block lookup via chat.search + // Block lookup via block file (rdwr: write ID, read JSON) Q_INVOKABLE QString fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId); signals: