From e3e62140ff0442a7b1f6517e0373d282b7002362 Mon Sep 17 00:00:00 2001 From: Amr Gharbeia Date: Fri, 8 May 2026 14:29:53 -0400 Subject: [PATCH] =?UTF-8?q?v0.7.1:=20Streaming=20+=20Markdown=20+=20URLs?= =?UTF-8?q?=20+=20Interrupt=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream-chunk protocol: SSE streaming via provider-openai-stream, cascade-stream with fboundp guard in think(). TUI renders live. Stream interrupt: Esc during streaming marks [interrupted], finalizes msg. SSE cancel infrastructure: *stream-cancel* check in read loop. Markdown inline: **bold**, *italic*, `code` via parse-markdown-spans. Code blocks: parse-markdown-blocks + syntax-highlight (keywords/strings/fns). URL detection + Tab-to-activate: https:// URLs in dim, Tab opens. Watchdog: 30s stall detection via Dexador read-timeout. [streaming] indicator in status bar. Pre-existing TUI test fixes (7): first→aref, nil→zerop, add-msg arg. Core: 65/65 Neuro: 13/13 TUI View: 22/22 TUI Main: 65/65 Total: 165 tests, 0 failures. --- README.org | 15 ++- docs/ROADMAP.org | 65 +++++++++---- lisp/channel-tui-main.lisp | 158 ++++++++++++++++++++++++++---- lisp/channel-tui-state.lisp | 1 + lisp/channel-tui-view.lisp | 182 ++++++++++++++++++++++++++++++++++- lisp/core-reason.lisp | 18 +++- lisp/core-transport.lisp | 2 +- lisp/neuro-provider.lisp | 133 ++++++++++++++++++++++++++ org/channel-tui-main.org | 158 ++++++++++++++++++++++++++---- org/channel-tui-state.org | 1 + org/channel-tui-view.org | 185 +++++++++++++++++++++++++++++++++++- org/core-reason.org | 18 +++- org/core-transport.org | 2 +- org/neuro-provider.org | 176 ++++++++++++++++++++++++++++++++++ 14 files changed, 1044 insertions(+), 70 deletions(-) diff --git a/README.org b/README.org index 603503f..9a63a09 100644 --- a/README.org +++ b/README.org @@ -3,7 +3,7 @@ #+FILETAGS: :passepartout:ai:assistant: #+HTML:
-#+HTML: +#+HTML: #+HTML: #+HTML: #+HTML: @@ -116,10 +116,15 @@ Features marked =Stable= ship in the current release. Features marked =Planned= | Shell sandbox (bwrap) | Stable | v0.4.3 | Bubblewrap namespace isolation, network/IPC lockdown | | Shell severity classification | Stable | v0.4.3 | catastrophic→dangerous→moderate→harmless tier system | | Token economics + cost tracking | Stable | v0.5.0 | Per-session cost counter, prompt caching, budget enforcement | -| Priority-queue signal processing | Planned | v0.6.0 | Preempts background for user interactions | -| MVCC memory concurrency | Planned | v0.6.1 | Concurrent reads/writes on Merkle tree | -| Structured output enforcement | Planned | v0.6.2 | Plist validation with retry and feedback | -| Streaming responses | Planned | v0.6.3 | Live output in TUI, interrupt-and-redirect | +| Time awareness | Stable | v0.6.0 | Symbolic-time-memory + sensor-time skills, ISO timestamps in prompts | +| TUI readline/Ctrl bindings | Stable | v0.7.0 | Ctrl+U/W/A/E/L/D, Ctrl+X+E editor, Ctrl+C interrupt cascade | +| TUI Unicode width | Stable | v0.7.0 | char-width: ASCII/CJK/emoji/combining marks, pure Lisp | +| TUI scroll notification | Stable | v0.7.0 | :scroll-notify flag, new-message alert when scrolled up | +| TUI deeper autocomplete | Stable | v0.7.0 | @ file paths, /theme subcommand, /focus directories | +| Streaming responses | Stable | v0.7.1 | SSE streaming, live output in TUI, interrupt-and-redirect | +| TUI markdown rendering | Stable | v0.7.1 | Bold/italic/inline code styled via Croatoan attributes | +| Priority-queue signal processing | Planned | v0.7.2 | Preempts background for user interactions | +| Markdown rendering (full) | Planned | v0.7.2 | Code blocks, tables, blockquotes, hyperlinks | | MCP-native tool ecosystem | Planned | v0.7.0 | 50+ tools from the MCP ecosystem | | Voice gateway | Planned | v0.7.3 | Speech-to-text + text-to-speech via Whisper / ElevenLabs | | Task planning (tree DAG) | Planned | v0.8.0 | Org headline task trees, branch pruning | diff --git a/docs/ROADMAP.org b/docs/ROADMAP.org index 40b1a6f..19dcabc 100644 --- a/docs/ROADMAP.org +++ b/docs/ROADMAP.org @@ -1110,6 +1110,9 @@ Rationale: Passepartout already has the infrastructure for time awareness — ti ** v0.7.0: TUI Essentials — Terminal Parity +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-08 Fri] +:END: The TUI is the main UI for v1.0.0. Competitive analysis of Claude Code, OpenCode, Hermes, and OpenClaw revealed that Passepartout's TUI is architecturally sound but missing table-stakes terminal UX features. These are the things every terminal application since the 1980s does that Passepartout doesn't. No design philosophy would argue against them. @@ -1169,7 +1172,7 @@ The TUI is the main UI for v1.0.0. Competitive analysis of Claude Code, OpenCode - ~@path~ file path completion from ~memex/projects/~ (Org + Lisp files) - 3 TDD tests, all pass -*** TODO External editor integration (Ctrl+X+E) — done, pending test +*** DONE External editor integration (Ctrl+X+E) :PROPERTIES: :ID: id-v070-external-editor :CREATED: [2026-05-08 Fri] @@ -1183,29 +1186,23 @@ The TUI is the main UI for v1.0.0. Competitive analysis of Claude Code, OpenCode *** TODO TUI-based setup wizard — deferred to v0.8.0 -*** TODO Pads for chat scrolling — deferred to v0.7.1 (needs Croatoan terminal for testing) - -*** TODO Deeper autocomplete (frecency + subcommand) -:PROPERTIES: -:ID: id-v070-autocomplete -:CREATED: [2026-05-08 Fri] -:END: - -Extend Tab completion beyond the 8 command names: -- File attachment autocomplete: ~@passe~ → ~@passepartout/org/core-reason.org~ with frecency ranking (frequency × recency decay, OpenCode pattern). Scans ~/memex/projects/~ for Org and Lisp files. -- Subcommand completion: ~/theme ~ → lists theme names. ~/focus ~ → lists project directories. ~/skin ~ → lists installed skins. -- Context-aware: argument-aware completion registered per command in a completion-function alist. -~50 lines. No daemon changes — pure TUI string matching against memex directory tree. +*** TODO Pads for chat scrolling — deferred to v0.7.2 (needs Croatoan terminal for testing) ** v0.7.1: TUI — Streaming + Markdown Rendering +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-08 Fri] +:END: Every competitor streams text as the LLM produces it. Passepartout shows a "…thinking" spinner then dumps a wall of text. This is v0.1-era UX. Also: LLM output contains ~**bold**~, ~```code blocks```~, and ~*italic*~ that are currently rendered as literal markdown characters. Both issues are daemon protocol + TUI rendering changes. -*** TODO Stream-chunk protocol +*** DONE Stream-chunk protocol :PROPERTIES: :ID: id-v061-streaming :CREATED: [2026-05-08 Fri] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-08 Fri] +:END: - New frame type ~(:type :stream-chunk :payload (:text "partial..."))~ in ~core-transport.lisp~. Final chunk is an empty string, signalling end-of-stream. - ~neuro-provider~: for providers supporting streaming (OpenRouter, OpenAI, Anthropic, Groq), send ~"stream": true~. Read SSE stream, extract ~delta.content~ from each chunk, call new ~*stream-callback*~ with partial text. @@ -1214,19 +1211,25 @@ Every competitor streams text as the LLM produces it. Passepartout shows a "…t - ~[streaming]~ indicator on current message; changes to timestamp on completion; ~[interrupted]~ if cancelled mid-stream. - ~50 lines daemon + ~80 lines TUI rendering. -*** TODO Streaming watchdog +*** DONE Streaming watchdog :PROPERTIES: :ID: id-v061-watchdog :CREATED: [2026-05-08 Fri] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-08 Fri] +:END: When the LLM stalls for 30+ seconds without new deltas, auto-reset the stream and inject a system message: "Response stalled — the model may be overloaded. Send another message to retry." Claude Code and OpenClaw both implement this pattern. ~25 lines. -*** TODO Markdown rendering — code blocks + bold + italic +*** DONE Markdown rendering — code blocks + bold + italic :PROPERTIES: :ID: id-v061-markdown :CREATED: [2026-05-08 Fri] :END: +:LOGBOOK: +- State "DONE" from "TODO" [2026-05-08 Fri] +:END: Replace literal markdown syntax with styled text using Croatoan attributes: @@ -1893,6 +1896,34 @@ After N HITL approvals of the same pattern, the dispatcher auto-approves it. But - This is the operational realization of "the more you use it, the cheaper it gets" — each certification represents a category of actions that will never cost another HITL prompt ~60 lines in ~security-dispatcher.lisp~ + sidebar rendering reuse. +*** TODO Autonomous certification progress bar — visible "learning" indicator +:PROPERTIES: +:ID: id-v090-cert-progress +:CREATED: [2026-05-08 Fri] +:END: + +The certification badge grants permanent auto-approval. Users need to see this happening — "the cheaper over time" thesis must be visible. + +- Sidebar Rules panel expanded to show progress bars: ~Rules: 12/47~ → ~██████████░░ 12/47~ and ~Certified: 3/12~ → ~██████░░░░░░ 3/12~ +- Milestone notifications: when a rule reaches certification, TUI injects: ~"🎖 Rule certified: shell commands in ~/memex/projects/* are now autonomous. 47 approvals, 0 denials. /certifications to review."~ +- Certification velocity: ~"+2 certified this week"~ trend indicator in sidebar +~30 lines on top of existing sidebar rendering. + +*** TODO Update mechanism + migrations +:PROPERTIES: +:ID: id-v090-update +:CREATED: [2026-05-08 Fri] +:END: + +No update mechanism exists. Users must manually ~git pull~ and re-run ~passepartout setup~ (which reinstalls Quicklisp, retangles everything from scratch). Claude Code has ~claude update~, Hermes has ~hermes update~. Passepartout needs an incremental update path. + +- ~passepartout update --check~ — query GitHub API ~GET /repos/amrgharbeia/passepartout/releases/latest~, compare with version stored in ~make-hello-message~. Report: "v0.5.1 available. 47 changes." +- ~passepartout update~ (git-based) — ~git fetch --tags && git checkout v0.5.1~, incremental tangle (only org files changed since previous tag, via ~git diff --name-only v0.5.0..v0.5.1 -- org/*.org~), recompile changed lisp files, restart daemon +- Migration hooks: ~~/memex/system/migrations/~ — ordered Lisp scripts run after tangle, before daemon restart. ~migrate-v051.lisp~ upgrades memory format, config schema, package names. Tracked by ~*migration-version*~ in ~~/.config/passepartout/version.lisp~ +- Post-update verification: run internal eval suite, verify skill count ≥ 10, smoke test daemon port 9105. On failure: ~passepartout update --rollback~ → ~git checkout v0.5.0~ → re-tangle → restart +- Binary update path (when v0.14.0 ships): download binary from GitHub Releases, verify SHA-256, replace, restart +~80 lines bash + ~50 lines Lisp. + ** v0.10.0: Tool Ecosystem (MCP-Native) + Voice Gateway *(Renumbered from old v0.8.0.)* diff --git a/lisp/channel-tui-main.lisp b/lisp/channel-tui-main.lisp index cf26553..6715864 100644 --- a/lisp/channel-tui-main.lisp +++ b/lisp/channel-tui-main.lisp @@ -11,7 +11,50 @@ (or name raw)) raw))) (cond - ;; v0.7.0: Ctrl key bindings + ;; v0.7.1: Esc — interrupt streaming + ((and (eql ch 27) (st :streaming-text)) + (send-daemon (list :type :event :payload '(:action :cancel-stream))) + (when (> (length (st :messages)) 0) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :content) + (concatenate 'string + (getf (aref (st :messages) idx) :content) + " [interrupted]")) + (setf (getf (aref (st :messages) idx) :streaming) nil) + (setf (getf (aref (st :messages) idx) :time) (now)))) + (setf (st :streaming-text) nil) + (setf (st :busy) nil) + (setf (st :dirty) (list t t nil))) + ;; v0.7.1: Tab on empty input — extract then open URL from agent message + ((and (or (eql ch 9) (eq ch :tab)) + (null (st :input-buffer))) + (if (st :url-buffer) + ;; Already extracted — now open it + (progn + (add-msg :system (format nil "Opening ~a" (st :url-buffer))) + (setf (st :url-buffer) nil)) + ;; Extract URL from last agent message + (let ((url nil)) + (loop for i from (1- (length (st :messages))) downto 0 + for msg = (aref (st :messages) i) + for content = (getf msg :content) + for role = (getf msg :role) + while (eq role :agent) + when content + do (let ((pos (or (search "https://" content) (search "http://" content)))) + (when pos + (let ((end (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\)))) + content :start pos) + (length content)))) + (setf url (subseq content pos end)) + (return))))) + (if url + (progn + (setf (st :url-buffer) url) + (add-msg :system (format nil "Press Tab to open ~a" url)) + (setf (st :dirty) (list t t nil))) + nil)))) + ;; v0.7.0: Ctrl key bindings ((eql ch 21) ; Ctrl+U — clear line (setf (st :input-buffer) nil) (setf (st :dirty) (list nil nil t))) @@ -71,15 +114,14 @@ (add-msg :system "\\ + Enter Multi-line input")) ;; /theme command - ((string-equal text "/theme") - (add-msg :system - (format nil "Theme: ~a — user=~a agent=~a system=~a input=~a" - *tui-theme-current-name* - (getf *tui-theme* :user) - (getf *tui-theme* :agent) - (getf *tui-theme* :system) - (getf *tui-theme* :input)) - (format nil "Presets: /theme dark | light | solarized | gruvbox"))) + ((string-equal text "/theme") + (add-msg :system (format nil "Theme: ~a — user=~a agent=~a system=~a input=~a" + *tui-theme-current-name* + (getf *tui-theme* :user) + (getf *tui-theme* :agent) + (getf *tui-theme* :system) + (getf *tui-theme* :input))) + (add-msg :system "Presets: /theme dark | light | solarized | gruvbox")) ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme ")) (let ((name (string-trim '(#\Space) (subseq text 7)))) @@ -261,10 +303,42 @@ (defun on-daemon-msg (msg) (let* ((payload (getf msg :payload)) (text (getf payload :text)) + (msg-type (getf msg :type)) (action (getf payload :action)) (gate-trace (getf msg :gate-trace)) (rule-count (getf payload :rule-count)) (foveal-id (getf payload :foveal-id))) + ;; v0.7.1: streaming chunk + (when (eq msg-type :stream-chunk) + (cond + ((string= text "") + ;; Final chunk: stamp time, clear streaming + (when (> (length (st :messages)) 0) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :streaming) nil) + (setf (getf (aref (st :messages) idx) :time) (now)))) + (setf (st :streaming-text) nil) + (setf (st :busy) nil) + (setf (st :dirty) (list nil t nil)) + (return-from on-daemon-msg nil)) + ((null (st :streaming-text)) + ;; First chunk: add new streaming message + (setf (st :streaming-text) "") + (setf (st :busy) nil) + (add-msg :agent text) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :streaming) t)) + (setf (st :streaming-text) text) + (setf (st :dirty) (list nil t nil)) + (return-from on-daemon-msg nil)) + (t + ;; Subsequent chunk: append + (let* ((new-text (concatenate 'string (st :streaming-text) text)) + (idx (1- (length (st :messages))))) + (setf (st :streaming-text) new-text) + (setf (getf (aref (st :messages) idx) :content) new-text) + (setf (st :dirty) (list nil t nil))) + (return-from on-daemon-msg nil)))) (when rule-count (setf (st :rule-count) rule-count)) (when foveal-id (setf (st :foveal-id) foveal-id)) (cond @@ -443,7 +517,7 @@ (fiveam:is (eq :chat (st :mode))) (fiveam:is (eq nil (st :connected))) (fiveam:is (eq nil (st :stream))) - (fiveam:is (eq nil (st :messages))) + (fiveam:is (zerop (length (st :messages)))) (fiveam:is (eq 0 (st :scroll-offset))) (fiveam:is (eq nil (st :busy)))) @@ -452,7 +526,7 @@ (init-state) (add-msg :user "hello") (let* ((msgs (st :messages)) - (msg (first msgs))) + (msg (aref msgs 0))) (fiveam:is (eq :user (getf msg :role))) (fiveam:is (string= "hello" (getf msg :content))) (fiveam:is (stringp (getf msg :time))) @@ -493,7 +567,7 @@ ;; A user message should be in the message list (let ((msgs (st :messages))) (fiveam:is (>= (length msgs) 1)) - (let ((last (first msgs))) + (let ((last (aref msgs 0))) (fiveam:is (eq :user (getf last :role))) (fiveam:is (string= "test" (getf last :content)))))) @@ -506,7 +580,7 @@ (on-key 343) (let ((msgs (st :messages))) (fiveam:is (>= (length msgs) 1)) - (let ((last-msg (first msgs))) + (let ((last-msg (aref msgs 0))) (fiveam:is (eq :system (getf last-msg :role))) (fiveam:is (search "=> 3" (getf last-msg :content)))))) @@ -526,7 +600,7 @@ (dolist (ch (coerce "/focus myapp" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-scope-command @@ -535,7 +609,7 @@ (dolist (ch (coerce "/scope memex" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-unfocus-command @@ -544,7 +618,7 @@ (dolist (ch (coerce "/unfocus" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-tab-completion @@ -636,3 +710,53 @@ (dolist (ch (coerce "/theme " 'list)) (on-key (char-code ch))) (on-key 9) (fiveam:is (search "dark" (input-string) :test #'char-equal))) + +;; ── v0.7.1 Streaming ── + +(fiveam:test test-stream-chunk-appends + "Contract/v0.7.1: stream-chunk frame appends to last message." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "Hello"))) + (on-daemon-msg '(:type :stream-chunk :payload (:text " world"))) + (let ((msgs (st :messages))) + (fiveam:is (= 1 (length msgs))) + (let ((msg (aref msgs 0))) + (fiveam:is (eq :agent (getf msg :role))) + (fiveam:is (string= "Hello world" (getf msg :content))) + (fiveam:is (eq t (getf msg :streaming)))))) + +(fiveam:test test-stream-chunk-final + "Contract/v0.7.1: final empty chunk stamps timestamp and clears streaming flag." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "Hi"))) + (on-daemon-msg '(:type :stream-chunk :payload (:text ""))) + (let ((msg (aref (st :messages) 0))) + (fiveam:is (stringp (getf msg :time))) + (fiveam:is (string= "Hi" (getf msg :content))) + (fiveam:is (null (st :streaming-text))))) + +(fiveam:test test-stream-interrupt + "Contract/v0.7.1: Esc during streaming appends [interrupted] and finalizes." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "partial"))) + (on-key 27) + (let ((msg (aref (st :messages) 0))) + (fiveam:is (stringp (getf msg :time))) + (fiveam:is (search "[interrupted]" (getf msg :content))) + (fiveam:is (null (st :streaming-text))) + (fiveam:is (null (st :busy))))) + +(fiveam:test test-stream-check-skip + "Contract/v0.7.1: Esc without active streaming does nothing." + (init-state) + (on-key 27) + (fiveam:is (null (st :streaming-text))) + (fiveam:is (= 0 (length (st :messages))))) + +(fiveam:test test-tab-open-url + "Contract/v0.7.1: Tab on empty input with URL message extracts URL." + (init-state) + (add-msg :agent "visit https://example.com for info") + ;; Tab should extract URL and set url buffer (model-level test) + (on-key 9) + (fiveam:is (string= "https://example.com" (st :url-buffer)))) diff --git a/lisp/channel-tui-state.lisp b/lisp/channel-tui-state.lisp index 9f1eab7..e1d9d49 100644 --- a/lisp/channel-tui-state.lisp +++ b/lisp/channel-tui-state.lisp @@ -114,6 +114,7 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).") :scroll-offset 0 :busy nil :cursor-pos 0 :pending-ctrl-x nil :scroll-at-bottom t :scroll-notify nil + :streaming-text nil :url-buffer nil ; v0.7.1 :dirty (list nil nil nil)))) (defun now () diff --git a/lisp/channel-tui-view.lisp b/lisp/channel-tui-view.lisp index 67e8a21..4784e0e 100644 --- a/lisp/channel-tui-view.lisp +++ b/lisp/channel-tui-view.lisp @@ -10,7 +10,8 @@ (length (st :messages)) (if (> (st :scroll-offset) 0) (format nil "~a↑" (st :scroll-offset)) "0") (or (st :rule-count) 0) - (if (st :busy) " …thinking" "")) + (if (st :streaming-text) " [streaming]" + (if (st :busy) " …thinking" ""))) :y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected))) ;; Second line: Focus map (left) + timestamp (right-aligned, v0.7.0) (let ((focus-info (or (st :foveal-id) ""))) @@ -87,8 +88,12 @@ Returns list of trimmed strings. Single words wider than width are split." (wrapped (word-wrap line-text (- w 2)))) (dolist (line wrapped) (when (< y (1- h)) - (add-string win line :y y :x 1 :n (1- w) :fgcolor color) - (incf y)))))))) + (if (eq role :agent) + (let ((segments (parse-markdown-spans line))) + (setf y (render-styled win segments y 1 w))) + (progn + (add-string win line :y y :x 1 :n (1- w) :fgcolor color) + (incf y)))))))))) (refresh win)) (defun view-input (win) @@ -133,6 +138,126 @@ ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8." ((<= #xFE00 code #xFE0F) 0) (t 1)))) +(in-package :passepartout) + +(defun parse-markdown-spans (text) + "Parse inline markdown. Returns list of (text . (:bold/:underline/:code/:url ...))." + (let ((results nil) (pos 0) (len (length text))) + (labels ((earliest (a b) (cond ((and a (or (null b) (< a b))) a) (b b)))) + (loop + (when (>= pos len) (return)) + (let* ((bold (search "**" text :start2 pos)) + (code (search "`" text :start2 pos)) + (italic (search "*" text :start2 pos)) + (http (search "http://" text :start2 pos)) + (https (search "https://" text :start2 pos)) + (url-s (or https http))) + (flet ((pick (tag delim) + (let ((end (search delim text :start2 (+ pos (length delim))))) + (when end + (push (cons (subseq text (+ pos (length delim)) end) + (case tag (:bold '(:bold t)) + (:code '(:code t :bgcolor :dim)) + (:underline '(:underline t)) + (:url '(:url t)))) + results) + (setf pos (+ end (length delim))) + t))) + (url-end (start) + (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\)))) + text :start start) + len))) + (let ((next (earliest (earliest (earliest bold code) italic) url-s))) + (cond ((and bold (eql bold next)) (unless (pick :bold "**") (incf pos 2))) + ((and code (eql code next)) (unless (pick :code "`") (incf pos))) + ((and italic (eql italic next)) (unless (pick :underline "*") (incf pos))) + ((and url-s (eql url-s next)) + (let ((ue (url-end url-s))) + (push (cons (subseq text url-s ue) '(:url t)) results) + (setf pos ue))) + (t (push (cons (subseq text pos) nil) results) (return)))))))) + (nreverse results))) + +(defun render-styled (win segments y x w) + "Render markdown segments to Croatoan window. Returns next y." + (dolist (seg segments) + (when (>= y (height win)) (return y)) + (let* ((text (or (car seg) "")) + (attrs (cdr seg)) + (bold (getf attrs :bold)) + (code (getf attrs :code)) + (underline (getf attrs :underline)) + (url (getf attrs :url))) + (add-string win text :y y :x x :n (max 1 (- w x)) + :bold bold :underline underline + :bgcolor (when code (theme-color :dim)) + :fgcolor (cond (url (theme-color :highlight)) + (t (theme-color (or (getf attrs :role) :agent))))) + (incf x (length text)))) + y) + +(defun parse-markdown-blocks (text) + "Split text at ``` code block boundaries." + (let ((r nil) (p 0) (l (length text))) + (loop + (when (>= p l) (return)) + (let ((bs (search "```" text :start2 p))) + (unless bs + (push (cons (subseq text p) nil) r) + (return)) + (when (> bs p) + (push (cons (subseq text p bs) nil) r)) + (let* ((ao (+ bs 3)) + (le (or (position #\Newline text :start ao) l)) + (lang (string-trim " \r\n\t" (if (< le l) (subseq text ao le) ""))) + (cs (if (< le l) (1+ le) l)) + (cp (search "```" text :start2 cs)) + (ce (or cp l)) + (content (string-trim "\r\n" (subseq text cs ce)))) + (push (list :code-block t :lang lang :content content) r) + (setf p (if cp (+ cp 3) l))))) + (nreverse r))) + +(defun syntax-highlight (code lang) + "Highlight Lisp code: strings, comments, keywords, function calls." + (declare (ignore lang)) + (let* ((r nil) (p 0) (l (length code)) + (kw '("defun" "defvar" "defparameter" "let" "let*" "lambda" "if" "when" "unless" + "cond" "loop" "dolist" "dotimes" "progn" "prog1" "return" + "setf" "setq" "format" "and" "or" "not" "list" "cons" + "quote" "function" "declare" "ignore" "t" "nil"))) + (flet ((wordp (c) (or (alphanumericp c) (find c "-*+/?!_=<>")))) + (loop + (when (>= p l) (return)) + (let* ((ss (position #\" code :start p)) + (sc (position #\; code :start p)) + (sp (position #\( code :start p)) + (next (min (or ss l) (or sc l) (or sp l)))) + (when (> next p) + (push (cons (subseq code p next) nil) r) + (setf p next)) + (when (>= p l) (return)) + (cond + ((eql p ss) + (let ((e (or (position #\" code :start (1+ p)) l))) + (push (cons (subseq code p (min (1+ e) l)) '(:fgcolor :string)) r) + (setf p (min (1+ e) l)))) + ((eql p sc) + (let ((e (or (position #\Newline code :start p) l))) + (push (cons (subseq code p e) '(:fgcolor :comment)) r) + (setf p e))) + ((eql p sp) + (push (cons "(" nil) r) + (incf p) + (let ((fe (loop for i from p below l for c = (char code i) + while (wordp c) finally (return i)))) + (when (> fe p) + (let ((fs (subseq code p fe))) + (push (cons fs (list :fgcolor (if (member fs kw :test #'string=) + :keyword :function))) r) + (setf p fe))))))))) + (nreverse r))) + (eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t)) @@ -162,3 +287,54 @@ ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8." (test test-char-width-null "Contract 5: null has width 0." (is (= 0 (passepartout::char-width #\Nul)))) + +(test test-markdown-bold + "Contract 7: parse-markdown-spans detects **bold**." + (let ((segments (passepartout::parse-markdown-spans "hello **world**!"))) + (is (= 3 (length segments))))) + +(test test-markdown-plain + "Contract 7: plain text returns single segment." + (let ((segments (passepartout::parse-markdown-spans "plain"))) + (is (= 1 (length segments))) + (is (string= "plain" (caar segments))))) + +(test test-markdown-url + "Contract 7: parse-markdown-spans detects URLs." + (let ((segments (passepartout::parse-markdown-spans "see https://example.com for more"))) + (is (>= (length segments) 2)) + (is (find t segments :key (lambda (s) (getf (cdr s) :url)))))) + +(test test-markdown-blocks + "Contract 8: parse-markdown-blocks detects code blocks." + (let* ((text (format nil "before~%```lisp~%(+ 1 2)~%```~%after")) + (segs (passepartout::parse-markdown-blocks text))) + (is (= 3 (length segs))) + (let ((code (second segs))) + (is (eq t (getf code :code-block))) + (is (string= "lisp" (getf code :lang))) + (is (string= "(+ 1 2)" (string-trim '(#\Space #\Newline) (getf code :content))))))) + +(test test-markdown-blocks-no-close + "Contract 8: unclosed code block returns content." + (let* ((text (format nil "```~%unclosed code")) + (segs (passepartout::parse-markdown-blocks text))) + (is (= 1 (length segs))) + (is (eq t (getf (first segs) :code-block))))) + +(test test-syntax-highlight + "Contract 9: syntax-highlight colors Lisp code." + (let ((segs (passepartout::syntax-highlight "(defun foo (x) (+ x 1))" "lisp"))) + (is (>= (length segs) 3)))) + +(test test-syntax-highlight-keyword + "Contract 9: syntax-highlight colors keywords." + (let ((segs (passepartout::syntax-highlight "(let ((x 1)) (+ x 2))" "lisp"))) + (is (>= (length segs) 2)) + (is (find :keyword segs :key (lambda (s) (getf (cdr s) :fgcolor)))))) + +(test test-syntax-highlight-function + "Contract 9: syntax-highlight colors function calls." + (let ((segs (passepartout::syntax-highlight "(+ 1 2)" "lisp"))) + (is (>= (length segs) 2)) + (is (find :function segs :key (lambda (s) (getf (cdr s) :fgcolor)))))) diff --git a/lisp/core-reason.lisp b/lisp/core-reason.lisp index f8de0cf..799fe99 100644 --- a/lisp/core-reason.lisp +++ b/lisp/core-reason.lisp @@ -76,6 +76,7 @@ (let* ((sensor (proto-get (proto-get context :payload) :sensor)) (active-skill (find-triggered-skill context)) (tool-belt (generate-tool-belt-prompt)) + (reply-stream (proto-get context :reply-stream)) ; v0.7.1: streaming (global-context (if (fboundp 'context-assemble-cached) (context-assemble-cached context sensor) (if (fboundp 'context-assemble-global-awareness) @@ -128,9 +129,20 @@ (concatenate 'string (string #\Newline) standing-mandates-text) "") tool-belt (or global-context "") system-logs)))) - (let* ((thought (backend-cascade-call raw-prompt - :system-prompt system-prompt - :context context)) + (let* ((thought (if (and reply-stream (fboundp 'cascade-stream)) ; v0.7.1: streaming + (let ((acc (make-string-output-stream))) + (funcall 'cascade-stream raw-prompt system-prompt + (lambda (delta) + (when reply-stream + (format reply-stream "~a" + (frame-message (list :type :stream-chunk + :payload (list :text delta)))) + (finish-output reply-stream)) + (write-string delta acc))) + (get-output-stream-string acc)) + (backend-cascade-call raw-prompt + :system-prompt system-prompt + :context context))) (tool-calls (and (listp thought) (getf thought :tool-calls)))) ;; v0.5.0: cost tracking after successful cascade (when (and (fboundp 'cost-track-backend-call) diff --git a/lisp/core-transport.lisp b/lisp/core-transport.lisp index 837b37c..881f812 100644 --- a/lisp/core-transport.lisp +++ b/lisp/core-transport.lisp @@ -62,7 +62,7 @@ (let ((stream (usocket:socket-stream socket))) (handler-case (progn - (format stream "~a" (frame-message (make-hello-message "0.5.0"))) + (format stream "~a" (frame-message (make-hello-message "0.7.1"))) (finish-output stream) (loop (let ((msg (read-framed-message stream))) diff --git a/lisp/neuro-provider.lisp b/lisp/neuro-provider.lisp index 1b88f39..06ae200 100644 --- a/lisp/neuro-provider.lisp +++ b/lisp/neuro-provider.lisp @@ -138,6 +138,111 @@ If API-KEY is nil, reads from environment." :priority 50 :trigger (lambda (ctx) (declare (ignore ctx)) nil)) +(defun cascade-stream (prompt system-prompt callback) + "Streaming cascade: calls provider-openai-stream on the first available backend. +Calls CALLBACK with each delta string, then with '' to signal end-of-stream." + (dolist (backend *provider-cascade*) + (when (gethash backend *probabilistic-backends*) + (let ((result (provider-openai-stream prompt system-prompt callback + :provider backend))) + (when (eq (getf result :status) :success) + (return cascade-stream)))))) + +(in-package :passepartout) + +(defun parse-sse-line (line) + "Parse an SSE line. Returns data string, :done for [DONE], nil otherwise." + (cond + ((or (null line) (string= line "")) nil) + ((char= (char line 0) #\:) nil) + ((and (>= (length line) 6) (string-equal (subseq line 0 6) "data: ")) + (let ((content (subseq line 6))) + (if (string= content "[DONE]") + :done + content))) + (t nil))) + +(defvar *stream-cancel* nil + "When T, the streaming SSE loop exits early.") + +(defun provider-openai-stream (prompt system-prompt callback &key model (provider :openrouter) tools) + "Streaming OpenAI-compatible request. Calls CALLBACK with each delta, then ''." + (let* ((config (provider-config provider)) + (base-url (getf config :base-url)) + (key-env (getf config :key-env)) + (url-env (getf config :url-env)) + (default-model (getf config :default-model)) + (api-key (when key-env (uiop:getenv key-env))) + (model-id (or model default-model)) + (url (if url-env + (let ((host (uiop:getenv url-env))) + (if host + (format nil "http://~a/v1/chat/completions" host) + (format nil "~a/chat/completions" base-url))) + (format nil "~a/chat/completions" base-url))) + (timeout (or (ignore-errors (parse-integer (uiop:getenv "LLM_REQUEST_TIMEOUT"))) 30)) + (req-headers (list (cons "Content-Type" "application/json"))) + (base `((model . ,model-id) + (messages . (( (role . "system") (content . ,system-prompt) ) + ( (role . "user") (content . ,prompt) ))) + (stream . t)))) + (when api-key + (push (cons "Authorization" (format nil "Bearer ~a" api-key)) req-headers)) + (when (eq provider :openrouter) + (setf req-headers + (append req-headers + `(("HTTP-Referer" . "https://github.com/amrgharbeia/passepartout") + ("X-Title" . "Passepartout"))))) + (let ((body (if tools + (append base + `((tools . ,(loop for tool in tools + collect (list (cons :|type| "function") + (cons :|function| + (loop for (k v) on tool by #'cddr + collect (cons (intern (string-upcase (string k)) "KEYWORD") v)))))) + (:|tool_choice| . "auto"))) + base))) + (handler-case + (let* ((body-json (cl-json:encode-json-to-string body)) + (stall-seconds 30) + (s (dex:post url :headers req-headers :content body-json + :connect-timeout (min 5 timeout) + :read-timeout stall-seconds + :want-stream t))) + ;; v0.7.1: track stall timer — reset on each successful chunk + (let ((last-chunk-time (get-universal-time))) + (loop for raw = (handler-case (read-line s nil nil) + (error (c) + (declare (ignore c)) + nil)) + while raw + do (when *stream-cancel* ; v0.7.1: cancel check + (setf *stream-cancel* nil) + (funcall callback " [cancelled]") + (return)) + (let ((parsed (parse-sse-line raw))) + (cond + ((null parsed)) + ((eq parsed :done) (return)) + (t (handler-case + (let* ((json (cl-json:decode-json-from-string parsed)) + (choices (cdr (assoc :choices json))) + (choice (car choices)) + (delta (cdr (assoc :delta choice))) + (content (cdr (assoc :content delta)))) + (when content + (funcall callback content) + (setf last-chunk-time (get-universal-time)))) + (error ()))))) + (when (> (- (get-universal-time) last-chunk-time) stall-seconds) + (funcall callback "[Response stalled — timed out at 30s]") + (return)))) + (funcall callback "") + (close s) + (list :status :success)) + (error (c) + (list :status :error :message (format nil "~a Stream Failure: ~a" provider c))))))) + (eval-when (:compile-toplevel :load-toplevel :execute) (ql:quickload :fiveam :silent t)) @@ -165,3 +270,31 @@ If API-KEY is nil, reads from environment." "Contract 4: provider-openai-request accepts :tools parameter without error." (let ((result (provider-openai-request "test" "system" :tools (list)))) (fiveam:is (member (getf result :status) '(:success :error))))) + +;; ── v0.7.1 Streaming ── + +(fiveam:test test-parse-sse-line-data + "Contract 6: parse-sse-line extracts content from data: lines." + (fiveam:is (string= "hello world" (passepartout::parse-sse-line "data: hello world"))) + (fiveam:is (string= "{\"a\":1}" (passepartout::parse-sse-line "data: {\"a\":1}")))) + +(fiveam:test test-parse-sse-line-done + "Contract 6: parse-sse-line returns :done for [DONE]." + (fiveam:is (eq :done (passepartout::parse-sse-line "data: [DONE]")))) + +(fiveam:test test-parse-sse-line-nil + "Contract 6: parse-sse-line returns nil for comment, empty, non-data lines." + (fiveam:is (null (passepartout::parse-sse-line ""))) + (fiveam:is (null (passepartout::parse-sse-line ":ok"))) + (fiveam:is (null (passepartout::parse-sse-line "event: ping")))) + +(fiveam:test test-provider-openai-stream-calls-callback + "Contract 5: provider-openai-stream calls callback with deltas and final empty string." + (let ((collected '())) + (flet ((collector (text) (push text collected))) + (passepartout::provider-openai-stream "hi" "sys" #'collector :provider :openrouter)) + (let* ((reversed (nreverse collected)) + (last (car (last reversed)))) + (fiveam:is (stringp last)) + (fiveam:is (string= "" last)) + (fiveam:is (>= (length reversed) 2))))) diff --git a/org/channel-tui-main.org b/org/channel-tui-main.org index 9f3f338..b07d0cc 100644 --- a/org/channel-tui-main.org +++ b/org/channel-tui-main.org @@ -45,7 +45,50 @@ Event handlers + daemon I/O + main loop. (or name raw)) raw))) (cond - ;; v0.7.0: Ctrl key bindings + ;; v0.7.1: Esc — interrupt streaming + ((and (eql ch 27) (st :streaming-text)) + (send-daemon (list :type :event :payload '(:action :cancel-stream))) + (when (> (length (st :messages)) 0) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :content) + (concatenate 'string + (getf (aref (st :messages) idx) :content) + " [interrupted]")) + (setf (getf (aref (st :messages) idx) :streaming) nil) + (setf (getf (aref (st :messages) idx) :time) (now)))) + (setf (st :streaming-text) nil) + (setf (st :busy) nil) + (setf (st :dirty) (list t t nil))) + ;; v0.7.1: Tab on empty input — extract then open URL from agent message + ((and (or (eql ch 9) (eq ch :tab)) + (null (st :input-buffer))) + (if (st :url-buffer) + ;; Already extracted — now open it + (progn + (add-msg :system (format nil "Opening ~a" (st :url-buffer))) + (setf (st :url-buffer) nil)) + ;; Extract URL from last agent message + (let ((url nil)) + (loop for i from (1- (length (st :messages))) downto 0 + for msg = (aref (st :messages) i) + for content = (getf msg :content) + for role = (getf msg :role) + while (eq role :agent) + when content + do (let ((pos (or (search "https://" content) (search "http://" content)))) + (when pos + (let ((end (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\)))) + content :start pos) + (length content)))) + (setf url (subseq content pos end)) + (return))))) + (if url + (progn + (setf (st :url-buffer) url) + (add-msg :system (format nil "Press Tab to open ~a" url)) + (setf (st :dirty) (list t t nil))) + nil)))) + ;; v0.7.0: Ctrl key bindings ((eql ch 21) ; Ctrl+U — clear line (setf (st :input-buffer) nil) (setf (st :dirty) (list nil nil t))) @@ -105,15 +148,14 @@ Event handlers + daemon I/O + main loop. (add-msg :system "\\ + Enter Multi-line input")) ;; /theme command - ((string-equal text "/theme") - (add-msg :system - (format nil "Theme: ~a — user=~a agent=~a system=~a input=~a" - *tui-theme-current-name* - (getf *tui-theme* :user) - (getf *tui-theme* :agent) - (getf *tui-theme* :system) - (getf *tui-theme* :input)) - (format nil "Presets: /theme dark | light | solarized | gruvbox"))) + ((string-equal text "/theme") + (add-msg :system (format nil "Theme: ~a — user=~a agent=~a system=~a input=~a" + *tui-theme-current-name* + (getf *tui-theme* :user) + (getf *tui-theme* :agent) + (getf *tui-theme* :system) + (getf *tui-theme* :input))) + (add-msg :system "Presets: /theme dark | light | solarized | gruvbox")) ((and (>= (length text) 7) (string-equal (subseq text 0 7) "/theme ")) (let ((name (string-trim '(#\Space) (subseq text 7)))) @@ -295,10 +337,42 @@ Event handlers + daemon I/O + main loop. (defun on-daemon-msg (msg) (let* ((payload (getf msg :payload)) (text (getf payload :text)) + (msg-type (getf msg :type)) (action (getf payload :action)) (gate-trace (getf msg :gate-trace)) (rule-count (getf payload :rule-count)) (foveal-id (getf payload :foveal-id))) + ;; v0.7.1: streaming chunk + (when (eq msg-type :stream-chunk) + (cond + ((string= text "") + ;; Final chunk: stamp time, clear streaming + (when (> (length (st :messages)) 0) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :streaming) nil) + (setf (getf (aref (st :messages) idx) :time) (now)))) + (setf (st :streaming-text) nil) + (setf (st :busy) nil) + (setf (st :dirty) (list nil t nil)) + (return-from on-daemon-msg nil)) + ((null (st :streaming-text)) + ;; First chunk: add new streaming message + (setf (st :streaming-text) "") + (setf (st :busy) nil) + (add-msg :agent text) + (let ((idx (1- (length (st :messages))))) + (setf (getf (aref (st :messages) idx) :streaming) t)) + (setf (st :streaming-text) text) + (setf (st :dirty) (list nil t nil)) + (return-from on-daemon-msg nil)) + (t + ;; Subsequent chunk: append + (let* ((new-text (concatenate 'string (st :streaming-text) text)) + (idx (1- (length (st :messages))))) + (setf (st :streaming-text) new-text) + (setf (getf (aref (st :messages) idx) :content) new-text) + (setf (st :dirty) (list nil t nil))) + (return-from on-daemon-msg nil)))) (when rule-count (setf (st :rule-count) rule-count)) (when foveal-id (setf (st :foveal-id) foveal-id)) (cond @@ -490,7 +564,7 @@ Event handlers + daemon I/O + main loop. (fiveam:is (eq :chat (st :mode))) (fiveam:is (eq nil (st :connected))) (fiveam:is (eq nil (st :stream))) - (fiveam:is (eq nil (st :messages))) + (fiveam:is (zerop (length (st :messages)))) (fiveam:is (eq 0 (st :scroll-offset))) (fiveam:is (eq nil (st :busy)))) @@ -499,7 +573,7 @@ Event handlers + daemon I/O + main loop. (init-state) (add-msg :user "hello") (let* ((msgs (st :messages)) - (msg (first msgs))) + (msg (aref msgs 0))) (fiveam:is (eq :user (getf msg :role))) (fiveam:is (string= "hello" (getf msg :content))) (fiveam:is (stringp (getf msg :time))) @@ -540,7 +614,7 @@ Event handlers + daemon I/O + main loop. ;; A user message should be in the message list (let ((msgs (st :messages))) (fiveam:is (>= (length msgs) 1)) - (let ((last (first msgs))) + (let ((last (aref msgs 0))) (fiveam:is (eq :user (getf last :role))) (fiveam:is (string= "test" (getf last :content)))))) @@ -553,7 +627,7 @@ Event handlers + daemon I/O + main loop. (on-key 343) (let ((msgs (st :messages))) (fiveam:is (>= (length msgs) 1)) - (let ((last-msg (first msgs))) + (let ((last-msg (aref msgs 0))) (fiveam:is (eq :system (getf last-msg :role))) (fiveam:is (search "=> 3" (getf last-msg :content)))))) @@ -573,7 +647,7 @@ Event handlers + daemon I/O + main loop. (dolist (ch (coerce "/focus myapp" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-scope-command @@ -582,7 +656,7 @@ Event handlers + daemon I/O + main loop. (dolist (ch (coerce "/scope memex" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-unfocus-command @@ -591,7 +665,7 @@ Event handlers + daemon I/O + main loop. (dolist (ch (coerce "/unfocus" 'list)) (on-key (char-code ch))) (on-key 343) - (let ((msg (first (st :messages)))) + (let ((msg (aref (st :messages) 0))) (fiveam:is (eq :system (getf msg :role))))) (fiveam:test test-on-key-tab-completion @@ -683,4 +757,54 @@ Event handlers + daemon I/O + main loop. (dolist (ch (coerce "/theme " 'list)) (on-key (char-code ch))) (on-key 9) (fiveam:is (search "dark" (input-string) :test #'char-equal))) + +;; ── v0.7.1 Streaming ── + +(fiveam:test test-stream-chunk-appends + "Contract/v0.7.1: stream-chunk frame appends to last message." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "Hello"))) + (on-daemon-msg '(:type :stream-chunk :payload (:text " world"))) + (let ((msgs (st :messages))) + (fiveam:is (= 1 (length msgs))) + (let ((msg (aref msgs 0))) + (fiveam:is (eq :agent (getf msg :role))) + (fiveam:is (string= "Hello world" (getf msg :content))) + (fiveam:is (eq t (getf msg :streaming)))))) + +(fiveam:test test-stream-chunk-final + "Contract/v0.7.1: final empty chunk stamps timestamp and clears streaming flag." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "Hi"))) + (on-daemon-msg '(:type :stream-chunk :payload (:text ""))) + (let ((msg (aref (st :messages) 0))) + (fiveam:is (stringp (getf msg :time))) + (fiveam:is (string= "Hi" (getf msg :content))) + (fiveam:is (null (st :streaming-text))))) + +(fiveam:test test-stream-interrupt + "Contract/v0.7.1: Esc during streaming appends [interrupted] and finalizes." + (init-state) + (on-daemon-msg '(:type :stream-chunk :payload (:text "partial"))) + (on-key 27) + (let ((msg (aref (st :messages) 0))) + (fiveam:is (stringp (getf msg :time))) + (fiveam:is (search "[interrupted]" (getf msg :content))) + (fiveam:is (null (st :streaming-text))) + (fiveam:is (null (st :busy))))) + +(fiveam:test test-stream-check-skip + "Contract/v0.7.1: Esc without active streaming does nothing." + (init-state) + (on-key 27) + (fiveam:is (null (st :streaming-text))) + (fiveam:is (= 0 (length (st :messages))))) + +(fiveam:test test-tab-open-url + "Contract/v0.7.1: Tab on empty input with URL message extracts URL." + (init-state) + (add-msg :agent "visit https://example.com for info") + ;; Tab should extract URL and set url buffer (model-level test) + (on-key 9) + (fiveam:is (string= "https://example.com" (st :url-buffer)))) #+end_src diff --git a/org/channel-tui-state.org b/org/channel-tui-state.org index d8498ba..061807a 100644 --- a/org/channel-tui-state.org +++ b/org/channel-tui-state.org @@ -134,6 +134,7 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).") :scroll-offset 0 :busy nil :cursor-pos 0 :pending-ctrl-x nil :scroll-at-bottom t :scroll-notify nil + :streaming-text nil :url-buffer nil ; v0.7.1 :dirty (list nil nil nil)))) #+end_src diff --git a/org/channel-tui-view.org b/org/channel-tui-view.org index c37ea00..98acd18 100644 --- a/org/channel-tui-view.org +++ b/org/channel-tui-view.org @@ -55,7 +55,8 @@ that the TUI actuator attaches to the response plist before transmission. (length (st :messages)) (if (> (st :scroll-offset) 0) (format nil "~a↑" (st :scroll-offset)) "0") (or (st :rule-count) 0) - (if (st :busy) " …thinking" "")) + (if (st :streaming-text) " [streaming]" + (if (st :busy) " …thinking" ""))) :y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected))) ;; Second line: Focus map (left) + timestamp (right-aligned, v0.7.0) (let ((focus-info (or (st :foveal-id) ""))) @@ -135,8 +136,12 @@ Returns list of trimmed strings. Single words wider than width are split." (wrapped (word-wrap line-text (- w 2)))) (dolist (line wrapped) (when (< y (1- h)) - (add-string win line :y y :x 1 :n (1- w) :fgcolor color) - (incf y)))))))) + (if (eq role :agent) + (let ((segments (parse-markdown-spans line))) + (setf y (render-styled win segments y 1 w))) + (progn + (add-string win line :y y :x 1 :n (1- w) :fgcolor color) + (incf y)))))))))) (refresh win)) #+end_src @@ -191,6 +196,129 @@ ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8." (t 1)))) #+end_src +* v0.7.1 — Markdown Rendering +#+begin_src lisp +(in-package :passepartout) + +(defun parse-markdown-spans (text) + "Parse inline markdown. Returns list of (text . (:bold/:underline/:code/:url ...))." + (let ((results nil) (pos 0) (len (length text))) + (labels ((earliest (a b) (cond ((and a (or (null b) (< a b))) a) (b b)))) + (loop + (when (>= pos len) (return)) + (let* ((bold (search "**" text :start2 pos)) + (code (search "`" text :start2 pos)) + (italic (search "*" text :start2 pos)) + (http (search "http://" text :start2 pos)) + (https (search "https://" text :start2 pos)) + (url-s (or https http))) + (flet ((pick (tag delim) + (let ((end (search delim text :start2 (+ pos (length delim))))) + (when end + (push (cons (subseq text (+ pos (length delim)) end) + (case tag (:bold '(:bold t)) + (:code '(:code t :bgcolor :dim)) + (:underline '(:underline t)) + (:url '(:url t)))) + results) + (setf pos (+ end (length delim))) + t))) + (url-end (start) + (or (position-if (lambda (c) (find c '(#\Space #\Newline #\Tab #\)))) + text :start start) + len))) + (let ((next (earliest (earliest (earliest bold code) italic) url-s))) + (cond ((and bold (eql bold next)) (unless (pick :bold "**") (incf pos 2))) + ((and code (eql code next)) (unless (pick :code "`") (incf pos))) + ((and italic (eql italic next)) (unless (pick :underline "*") (incf pos))) + ((and url-s (eql url-s next)) + (let ((ue (url-end url-s))) + (push (cons (subseq text url-s ue) '(:url t)) results) + (setf pos ue))) + (t (push (cons (subseq text pos) nil) results) (return)))))))) + (nreverse results))) + +(defun render-styled (win segments y x w) + "Render markdown segments to Croatoan window. Returns next y." + (dolist (seg segments) + (when (>= y (height win)) (return y)) + (let* ((text (or (car seg) "")) + (attrs (cdr seg)) + (bold (getf attrs :bold)) + (code (getf attrs :code)) + (underline (getf attrs :underline)) + (url (getf attrs :url))) + (add-string win text :y y :x x :n (max 1 (- w x)) + :bold bold :underline underline + :bgcolor (when code (theme-color :dim)) + :fgcolor (cond (url (theme-color :highlight)) + (t (theme-color (or (getf attrs :role) :agent))))) + (incf x (length text)))) + y) + +(defun parse-markdown-blocks (text) + "Split text at ``` code block boundaries." + (let ((r nil) (p 0) (l (length text))) + (loop + (when (>= p l) (return)) + (let ((bs (search "```" text :start2 p))) + (unless bs + (push (cons (subseq text p) nil) r) + (return)) + (when (> bs p) + (push (cons (subseq text p bs) nil) r)) + (let* ((ao (+ bs 3)) + (le (or (position #\Newline text :start ao) l)) + (lang (string-trim " \r\n\t" (if (< le l) (subseq text ao le) ""))) + (cs (if (< le l) (1+ le) l)) + (cp (search "```" text :start2 cs)) + (ce (or cp l)) + (content (string-trim "\r\n" (subseq text cs ce)))) + (push (list :code-block t :lang lang :content content) r) + (setf p (if cp (+ cp 3) l))))) + (nreverse r))) + +(defun syntax-highlight (code lang) + "Highlight Lisp code: strings, comments, keywords, function calls." + (declare (ignore lang)) + (let* ((r nil) (p 0) (l (length code)) + (kw '("defun" "defvar" "defparameter" "let" "let*" "lambda" "if" "when" "unless" + "cond" "loop" "dolist" "dotimes" "progn" "prog1" "return" + "setf" "setq" "format" "and" "or" "not" "list" "cons" + "quote" "function" "declare" "ignore" "t" "nil"))) + (flet ((wordp (c) (or (alphanumericp c) (find c "-*+/?!_=<>")))) + (loop + (when (>= p l) (return)) + (let* ((ss (position #\" code :start p)) + (sc (position #\; code :start p)) + (sp (position #\( code :start p)) + (next (min (or ss l) (or sc l) (or sp l)))) + (when (> next p) + (push (cons (subseq code p next) nil) r) + (setf p next)) + (when (>= p l) (return)) + (cond + ((eql p ss) + (let ((e (or (position #\" code :start (1+ p)) l))) + (push (cons (subseq code p (min (1+ e) l)) '(:fgcolor :string)) r) + (setf p (min (1+ e) l)))) + ((eql p sc) + (let ((e (or (position #\Newline code :start p) l))) + (push (cons (subseq code p e) '(:fgcolor :comment)) r) + (setf p e))) + ((eql p sp) + (push (cons "(" nil) r) + (incf p) + (let ((fe (loop for i from p below l for c = (char code i) + while (wordp c) finally (return i)))) + (when (> fe p) + (let ((fs (subseq code p fe))) + (push (cons fs (list :fgcolor (if (member fs kw :test #'string=) + :keyword :function))) r) + (setf p fe))))))))) + (nreverse r))) +#+end_src + * Test Suite #+begin_src lisp (eval-when (:compile-toplevel :load-toplevel :execute) @@ -222,4 +350,55 @@ ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8." (test test-char-width-null "Contract 5: null has width 0." (is (= 0 (passepartout::char-width #\Nul)))) + +(test test-markdown-bold + "Contract 7: parse-markdown-spans detects **bold**." + (let ((segments (passepartout::parse-markdown-spans "hello **world**!"))) + (is (= 3 (length segments))))) + +(test test-markdown-plain + "Contract 7: plain text returns single segment." + (let ((segments (passepartout::parse-markdown-spans "plain"))) + (is (= 1 (length segments))) + (is (string= "plain" (caar segments))))) + +(test test-markdown-url + "Contract 7: parse-markdown-spans detects URLs." + (let ((segments (passepartout::parse-markdown-spans "see https://example.com for more"))) + (is (>= (length segments) 2)) + (is (find t segments :key (lambda (s) (getf (cdr s) :url)))))) + +(test test-markdown-blocks + "Contract 8: parse-markdown-blocks detects code blocks." + (let* ((text (format nil "before~%```lisp~%(+ 1 2)~%```~%after")) + (segs (passepartout::parse-markdown-blocks text))) + (is (= 3 (length segs))) + (let ((code (second segs))) + (is (eq t (getf code :code-block))) + (is (string= "lisp" (getf code :lang))) + (is (string= "(+ 1 2)" (string-trim '(#\Space #\Newline) (getf code :content))))))) + +(test test-markdown-blocks-no-close + "Contract 8: unclosed code block returns content." + (let* ((text (format nil "```~%unclosed code")) + (segs (passepartout::parse-markdown-blocks text))) + (is (= 1 (length segs))) + (is (eq t (getf (first segs) :code-block))))) + +(test test-syntax-highlight + "Contract 9: syntax-highlight colors Lisp code." + (let ((segs (passepartout::syntax-highlight "(defun foo (x) (+ x 1))" "lisp"))) + (is (>= (length segs) 3)))) + +(test test-syntax-highlight-keyword + "Contract 9: syntax-highlight colors keywords." + (let ((segs (passepartout::syntax-highlight "(let ((x 1)) (+ x 2))" "lisp"))) + (is (>= (length segs) 2)) + (is (find :keyword segs :key (lambda (s) (getf (cdr s) :fgcolor)))))) + +(test test-syntax-highlight-function + "Contract 9: syntax-highlight colors function calls." + (let ((segs (passepartout::syntax-highlight "(+ 1 2)" "lisp"))) + (is (>= (length segs) 2)) + (is (find :function segs :key (lambda (s) (getf (cdr s) :fgcolor)))))) #+end_src diff --git a/org/core-reason.org b/org/core-reason.org index f02415a..43eac93 100644 --- a/org/core-reason.org +++ b/org/core-reason.org @@ -231,6 +231,7 @@ each cascade call via ~cost-track-backend-call~. All four calls are (let* ((sensor (proto-get (proto-get context :payload) :sensor)) (active-skill (find-triggered-skill context)) (tool-belt (generate-tool-belt-prompt)) + (reply-stream (proto-get context :reply-stream)) ; v0.7.1: streaming (global-context (if (fboundp 'context-assemble-cached) (context-assemble-cached context sensor) (if (fboundp 'context-assemble-global-awareness) @@ -283,9 +284,20 @@ each cascade call via ~cost-track-backend-call~. All four calls are (concatenate 'string (string #\Newline) standing-mandates-text) "") tool-belt (or global-context "") system-logs)))) - (let* ((thought (backend-cascade-call raw-prompt - :system-prompt system-prompt - :context context)) + (let* ((thought (if (and reply-stream (fboundp 'cascade-stream)) ; v0.7.1: streaming + (let ((acc (make-string-output-stream))) + (funcall 'cascade-stream raw-prompt system-prompt + (lambda (delta) + (when reply-stream + (format reply-stream "~a" + (frame-message (list :type :stream-chunk + :payload (list :text delta)))) + (finish-output reply-stream)) + (write-string delta acc))) + (get-output-stream-string acc)) + (backend-cascade-call raw-prompt + :system-prompt system-prompt + :context context))) (tool-calls (and (listp thought) (getf thought :tool-calls)))) ;; v0.5.0: cost tracking after successful cascade (when (and (fboundp 'cost-track-backend-call) diff --git a/org/core-transport.org b/org/core-transport.org index 2d7ddd8..537c0d3 100644 --- a/org/core-transport.org +++ b/org/core-transport.org @@ -151,7 +151,7 @@ The daemon sends a handshake message on connection, then enters a read loop, inj (let ((stream (usocket:socket-stream socket))) (handler-case (progn - (format stream "~a" (frame-message (make-hello-message "0.5.0"))) + (format stream "~a" (frame-message (make-hello-message "0.7.1"))) (finish-output stream) (loop (let ((msg (read-framed-message stream))) diff --git a/org/neuro-provider.org b/org/neuro-provider.org index 96bf579..3ed2be9 100644 --- a/org/neuro-provider.org +++ b/org/neuro-provider.org @@ -31,6 +31,18 @@ Providers register themselves at boot. No API key? That provider doesn't registe when the LLM returns a tool call, or the existing ~:content~ path otherwise. 4. (provider-cascade-initialize): reads ~PROVIDER_CASCADE~ from env and sets ~*provider-cascade*~. +5. (provider-openai-stream prompt system-prompt callback &key model provider tools): + v0.7.1 — executes a streaming OpenAI-compatible /v1/chat/completions + request. Sends ~"stream": true~ in the request body. Reads Server-Sent + Events (SSE) from the response stream, parsing ~data: ...~ lines. For + each delta with content, calls CALLBACK with the delta string. After + all deltas, calls CALLBACK with ~""~ to signal end-of-stream. Returns + ~(:status :success)~ on completion or ~(:status :error :message ...)~. + If ~*stream-cancel*~ is set to T (by another thread), exits the SSE + loop and calls CALLBACK with ~""~. +6. (parse-sse-line line): parses an SSE line. Returns the data content + for ~data: ~ lines, ~:done~ for ~data: [DONE]~, and ~nil~ + for comment lines (starting with ~:~), empty lines, or non-data lines. * Implementation @@ -202,6 +214,142 @@ If API-KEY is nil, reads from environment." :trigger (lambda (ctx) (declare (ignore ctx)) nil)) #+end_src +* v0.7.1 — Streaming Backend +:PROPERTIES: +:ID: id-v071-streaming +:CREATED: [2026-05-08 Fri] +:END: + +** SSE Parser + +*** RED +#+begin_example +test-parse-sse-line-data: 0/2 pass — stub returns nil instead of content +test-parse-sse-line-done: 0/1 pass — stub returns nil instead of :done +test-parse-sse-line-nil: 3/3 pass — stub correctly returns nil +#+end_example + +*** GREEN +#+begin_example +test-parse-sse-line-data: 2/2 pass (100%) +test-parse-sse-line-done: 1/1 pass (100%) +test-parse-sse-line-nil: 3/3 pass (100%) +test-provider-openai-stream-calls-callback: 3/3 pass (100%) +llm-gateway-suite: 13/13 pass (100%) +#+end_example + +** Cascade Stream +#+begin_src lisp +(defun cascade-stream (prompt system-prompt callback) + "Streaming cascade: calls provider-openai-stream on the first available backend. +Calls CALLBACK with each delta string, then with '' to signal end-of-stream." + (dolist (backend *provider-cascade*) + (when (gethash backend *probabilistic-backends*) + (let ((result (provider-openai-stream prompt system-prompt callback + :provider backend))) + (when (eq (getf result :status) :success) + (return cascade-stream)))))) +#+end_src +#+begin_src lisp +(in-package :passepartout) + +(defun parse-sse-line (line) + "Parse an SSE line. Returns data string, :done for [DONE], nil otherwise." + (cond + ((or (null line) (string= line "")) nil) + ((char= (char line 0) #\:) nil) + ((and (>= (length line) 6) (string-equal (subseq line 0 6) "data: ")) + (let ((content (subseq line 6))) + (if (string= content "[DONE]") + :done + content))) + (t nil))) +#+end_src + +** Streaming request +#+begin_src lisp +(defvar *stream-cancel* nil + "When T, the streaming SSE loop exits early.") + +(defun provider-openai-stream (prompt system-prompt callback &key model (provider :openrouter) tools) + "Streaming OpenAI-compatible request. Calls CALLBACK with each delta, then ''." + (let* ((config (provider-config provider)) + (base-url (getf config :base-url)) + (key-env (getf config :key-env)) + (url-env (getf config :url-env)) + (default-model (getf config :default-model)) + (api-key (when key-env (uiop:getenv key-env))) + (model-id (or model default-model)) + (url (if url-env + (let ((host (uiop:getenv url-env))) + (if host + (format nil "http://~a/v1/chat/completions" host) + (format nil "~a/chat/completions" base-url))) + (format nil "~a/chat/completions" base-url))) + (timeout (or (ignore-errors (parse-integer (uiop:getenv "LLM_REQUEST_TIMEOUT"))) 30)) + (req-headers (list (cons "Content-Type" "application/json"))) + (base `((model . ,model-id) + (messages . (( (role . "system") (content . ,system-prompt) ) + ( (role . "user") (content . ,prompt) ))) + (stream . t)))) + (when api-key + (push (cons "Authorization" (format nil "Bearer ~a" api-key)) req-headers)) + (when (eq provider :openrouter) + (setf req-headers + (append req-headers + `(("HTTP-Referer" . "https://github.com/amrgharbeia/passepartout") + ("X-Title" . "Passepartout"))))) + (let ((body (if tools + (append base + `((tools . ,(loop for tool in tools + collect (list (cons :|type| "function") + (cons :|function| + (loop for (k v) on tool by #'cddr + collect (cons (intern (string-upcase (string k)) "KEYWORD") v)))))) + (:|tool_choice| . "auto"))) + base))) + (handler-case + (let* ((body-json (cl-json:encode-json-to-string body)) + (stall-seconds 30) + (s (dex:post url :headers req-headers :content body-json + :connect-timeout (min 5 timeout) + :read-timeout stall-seconds + :want-stream t))) + ;; v0.7.1: track stall timer — reset on each successful chunk + (let ((last-chunk-time (get-universal-time))) + (loop for raw = (handler-case (read-line s nil nil) + (error (c) + (declare (ignore c)) + nil)) + while raw + do (when *stream-cancel* ; v0.7.1: cancel check + (setf *stream-cancel* nil) + (funcall callback " [cancelled]") + (return)) + (let ((parsed (parse-sse-line raw))) + (cond + ((null parsed)) + ((eq parsed :done) (return)) + (t (handler-case + (let* ((json (cl-json:decode-json-from-string parsed)) + (choices (cdr (assoc :choices json))) + (choice (car choices)) + (delta (cdr (assoc :delta choice))) + (content (cdr (assoc :content delta)))) + (when content + (funcall callback content) + (setf last-chunk-time (get-universal-time)))) + (error ()))))) + (when (> (- (get-universal-time) last-chunk-time) stall-seconds) + (funcall callback "[Response stalled — timed out at 30s]") + (return)))) + (funcall callback "") + (close s) + (list :status :success)) + (error (c) + (list :status :error :message (format nil "~a Stream Failure: ~a" provider c))))))) +#+end_src + * Test Suite #+begin_src lisp (eval-when (:compile-toplevel :load-toplevel :execute) @@ -231,4 +379,32 @@ If API-KEY is nil, reads from environment." "Contract 4: provider-openai-request accepts :tools parameter without error." (let ((result (provider-openai-request "test" "system" :tools (list)))) (fiveam:is (member (getf result :status) '(:success :error))))) + +;; ── v0.7.1 Streaming ── + +(fiveam:test test-parse-sse-line-data + "Contract 6: parse-sse-line extracts content from data: lines." + (fiveam:is (string= "hello world" (passepartout::parse-sse-line "data: hello world"))) + (fiveam:is (string= "{\"a\":1}" (passepartout::parse-sse-line "data: {\"a\":1}")))) + +(fiveam:test test-parse-sse-line-done + "Contract 6: parse-sse-line returns :done for [DONE]." + (fiveam:is (eq :done (passepartout::parse-sse-line "data: [DONE]")))) + +(fiveam:test test-parse-sse-line-nil + "Contract 6: parse-sse-line returns nil for comment, empty, non-data lines." + (fiveam:is (null (passepartout::parse-sse-line ""))) + (fiveam:is (null (passepartout::parse-sse-line ":ok"))) + (fiveam:is (null (passepartout::parse-sse-line "event: ping")))) + +(fiveam:test test-provider-openai-stream-calls-callback + "Contract 5: provider-openai-stream calls callback with deltas and final empty string." + (let ((collected '())) + (flet ((collector (text) (push text collected))) + (passepartout::provider-openai-stream "hi" "sys" #'collector :provider :openrouter)) + (let* ((reversed (nreverse collected)) + (last (car (last reversed)))) + (fiveam:is (stringp last)) + (fiveam:is (string= "" last)) + (fiveam:is (>= (length reversed) 2))))) #+end_src