sessions: Alma's chats are on the Sessions page, read only (mamba on Discord: magpie现在是不是还不能管理alma的会话呢) — Alma keeps every chat in one SQLite database in WAL mode, chat_threads.db in its data folder (~/Library/Application Support/alma on a Mac, $XDG_CONFIG_HOME/alma on Linux, %APPDATA%\alma on Windows): chat_threads (title, "New Chat" until named; model; workspace → its folder; incognito), chat_messages (AI SDK UIMessages in a tree, subagents' under parent_tool_call_id) and usage_records / aux_usage_records (a reply's tokens, cache reads and writes included in input). magpie now lists each chat that has something in it, incognito ones left out, and so are the chats Alma's scheduled tasks run in (thousands of them, which would bury every other agent's sessions), told the way Alma itself tells them: metadata.isCron, or a title starting "⏰ Cron:" (the title is set before the flag; 39 here have only the title): its title (else its first prompt), its folder, prompts and replies, tools and skills (tool-<Name>, dynamic-tool, Skill's skill), and models and tokens from the usage records (the model without Alma's provider id or plugin:<plugin>:<provider>:), priced like the others. It reads through a read-only connection, one read transaction a chat, and reads a chat again only when its own rows changed (updated_at, its messages' count, time and bytes, its usage rows), since the database's files change with every write to any chat. Alma's chats can't be deleted, moved or resumed from magpie: a chat is rows in Alma's live database, with triggers, a full-text index and other tables pointing at it, that only Alma can change safely, and Alma has no command to pick a chat up again; its data folder is in Dirs for discovery. Against a copy of the real database (6126 threads, 25424 messages, 11038 usage rows): 928 chats listed (3763 scheduled ones and the empty ones not), 18300 messages, all titled, every token total equal to the database's sums for those chats; the first listing takes 2.4 s, later ones 0.1 s. TestAlmaSessions (with a chat flagged isCron and one titled "⏰ Cron:" only, each failing without its rule), TestAlmaChatReadAgainWhenItChanges (a title and a usage row changed with no newer time) and TestAlmaModel fail without the change; tests set APPDATA so Windows never reads the real Alma. go vet, builds (darwin, GOOS=linux -tags nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried in the GUI against the live app.

This commit is contained in:
yetone committed 2026-10-03 19:18:46 +08:00
1 parent 2eb9266395
commit dc3deaf09a
5 files changed
+662 -5

No files matched your search

+370
View File
@@ -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
// ("<provider id>:<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-<Name> 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
// #<thread id>, 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, "<provider id>:<model>" (a
// plugin's "plugin:<plugin>:<provider>:<model>"), 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" {
// <plugin>:<its provider>:<model>
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
}
+277
View File
@@ -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)
}
}
}
+3 -1
View File
@@ -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
+10 -4
View File
@@ -2,7 +2,7 @@
// files — Claude Code's projects/*/<id>.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":
+2
View File
@@ -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":