Skip to content

Commit 2cd74a4

Browse files
committed
Add /export and /import commands to transfer chats between machines (#28)
1 parent b20788c commit 2cd74a4

5 files changed

Lines changed: 297 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add built-in variants for `glm-5.2` (case-insensitive): `none`, `medium`, `high`.
6+
- New `/export` and `/import` commands to transfer a chat between machines as a structured, resumable file preserving model, variant, usage and chat-id. #28
67

78
## 0.143.1
89

integration-test/integration/chat/commands_test.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
{:name "compact" :arguments [{:name "additional-input"}]}
3535
{:name "fork" :arguments []}
3636
{:name "resume" :arguments [{:name "chat-id"}]}
37+
{:name "export" :arguments [{:name "filepath"}]}
38+
{:name "import" :arguments [{:name "filepath"}]}
3739
{:name "remote" :arguments []}
3840
{:name "config" :arguments []}
3941
{:name "doctor" :arguments []}

src/eca/features/chat/export.clj

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
(ns eca.features.chat.export
2+
"Structured export/import of a single chat to/from an EDN file.
3+
4+
Unlike eca.features.chat.debug (which obfuscates for privacy/size), this
5+
preserves the chat verbatim so it can be re-imported as a fully resumable
6+
chat: messages plus model, variant, agent/subagent, usage, trust and the
7+
original chat-id (so provider/cache reuse keeps working). Issue #28."
8+
(:require
9+
[babashka.fs :as fs]
10+
[clojure.edn :as edn]
11+
[clojure.java.io :as io]
12+
[clojure.pprint :as pprint]
13+
[clojure.string :as string]
14+
[eca.config :as config]))
15+
16+
(set! *warn-on-reflection* true)
17+
18+
(def ^:private export-version 1)
19+
20+
(def ^:private exported-chat-keys
21+
"Chat fields preserved on export. Excludes live/transient state
22+
(:tool-calls promises/futures, :last-status-payload) and the
23+
machine/provider-specific :prompt-cache."
24+
[:id :title :title-custom? :status :created-at :updated-at :login-provider
25+
:model :variant :last-api :trust :prompt-id :user-prompt-count :subagent
26+
:parent-chat-id :startup-context :messages :usage :task])
27+
28+
(defn build-export
29+
"Build the export envelope map for chat-id from the in-memory db."
30+
[db chat-id]
31+
{:eca/version (config/eca-version)
32+
:eca/export-version export-version
33+
:exported-at (System/currentTimeMillis)
34+
:chat (select-keys (get-in db [:chats chat-id]) exported-chat-keys)})
35+
36+
(defn export->edn [export]
37+
(binding [*print-namespace-maps* false]
38+
(with-out-str (pprint/pprint export))))
39+
40+
(defn ^:private slugify [s]
41+
(let [slug (-> (or s "")
42+
string/lower-case
43+
(string/replace #"[^a-z0-9]+" "-")
44+
(string/replace #"^-+|-+$" ""))]
45+
(when-not (string/blank? slug)
46+
(subs slug 0 (min 60 (count slug))))))
47+
48+
(defn ^:private default-filename [chat]
49+
(let [name (or (slugify (:title chat)) (:id chat) "chat")]
50+
(str "eca-chat-" name ".edn")))
51+
52+
(defn default-filepath [chat]
53+
(str (fs/file (fs/temp-dir) (default-filename chat))))
54+
55+
(defn ^:private expand [filepath]
56+
(str (fs/expand-home (string/trim filepath))))
57+
58+
(defn ^:private dir-target?
59+
"True when the destination is a directory rather than a file: `.`/`..`, a path
60+
ending with a separator, or an already-existing directory."
61+
[trimmed expanded]
62+
(or (contains? #{"." ".."} trimmed)
63+
(boolean (re-find #"[/\\]$" trimmed))
64+
(fs/directory? expanded)))
65+
66+
(defn ^:private resolve-export-path
67+
"Resolve the destination file path: blank -> temp-dir default; a directory
68+
target -> default filename inside it; otherwise the given path verbatim."
69+
[filepath chat]
70+
(-> (if (string/blank? filepath)
71+
(default-filepath chat)
72+
(let [trimmed (string/trim filepath)
73+
expanded (expand filepath)]
74+
(if (dir-target? trimmed expanded)
75+
(str (fs/path expanded (default-filename chat)))
76+
expanded)))
77+
fs/absolutize
78+
fs/normalize
79+
str))
80+
81+
(defn export-chat!
82+
"Serialize chat-id as EDN to filepath (defaults to /tmp/eca-chat-<title>.edn
83+
when blank). Returns {:path <abs-path> :message-count <n>} on success or
84+
{:error <msg>} on failure."
85+
[{:keys [db chat-id filepath]}]
86+
(try
87+
(if-let [chat (get-in db [:chats chat-id])]
88+
(let [path (resolve-export-path filepath chat)]
89+
(io/make-parents path)
90+
(spit path (export->edn (build-export db chat-id)))
91+
{:path path
92+
:message-count (count (:messages chat))})
93+
{:error (str "Chat not found: " chat-id)})
94+
(catch Exception e
95+
{:error (.getMessage e)})))
96+
97+
(defn ^:private valid-export? [data]
98+
(and (map? data)
99+
(contains? data :eca/export-version)
100+
(map? (:chat data))
101+
(string? (get-in data [:chat :id]))))
102+
103+
(defn import-chat!
104+
"Read and validate an exported chat EDN file. Returns {:chat <chat-map>} with
105+
the verbatim chat (its original :id preserved) or {:error <msg>}."
106+
[{:keys [filepath]}]
107+
(try
108+
(if (string/blank? filepath)
109+
{:error "Missing filepath. Ex: /import /tmp/chat.edn"}
110+
(let [path (expand filepath)]
111+
(if-not (fs/exists? path)
112+
{:error (str "File not found: " path)}
113+
(let [data (edn/read-string (slurp path))]
114+
(if (valid-export? data)
115+
{:chat (:chat data)}
116+
{:error "Not a valid ECA chat export file."})))))
117+
(catch Exception e
118+
{:error (.getMessage e)})))

src/eca/features/commands.clj

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
[eca.config :as config]
99
[eca.db :as db]
1010
[eca.features.chat.debug :as f.chat.debug]
11+
[eca.features.chat.export :as f.chat.export]
1112
[eca.features.chat.lifecycle :as lifecycle]
1213
[eca.features.index :as f.index]
1314
[eca.features.login :as f.login]
@@ -174,6 +175,14 @@
174175
:type :native
175176
:description "Resume the specified chat-id. Blank to list chats or 'latest'."
176177
:arguments [{:name "chat-id"}]}
178+
{:name "export"
179+
:type :native
180+
:description "Export the current chat to a file or directory to transfer/import it elsewhere. Ex: /export /tmp/chat.edn or /export ."
181+
:arguments [{:name "filepath"}]}
182+
{:name "import"
183+
:type :native
184+
:description "Import a chat from a file as a new resumable chat. Ex: /import /tmp/chat.edn"
185+
:arguments [{:name "filepath"}]}
177186
{:name "remote"
178187
:type :native
179188
:description "Show remote server connection details."
@@ -840,6 +849,47 @@
840849
"Content is obfuscated (text/reasoning/tool input/output); share this file for debugging."))]
841850
{:type :chat-messages
842851
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text text}]}]}}})
852+
"export" (let [{:keys [path message-count error]}
853+
(f.chat.export/export-chat! {:db db
854+
:chat-id chat-id
855+
:filepath (first args)})
856+
text (if error
857+
(str "Failed to export chat: " error)
858+
(multi-str (str "Chat exported to `" path "`")
859+
(str "Messages: " message-count)
860+
""
861+
(str "Import it elsewhere with `/import " path "`")))]
862+
{:type :chat-messages
863+
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text text}]}]}}})
864+
"import" (let [{:keys [chat error]} (f.chat.export/import-chat! {:filepath (first args)})]
865+
(if error
866+
{:type :chat-messages
867+
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text (str "Failed to import chat: " error)}]}]}}}
868+
(let [imported-id (:id chat)
869+
replaced? (contains? (:chats db) imported-id)
870+
model-available? (contains? (:models db) (:model chat))
871+
imported-chat (-> chat
872+
(dissoc :prompt-finished? :auto-compacting? :compacting?
873+
:tool-calls :last-status-payload)
874+
(assoc :status :idle :prompt-finished? true))
875+
summary (multi-str
876+
(str "Imported chat: " (or (:title imported-chat) imported-id))
877+
(when replaced? "Replaced an existing chat with the same id.")
878+
(when-not model-available?
879+
(str "Model `" (:model imported-chat) "` is not available here; switch with `/model` or login.")))]
880+
;; Persist under the original id so the chat stays /resume-able
881+
;; and reuses provider/cache keyed by chat-id (#28).
882+
(swap! db* assoc-in [:chats imported-id] imported-chat)
883+
(db/update-workspaces-cache! @db* metrics)
884+
(messenger/chat-opened messenger {:chat-id imported-id :title (:title imported-chat)})
885+
;; Align the client's model/trust with the imported chat (like /resume).
886+
(config/notify-selected-model-changed! (:model imported-chat) db* messenger config (:variant imported-chat))
887+
(config/notify-selected-trust-changed! (:trust imported-chat) db* messenger)
888+
{:type :chat-messages
889+
:chats {imported-id {:title (:title imported-chat)
890+
:messages (concat [{:role "system" :content [{:type :text :text summary}]}]
891+
(:messages imported-chat))}
892+
chat-id {:messages [{:role "system" :content [{:type :text :text (str "Imported chat to: " (or (:title imported-chat) imported-id))}]}]}}})))
843893
"repo-map-show" {:type :chat-messages
844894
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text (f.index/repo-map db config {:as-string? true})}]}]}}}
845895
"rules" (let [roots (:workspace-folders db)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
(ns eca.features.chat.export-test
2+
(:require
3+
[babashka.fs :as fs]
4+
[clojure.edn :as edn]
5+
[clojure.string :as string]
6+
[clojure.test :refer [deftest is testing]]
7+
[eca.features.chat.export :as sut]))
8+
9+
(def ^:private sample-chat
10+
{:id "chat-1"
11+
:title "Fix the Login Bug!"
12+
:title-custom? true
13+
:status :idle
14+
:created-at 1
15+
:updated-at 2
16+
:login-provider "anthropic"
17+
:model "anthropic/claude-sonnet-4-6"
18+
:variant "high"
19+
:last-api :anthropic
20+
:trust true
21+
:prompt-id #uuid "00000000-0000-0000-0000-000000000001"
22+
:user-prompt-count 2
23+
:subagent false
24+
:usage {:total-input-tokens 100 :total-output-tokens 50}
25+
:messages [{:role "user" :content "hello there" :content-id "c1" :created-at 1}
26+
{:role "assistant" :content [{:type :text :text "general kenobi"}] :content-id "c2" :created-at 2}
27+
{:role "tool_call" :content {:id "t1" :name "read_file" :full-name "eca__read_file"
28+
:origin :native :arguments {:path "/tmp/x"} :api :anthropic} :content-id "c3" :created-at 3}]
29+
:task {:next-id 2 :active-summary nil
30+
:tasks [{:id 1 :subject "do" :description "d" :status :done :priority :high}]}
31+
;; live/transient state that must NOT be exported:
32+
:tool-calls {"t1" {:status :completed :approved?* (promise) :future-cleanup-complete?* (promise) :future (future 1)}}
33+
:last-status-payload {:x 1}
34+
:prompt-cache {:provider :stuff}})
35+
36+
(def ^:private sample-db
37+
{:chats {"chat-1" sample-chat}})
38+
39+
(deftest build-export-test
40+
(let [export (sut/build-export sample-db "chat-1")
41+
chat (:chat export)]
42+
(testing "envelope carries version tags"
43+
(is (contains? export :eca/version))
44+
(is (contains? export :eca/export-version)))
45+
(testing "resumable fields preserved verbatim (no obfuscation)"
46+
(is (= "chat-1" (:id chat)))
47+
(is (= "Fix the Login Bug!" (:title chat)))
48+
(is (= "anthropic/claude-sonnet-4-6" (:model chat)))
49+
(is (= "high" (:variant chat)))
50+
(is (= :anthropic (:last-api chat)))
51+
(is (= true (:trust chat)))
52+
(is (= 2 (:user-prompt-count chat)))
53+
(is (= {:total-input-tokens 100 :total-output-tokens 50} (:usage chat)))
54+
(is (= "do" (get-in chat [:task :tasks 0 :subject]))))
55+
(testing "message content kept verbatim and in order"
56+
(is (= 3 (count (:messages chat))))
57+
(is (= "hello there" (:content (first (:messages chat)))))
58+
(is (= ["user" "assistant" "tool_call"] (mapv :role (:messages chat))))
59+
(is (= "/tmp/x" (get-in (nth (:messages chat) 2) [:content :arguments :path]))))
60+
(testing "live/transient state excluded"
61+
(is (not (contains? chat :tool-calls)))
62+
(is (not (contains? chat :last-status-payload)))
63+
(is (not (contains? chat :prompt-cache))))))
64+
65+
(deftest export->edn-round-trips-test
66+
(testing "the export serializes and reads back equal (incl. #uuid prompt-id)"
67+
(let [export (sut/build-export sample-db "chat-1")
68+
read-back (edn/read-string (sut/export->edn export))]
69+
(is (= export read-back))
70+
(is (= #uuid "00000000-0000-0000-0000-000000000001" (get-in read-back [:chat :prompt-id]))))))
71+
72+
(deftest export-import-round-trip-test
73+
(testing "export-chat! then import-chat! preserves the chat"
74+
(let [tmp (str (fs/path (fs/temp-dir) (str "eca-export-test-" (random-uuid) ".edn")))
75+
{:keys [path message-count error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath tmp})]
76+
(is (nil? error))
77+
(is (= 3 message-count))
78+
(is (fs/exists? path))
79+
(let [{:keys [chat error]} (sut/import-chat! {:filepath path})]
80+
(is (nil? error))
81+
(is (= "chat-1" (:id chat)))
82+
(is (= "anthropic/claude-sonnet-4-6" (:model chat)))
83+
(is (= "high" (:variant chat)))
84+
(is (= {:total-input-tokens 100 :total-output-tokens 50} (:usage chat)))
85+
(is (= (get-in (sut/build-export sample-db "chat-1") [:chat :messages]) (:messages chat))))
86+
(fs/delete-if-exists path))))
87+
88+
(deftest export-chat!-defaults-and-errors-test
89+
(testing "blank filepath falls back to a slugified temp path"
90+
(let [{:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath ""})]
91+
(is (nil? error))
92+
(is (fs/exists? path))
93+
(is (string/includes? path "eca-chat-fix-the-login-bug"))
94+
(fs/delete-if-exists path)))
95+
(testing "missing chat returns an error"
96+
(is (:error (sut/export-chat! {:db sample-db :chat-id "nope" :filepath "/tmp/x.edn"})))))
97+
98+
(deftest export-chat!-directory-target-test
99+
(testing "an existing directory writes the default filename inside it"
100+
(let [dir (str (fs/path (fs/temp-dir) (str "eca-export-dir-" (random-uuid))))]
101+
(fs/create-dirs dir)
102+
(let [{:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath dir})]
103+
(is (nil? error))
104+
(is (= "eca-chat-fix-the-login-bug.edn" (fs/file-name path)))
105+
(is (string/starts-with? path dir))
106+
(is (fs/exists? path)))
107+
(fs/delete-tree dir)))
108+
(testing "a trailing-separator path creates the directory and writes inside it"
109+
(let [dir (str (fs/path (fs/temp-dir) (str "eca-export-newdir-" (random-uuid))))
110+
{:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath (str dir "/")})]
111+
(is (nil? error))
112+
(is (= "eca-chat-fix-the-login-bug.edn" (fs/file-name path)))
113+
(is (string/starts-with? path dir))
114+
(is (fs/exists? path))
115+
(fs/delete-tree dir))))
116+
117+
(deftest import-chat!-errors-test
118+
(testing "blank filepath errors"
119+
(is (:error (sut/import-chat! {:filepath ""}))))
120+
(testing "non-existent file errors"
121+
(is (:error (sut/import-chat! {:filepath (str (fs/path (fs/temp-dir) (str "nope-" (random-uuid) ".edn")))}))))
122+
(testing "content that is not an ECA export errors"
123+
(let [tmp (str (fs/path (fs/temp-dir) (str "eca-bad-" (random-uuid) ".edn")))]
124+
(spit tmp (pr-str {:not "an export"}))
125+
(is (:error (sut/import-chat! {:filepath tmp})))
126+
(fs/delete-if-exists tmp))))

0 commit comments

Comments
 (0)