From dc3deaf09a418daa325b72a069bbde71fa7d4e62 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 3 Oct 2026 19:10:52 +0800 Subject: [PATCH] =?UTF-8?q?sessions:=20Alma's=20chats=20are=20on=20the=20S?= =?UTF-8?q?essions=20page,=20read=20only=20(mamba=20on=20Discord:=20magpie?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E6=98=AF=E4=B8=8D=E6=98=AF=E8=BF=98=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E7=AE=A1=E7=90=86alma=E7=9A=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=91=A2)=20=E2=80=94=20Alma=20keeps=20every=20chat=20in=20one?= =?UTF-8?q?=20SQLite=20database=20in=20WAL=20mode,=20chat=5Fthreads.db=20i?= =?UTF-8?q?n=20its=20data=20folder=20(~/Library/Application=20Support/alma?= =?UTF-8?q?=20on=20a=20Mac,=20$XDG=5FCONFIG=5FHOME/alma=20on=20Linux,=20%A?= =?UTF-8?q?PPDATA%\alma=20on=20Windows):=20chat=5Fthreads=20(title,=20"New?= =?UTF-8?q?=20Chat"=20until=20named;=20model;=20workspace=20=E2=86=92=20it?= =?UTF-8?q?s=20folder;=20incognito),=20chat=5Fmessages=20(AI=20SDK=20UIMes?= =?UTF-8?q?sages=20in=20a=20tree,=20subagents'=20under=20parent=5Ftool=5Fc?= =?UTF-8?q?all=5Fid)=20and=20usage=5Frecords=20/=20aux=5Fusage=5Frecords?= =?UTF-8?q?=20(a=20reply's=20tokens,=20cache=20reads=20and=20writes=20incl?= =?UTF-8?q?uded=20in=20input).=20magpie=20now=20lists=20each=20chat=20that?= =?UTF-8?q?=20has=20something=20in=20it,=20incognito=20ones=20left=20out,?= =?UTF-8?q?=20and=20so=20are=20the=20chats=20Alma's=20scheduled=20tasks=20?= =?UTF-8?q?run=20in=20(thousands=20of=20them,=20which=20would=20bury=20eve?= =?UTF-8?q?ry=20other=20agent's=20sessions),=20told=20the=20way=20Alma=20i?= =?UTF-8?q?tself=20tells=20them:=20metadata.isCron,=20or=20a=20title=20sta?= =?UTF-8?q?rting=20"=E2=8F=B0=20Cron:"=20(the=20title=20is=20set=20before?= =?UTF-8?q?=20the=20flag;=2039=20here=20have=20only=20the=20title):=20its?= =?UTF-8?q?=20title=20(else=20its=20first=20prompt),=20its=20folder,=20pro?= =?UTF-8?q?mpts=20and=20replies,=20tools=20and=20skills=20(tool-,=20?= =?UTF-8?q?dynamic-tool,=20Skill's=20skill),=20and=20models=20and=20tokens?= =?UTF-8?q?=20from=20the=20usage=20records=20(the=20model=20without=20Alma?= =?UTF-8?q?'s=20provider=20id=20or=20plugin:::),=20price?= =?UTF-8?q?d=20like=20the=20others.=20It=20reads=20through=20a=20read-only?= =?UTF-8?q?=20connection,=20one=20read=20transaction=20a=20chat,=20and=20r?= =?UTF-8?q?eads=20a=20chat=20again=20only=20when=20its=20own=20rows=20chan?= =?UTF-8?q?ged=20(updated=5Fat,=20its=20messages'=20count,=20time=20and=20?= =?UTF-8?q?bytes,=20its=20usage=20rows),=20since=20the=20database's=20file?= =?UTF-8?q?s=20change=20with=20every=20write=20to=20any=20chat.=20Alma's?= =?UTF-8?q?=20chats=20can't=20be=20deleted,=20moved=20or=20resumed=20from?= =?UTF-8?q?=20magpie:=20a=20chat=20is=20rows=20in=20Alma's=20live=20databa?= =?UTF-8?q?se,=20with=20triggers,=20a=20full-text=20index=20and=20other=20?= =?UTF-8?q?tables=20pointing=20at=20it,=20that=20only=20Alma=20can=20chang?= =?UTF-8?q?e=20safely,=20and=20Alma=20has=20no=20command=20to=20pick=20a?= =?UTF-8?q?=20chat=20up=20again;=20its=20data=20folder=20is=20in=20Dirs=20?= =?UTF-8?q?for=20discovery.=20Against=20a=20copy=20of=20the=20real=20datab?= =?UTF-8?q?ase=20(6126=20threads,=2025424=20messages,=2011038=20usage=20ro?= =?UTF-8?q?ws):=20928=20chats=20listed=20(3763=20scheduled=20ones=20and=20?= =?UTF-8?q?the=20empty=20ones=20not),=2018300=20messages,=20all=20titled,?= =?UTF-8?q?=20every=20token=20total=20equal=20to=20the=20database's=20sums?= =?UTF-8?q?=20for=20those=20chats;=20the=20first=20listing=20takes=202.4?= =?UTF-8?q?=20s,=20later=20ones=200.1=20s.=20TestAlmaSessions=20(with=20a?= =?UTF-8?q?=20chat=20flagged=20isCron=20and=20one=20titled=20"=E2=8F=B0=20?= =?UTF-8?q?Cron:"=20only,=20each=20failing=20without=20its=20rule),=20Test?= =?UTF-8?q?AlmaChatReadAgainWhenItChanges=20(a=20title=20and=20a=20usage?= =?UTF-8?q?=20row=20changed=20with=20no=20newer=20time)=20and=20TestAlmaMo?= =?UTF-8?q?del=20fail=20without=20the=20change;=20tests=20set=20APPDATA=20?= =?UTF-8?q?so=20Windows=20never=20reads=20the=20real=20Alma.=20go=20vet,?= =?UTF-8?q?=20builds=20(darwin,=20GOOS=3Dlinux=20-tags=20nogui,=20GOOS=3Dw?= =?UTF-8?q?indows)=20and=20go=20test=20-tags=20nogui=20-count=3D1=20./...?= =?UTF-8?q?=20under=20a=20temp=20HOME=20pass.=20Not=20tried=20in=20the=20G?= =?UTF-8?q?UI=20against=20the=20live=20app.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/sessions/alma.go | 370 +++++++++++++++++++++++++++++ internal/sessions/alma_test.go | 277 +++++++++++++++++++++ internal/sessions/manage.go | 4 +- internal/sessions/sessions.go | 14 +- internal/sessions/sessions_test.go | 2 + 5 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 internal/sessions/alma.go create mode 100644 internal/sessions/alma_test.go diff --git a/internal/sessions/alma.go b/internal/sessions/alma.go new file mode 100644 index 00000000..a260fdbf --- /dev/null +++ b/internal/sessions/alma.go @@ -0,0 +1,370 @@ +package sessions + +import ( + "context" + "database/sql" + "encoding/json" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Alma (the desktop AI chat app) keeps its chats in one SQLite database, +// chat_threads.db, in its data folder — Electron's userData for the app +// "alma": ~/Library/Application Support/alma on a Mac, $XDG_CONFIG_HOME/alma +// (~/.config/alma) on Linux, %APPDATA%\alma on Windows. It is in WAL mode +// and written all the while Alma runs. +// +// chat_threads id, title ("New Chat" until it has one), model +// (":"), created_at, updated_at (ISO), +// workspace_id (→ workspaces.path, the folder it works in), +// is_incognito, metadata ({"activePath":[…],"isCron":…}) +// chat_messages id, thread_id, parent_id/slot_id/depth (a tree: an edited +// prompt or a reply asked again is a sibling), message (the +// AI SDK's UIMessage as JSON: id, role, parts — text, +// reasoning, file, step-start, tool- with its input and +// output, dynamic-tool with toolName), timestamp, metadata +// ({"usage":…,"isCompactionIndicator":…}), and +// parent_tool_call_id on a subagent's messages +// usage_records one row per reply: thread_id, model, provider_id, and its +// input_tokens (cache reads and writes included), +// cached_input_tokens, cache_write_input_tokens and +// output_tokens (reasoning included), timestamp +// aux_usage_records the same for side work (titles, memory), by thread +// +// magpie only reads it, through a read-only connection, and never deletes +// or moves a chat: a chat is rows in Alma's live database, with triggers, +// a full-text index and other tables pointing at it, which only Alma can +// change safely. Alma has no command to pick a chat up again, so its +// sessions are listed read only. + +// AlmaDir is Alma's data folder. +func AlmaDir() string { + d, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(d, "alma") +} + +func almaDB() string { + d := AlmaDir() + if d == "" { + return "" + } + return filepath.Join(d, "chat_threads.db") +} + +// almaStore is Alma's database while a listing reads it, and the columns +// its version has. +type almaStore struct { + db *sql.DB + threads, messages, workspaces map[string]bool + usage, aux map[string]bool +} + +func almaTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, strings.TrimSpace(s)) + return t +} + +// almaCronTitle starts the title of a chat a scheduled task of Alma's runs in. +const almaCronTitle = "⏰ Cron:" + +// almaFiles are Alma's chats, one a session: its path the database's and +// #, its size the bytes of its messages, its time the latest +// change seen, and rev all that tells a chat changed — the database's +// files change with every write to any chat, so a chat is read again only +// when its own rows did. +func almaFiles() []file { + path := almaDB() + if path == "" || !fileExists(path) { + return nil + } + db := openDB(path) + if db == nil { + return nil + } + st := &almaStore{ + db: db, + threads: hermesColumns(db, "chat_threads"), + messages: hermesColumns(db, "chat_messages"), + workspaces: hermesColumns(db, "workspaces"), + usage: hermesColumns(db, "usage_records"), + aux: hermesColumns(db, "aux_usage_records"), + } + if !st.threads["id"] || !st.messages["thread_id"] || !st.messages["message"] { + return nil + } + usage := "0, ''" + if st.usage["thread_id"] { + usage = "(SELECT COUNT(*) FROM usage_records u WHERE u.thread_id = t.id), " + + "COALESCE((SELECT MAX(timestamp) FROM usage_records u WHERE u.thread_id = t.id), '')" + } + // incognito chats are left out, and so are the chats Alma's scheduled + // tasks run in — thousands of them, which would bury every other + // session — told the way Alma tells them: metadata.isCron, or a title + // starting "⏰ Cron:" (the title is set before the flag) + var conds []string + if st.threads["is_incognito"] { + conds = append(conds, "COALESCE(t.is_incognito, 0) = 0") + } + if st.threads["metadata"] { + conds = append(conds, "COALESCE(json_extract(CASE WHEN json_valid(t.metadata) THEN t.metadata END, '$.isCron'), 0) = 0") + } + if st.threads["title"] { + conds = append(conds, "instr(COALESCE(t.title, ''), '"+almaCronTitle+"') <> 1") + } + where := "" + if len(conds) > 0 { + where = " WHERE " + strings.Join(conds, " AND ") + } + // created_at and the thread id are in an index of Alma's; the bytes are + // read from each row's header, not its text + q := "SELECT t.id, " + almaExpr(st.threads, "t", "updated_at", "''") + + ", COALESCE(m.n, 0), COALESCE(m.last, ''), COALESCE(m.bytes, 0), " + usage + + " FROM chat_threads t LEFT JOIN (SELECT thread_id, COUNT(*) n, MAX(" + almaColumn(st.messages, "created_at", "timestamp") + + ") last, SUM(octet_length(message)) bytes FROM chat_messages GROUP BY thread_id) m ON m.thread_id = t.id" + where + rows, err := db.Query(q) + if err != nil { + return nil + } + defer rows.Close() + var out []file + for rows.Next() { + var id, updated, last, usageLast string + var n, bytes, usageN int64 + if rows.Scan(&id, &updated, &n, &last, &bytes, &usageN, &usageLast) != nil || id == "" { + continue + } + if n == 0 && usageN == 0 { + continue // a chat opened and left: nothing was said in it + } + mod := almaTime(updated) + for _, t := range []string{last, usageLast} { + if at := almaTime(t); at.After(mod) { + mod = at + } + } + out = append(out, file{ + agent: "alma", + key: "alma:" + id, + path: path + "#" + url.QueryEscape(id), + sid: id, + main: true, + size: bytes, + mod: mod, + alma: st, + rev: strings.Join([]string{updated, strconv.FormatInt(n, 10), last, strconv.FormatInt(bytes, 10), strconv.FormatInt(usageN, 10), usageLast}, "|"), + readOnly: true, + }) + } + return out +} + +// almaExpr is a column of the table aliased as alias, else fallback when +// its version has no such column. +func almaExpr(cols map[string]bool, alias, name, fallback string) string { + if cols[name] { + return "COALESCE(" + alias + "." + name + ", " + fallback + ")" + } + return fallback +} + +// almaColumn is the first of the names the table has, an empty string +// literal for none. +func almaColumn(cols map[string]bool, names ...string) string { + for _, n := range names { + if cols[n] { + return n + } + } + return "''" +} + +// almaMessage is what magpie reads of a message: who said it, and its parts' +// kinds, words and tools. +type almaMessage struct { + Role string `json:"role"` + Parts []struct { + Type string `json:"type"` + Text string `json:"text"` + ToolName string `json:"toolName"` + Input json.RawMessage `json:"input"` + } `json:"parts"` +} + +type almaMeta struct { + Indicator bool `json:"isCompactionIndicator"` +} + +// almaModel is a model as Alma names it, ":" (a +// plugin's "plugin:::"), without Alma's provider. +func almaModel(model, providerID string) string { + m := strings.TrimSpace(model) + if providerID == "" { + providerID, _, _ = strings.Cut(m, ":") + } + rest, ok := strings.CutPrefix(m, providerID+":") + if !ok { + return m + } + if providerID == "plugin" { + // :: + if parts := strings.SplitN(rest, ":", 3); len(parts) == 3 && parts[2] != "" { + return parts[2] + } + } + if rest == "" { + return m + } + return rest +} + +// parseAlma reads one chat in a read transaction, a snapshot while Alma +// writes on. +func parseAlma(f file) *state { + s := &state{Size: f.size, Mod: f.mod.UnixNano(), DBRevision: f.rev} + st := f.alma + if st == nil { + return s + } + tx, err := st.db.BeginTx(context.Background(), nil) + if err != nil { + return &state{} + } + defer tx.Rollback() + + cwd := "''" + join := "" + if st.threads["workspace_id"] && st.workspaces["id"] && st.workspaces["path"] { + cwd, join = "COALESCE(w.path, '')", " LEFT JOIN workspaces w ON w.id = t.workspace_id" + } + var named, created string + if err := tx.QueryRow("SELECT "+almaExpr(st.threads, "t", "title", "''")+", "+almaExpr(st.threads, "t", "created_at", "''")+", "+cwd+ + " FROM chat_threads t"+join+" WHERE t.id = ?", f.sid).Scan(&named, &created, &s.Cwd); err != nil { + return &state{} + } + if named = title(named); named != "New Chat" { + s.Title = named + } + s.saw(almaTime(created), false) + + sub := "''" + if st.messages["parent_tool_call_id"] { + sub = "COALESCE(parent_tool_call_id, '')" + } + order := almaColumn(st.messages, "timestamp", "created_at") + rows, err := tx.Query("SELECT message, "+hermesExpr(st.messages, "timestamp", "''")+", "+hermesExpr(st.messages, "metadata", "'{}'")+", "+sub+ + " FROM chat_messages WHERE thread_id = ? ORDER BY "+order+", rowid", f.sid) + if err != nil { + return &state{} + } + first := "" + for rows.Next() { + var raw, at, meta, parent string + if rows.Scan(&raw, &at, &meta, &parent) != nil { + continue + } + var m almaMessage + if json.Unmarshal([]byte(raw), &m) != nil { + continue + } + var md almaMeta + _ = json.Unmarshal([]byte(meta), &md) + t := almaTime(at) + main := parent == "" + s.saw(t, main) + if md.Indicator { + continue // the note a compaction leaves, not something said + } + d := s.day(dateOf(t)) + switch m.Role { + case "user": + if !main { + continue + } + d.Prompts++ + if first == "" { + for _, p := range m.Parts { + if p.Type == "text" && strings.TrimSpace(p.Text) != "" { + first = title(p.Text) + break + } + } + } + case "assistant": + if main { + d.Replies++ + } + for _, p := range m.Parts { + name := "" + if n, ok := strings.CutPrefix(p.Type, "tool-"); ok { + name = n + } else if p.Type == "dynamic-tool" { + name = p.ToolName + } + if name == "" { + continue + } + skill := "" + if name == "Skill" { + var in struct { + Skill string `json:"skill"` + } + if json.Unmarshal(p.Input, &in) == nil { + skill = in.Skill + } + } + s.tool(t, name, skill) + } + } + } + rows.Close() + if rows.Err() != nil { + return &state{} + } + if s.Title == "" { + s.Title = first + } + s.First = first + + for _, table := range []struct { + name string + cols map[string]bool + }{{"usage_records", st.usage}, {"aux_usage_records", st.aux}} { + if !table.cols["thread_id"] { + continue + } + q := "SELECT " + hermesSelect(table.cols, []string{"model", "provider_id", "timestamp"}, "''") + ", " + + hermesSelect(table.cols, []string{"input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens"}, "0") + + " FROM " + table.name + " WHERE thread_id = ?" + rows, err := tx.Query(q, f.sid) + if err != nil { + return &state{} + } + for rows.Next() { + var model, provider, at string + var in, cached, written, out int + if rows.Scan(&model, &provider, &at, &in, &cached, &written, &out) != nil { + continue + } + t := almaTime(at) + if t.IsZero() { + t = s.Last + } + if t.After(s.Last) { + s.Last = t + } + s.use(dateOf(t), almaModel(model, provider), Tokens{Input: max(0, in-cached-written), Output: out, CacheRead: cached, CacheWrite: written}) + } + rows.Close() + if rows.Err() != nil { + return &state{} + } + } + return s +} diff --git a/internal/sessions/alma_test.go b/internal/sessions/alma_test.go new file mode 100644 index 00000000..8f178bf7 --- /dev/null +++ b/internal/sessions/alma_test.go @@ -0,0 +1,277 @@ +package sessions + +import ( + "bytes" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// almaSchema is Alma's tables as far as magpie reads them, with the +// columns of chat_threads.db that matter here. +const almaSchema = ` +CREATE TABLE workspaces (id TEXT PRIMARY KEY, path TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE TABLE chat_threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, model TEXT, metadata TEXT DEFAULT '{}', + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_incognito INTEGER DEFAULT 0, workspace_id TEXT, parent_thread_id TEXT); +CREATE TABLE chat_messages (id TEXT, thread_id TEXT NOT NULL, parent_id TEXT, slot_id TEXT, depth TEXT NOT NULL, message TEXT NOT NULL, + timestamp TEXT NOT NULL, metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, parent_tool_call_id TEXT); +CREATE INDEX idx_messages_version_info ON chat_messages(thread_id, timestamp, id, slot_id, created_at); +CREATE TABLE usage_records (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, thread_id TEXT NOT NULL, model TEXT, provider_id TEXT, + date TEXT NOT NULL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cached_input_tokens INTEGER DEFAULT 0, + cache_write_input_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, + timestamp TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE aux_usage_records (id TEXT PRIMARY KEY, purpose TEXT NOT NULL, model TEXT, provider_id TEXT, thread_id TEXT, + date TEXT NOT NULL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cached_input_tokens INTEGER DEFAULT 0, + cache_write_input_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, + timestamp TEXT NOT NULL, created_at TEXT NOT NULL);` + +// almaFixture makes Alma's database in its data folder, in WAL mode as +// Alma keeps it: a chat in a workspace with a tool loop, a subagent's +// reply, a compaction's note and two models' usage; a chat Alma hasn't +// named yet; one opened and left empty; an incognito one; and two that +// Alma's scheduled tasks ran in. +func almaFixture(t *testing.T) string { + t.Helper() + dir := AlmaDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "chat_threads.db") + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(almaSchema); err != nil { + t.Fatal(err) + } + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.Exec(q, args...); err != nil { + t.Fatal(err) + } + } + exec(`INSERT INTO workspaces VALUES ('ws1', '/work/alma', 'alma', '2026-10-01T09:00:00.000Z', '2026-10-01T09:00:00.000Z')`) + exec(`INSERT INTO chat_threads (id, title, model, created_at, updated_at, workspace_id) VALUES + ('thA', 'Fix the build', 'claude-subscription:claude-opus-5-5', '2026-10-01T10:00:00.000Z', '2026-10-01T10:00:30.000Z', 'ws1'), + ('thB', 'New Chat', 'gemini:gemini-3.1-pro-preview', '2026-10-01T11:00:00.000Z', '2026-10-01T11:00:00.000Z', NULL), + ('thC', 'New Chat', NULL, '2026-10-01T12:00:00.000Z', '2026-10-01T12:00:00.000Z', NULL)`) + exec(`INSERT INTO chat_threads (id, title, created_at, updated_at, is_incognito) VALUES + ('thD', 'Secret', '2026-10-01T13:00:00.000Z', '2026-10-01T13:00:00.000Z', 1)`) + // chats Alma's scheduled tasks ran in: one flagged in its metadata, one + // (from before the flag was set) known only by its title + exec(`INSERT INTO chat_threads (id, title, created_at, updated_at, metadata) VALUES + ('thE', 'Daily digest', '2026-10-01T14:00:00.000Z', '2026-10-01T14:00:00.000Z', '{"isCron":true}'), + ('thF', '⏰ Cron: daily digest (Retry 1)', '2026-10-01T15:00:00.000Z', '2026-10-01T15:00:00.000Z', '{}')`) + msg := func(id, thread, at, message, meta, parent string) { + t.Helper() + var p any + if parent != "" { + p = parent + } + exec(`INSERT INTO chat_messages (id, thread_id, slot_id, depth, message, timestamp, metadata, created_at, updated_at, parent_tool_call_id) + VALUES (?, ?, ?, '0.0', ?, ?, ?, ?, ?, ?)`, id, thread, id, message, at, meta, at, at, p) + } + msg("thA--user-1", "thA", "2026-10-01T10:00:00.000Z", + `{"id":"u1","role":"user","parts":[{"type":"text","text":"Please fix the build"},{"type":"file","url":"x","mediaType":"image/png"}]}`, `{"source":"app"}`, "") + msg("thA--a1", "thA", "2026-10-01T10:00:05.000Z", + `{"id":"a1","role":"assistant","parts":[{"type":"step-start"},{"type":"reasoning","text":"hm"}, + {"type":"tool-Bash","toolCallId":"c1","state":"output-available","input":{"command":"make"},"output":"ok"}, + {"type":"tool-Skill","toolCallId":"c2","state":"output-available","input":{"skill":"image-gen"},"output":"loaded"}, + {"type":"tool-Task","toolCallId":"c3","state":"output-available","input":"not an object","output":"done"}, + {"type":"dynamic-tool","toolName":"mcp_search","toolCallId":"c4","state":"output-available","input":{}}, + {"type":"text","text":"Fixed."}]}`, `{"usage":{"inputTokens":1000}}`, "") + msg("thA--sub1", "thA", "2026-10-01T10:00:03.000Z", + `{"id":"s1","role":"assistant","parts":[{"type":"tool-Read","toolCallId":"r1","state":"output-available","input":{"path":"Makefile"}}]}`, + `{"subagentTaskId":"t1"}`, "c3") + msg("thA--compact", "thA", "2026-10-01T10:00:20.000Z", + `{"id":"k1","role":"assistant","parts":[{"type":"text","text":"Summary of the chat so far"}]}`, + `{"isCompactionIndicator":true,"compactionSummary":"…"}`, "") + msg("thB--user-1", "thB", "2026-10-01T11:00:01.000Z", + `{"id":"u2","role":"user","parts":[{"type":"text","text":"What is\nWAL?"}]}`, `{}`, "") + msg("thD--user-1", "thD", "2026-10-01T13:00:01.000Z", + `{"id":"u3","role":"user","parts":[{"type":"text","text":"hidden"}]}`, `{}`, "") + for _, th := range []string{"thE", "thF"} { + msg(th+"--user-1", th, "2026-10-01T16:00:01.000Z", + `{"id":"`+th+`u","role":"user","parts":[{"type":"text","text":"Summarise today's mail"}]}`, `{}`, "") + msg(th+"--a1", th, "2026-10-01T16:00:02.000Z", + `{"id":"`+th+`a","role":"assistant","parts":[{"type":"text","text":"Done."}]}`, `{}`, "") + } + exec(`INSERT INTO usage_records (id, message_id, thread_id, model, provider_id, date, input_tokens, output_tokens, cached_input_tokens, cache_write_input_tokens, reasoning_tokens, total_tokens, timestamp, created_at) VALUES + ('u1', 'thA--a1', 'thA', 'plugin:openai-codex-auth:openai-codex:gpt-6-astra', 'plugin', '2026-10-01', 1000, 50, 600, 0, 10, 1050, '2026-10-01T10:00:05.000Z', '2026-10-01T10:00:05.000Z'), + ('u2', 'thA--a1', 'thA', 'claude-subscription:claude-opus-5-5', 'claude-subscription', '2026-10-01', 500, 20, 100, 300, 0, 520, '2026-10-01T10:00:06.000Z', '2026-10-01T10:00:06.000Z')`) + exec(`INSERT INTO aux_usage_records (id, purpose, model, provider_id, thread_id, date, input_tokens, output_tokens, timestamp, created_at) VALUES + ('x1', 'title', 'gemini:gemini-3.1-flash-lite-preview', 'gemini', 'thB', '2026-10-01', 30, 5, '2026-10-01T11:00:02.000Z', '2026-10-01T11:00:02.000Z')`) + return path +} + +func almaSession(t *testing.T, list []Session, id string) Session { + t.Helper() + for _, s := range list { + if s.Agent == "alma" && s.ID == id { + return s + } + } + t.Fatalf("Alma chat %q not listed: %+v", id, list) + return Session{} +} + +func TestAlmaSessions(t *testing.T) { + setup(t) + path := almaFixture(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + list := List(0) + a := almaSession(t, list, "thA") + if !a.ReadOnly || a.Resume != "" || a.Title != "Fix the build" || a.Cwd != "/work/alma" { + t.Errorf("chat A: %+v", a) + } + if got := model(a, "gpt-6-astra").Tokens; got != (Tokens{Input: 400, Output: 50, CacheRead: 600}) { + t.Errorf("gpt-6-astra = %+v, want Alma's input less its cache reads", got) + } + if got := model(a, "claude-opus-5-5").Tokens; got != (Tokens{Input: 100, Output: 20, CacheRead: 100, CacheWrite: 300}) { + t.Errorf("claude-opus-5-5 = %+v, want Alma's input less its cache reads and writes", got) + } + if a.Cost == 0 || a.Unpriced != 0 { + t.Errorf("chat A priced: cost %v, unpriced %d", a.Cost, a.Unpriced) + } + if want := time.Date(2026, 10, 1, 10, 0, 20, 0, time.UTC); !a.Last.Equal(want) || !a.Start.Equal(time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)) { + t.Errorf("chat A times %v – %v", a.Start, a.Last) + } + b := almaSession(t, list, "thB") + if b.Title != "What is WAL?" || b.Cwd != "" { + t.Errorf("an unnamed chat is titled by its first prompt: %+v", b) + } + if got := model(b, "gemini-3.1-flash-lite-preview").Tokens; got != (Tokens{Input: 30, Output: 5}) { + t.Errorf("side work's usage = %+v", got) + } + for _, s := range list { + if s.Agent == "alma" && (s.ID == "thC" || s.ID == "thD" || s.ID == "thE" || s.ID == "thF") { + t.Errorf("an empty, incognito or scheduled chat was listed: %+v", s) + } + } + + // the Sessions page: listed, never deletable; prompts and replies of the + // chat itself, not its subagent's or a compaction's note + managed := ListAgent("alma") + if len(managed) != 2 { + t.Fatalf("ListAgent(alma) = %+v", managed) + } + for _, m := range managed { + if m.Deletable { + t.Errorf("an Alma chat is deletable: %+v", m) + } + if m.ID == "thA" && (m.Messages != 2 || m.Size == 0) { + t.Errorf("chat A on the page: %d messages, %d bytes", m.Messages, m.Size) + } + } + found := false + for _, ac := range Agents() { + if ac.Agent == "alma" { + found = true + if ac.Count != 2 || ac.Deletable { + t.Errorf("Agents: %+v", ac) + } + } + } + if !found { + t.Error("Alma missing from Agents()") + } + if _, err := Delete("alma", "thA"); err == nil { + t.Error("Delete took an Alma chat out of Alma's database") + } + + // the tools and skills it called, its subagent's too + st := StatsAt(0, time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)) + var sum Summary + for _, s := range st.Sessions { + if s.Key == "alma:thA" { + sum = s + } + } + if sum.Prompts != 1 || sum.Replies != 1 || sum.ToolCalls != 5 { + t.Errorf("chat A stats: prompts %d replies %d tools %d", sum.Prompts, sum.Replies, sum.ToolCalls) + } + for _, tool := range []string{"Bash", "Skill", "Task", "mcp_search", "Read"} { + if sum.tools[tool] != 1 { + t.Errorf("tool %s called %d times, want 1 (%v)", tool, sum.tools[tool], sum.tools) + } + } + if sum.skills["image-gen"] != 1 { + t.Errorf("skills %v", sum.skills) + } + + found = false + for _, d := range Dirs() { + if d == AlmaDir() { + found = true + } + } + if !found { + t.Errorf("Dirs() %v leaves out Alma's", Dirs()) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Error("reading Alma's chats changed its database") + } +} + +// Alma writes to its WAL all the time; a chat is read again when its own +// rows change, though the database file's size and time don't, nor the +// chat's latest time (a reply's usage written after a later message). +func TestAlmaChatReadAgainWhenItChanges(t *testing.T) { + setup(t) + path := almaFixture(t) + if b := almaSession(t, List(0), "thB"); b.Title != "What is WAL?" { + t.Fatalf("chat B: %+v", b) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`UPDATE chat_threads SET title = 'Explaining WAL' WHERE id = 'thB'`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO usage_records (id, message_id, thread_id, model, provider_id, date, input_tokens, output_tokens, timestamp, created_at) + VALUES ('u3', 'thB--a', 'thB', 'gemini:gemini-3.1-pro-preview', 'gemini', '2026-10-01', 70, 9, '2026-10-01T11:00:00.500Z', '2026-10-01T11:00:00.500Z')`); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + b := almaSession(t, List(0), "thB") + if b.Title != "Explaining WAL" || model(b, "gemini-3.1-pro-preview").Tokens != (Tokens{Input: 70, Output: 9}) { + t.Errorf("chat B not read again: %+v", b) + } +} + +func TestAlmaModel(t *testing.T) { + for _, c := range []struct{ model, provider, want string }{ + {"plugin:openai-codex-auth:openai-codex:gpt-5.4", "plugin", "gpt-5.4"}, + {"claude-subscription:claude-opus-4-6", "claude-subscription", "claude-opus-4-6"}, + {"muqbtywmwhw85ee22l:anthropic/claude-opus-5-5", "muqbtywmwhw85ee22l", "anthropic/claude-opus-5-5"}, + {"gemini:gemini-3.1-pro-preview", "", "gemini-3.1-pro-preview"}, + {"ollama:qwen3:8b", "ollama", "qwen3:8b"}, + {"gemini-2.5-pro", "", "gemini-2.5-pro"}, + } { + if got := almaModel(c.model, c.provider); got != c.want { + t.Errorf("almaModel(%q, %q) = %q, want %q", c.model, c.provider, got, c.want) + } + } +} diff --git a/internal/sessions/manage.go b/internal/sessions/manage.go index 81271a1b..4076bd4d 100644 --- a/internal/sessions/manage.go +++ b/internal/sessions/manage.go @@ -22,7 +22,9 @@ import ( // // Only the agents whose sessions are files of their own can be deleted: // Claude Code's (and Qoder's and WorkBuddy's, kept the same way), Codex's, -// Pi's, omp's and Cursor CLI's (a chat's folder, its store and meta.json). The agents' indexes are left as they are: Codex's +// Pi's, omp's and Cursor CLI's (a chat's folder, its store and meta.json). +// Hermes's and Alma's are rows in a database the agent keeps open and +// writes, so they are only listed. The agents' indexes are left as they are: Codex's // session_index.jsonl (names by thread id) and its state database, and // Claude Code's history.jsonl (the prompts typed, for the up arrow), are // written by the agent while it runs, and a name or a prompt left for a diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index f2c5d72d..bef3b4c9 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -2,7 +2,7 @@ // files — Claude Code's projects/*/.jsonl (and Qoder's, the same kind), // Codex's rollout files, OpenCode's database (or its older JSON files) and // ZCode's, Pi's session files and omp's, DeepSeek Harness's, Cline's, Grok -// Build's, WorkBuddy's, Droid's and Cursor CLI's chat stores — with the tokens each spent, what that cost at the +// Build's, WorkBuddy's, Droid's, Cursor CLI's and Alma's chat stores — with the tokens each spent, what that cost at the // effective price, and the command that resumes it. It only ever reads the // agents' folders. // @@ -70,7 +70,7 @@ type Model struct { // Session is one agent session. type Session struct { ReadOnly bool `json:"read_only,omitempty"` - Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, droid, cursor, hermes + Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, droid, cursor, hermes, alma ID string `json:"id"` Cwd string `json:"cwd"` Title string `json:"title"` // the first prompt, else the agent's own title @@ -295,6 +295,9 @@ type file struct { // transcript manifest string hermes *hermesDB + // Alma: its database, and what tells the chat's rows changed + alma *almaStore + rev string readOnly bool } @@ -366,7 +369,7 @@ func allFiles() []file { var out []file for _, fs := range [][]file{callFiles(), openCodeFiles(), piFiles(), zcodeFiles(), dshFiles(), clineFiles(), ccFiles("qoder", QoderDir("qoder")), ccFiles("qoder-cn", QoderDir("qoder-cn")), - grokFiles(), workbuddyFiles(), droidFiles(), ompFiles(), cursorFiles(), hermesFiles()} { + grokFiles(), workbuddyFiles(), droidFiles(), ompFiles(), cursorFiles(), hermesFiles(), almaFiles()} { out = append(out, fs...) } return out @@ -389,6 +392,7 @@ func Dirs() []string { {OmpDir(), filepath.Join(OmpDir(), "sessions")}, {FactoryDir(), filepath.Join(FactoryDir(), "sessions")}, {CursorDir(), filepath.Join(CursorDir(), "chats")}, + {AlmaDir(), almaDB()}, } { if _, err := os.Stat(d.sessions); err == nil { out = append(out, d.dir) @@ -600,7 +604,7 @@ func writeCache(c *save) { func refresh(want, all []file) { var todo []file for _, f := range want { - if s := cache[f.path]; s == nil || s.Size != f.size || s.Mod != f.mod.UnixNano() || (f.agent == "hermes" && (f.hermes == nil || f.hermes.revision == "" || s.DBRevision != f.hermes.revision)) { + if s := cache[f.path]; s == nil || s.Size != f.size || s.Mod != f.mod.UnixNano() || (f.agent == "hermes" && (f.hermes == nil || f.hermes.revision == "" || s.DBRevision != f.hermes.revision)) || (f.agent == "alma" && s.DBRevision != f.rev) { todo = append(todo, f) } } @@ -915,6 +919,8 @@ func parse(f file, old *state) *state { switch f.agent { case "hermes": return parseHermes(f) + case "alma": + return parseAlma(f) case "opencode", "zcode": return parseOpenCode(f) case "dsh": diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index f16eb510..021d13d4 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -57,6 +57,8 @@ func setup(t *testing.T) (claude, codex string) { // the home folder: never the real one's t.Setenv("HOME", filepath.Join(dir, "home")) t.Setenv("USERPROFILE", filepath.Join(dir, "home")) + // Alma keeps its chats in the user's config folder: %APPDATA% on Windows + t.Setenv("APPDATA", filepath.Join(dir, "appdata")) PriceOf = func(_ settings.Settings, m string) (catalog.Price, bool) { switch m { case "claude-opus-5-5":