fix: capture mlx-omni reasoning tokens; strip empty turns for expert; add /save

ober

b806178ebf4e07c3ae21b8682fd39fa0bf7e001c

diff --git a/src/jcode/core/expert.ss b/src/jcode/core/expert.ss
index 29c802f..c5dab35 100644
--- a/src/jcode/core/expert.ss
+++ b/src/jcode/core/expert.ss
@@ -93,6 +93,33 @@
       (config-ref "expert" "model"))
     ""))
 
+;; An assistant turn that has neither visible content nor tool calls is
+;; what tripped deepseek's `content or tool_calls must be set` validator.
+;; Such turns happen when the primary model reasons-only (mlx-omni puts
+;; the tokens in the `reasoning` field) and we have nothing to forward.
+;; Drop them before serializing — and drop any orphaned tool-result that
+;; would otherwise reference a tool_call we just removed.
+(def (assistant-empty? msg)
+  (and (equal? (message-role msg) "assistant")
+       (let ((c (message-content msg))
+             (tcs (message-tool-calls msg)))
+         (and (or (not c) (and (string? c) (= (string-length c) 0)))
+              (or (not tcs) (and (list? tcs) (null? tcs)))))))
+
+(def (strip-empty-assistant-turns messages)
+  (let loop ((in messages) (out '()))
+    (cond
+      ((null? in) (reverse out))
+      ((assistant-empty? (car in))
+       ;; Also drop the immediately-following tool result(s) — they would
+       ;; reference the dropped assistant's tool_call_ids.
+       (let skip ((rest (cdr in)))
+         (cond
+           ((and (pair? rest) (equal? (message-role (car rest)) "tool"))
+            (skip (cdr rest)))
+           (else (loop rest out)))))
+      (else (loop (cdr in) (cons (car in) out))))))
+
 ;; When escalating, rebuild the leading system message to include a brief
 ;; note that the expert is being consulted and why. Anthropic collapses
 ;; system messages into a single body field; OpenAI/others take one at the
@@ -101,7 +128,8 @@
 (def (with-expert-handoff messages reason-text)
   (let ((handoff-note
           (format "\n\n[EXPERT HANDOFF] You are being consulted as the expert model because the primary model appeared stuck or low-confidence (~a). The conversation above is the full context. Provide the best answer you can for the user's most recent request — be concrete and direct."
-                  reason-text)))
+                  reason-text))
+        (messages (strip-empty-assistant-turns messages)))
     (cond
       ((null? messages) messages)
       ((equal? (message-role (car messages)) "system")
diff --git a/src/jcode/core/models.ss b/src/jcode/core/models.ss
index 050a1fa..ac478b0 100644
--- a/src/jcode/core/models.ss
+++ b/src/jcode/core/models.ss
@@ -427,6 +427,11 @@
       ((string-contains mid "o3")                       200000)
       ((string-contains mid "gemini-1.5")               1000000)
       ((string-contains mid "gemini-2")                 1000000)
+      ;; Local mlx_lm LoRAs: KV cache stays fp16 even when weights are
+      ;; quantized, so the Metal wired-memory cap (not the architectural
+      ;; ctx limit) dominates. Keep this conservative so compaction kicks
+      ;; in before prefill blows the 32 GB wired budget.
+      ((string-contains mid "jerboa-mlx")               16384)
       ((string-contains mid "qwen3")                    32768)
       ((string-contains mid "llama-3")                  131072)
       ((string-contains mid "deepseek")                 131072)
diff --git a/src/jcode/provider/provider.ss b/src/jcode/provider/provider.ss
index 8d551b3..fbab0ae 100644
--- a/src/jcode/provider/provider.ss
+++ b/src/jcode/provider/provider.ss
@@ -998,6 +998,10 @@
          (headers (openai-headers provider))
          (body    (openai-stream-body provider messages tools))
          (text-acc (open-output-string))
+         ;; Reasoning tokens (mlx-omni `reasoning`, DeepSeek-R1
+         ;; `reasoning_content`). Captured separately so an otherwise
+         ;; content-less turn still has something to record.
+         (reasoning-acc (open-output-string))
          ;; tool-call accumulators: index -> alist with id/name/args-so-far
          (tc-table (make-hash-table))
          (usage-acc (make-hash-table))
@@ -1052,6 +1056,15 @@
                          (when (and content (string? content) (> (string-length content) 0))
                            (put-string text-acc content)
                            (token-cb content)))
+                       ;; Reasoning token (mlx-omni `reasoning`, R1
+                       ;; `reasoning_content`). Stream to UI so the user
+                       ;; sees thinking, and accumulate so an otherwise
+                       ;; empty turn still records something downstream.
+                       (let ((r (or (hash-get delta "reasoning")
+                                    (hash-get delta "reasoning_content"))))
+                         (when (and r (string? r) (> (string-length r) 0))
+                           (put-string reasoning-acc r)
+                           (token-cb r)))
                        ;; Tool call fragments
                        (let ((tcs (hash-get delta "tool_calls")))
                          (when (and tcs (list? tcs))
@@ -1079,7 +1092,17 @@
         (log-error logger "stream-http-error"
           `((status . ,http-status) (url . ,url)))))
     ;; Build result
-    (let* ((content (get-output-string text-acc))
+    (let* ((raw-content (get-output-string text-acc))
+           (reasoning   (get-output-string reasoning-acc))
+           ;; If the model emitted only reasoning, fold it into content as
+           ;; <think>...</think> so the turn isn't empty (which would
+           ;; otherwise trip the auto-escalator and produce an invalid
+           ;; assistant message for downstream providers).
+           (content (cond
+                      ((> (string-length raw-content) 0) raw-content)
+                      ((> (string-length reasoning) 0)
+                       (string-append "<think>" reasoning "</think>"))
+                      (else "")))
            (indices (sort < (hash-keys tc-table)))
            (tool-calls
              (map (lambda (idx)
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index f9b255c..3495136 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -526,6 +526,7 @@
                "  /clear      Start new session"
                "  /compact    Summarize older turns to free up context"
                "  /sessions   List sessions"
+               "  /save [path]  Export current session to markdown (default ~/jcode-session-<id>.md)"
                "  /search <term>  Search session history"
                "  /mcp        Toggle MCP tools on/off (for local models)"
                "  /ask-claude    Second opinion from claude CLI (sandboxed)"
@@ -624,6 +625,11 @@
                  (map (lambda (s) (format "  ~a  ~a" (session-id s) (session-title s)))
                       sessions)
                  "\n"))))))
+      ((equal? cmd "save")
+       (handle-save! state ""))
+      ((string-prefix? "save " cmd)
+       (handle-save! state
+         (substring cmd 5 (string-length cmd))))
       ((string-prefix? "search " cmd)
        (let* ((term (string-trim (substring cmd 7 (string-length cmd))))
               (results (session-search term)))
@@ -1121,6 +1127,64 @@
             (send-worker-event! gen
               (list 'ask-result provider result))))))))
 
+;; ---- /save [path] ----
+;; Dump the visible message thread to a markdown file. Sessions persist in
+;; ~/.jcode/sessions.db but a plain-text artifact is easier to share/diff.
+(def (msg-block->markdown blk port)
+  (case (msg-block-role blk)
+    ((user)
+     (display "## user\n\n" port)
+     (display (or (msg-block-content blk) "") port)
+     (display "\n\n" port))
+    ((assistant)
+     (display "## assistant\n\n" port)
+     (let ((th (msg-block-thinking blk)))
+       (when (and th (string? th) (> (string-length th) 0))
+         (display "<details><summary>reasoning</summary>\n\n" port)
+         (display th port)
+         (display "\n\n</details>\n\n" port)))
+     (display (or (msg-block-content blk) "") port)
+     (display "\n\n" port))
+    ((tool)
+     (display "### tool: " port)
+     (display (or (msg-block-tool-name blk) "?") port)
+     (display "\n\n```\n" port)
+     (display (or (msg-block-content blk) "") port)
+     (display "\n```\n\n" port))
+    ((error)
+     (display "### error\n\n```\n" port)
+     (display (or (msg-block-content blk) "") port)
+     (display "\n```\n\n" port))
+    ((system)
+     (display "### system\n\n" port)
+     (display (or (msg-block-content blk) "") port)
+     (display "\n\n" port))))
+
+(def (handle-save! state arg)
+  (let* ((raw  (string-trim arg))
+         (home (or (getenv "HOME") "."))
+         (sid  (or (app-state-session-id state) "untitled"))
+         (default-path
+           (string-append home "/jcode-session-" sid ".md"))
+         (p1   (if (string=? raw "") default-path raw))
+         (path (if (and (>= (string-length p1) 2)
+                        (string=? (substring p1 0 2) "~/"))
+                 (string-append home (substring p1 1 (string-length p1)))
+                 p1)))
+    (guard (e [#t (add-message! state
+                    (msg-block-error
+                      (format "Save failed: ~a" (err->string e))))])
+      (when (file-exists? path) (delete-file path))
+      (call-with-output-file path
+        (lambda (p)
+          (display "# jcode session " p) (display sid p)
+          (newline p) (newline p)
+          (for-each
+            (lambda (m) (msg-block->markdown m p))
+            (app-state-messages state))))
+      (add-message! state
+        (msg-block-system (format "Saved session to ~a" path))))))
+
 ;; ---- /expert <prompt> ----
 ;; Force the next turn to the configured expert by parameterizing the
 ;; provider/model overrides for the duration of run-agent!. The worker