Skip to content

Commit 99a2ea2

Browse files
committed
feat(zaly): add Zaly as a backend (session JSONL, mixed billing split)
1 parent a79c707 commit 99a2ea2

8 files changed

Lines changed: 1090 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ Code/Insiders/VSCodium variants, or `--vscode-dir` — from WSL, pointed at the
2323
store — `VscodeStore`),
2424
**pi-agent** sessions (`~/.pi/agent/sessions/**/*.jsonl` or `$PI_AGENT_DIR`, `PiStore`),
2525
**OpenClaw** gateway sessions (`~/.openclaw/agents/**/sessions/*.jsonl` or `$OPENCLAW_DIR`,
26-
`OpenClawStore`), and a **JSONL/NDJSON of logged API requests** (the per-line twin of the
26+
`OpenClawStore`), **Zaly** sessions
27+
(`~/.local/share/zaly/sessions/*/*/session.jsonl` or `$ZALY_DATA`/`$ZALY_ROOT`,
28+
`ZalyStore`), and a **JSONL/NDJSON of logged API requests** (the per-line twin of the
2729
CSV source; `--jsonl`, default `~/.config/opentab/requests.jsonl`, `JsonlStore`).
2830
`CombinedStore` merges them. Pick with
29-
`--source {auto,opencode,claude,codex,hermes,csv,jsonl,copilot,vscode,pi,openclaw,all}` or
30-
switch live with **`c`**.
31+
`--source {auto,opencode,claude,codex,hermes,csv,jsonl,copilot,vscode,pi,openclaw,zaly,all}`
32+
or switch live with **`c`**.
3133

3234
Cost model: Claude Code, Codex, and Copilot (CLI and VS Code) record **no per-message
3335
cost**, so they behave like an OpenCode *subscription* session — **$0 in normal mode**, a
34-
list-price estimate (tokens × API price) only under **`$`**. Hermes, CSV/JSONL, pi, and
35-
OpenClaw are **mixed**: subscription/OAuth routes stay $0/estimated, metered routes record
36-
real spend. See each backend's note under Architecture.
36+
list-price estimate (tokens × API price) only under **`$`**. Hermes, CSV/JSONL, pi,
37+
OpenClaw, and Zaly are **mixed**: subscription/OAuth routes stay $0/estimated, metered
38+
routes record real spend. See each backend's note under Architecture.
3739

3840
## Commands
3941

@@ -118,7 +120,7 @@ src/opentab/
118120
sources.py make_store/resolve_source/available_sources/source_cycle + path routing
119121
state.py load_state/save_state/apply_state
120122
themes.py THEMES palettes (single source for the web browser + the TUI) + hex math
121-
stores/ opencode, claude, codex, hermes, csv_source, jsonl_source, copilot, vscode, pi, openclaw, combined, cached
123+
stores/ opencode, claude, codex, hermes, csv_source, jsonl_source, copilot, vscode, pi, openclaw, zaly, combined, cached
122124
tui/ renderer (Renderer), app (App)
123125
web.py build_payload/session_extras + html_command/serve_command (ReportServer)
124126
webpage.py render_html: the self-contained browser page (inline CSS/JS strings)
@@ -157,7 +159,7 @@ Three logical layers (the class names below live in the files above — `Store`
157159
`getattr`, each gated by `supports_*(workflow_id)` so the merged view hides an
158160
unsupported tab rather than showing it empty (`CombinedStore` routes by owning backend).
159161
Turns is implemented by every backend except Hermes; Tools by OpenCode, Claude, Codex,
160-
pi, and CSV/JSONL (the others record no per-step tool calls with usage). Records
162+
pi, Zaly, and CSV/JSONL (the others record no per-step tool calls with usage). Records
161163
**no per-message cost** → a *subscription* backend: `model_breakdown` reports `cost=0`
162164
with tokens in the `unpriced_*`/`root_unpriced_*` splits, so the normal `$` machinery
163165
gives **$0 / list-price estimate** with no special-casing. `records_cost=False` drives
@@ -297,7 +299,31 @@ grouping and the regenerate/append dedup) and **Tools** (the optional `tool` col
297299
OpenClaw's generic cwd); messages dedupe by record `id` across live + archived
298300
(`.jsonl.reset.`/`.jsonl.deleted.`). No subagent tree; usage-less sessions dropped.
299301
Implements **Turns** exactly like pi (epoch-seconds timestamps).
300-
- **`JsonlStore`** — ninth backend over a **JSONL/NDJSON of logged API requests** (one JSON
302+
- **`ZalyStore`** — ninth backend over **Zaly** session NDJSON
303+
(`<data>/sessions/<encoded-workspace>/<uuid>/session.jsonl`; data resolved like zaly's
304+
own envPaths — `$ZALY_DATA` / `$ZALY_ROOT/data` / `$XDG_DATA_HOME/zaly`, default
305+
`~/.local/share/zaly`, or `--zaly-dir`), same methods. Each session is one append-only
306+
JSONL **DAG** (`session-settings` snapshots `{sessionId, cwd/workspace, modelId}`;
307+
resume/fork append to the *same* file, so no cross-file dedup — assistant messages
308+
still dedupe by `message.id`, and abandoned regenerated branches count: each was a
309+
real API call). Usage lives on `message.meta.usage` (model on `meta.modelId`, already
310+
provider-qualified — `openai-codex/gpt-5.6-sol` — used verbatim): **Anthropic-normalized**
311+
tokens (`input` already uncached — zaly's OpenAI adapter subtracts `cached_tokens`
312+
itself; cacheRead/cacheWrite separate) with **`reasoning` a subset of `output`**
313+
(OpenAI's reasoning_tokens detail) → reported as 0 like Codex/Copilot, since opentab's
314+
reasoning column is additive and counting it would double-bill under `$`. The
315+
per-message `usage.cost` is a **per-component USD object** ({input, output, cacheRead,
316+
…}, no `.total` — summed) computed from zaly's catalog for **every** route, so the
317+
pi/OpenClaw **metered-vs-subscription split**: subscription when the `meta.modelId`
318+
provider prefix is an OAuth login in zaly's `<state>/auth.json`
319+
(`{provider: {type: "oauth"|"api-key"}}`, `$ZALY_STATE`-resolved, read-only) or matches
320+
`_SUBSCRIPTION_MARKERS` (codex/copilot/ollama/lm-studio/…); **`records_cost`
321+
per-instance**. Workspace → git root; settings `sessionId` beats the dir name. **No
322+
subagent tree** — zaly writes subagent transcripts to tmpdir and does *not* fold their
323+
usage into the parent (a latent undercount, documented in the docstring). Settings-only
324+
sessions (zaly launched, nothing asked) are dropped. Implements **Turns** (epoch-ms
325+
timestamps, ▸-grouped like pi) and **Tools** (the step's `tool-call` content parts).
326+
- **`JsonlStore`** — tenth backend over a **JSONL/NDJSON of logged API requests** (one JSON
301327
object per line, generic). **Subclasses `CsvStore`** — its per-line twin — inheriting
302328
OpenAI-style token accounting, mixed per-row cost (`records_cost` per-instance;
303329
`cost_usd`/`credits`), provider-prefixed models, synthetic `(date, project)` sessions,
@@ -375,7 +401,7 @@ tuples `month_tabs`/`day_tabs`/`project_tabs`/`workflow_tabs`. `current_tabs()`
375401
source of truth (don't index a class tuple directly): it appends, in order, a **Turns**
376402
tab (when `supports_turns(id)` — every backend except Hermes, whose DB records no
377403
per-message usage) and a **Tools** tab (when
378-
`supports_tools(id)` — OpenCode, Claude, Codex, pi, and CSV/JSONL sessions whose log
404+
`supports_tools(id)` — OpenCode, Claude, Codex, pi, Zaly, and CSV/JSONL sessions whose log
379405
carries the optional `tool` column) to the *selected* session's `workflow_tabs`, each
380406
gated per-session so the merged view hides an unsupported tab rather than showing it
381407
empty, and injects **Sources** in the merged view — so `draw_detail` dispatches the

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ no accounts — it opens those files **read-only**. Standard-library-only at run
2727

2828
- **One tab for every tool**[OpenCode](https://opencode.ai),
2929
[Claude Code](https://claude.com/claude-code), [Codex](https://developers.openai.com/codex),
30-
Hermes, GitHub Copilot (its CLI and Copilot Chat in VS Code), pi-agent, OpenClaw, and
30+
Hermes, GitHub Copilot (its CLI and Copilot Chat in VS Code), pi-agent, OpenClaw,
31+
[zaly](https://github.com/folke/zaly), and
3132
CSV/JSONL logs of your own API requests — [each detailed below](#data-sources).
3233
- **Drill, don't scroll** — month → day → project → session → model, down the recursive
3334
subagent tree, with a live fuzzy filter (fzf-style) and live date-range scoping.
@@ -128,6 +129,7 @@ opentab --source copilot # GitHub Copilot CLI (default ~/.co
128129
opentab --source vscode # Copilot Chat in VS Code (every installed variant)
129130
opentab --source pi # pi-agent (default ~/.pi/agent/sessions)
130131
opentab --source openclaw # OpenClaw gateway (default ~/.openclaw)
132+
opentab --source zaly # zaly (default ~/.local/share/zaly)
131133
opentab --csv requests.csv # a CSV of logged API requests (or --jsonl)
132134
opentab --source all # all present sources, merged
133135
```
@@ -145,6 +147,7 @@ What each tool's records support on top:
145147
| Copilot Chat in VS Code | tokens only — `$` estimates ||||
146148
| pi-agent | mixed — metered real, rest estimated ||||
147149
| OpenClaw | mixed — metered real, rest estimated ||||
150+
| zaly | mixed — metered real, rest estimated ||||
148151
| CSV / JSONL request logs | mixed — per-row cost column ||| ✓ ² |
149152

150153
<sub>**Subagent tree** — recursive per-subagent cost under the session that delegated ·
@@ -260,6 +263,22 @@ Where each tool's records live, and their quirks:
260263

261264
</details>
262265

266+
<details>
267+
<summary><strong>zaly</strong> — session JSONL · mixed: metered real, plan routes estimated</summary>
268+
269+
- **Reads** `~/.local/share/zaly/sessions/*/*/session.jsonl` (`--zaly-dir`, honors
270+
`$ZALY_DATA` and `$ZALY_ROOT`).
271+
- **Cost**: zaly prices every message from its model catalog regardless of route, so —
272+
like pi and OpenClaw — only **metered** routes (a direct API key) count as real
273+
spend; OAuth/plan logins (a ChatGPT-plan `openai-codex`, Claude Pro/Max) and local
274+
models stay `$0` and are estimated under `$`. The split is read from zaly's
275+
`auth.json`, read-only.
276+
- **Notes**: projects fold to the workspace's git root; resume/fork append to the same
277+
file, so nothing double-counts. Subagent transcripts are not persisted by zaly
278+
(they live in the temp dir), so their usage can't be shown.
279+
280+
</details>
281+
263282
<details>
264283
<summary><strong>CSV / JSONL request logs</strong> — bring your own ledger · mixed per row</summary>
265284

@@ -447,9 +466,9 @@ Local-only, no network, no telemetry, no accounts — it opens every source file
447466
<summary><strong>Full transparency</strong> — everything it reads, writes, and runs</summary>
448467

449468
- **Reads** your tools' own records, read-only: OpenCode's SQLite DB, the JSONL
450-
transcripts of Claude Code / Codex / pi-agent / OpenClaw, Hermes' SQLite DB, the Copilot
451-
CLI's OpenTelemetry export, VS Code's Copilot Chat session store, and a CSV/JSONL of
452-
logged API requests (`--csv`/`--jsonl`).
469+
transcripts of Claude Code / Codex / pi-agent / OpenClaw / zaly, Hermes' SQLite DB, the
470+
Copilot CLI's OpenTelemetry export, VS Code's Copilot Chat session store, and a
471+
CSV/JSONL of logged API requests (`--csv`/`--jsonl`).
453472
To fold git worktrees into their main repo it also reads project `.git` files (no `git`
454473
process is spawned; disable with `--no-worktrees`).
455474
- **Writes** a small preferences file at `~/.config/opentab/state.json` (your last

src/opentab/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""OpenTab — a lazygit-style browser for your AI coding spend.
22
33
Reads OpenCode, Claude Code, Codex, Hermes, the Copilot CLI, pi-agent, OpenClaw,
4-
and logged-request CSVs read-only and shows cost by month / day / project /
4+
Zaly, and logged-request CSVs read-only and shows cost by month / day / project /
55
session / model, including the recursive subagent tree.
66
77
This package is the modular form of what used to be a single ``opentab`` script.
@@ -139,6 +139,7 @@
139139
from opentab.stores.opencode import MODEL_EXPR, MSG_MODEL_EXPR, MSG_TOKEN_TOTAL_EXPR, Store
140140
from opentab.stores.pi import PiStore
141141
from opentab.stores.vscode import VscodeStore
142+
from opentab.stores.zaly import ZalyStore
142143
from opentab.themes import (
143144
DEFAULT_THEME,
144145
THEME_IDS,

src/opentab/cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DEFAULT_JSONL_PATH,
2828
_default_openclaw_dir,
2929
_default_pi_dir,
30+
_default_zaly_dir,
3031
_jsonl_dir_available,
3132
_route_path_arg,
3233
resolve_source,
@@ -55,15 +56,16 @@ def parse_args() -> argparse.Namespace:
5556
"vscode",
5657
"pi",
5758
"openclaw",
59+
"zaly",
5860
"all",
5961
),
6062
default="auto",
6163
help="data source: opencode (SQLite), claude (Claude Code transcripts), codex "
6264
"(Codex CLI rollouts), hermes (Hermes Agent DB), csv (a CSV of logged API "
6365
"requests, e.g. GitHub Copilot), jsonl (an NDJSON of logged API requests), "
6466
"copilot (GitHub Copilot CLI via its OTEL export), vscode (Copilot Chat sessions "
65-
"in VS Code), pi (pi-agent sessions), openclaw (OpenClaw gateway sessions), or "
66-
"all (merged); auto merges every present "
67+
"in VS Code), pi (pi-agent sessions), openclaw (OpenClaw gateway sessions), "
68+
"zaly (Zaly sessions), or all (merged); auto merges every present "
6769
"source (default: auto). Or just pass a file path -- e.g. `opentab requests.csv`",
6870
)
6971
parser.add_argument(
@@ -118,6 +120,12 @@ def parse_args() -> argparse.Namespace:
118120
help="OpenClaw gateway home holding agents/ and openclaw.json (for --source "
119121
"openclaw); honors $OPENCLAW_DIR, default ~/.openclaw",
120122
)
123+
parser.add_argument(
124+
"--zaly-dir",
125+
default=_default_zaly_dir(),
126+
help="Zaly data directory holding sessions/ (for --source zaly); honors "
127+
"$ZALY_DATA and $ZALY_ROOT, default ~/.local/share/zaly",
128+
)
121129
parser.add_argument(
122130
"--csv",
123131
default=None,

src/opentab/sources.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from opentab.stores.opencode import Store
1919
from opentab.stores.pi import PiStore
2020
from opentab.stores.vscode import VscodeStore
21+
from opentab.stores.zaly import ZalyStore, default_zaly_data_dir
2122

2223
DEFAULT_CSV_PATH = os.path.expanduser("~/.config/opentab/requests.csv")
2324
DEFAULT_JSONL_PATH = os.path.expanduser("~/.config/opentab/requests.jsonl")
@@ -69,6 +70,14 @@ def _default_openclaw_dir() -> str:
6970
return env or os.path.expanduser("~/.openclaw")
7071

7172

73+
def _default_zaly_dir() -> str:
74+
# Zaly's data dir (holding sessions/), resolved like zaly's own envPaths:
75+
# $ZALY_DATA / $ZALY_ROOT/data / $XDG_DATA_HOME/zaly, default ~/.local/share/zaly
76+
# (LOCALAPPDATA\\zaly\\Data on native Windows). Lives in the store module so the
77+
# auth.json (state-dir) twin sits beside it.
78+
return default_zaly_data_dir()
79+
80+
7281
# Which --flag each concrete source reads its path from (used to route a bare
7382
# positional path into the right slot).
7483
_PATH_SLOT = {
@@ -82,6 +91,7 @@ def _default_openclaw_dir() -> str:
8291
"vscode": "vscode_dir",
8392
"pi": "pi_dir",
8493
"openclaw": "openclaw_dir",
94+
"zaly": "zaly_dir",
8595
}
8696

8797

@@ -164,6 +174,18 @@ def _openclaw_available(root_dir: str) -> bool:
164174
)
165175

166176

177+
def _zaly_available(root_dir: str) -> bool:
178+
# Zaly sessions live at <data>/sessions/<encoded-workspace>/<uuid>/session.jsonl;
179+
# check that precise shape (launching zaly writes settings-only files, but only under
180+
# that layout) so an unrelated zaly-named directory never trips detection.
181+
if not root_dir or not os.path.isdir(root_dir):
182+
return False
183+
return (
184+
next(glob.iglob(os.path.join(root_dir, "sessions", "*", "*", "session.jsonl")), None)
185+
is not None
186+
)
187+
188+
167189
def _copilot_otel_available(args: argparse.Namespace) -> bool:
168190
# Copilot CLI usage lives only in its (opt-in) OTEL export: the export directory
169191
# holding *.jsonl, or the single file named by $COPILOT_OTEL_FILE_EXPORTER_PATH.
@@ -210,6 +232,7 @@ def _vscode_available(args: argparse.Namespace) -> bool:
210232
"vscode": "VS Code",
211233
"pi": "Pi",
212234
"openclaw": "OpenClaw",
235+
"zaly": "Zaly",
213236
"all": "all",
214237
}
215238

@@ -222,6 +245,7 @@ def _vscode_available(args: argparse.Namespace) -> bool:
222245
"Hermes": "hermes --resume",
223246
"Copilot": "copilot --resume",
224247
"Pi": "pi --session",
248+
"Zaly": "zaly --session",
225249
}
226250

227251

@@ -242,6 +266,7 @@ def _detect_fingerprint(args: argparse.Namespace) -> tuple:
242266
"vscode_dir",
243267
"pi_dir",
244268
"openclaw_dir",
269+
"zaly_dir",
245270
)
246271
) + (os.environ.get("COPILOT_OTEL_FILE_EXPORTER_PATH", ""),)
247272

@@ -277,6 +302,8 @@ def available_sources(args: argparse.Namespace) -> list[str]:
277302
keys.append("pi")
278303
if _openclaw_available(getattr(args, "openclaw_dir", "")):
279304
keys.append("openclaw")
305+
if _zaly_available(getattr(args, "zaly_dir", "")):
306+
keys.append("zaly")
280307
args._available_sources = (fp, keys)
281308
return list(keys)
282309

@@ -389,6 +416,14 @@ def _build_store(args: argparse.Namespace, key: str) -> tuple[object, str]:
389416
f"OpenClaw home holding agents/*/sessions/*.jsonl (looked in {args.openclaw_dir})."
390417
)
391418
return OpenClawStore(args.openclaw_dir, args), "OpenTab: loading OpenClaw sessions…\r"
419+
if key == "zaly":
420+
if not _zaly_available(getattr(args, "zaly_dir", "")):
421+
raise SystemExit(
422+
"No Zaly sessions found. Point --zaly-dir (or $ZALY_DATA) at a Zaly data "
423+
"directory holding sessions/*/*/session.jsonl "
424+
f"(looked in {getattr(args, 'zaly_dir', '')})."
425+
)
426+
return ZalyStore(args.zaly_dir, args), "OpenTab: loading Zaly sessions…\r"
392427
if not os.path.exists(args.db):
393428
raise SystemExit(f"OpenCode database not found: {args.db}")
394429
return Store(args.db, args), "OpenTab: loading OpenCode database…\r"

0 commit comments

Comments
 (0)