Skip to content

Commit 25e085a

Browse files
ericdalloeca-agent
andcommitted
Add message flags — named checkpoints for resuming and forking chats
- New "flag" message role stored in the chat messages vector - chat/addFlag request: inserts a flag after any message type (matches both contentId and content.id fields) - chat/removeFlag request: removes a flag by contentId - chat/fork request: forks chat up to a given contentId - Flags filtered from LLM context in messages-after-last-compact-marker - /resume listing shows 🚩 with flag names for labeled chats - Protocol docs for ChatFlagContent, chat/addFlag, chat/removeFlag, chat/fork Closes #395 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent d4c5680 commit 25e085a

File tree

8 files changed

+243
-11
lines changed

8 files changed

+243
-11
lines changed

CHANGELOG.md

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

55
- Add `chatRetentionDays` config to control chat and cache cleanup retention period, default changed from 7 to 14 days. Set to 0 to disable cleanup. #393
66
- Preserve full chat history across compactions using tombstone markers instead of replacing messages. #394
7+
- Add message flags — named checkpoints for resuming and forking chats. #395
78

89
## 0.123.3
910

docs/protocol.md

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,8 @@ type ChatContent =
589589
| ChatToolCallRunningContent
590590
| ChatToolCalledContent
591591
| ChatToolCallRejectedContent
592-
| ChatMetadataContent;
592+
| ChatMetadataContent
593+
| ChatFlagContent;
593594

594595
/**
595596
* Simple text message from the LLM
@@ -1192,6 +1193,25 @@ interface ChatMetadataContent {
11921193
title: string;
11931194
}
11941195

1196+
/**
1197+
* A named checkpoint flag in the chat history.
1198+
* Flags act as bookmarks that survive across sessions
1199+
* and are discoverable via timeline.
1200+
*/
1201+
interface ChatFlagContent {
1202+
type: 'flag';
1203+
1204+
/**
1205+
* The flag display text.
1206+
*/
1207+
text: string;
1208+
1209+
/**
1210+
* Unique identifier for this flag.
1211+
*/
1212+
contentId: string;
1213+
}
1214+
11951215
```
11961216

11971217
### Chat approve tool call (➡️)
@@ -1512,6 +1532,107 @@ _Response:_
15121532
interface ChatClearResponse {}
15131533
```
15141534

1535+
### Chat add flag (➡️)
1536+
1537+
A client request to add a named flag (checkpoint) to the chat history.
1538+
The flag is inserted after the message identified by the given `contentId`.
1539+
The `contentId` matches against both message-level `contentId` (user messages) and
1540+
content `id` fields (tool calls, reasons, etc), allowing placement between any message types.
1541+
Flags are persisted as messages and propagate automatically with fork and resume.
1542+
1543+
_Request:_
1544+
1545+
* method: `chat/addFlag`
1546+
* params: `ChatAddFlagParams` defined as follows:
1547+
1548+
```typescript
1549+
interface ChatAddFlagParams {
1550+
/**
1551+
* The chat session identifier.
1552+
*/
1553+
chatId: string;
1554+
1555+
/**
1556+
* The id of the message after which the flag is inserted.
1557+
* Matches against message contentId or content.id fields.
1558+
*/
1559+
contentId: string;
1560+
1561+
/**
1562+
* The flag display text.
1563+
*/
1564+
text: string;
1565+
}
1566+
```
1567+
1568+
_Response:_
1569+
1570+
```typescript
1571+
interface ChatAddFlagResponse {}
1572+
```
1573+
1574+
### Chat remove flag (➡️)
1575+
1576+
A client request to remove a flag from the chat history.
1577+
The client is responsible for removing the flag from the UI after a successful response.
1578+
1579+
_Request:_
1580+
1581+
* method: `chat/removeFlag`
1582+
* params: `ChatRemoveFlagParams` defined as follows:
1583+
1584+
```typescript
1585+
interface ChatRemoveFlagParams {
1586+
/**
1587+
* The chat session identifier.
1588+
*/
1589+
chatId: string;
1590+
1591+
/**
1592+
* The content id of the flag to remove.
1593+
*/
1594+
contentId: string;
1595+
}
1596+
```
1597+
1598+
_Response:_
1599+
1600+
```typescript
1601+
interface ChatRemoveFlagResponse {}
1602+
```
1603+
1604+
### Chat fork (➡️)
1605+
1606+
A client request to fork the chat into a new chat with messages up to and including
1607+
the message identified by the given `contentId`. The server creates the new chat,
1608+
sends a `chat/opened` notification with the new chat, then streams the kept messages
1609+
via `chat/contentReceived`. A system message is also sent to the original chat.
1610+
1611+
_Request:_
1612+
1613+
* method: `chat/fork`
1614+
* params: `ChatForkParams` defined as follows:
1615+
1616+
```typescript
1617+
interface ChatForkParams {
1618+
/**
1619+
* The chat session identifier of the source chat.
1620+
*/
1621+
chatId: string;
1622+
1623+
/**
1624+
* The content id of the message up to which to fork (inclusive).
1625+
*/
1626+
contentId: string;
1627+
}
1628+
```
1629+
1630+
_Response:_
1631+
1632+
```typescript
1633+
interface ChatForkResponse {}
1634+
```
1635+
15151636
### Chat cleared (⬅️)
15161637

15171638
A server notification to clear a chat UI, currently supporting removing only messages of the chat.

src/eca/db.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
:status (or :idle :running :stopping :login)
5858
:created-at :number
5959
:login-provider :string
60-
:messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason" "compact_marker" "server_tool_use" "server_tool_result")
60+
:messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason" "compact_marker" "flag" "server_tool_use" "server_tool_result")
6161
:content (or :string [::any-map]) ;; string for simple text, map/vector for structured content
6262
:content-id :string}]
6363
:task {:next-id :number

src/eca/features/chat.clj

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,11 @@
201201
:content {:type :text
202202
:text (if (:auto? message-content)
203203
"── Chat auto-compacted ──"
204-
"── Chat compacted ──")}}]))
204+
"── Chat compacted ──")}}]
205+
"flag" [{:role :system
206+
:content {:type :flag
207+
:text (:text message-content)
208+
:contentId content-id}}]))
205209

206210
(defn ^:private send-chat-contents! [messages chat-ctx]
207211
(doseq [message messages]
@@ -1169,3 +1173,80 @@
11691173
:db* db*
11701174
:messenger messenger}))
11711175
{}))
1176+
1177+
(defn ^:private find-last-message-idx
1178+
"Find the last message index matching content-id by checking both
1179+
:content-id (user messages) and [:content :id] (tool calls, etc)."
1180+
[messages content-id]
1181+
(loop [i (dec (count messages))]
1182+
(cond
1183+
(neg? i) nil
1184+
(let [msg (messages i)]
1185+
(or (= content-id (:content-id msg))
1186+
(= content-id (get-in msg [:content :id])))) i
1187+
:else (recur (dec i)))))
1188+
1189+
(defn add-flag
1190+
"Add a named flag after the message identified by content-id.
1191+
Searches both :content-id and [:content :id] to support placement
1192+
after any message type (user, tool call, reason, etc).
1193+
Clears and replays the chat to render the flag at the correct position."
1194+
[{:keys [chat-id content-id text]} db* messenger metrics]
1195+
(let [messages (vec (get-in @db* [:chats chat-id :messages]))
1196+
insert-idx (find-last-message-idx messages content-id)]
1197+
(when insert-idx
1198+
(let [flag-id (str (random-uuid))
1199+
flag-msg {:role "flag" :content {:text text} :content-id flag-id}
1200+
insert-after (inc insert-idx)
1201+
new-messages (into (subvec messages 0 insert-after)
1202+
(cons flag-msg (subvec messages insert-after)))]
1203+
(swap! db* assoc-in [:chats chat-id :messages] new-messages)
1204+
(db/update-workspaces-cache! @db* metrics)
1205+
(messenger/chat-cleared messenger {:chat-id chat-id :messages true})
1206+
(send-chat-contents! new-messages {:chat-id chat-id :db* db* :messenger messenger})))
1207+
{}))
1208+
1209+
(defn remove-flag
1210+
"Remove a flag message identified by content-id from the chat."
1211+
[{:keys [chat-id content-id]} db* metrics]
1212+
(when-let [messages (get-in @db* [:chats chat-id :messages])]
1213+
(let [new-messages (vec (remove #(and (= "flag" (:role %))
1214+
(= content-id (:content-id %)))
1215+
messages))]
1216+
(when (not= (count new-messages) (count messages))
1217+
(swap! db* assoc-in [:chats chat-id :messages] new-messages)
1218+
(db/update-workspaces-cache! @db* metrics))))
1219+
{})
1220+
1221+
(defn fork-chat
1222+
"Fork the chat creating a new chat with messages up to and including
1223+
the message identified by content-id."
1224+
[{:keys [chat-id content-id]} db* messenger metrics]
1225+
(let [chat (get-in @db* [:chats chat-id])
1226+
messages (vec (:messages chat))
1227+
target-idx (find-last-message-idx messages content-id)]
1228+
(when target-idx
1229+
(let [new-id (str (random-uuid))
1230+
now (System/currentTimeMillis)
1231+
new-title (f.commands/fork-title (:title chat))
1232+
kept-messages (subvec messages 0 (inc target-idx))
1233+
new-chat {:id new-id
1234+
:title new-title
1235+
:status :idle
1236+
:created-at now
1237+
:updated-at now
1238+
:model (:model chat)
1239+
:last-api (:last-api chat)
1240+
:messages kept-messages
1241+
:prompt-finished? true}]
1242+
(swap! db* assoc-in [:chats new-id] new-chat)
1243+
(db/update-workspaces-cache! @db* metrics)
1244+
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
1245+
(send-chat-contents! kept-messages {:chat-id new-id :db* db* :messenger messenger})
1246+
(lifecycle/send-content! {:messenger messenger :chat-id new-id}
1247+
:system
1248+
(assoc-some {:type :metadata} :title new-title))
1249+
(lifecycle/send-content! {:messenger messenger :chat-id chat-id}
1250+
:system
1251+
{:type :text :text (str "Chat forked to: " new-title)})))
1252+
{}))

src/eca/features/commands.clj

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
(set! *warn-on-reflection* true)
2727

28-
(defn ^:private fork-title [title]
28+
(defn fork-title [title]
2929
(let [title (or title "Untitled")]
3030
(if-let [[_ base n] (re-matches #"(.+?)\s*\((\d+)\)\s*$" title)]
3131
(str base " (" (inc (parse-long n)) ")")
@@ -389,12 +389,18 @@
389389
(fn [s chat-id]
390390
(let [chat (get chats chat-id)
391391
msgs-count (count (filter #(= "user" (:role %))
392-
(:messages chat)))]
392+
(:messages chat)))
393+
flags (->> (:messages chat)
394+
(filter #(= "flag" (:role %)))
395+
(map #(get-in % [:content :text])))]
393396
(if (> msgs-count 0)
394-
(str s (format "%s - %s - %s\n"
397+
(str s (format "%s - %s - %s%s\n"
395398
(inc (.indexOf ^PersistentVector chats-ids chat-id))
396399
(shared/ms->presentable-date (:created-at chat) "dd/MM/yyyy HH:mm")
397-
(or (:title chat) (format "No chat title (%s user messages)" msgs-count))))
400+
(or (:title chat) (format "No chat title (%s user messages)" msgs-count))
401+
(if (seq flags)
402+
(str " 🚩 " (string/join ", " flags))
403+
"")))
398404
s)))
399405
""
400406
chats-ids)

src/eca/handlers.clj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,18 @@
226226
(metrics/task metrics :eca/chat-rollback
227227
(f.chat/rollback-chat params db* messenger)))
228228

229+
(defn chat-add-flag [{:keys [db* metrics messenger]} params]
230+
(metrics/task metrics :eca/chat-add-flag
231+
(f.chat/add-flag params db* messenger metrics)))
232+
233+
(defn chat-remove-flag [{:keys [db* metrics]} params]
234+
(metrics/task metrics :eca/chat-remove-flag
235+
(f.chat/remove-flag params db* metrics)))
236+
237+
(defn chat-fork [{:keys [db* metrics messenger]} params]
238+
(metrics/task metrics :eca/chat-fork
239+
(f.chat/fork-chat params db* messenger metrics)))
240+
229241
(defn mcp-stop-server [{:keys [db* messenger metrics config]} params]
230242
(metrics/task metrics :eca/mcp-stop-server
231243
(f.tools/stop-server! (:name params) db* messenger config metrics)))

src/eca/server.clj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@
106106
(defmethod jsonrpc.server/receive-request "chat/rollback" [_ components params]
107107
(eventually (handlers/chat-rollback (with-config components) params)))
108108

109+
(defmethod jsonrpc.server/receive-request "chat/addFlag" [_ components params]
110+
(eventually (handlers/chat-add-flag (with-config components) params)))
111+
112+
(defmethod jsonrpc.server/receive-request "chat/removeFlag" [_ components params]
113+
(eventually (handlers/chat-remove-flag (with-config components) params)))
114+
115+
(defmethod jsonrpc.server/receive-request "chat/fork" [_ components params]
116+
(eventually (handlers/chat-fork (with-config components) params)))
117+
109118
(defmethod jsonrpc.server/receive-notification "mcp/stopServer" [_ components params]
110119
(async-notify (handlers/mcp-stop-server (with-config components) params)))
111120

src/eca/shared.clj

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@
316316

317317
(defn messages-after-last-compact-marker
318318
"Returns messages after the last compact_marker, or all messages if none exists.
319+
Filters out flag messages which are UI-only markers not meant for the LLM.
319320
Scans from the end for efficiency since markers are typically near the tail."
320321
[messages]
321322
(let [messages (vec messages)
@@ -324,10 +325,11 @@
324325
(cond
325326
(neg? i) -1
326327
(= "compact_marker" (:role (messages i))) i
327-
:else (recur (dec i))))]
328-
(if (neg? last-idx)
329-
messages
330-
(subvec messages (inc last-idx)))))
328+
:else (recur (dec i))))
329+
result (if (neg? last-idx)
330+
messages
331+
(subvec messages (inc last-idx)))]
332+
(vec (remove #(= "flag" (:role %)) result))))
331333

332334
(defn compact-side-effect!
333335
"Performs side effects after chat compaction: appends a compact_marker tombstone

0 commit comments

Comments
 (0)