Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ configuration keys, and keyboard shortcuts.
## Sessions and automation

Interactive sessions auto-save by default. For a one-shot command, use
`--save-session` on the built-in backends if you want it available through
`--save-session` with any backend to make it available through
`--resume last`:

```bash
Expand All @@ -404,10 +404,28 @@ qmax-code --resume last
qmax-code --list-sessions
```

Claude Code, Codex, and OpenCode manage their own native CLI session and resume
state. qmax-code mirrors successful interactive turns into its in-memory
history, but `--save-session` does not replace a CLI backend's native resume
mechanism.
Switching models, effort levels, or providers preserves one shared conversation.
Claude Code, Codex, Antigravity, and OpenCode reuse their native session when
available; returning to a backend transfers the turns it missed. Built-in
Anthropic, Cerebras, and Ollama receive the shared history, including exposed
CLI tool activity. `/save`, `/resume`, and `--resume` retain this state across
restarts. `--resume last -p "continue the review" --save-session` also works.

The retained transcript is separate from the model's compacted working context.
Large conversations use a private, searchable JSONL file plus recent context;
the destination agent can retrieve older details with its file tools. Provider
context windows still apply. Hidden reasoning and provider-private state cannot
be transferred, and image interpretation depends on the destination's support.
Only content exposed to qmax-code can be retained; this cannot recover history
that an older version already discarded.

Saved conversations live in `~/.qmax-code/sessions/`; those carrying a portable
transcript are kept for 90 days, legacy sessions for 7. `/clear` resets the
shared conversation and native resume state and starts a new session ID, so the
conversation you cleared stays listed by `/sessions` and remains resumable.
Save errors are reported.
If a native session is rejected by its CLI, the failed turn remains in the
transcript and the next request restores portable context into a fresh session.

Cloud session sync is opt-in:

Expand Down
25 changes: 25 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,28 @@ Report vulnerabilities by emailing **strazhnyk@gmail.com**. Include a descriptio
of the issue, steps to reproduce, and any relevant environment details. You will
receive a response within 48 hours. We ask that you give us reasonable time to
address the issue before any public disclosure.

## Local Conversation Retention

Saved conversations include user/assistant messages, tool inputs and outputs
exposed to qmax-code, and native CLI session identifiers and workspace paths.
They do not collect hidden reasoning, provider configuration, or credentials
from the environment. Switching providers makes retained conversation content
available to the newly selected provider.

Session writes use an owner-only temporary file and atomic replacement.
Portable transcripts in `~/.qmax-code/sessions/` are reclaimed after 90 days;
legacy sessions keep the seven-day cleanup. Large handoffs and built-in
compaction use an owner-only temporary directory containing a searchable
transcript. These temporary files are removed on clear, on normal exit, and on
the `Ctrl+C` / `SIGTERM` exit paths; only an uncatchable kill (`SIGKILL`,
power loss) can leave them in the OS temporary directory.

Structured credential fields are redacted before saving or transferring
archived content, along with unambiguous credential shapes in prose. Retained
content is redacted more conservatively than displayed content: it is replayed
as the conversation itself, so an over-eager match would silently corrupt the
only copy of your context. Redaction is best effort, not a substitute for
keeping secrets out of prompts and tool output. Turning off
auto-save prevents automatic qmax session writes, including on exit; `/save`
still writes explicitly. Native CLIs manage their own persistence independently.
31 changes: 28 additions & 3 deletions codexrunner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,11 @@ type PresentationKind uint8
const (
PresentationText PresentationKind = iota + 1
PresentationPlanLimit
// PresentationTool carries exposed tool activity only to the content boundary.
PresentationTool
)

// Presentation is the only callback value that may contain response text.
// Presentation is the only callback value that may contain response or tool content.
type Presentation struct {
Kind PresentationKind
Text string
Expand Down Expand Up @@ -369,6 +371,18 @@ func (r *Runner) handleEvent(ctx context.Context, turn Turn, event wireEvent, re
return emitEvent(ctx, turn.Hooks.Events, Event{Kind: EventTurnStarted})
case "item.completed":
if event.Item.Type != "agent_message" {
if turn.Hooks.Presenter != nil {
switch event.Item.Type {
case "command_execution", "mcp_tool_call", "file_change", "web_search", "todo_list":
// Encoding a fixed struct cannot fail; if it somehow does,
// skip the record rather than failing the whole turn.
if data, err := json.Marshal(event.Item); err == nil {
if err := turn.Hooks.Presenter.Present(ctx, Presentation{Kind: PresentationTool, Text: string(data)}); err != nil {
return ErrPresenter
}
}
}
}
return nil
}
if turn.Hooks.Presenter != nil {
Expand Down Expand Up @@ -431,8 +445,19 @@ type wireEvent struct {
Message string `json:"message"`
Error json.RawMessage `json:"error"`
Item struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
Command string `json:"command,omitempty"`
Output string `json:"aggregated_output,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
Server string `json:"server,omitempty"`
Tool string `json:"tool,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Changes json.RawMessage `json:"changes,omitempty"`
Query string `json:"query,omitempty"`
Items json.RawMessage `json:"items,omitempty"`
Status string `json:"status,omitempty"`
} `json:"item"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
Expand Down
26 changes: 26 additions & 0 deletions codexrunner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,29 @@ func assertPromptAbsent(t *testing.T, prompt string, args []string, err error) {
t.Fatal("prompt escaped into a public error")
}
}

func TestToolContentOnlyReachesPresenter(t *testing.T) {
executor := &scriptedExecutor{streams: []string{eventStream(t,
map[string]any{"type": "thread.started", "thread_id": firstThreadID},
map[string]any{"type": "item.completed", "item": map[string]any{"type": "command_execution", "command": "go test ./...", "aggregated_output": "test result detail", "exit_code": 0}},
map[string]any{"type": "item.completed", "item": map[string]any{"type": "reasoning", "text": "private reasoning marker"}},
)}}
var events []Event
var presentations []Presentation
result, err := New(Options{Executor: executor}).Run(context.Background(), Turn{Hooks: Hooks{
Events: EventSinkFunc(func(_ context.Context, event Event) error { events = append(events, event); return nil }),
Presenter: PresenterFunc(func(_ context.Context, presentation Presentation) error {
presentations = append(presentations, presentation)
return nil
}),
}})
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || result.Response != "" {
t.Fatal("tool content escaped into lifecycle events or response")
}
if len(presentations) != 1 || presentations[0].Kind != PresentationTool || !strings.Contains(presentations[0].Text, "test result detail") || strings.Contains(presentations[0].Text, "private reasoning marker") {
t.Fatal("tool/hidden reasoning boundary failed")
}
}
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ qmax-code serve --mcp
| `-p "PROMPT"` | Run one prompt and exit. |
| `--resume ID` | Resume a saved qmax-code session; use `last` for the newest. |
| `--list-sessions` | List recent saved sessions and exit. |
| `--save-session` | Save this run even when automatic saving is disabled. Applies to interactive and one-shot built-in-backend sessions; CLI backends manage native resume state. |
| `--save-session` | Save this run even when automatic saving is disabled. Applies to interactive and one-shot sessions on every backend, including shared context and CLI resume checkpoints. |
| `--verbose` | Show tool calls and raw responses. |
| `--professional` | Disable the cat personality for this run. |
| `-q` | Reserved for a future quiet/CI output mode; currently has no effect. |
Expand Down
48 changes: 31 additions & 17 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ func (m OllamaMode) String() string {
// unexported fields (tools, client, cancel, priorSessions, sessionsFetched)
// are pure internals.
type Agent struct {
Cfg AgentConfig
AppConfig *api.Config // persistent user preferences
History []api.Message
Usage api.TokenUsage
Cfg AgentConfig
AppConfig *api.Config // persistent user preferences
History []api.Message
Conversation api.ConversationState
SessionID string
contextArchive string
Usage api.TokenUsage
// LastContextTokens is the input-token count reported by the most recent
// model request. Unlike Usage.InputTokens, it is not cumulative and can be
// used to estimate the currently occupied context window in the TUI.
Expand Down Expand Up @@ -130,7 +133,9 @@ func NewAgent(cfg AgentConfig) *Agent {

// ClearHistory resets conversation history.
func (a *Agent) ClearHistory() {
a.CleanupConversation()
a.History = []api.Message{}
a.Conversation = api.ConversationState{}
a.LastContextTokens = 0
}

Expand All @@ -150,25 +155,26 @@ func (a *Agent) Run(prompt string) (string, error) {
// This is a new top-level turn; do not show a prior request's context size
// if this request fails before the provider reports usage.
a.LastContextTokens = 0
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "user",
Content: prompt,
})

for iterations := 0; iterations < maxIterations; iterations++ {
a.compressHistory()
resp, err := a.callAPI()
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}

a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "assistant",
Content: resp.Content,
})

if resp.StopReason == "tool_use" {
toolResults := a.executeToolCalls(resp.Content, context.Background())
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "user",
Content: toolResults,
})
Expand All @@ -188,7 +194,11 @@ const (
maxIterations = 50
)

// compressHistory is best-effort: it never fails a turn. The searchable archive
// it writes is an optimization on top of compaction, so a read-only or full
// temp directory degrades the summary instead of aborting the agent loop.
func (a *Agent) compressHistory() {
a.EnsureTranscript()
// Rough token estimate: 4 chars ≈ 1 token
totalChars := 0
for _, msg := range a.History {
Expand All @@ -208,18 +218,22 @@ func (a *Agent) compressHistory() {
// Build a summary of older messages
var summary strings.Builder
summary.WriteString("[Previous conversation summary]\n")
if path, err := a.writeContextArchive(); err == nil {
summary.WriteString(archiveInstruction(path))
}
oldMessages := a.History[:len(a.History)-6]
for _, msg := range oldMessages {
role := msg.Role
switch v := msg.Content.(type) {
case string:
blocks, text, isString := normalizeContent(msg.Content)
if isString {
v := text
if len(v) > 200 {
summary.WriteString(fmt.Sprintf("%s: %s...\n", role, v[:200]))
} else {
summary.WriteString(fmt.Sprintf("%s: %s\n", role, v))
}
case []api.ContentBlock:
for _, block := range v {
} else {
for _, block := range blocks {
if block.Type == "text" && block.Text != "" {
text := block.Text
if len(text) > 200 {
Expand Down Expand Up @@ -250,7 +264,7 @@ func (a *Agent) compressHistory() {
// If the first kept message is a user tool_result without a preceding assistant tool_use,
// skip it to avoid orphaned tool_results
if len(keep) > 0 && keep[0].Role == "user" {
if blocks, ok := keep[0].Content.([]api.ContentBlock); ok && len(blocks) > 0 && blocks[0].Type == "tool_result" {
if blocks, _, _ := normalizeContent(keep[0].Content); len(blocks) > 0 && blocks[0].Type == "tool_result" {
keep = keep[1:] // skip orphaned tool_result
}
}
Expand Down Expand Up @@ -318,7 +332,7 @@ func BuildUserContent(text string, images []tui.ImageAttachment) interface{} {

// RunStreamingWithImages is like RunStreaming but supports image attachments.
func (a *Agent) RunStreamingWithImages(prompt string, images []tui.ImageAttachment, term *tui.Terminal) (string, error) {
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "user",
Content: BuildUserContent(prompt, images),
})
Expand All @@ -327,7 +341,7 @@ func (a *Agent) RunStreamingWithImages(prompt string, images []tui.ImageAttachme
}

func (a *Agent) RunStreaming(prompt string, term *tui.Terminal) (string, error) {
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "user",
Content: prompt,
})
Expand Down Expand Up @@ -392,7 +406,7 @@ func (a *Agent) runStreamingLoop(term *tui.Terminal) (string, error) {
a.cancelMu.Unlock()
cancel()
if ollamaErr == nil && ollamaText != "" {
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "assistant",
Content: []api.ContentBlock{{Type: "text", Text: ollamaText}},
})
Expand All @@ -414,7 +428,7 @@ func (a *Agent) runStreamingLoop(term *tui.Terminal) (string, error) {
}

// Add assistant response to history
a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "assistant",
Content: content,
})
Expand All @@ -438,7 +452,7 @@ func (a *Agent) runStreamingLoop(term *tui.Terminal) (string, error) {
a.cancelMu.Unlock()
cancel()

a.History = append(a.History, api.Message{
a.AppendHistory(api.Message{
Role: "user",
Content: toolResults,
})
Expand Down
6 changes: 5 additions & 1 deletion internal/agent/agy_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
// 4. qmax-code parses NDJSON events and renders them
// 5. conversation_id is kept for --conversation on the next turn
type AgyAgent struct {
TurnTranscript
agyBin string
modelID string // "" = agy default; otherwise --model
effort string // "low" | "medium" | "high"
Expand Down Expand Up @@ -264,7 +265,7 @@ func (a *AgyAgent) Run(userMsg string, term *tui.Terminal) (string, error) {
cwd = "."
}

message := userMsg
message := effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + userMsg
if conversationID == "" {
message = cliQASystemPrompt(a.sctx, agyQASystemPrompt) + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + userMsg
}
Expand Down Expand Up @@ -475,6 +476,9 @@ func (a *AgyAgent) parseStream(stdout io.Reader, term *tui.Terminal) string {
term.StreamText(su.TextDelta)
}
case "tool":
if su.ToolInfo != nil && su.State == "DONE" {
a.record("assistant", su.ToolInfo)
}
name := agyToolName(su)
if su.State == "ACTIVE" && !seenTool[su.StepIndex] {
seenTool[su.StepIndex] = true
Expand Down
20 changes: 19 additions & 1 deletion internal/agent/cc_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type PlanLimitReporter interface {
// 4. qmax-code parses CC's stream-json and renders in the terminal
// 5. CC session ID is saved for --resume on the next turn
type CCAgent struct {
TurnTranscript
claudeBin string
modelID string // "" = let CC decide; otherwise passed as --model
effort string // "low" | "medium" | "high" — injected into system prompt
Expand Down Expand Up @@ -604,6 +605,14 @@ func (a *CCAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, ter
continue
}
blocks := parseCCBlocks(event.Message.Content)
for _, b := range blocks {
// Text is deliberately not recorded here: the final answer also
// arrives on the "result" event and RunCLI appends that, so
// recording it again stores every CC reply twice.
if b.Type == "tool_use" || b.Type == "tool_result" {
a.record("assistant", b)
}
}
for _, block := range blocks {
switch block.Type {
case "text":
Expand Down Expand Up @@ -631,11 +640,20 @@ func (a *CCAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, ter
}

case "user":
// User events during an agentic loop carry tool_result blocks.
// Retain tool results, but not echoed prompts: RunCLI already stores
// the original request, and an echo may include the entire handoff.
if event.Message == nil {
continue
}
blocks := parseCCBlocks(event.Message.Content)
for _, b := range blocks {
// Recorded as assistant activity, not as a user turn: these are
// the CLI's own tool results, and counting them as user messages
// inflates the session turn count.
if b.Type == "tool_result" {
a.record("assistant", b)
}
}
for _, block := range blocks {
if block.Type == "tool_result" {
a.mu.Lock()
Expand Down
Loading
Loading