braingent-sto above the prompt: this project's chat sync, the 5h and weekly limits, the context window; /sto sync does the round trip

<img src="docs/banner.png" alt="braingent-sto" width="100%">
<img alt="Python" src="https://img.shields.io/badge/Python-3776AB?style=flat-square&logo=python&logoColor=white"> <img alt="React" src="https://img.shields.io/badge/React-61DAFB?style=flat-square&logo=react&logoColor=black"> <img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white"> <img alt="MCP" src="https://img.shields.io/badge/MCP-E38742?style=flat-square&logo=anthropic&logoColor=white">
<a href="#why-it-exists"><b>Why it exists</b></a> • <a href="#features">Features</a> • <a href="#stack">Stack</a> • <a href="#structure">Structure</a> • <a href="#setup">Setup</a> • <a href="#commands">Commands</a>
braingent-sto turns a private git repository into the substrate that Claude Code is missing: your ~/.claude config, your skills and plugins, the memories your agent writes, and the transcripts of every session, all synced between machines with two keystrokes. No daemon, no API key, no third-party service — a Python stdlib backend, git, and a terminal UI.
Claude Code still has no native way to sync config and skills across machines, and no cloud sync for skills, settings and memory. The ecosystem answers with three separate families of tools:
| Need | Existing tools | What they leave out |
|---|---|---|
| Persistent memory | claude-mem, engram, agentmemory | Config, skills and plugins stay on one machine; memory lives in a SQLite/vector store you can't read or diff |
| Config sync | claude-sync, claude-code-dotfiles | No sessions, no usage, no UI — sync and nothing else |
| Session browsing | claude-code-log, claude-code-trace, claude-history | Local-only: what you did on the laptop is invisible from the desktop |
Claude Code's own auto memory is part of the same gap: it writes plain markdown into ~/.claude/projects/<repo>/memory/, and the docs are explicit that those files "are not shared across machines or cloud environments".
STO is the union of the three, on one substrate. Every artifact is plain text in your own repo — memories are markdown with frontmatter, sessions are trimmed and redacted JSONL, config is the real files. You can read them, grep them, edit them by hand, and roll them back with git revert.
CLAUDE.md, nothing new to teach the model: Claude Code writes its auto memory as markdown, and STO mirrors those exact files between machines.sto push exports sessions, memories, ~/.claude modules and your vault, commits and pushes. sto pull applies them on the other machine — including installing missing plugins and marketplaces.~/.claude travel: claude-md, settings, keybindings, skills, agents, hooks, plugins. Memories, sessions and vault always travel.sto resume <id> (or e on the Sessions tab, per row or per project): it is filed under the path this machine registered for the project (w), so claude --resume and /resume find it. Without a sto keep it is rebuilt from the trimmed export as text-only turns (tool outputs are gone, the conversation is not); sto keep <id> archives one transcript untrimmed and gzipped when the full tool history matters.sto ui). Five tabs — Home, Sessions, Memory, Config, Help — built on Textual over the same engine the CLI uses: no HTTP in between, no second copy of any rule. Runs the same on Windows and Linux, and uv borrows Textual in a throwaway environment when it is not installed. The footer lists the verbs that work on the tab you are standing on, and nothing else.exporting sessions → staging → committing → pushing to origin) with a spinner, instead of freezing the screen until git returns.[[wikilinks]]. Opens as a chrome-less window with a project/type/machine sidebar, click-to-inspect detail panel and search. Built from the same files the repo syncs, so it shows every machine and every session, not just this one.ccusage when it is installed.braingent-sto/
├── scripts/
│ ├── sessions_server.py # engine: sessions, memory, config sync, git — stdlib only
│ ├── cli.py # `sto` — presentation over the engine
│ ├── tui_app.py # the TUI (Textual), over the same engine
│ ├── ui_data.py # computed state both front-ends read
│ ├── i18n.py # every user-facing string, en/es
│ ├── memory_graph.html # graph window template (canvas 2D, no CDN)
│ ├── dream_extract.py # transcript parsing and redaction
│ ├── install_sto_cli.sh # installs `sto` on Linux/macOS: ~/.local/bin + PATH
│ ├── install_sto_cli.ps1 # the same on Windows: both PowerShell profiles
│ └── test_*.py # the suite: no framework, no fixtures
├── app/ # React + Vite dashboard (optional)
├── knowledge/ # what travels: sessions/, memory/, config/
├── vault/ # curated knowledge: raw/ → wiki/ → outputs/
├── start.sh # launcher: backend + app + `sto`, Ctrl+C stops both
└── start.cmd # the same on Windows, from a double click
pip install, no virtualenv, no server to keep alive for the CLI: cli.py imports the engine directly.git. Your repo, your remote, your history. Conflicts are git conflicts, and you already know how to resolve those.uv borrows it in a throwaway environment when it is not installed.--app window.react-force-graph for the 2D/3D views.git clone https://github.com/SimonOcampo1/braingent-sto.git
cd braingent-sto
sh scripts/install_sto_cli.sh # Linux / macOS
powershell -ExecutionPolicy Bypass -File scripts/install_sto_cli.ps1 # Windows
Open a new terminal and you have sto. Now make the clone yours.
Every command below runs inside the
braingent-sto/folder you just cloned. The new GitHub repo is never cloned — you only need its URL.
upstream: ``bash git remote rename origin upstream ``origin — the only remote that ever sees your knowledge: ``bash git remote add origin git@github.com:<you>/<your-repo>.git ``bash git push -u origin main ``sto push exports sessions, memories and the ~/.claude modules you chose, commits and pushes them: ``bash sto push ``Steps 4 and 5 are both in the same folder: the first publishes the engine, the second publishes what the engine collected.
| remote | what lives there | who writes to it |
|---|---|---|
origin | the engine plus your sessions, memories and config | only you, with sto push |
upstream | the engine, nothing else | whoever publishes the OS |
Here you do clone — and what you clone is your private repo, not the public one.
git clone git@github.com:<you>/<your-repo>.git
cd <your-repo>
sh scripts/install_sto_cli.sh # Linux / macOS
powershell -ExecutionPolicy Bypass -File scripts/install_sto_cli.ps1 # Windows
sto pull # brings and installs what the other machines have
sto push # uploads the memories this machine already had
That machine almost certainly has months of memories of its own already — sto push picks them up on the first run, no setup, no import step. And pulling first is safe: a pull never overwrites a local memory you have not published yet.
From then on sto push / sto pull — or p / l inside sto ui — keep every machine identical. The same instructions live in sto ui › Config, next to the two remotes they are about.
sto update merges upstream into your repo. If you skipped the rename above it adds the remote itself, pointing at where the OS is published.
sto update # what is available
sto update --apply # bring it down (or `u` inside `sto ui`)
Your memories cannot be overwritten by an update, and this is structural rather than a promise: the published repo has never held a single file under knowledge/memory/, so a merge has nothing to bring there.
If you made your repo by copying files instead of cloning or forking, the two histories share no commit and git refuses to merge — the home says so. sto update --link grafts them once, keeping every local file exactly as it is; from then on sto update is an ordinary merge.
Keep the repo private. It carries your session transcripts and memories.
Want the web app too? ./start.sh (start.cmd on Windows) installs the front-end dependencies, starts the backend and Vite, and opens the dashboard. On Linux and macOS both servers log to .sto-cache/ and Ctrl+C takes them down together.
| Command | What it does | |||
|---|---|---|---|---|
sto / sto status | where sync stands: branch, ahead/behind, dirty | |||
sto push / sto pull | export and upload / download and apply | |||
sto ui | the terminal UI | |||
sto sessions [project] | sessions, most recent first | |||
sto show <id> | one transcript, through the pager | |||
sto keep <id> | archive a transcript in full so another machine can resume it | |||
sto resume <id> | bring a session from the repo into this machine's checkout and print the claude --resume line | |||
sto search <text> | full-text search across every session, every machine | |||
| `sto memory [project\ | show\ | search\ | sync]` | the memories in the repo |
sto skills [id] | installed skills, or one in full | |||
sto config | which ~/.claude modules are syncing | |||
sto machines | the machines feeding the repo | |||
sto usage | plan limits and recent spend | |||
| `sto graph [--memory\ | --open\ | <note>]` | memory graph window (read any memory in full), repo graph, or a node's neighbours | |
sto update [--apply] | bring OS updates down from upstream — never touches your knowledge | |||
| `sto badge [--install\ | --off]` | the ◆ STO badge in Claude Code's status line | ||
| `sto mod [status\ | sync\ | sync-memories\ | pull]` | JSON for the sto-band mod below |
sto-band modA band above the prompt with this project's chats to push and pull, whether this machine has a path for it, and the 5 h, weekly and context windows. Two buttons on the band, and the same as commands: sync chats (/sto sync) pulls, keeps and brings the project's chats, and pushes; sync memories (/sto mem) pulls and pushes, which is what moves the memories. With auto on (/sto auto toggles it, remembered across sessions) a session pulls when it opens and syncs the memories every 30 minutes. /sto alone refreshes the band.
/plugin install sto-band --marketplace SimonOcampo1/braingent-sto
Or, to have sto update update the mod too, add your clone as the marketplace: claude plugin marketplace add <your clone>, then the same /plugin install naming it, and /reload-plugins after each update.
Honesty beats a feature matrix:
start.cmd opens from the file manager, start.sh expects a terminal.The parking lot lives in ROADMAP.md: an MCP server so the agent can query the repo mid-session, sync that reminds you (or runs itself), a cross-platform TUI, and a few smaller things — each with the smallest sketch that would do it.
cd scripts
python test_sessions_server.py && python test_dream_extract.py && python test_cli.py && python test_ui_data.py
uv run --no-project --with textual python test_tui_app.py
No framework, no fixtures, no mocks beyond swapping a function for a lambda. The TUI suite skips itself when textual is missing, which is how the first line stays runnable on a machine that never opted into it.
hooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { StoStatus, Usage } from '../types'
5
6// The band asks `sto` for everything sync-related, so the rules stay in one
7// place (scripts/cli.py) and the strings in scripts/i18n.py. What the engine
8// already knows (limits, context) never leaves the engine.
9const status = atom({ plugin: 'sto-band', key: 'status' } as const, null)
10const error = atom({ plugin: 'sto-band', key: 'error' } as const, null)
11const usage = atom({ plugin: 'sto-band', key: 'usage' } as const, {})
12const isBusy = atom({ plugin: 'sto-band', key: 'isBusy' } as const, false)
13const isAuto = atom({ plugin: 'sto-band', key: 'isAuto' } as const, true)
14
15const REFRESH_MS = 60_000
16const AUTO_MS = 30 * 60_000
17const LONG = new Set(['sync', 'sync-memories', 'pull'])
18const ANSI: Record<string, string> = {
19 '31': 'red', '32': 'green', '33': 'yellow',
20 '34': 'blue', '35': 'magenta', '36': 'cyan',
21}
22
23export function toUsage(limits: readonly SessionRateLimit[], context: SessionContextUsage): Usage {
24 const pct = (kind: string) => limits.find(l => l.kind === kind)?.percentUsed
25 return { fiveHour: pct('five_hour'), week: pct('seven_day'), ctx: context.percent }
26}
27
28/** `sto mod <sub>` parsed, or the reason in one line. */
29async function sto($: EngineInterface, sub: string): Promise<{ data?: StoStatus & { message?: string }; error?: string }> {
30 try {
31 const cwd = await $.session.cwd()
32 const r = await $.process.run(['sto', 'mod', sub, '--cwd', cwd],
33 { timeoutMs: LONG.has(sub) ? 600_000 : 30_000 })
34 const data = JSON.parse(r.stdout)
35 return data.error ? { error: data.error } : { data }
36 } catch (err) {
37 return { error: 'sto: ' + String((err as Error)?.message ?? err).slice(0, 80) }
38 }
39}
40
41let lastAt = 0
42
43/** Ask `sto` again, at most once a minute unless somebody pressed for it. */
44async function refresh($: EngineInterface, force = false) {
45 const now = await $.clock.now()
46 if (!force && now - lastAt < REFRESH_MS) return
47 lastAt = now
48 const u = await $.session.usage()
49 await update($, usage, () => toUsage(u.rateLimits, u.context))
50 const r = await sto($, 'status')
51 await update($, error, () => r.error ?? null)
52 if (r.data) await update($, status, () => r.data!)
53}
54
55/** One sync at a time, whoever asked: a button, a command or the timer.
56 * `quiet` toasts only a failure, for the runs nobody pressed for. */
57async function runSync($: EngineInterface, sub: string, quiet = false): Promise<string> {
58 if (await read($, isBusy)) return 'STO: sync already running'
59 await update($, isBusy, () => true)
60 try {
61 const r = await sto($, sub)
62 const text = r.error ?? r.data?.message ?? 'STO: ok'
63 if (r.error || !quiet) $.ui.toast(text)
64 return text
65 } finally {
66 await update($, isBusy, () => false)
67 await refresh($, true)
68 }
69}
70
71/** Auto on or off, remembered across sessions in the store. */
72async function setAuto($: EngineInterface, isOn: boolean) {
73 await $.store.set('auto', isOn)
74 await update($, isAuto, () => isOn)
75}
76
77export const register: Register = on => {
78 on('session.start', async ($, e, next) => {
79 await $.command.register({
80 name: 'sto',
81 description: '/sto sync: chats round trip. /sto mem: memories (pull, push). /sto auto: toggle. Bare: refresh',
82 })
83 const auto = (await $.store.get('auto')) !== false
84 await update($, isAuto, () => auto)
85 // pull before anything else: the memories another machine wrote land
86 // before the first prompt reads them
87 void (auto ? runSync($, 'pull', true) : refresh($, true))
88 $.clock.every(AUTO_MS, () => {
89 void (async () => {
90 if (await read($, isAuto)) await runSync($, 'sync-memories', true)
91 })()
92 })
93 return next(e)
94 })
95
96 on('session.measure', async ($, e, next) => {
97 await update($, usage, () => toUsage(e.rateLimits, e.context))
98 return next(e)
99 })
100
101 on('turn.complete', async ($, e, next) => {
102 void refresh($)
103 return next(e)
104 })
105
106 on('command.run', { command: 'sto' }, async ($, e) => {
107 const arg = (e.args ?? '').trim()
108 if (arg === 'sync') return { text: await runSync($, 'sync') }
109 if (arg === 'mem') return { text: await runSync($, 'sync-memories') }
110 if (arg === 'auto') {
111 const turnOn = !(await read($, isAuto))
112 await setAuto($, turnOn)
113 return { text: 'STO auto: ' + (turnOn ? 'on' : 'off') }
114 }
115 await refresh($, true)
116 return { text: (await read($, error)) ?? 'STO: ok' }
117 })
118
119 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120 if (e.props.hasSurvey) return next(e)
121 const s = await read($, status)
122 const err = await read($, error)
123 const u = await read($, usage)
124 const busy = await read($, isBusy)
125 const auto = await read($, isAuto)
126 const { Box, Button, Text } = $.ui.resolve(e)
127 const L = (k: string, en: string) => s?.labels?.[k] ?? en
128 const accent = ANSI[s?.accent ?? '36'] ?? 'cyan'
129 const pct = (label: string, v?: number) =>
130 v === undefined ? null : <Text dimColor>{label} {Math.round(v)}% </Text>
131
132 return (
133 <Box>
134 <Text color={accent} bold>STO </Text>
135 {busy && <Text color={accent}>{L('mod_syncing', 'syncing...')} </Text>}
136 {!busy && s && (
137 <Text>
138 {L('mod_chats', 'chats')}{' '}
139 {s.up || s.down
140 ? <Text color={accent}>{'↑'}{s.up} {'↓'}{s.down}</Text>
141 : <Text dimColor>{L('mod_uptodate', 'up to date')}</Text>}
142 {!s.pathSaved && <Text color="warning"> {L('mod_no_path', 'no path')}</Text>}
143 {(s.ahead > 0 || s.behind > 0) &&
144 <Text dimColor> {L('mod_repo', 'repo')} {'↑'}{s.ahead} {'↓'}{s.behind}</Text>}
145 {' '}
146 </Text>
147 )}
148 {!busy && err && <Text color="warning">{err.slice(0, 60)} </Text>}
149 {pct(L('mod_5h', '5h'), u.fiveHour)}
150 {pct(L('mod_week', 'wk'), u.week)}
151 {pct(L('mod_ctx', 'ctx'), u.ctx)}
152 {!busy && <Button key="chats" label={L('mod_btn_chats', 'sync chats')}
153 onPress={() => void runSync($, 'sync')} />}
154 {!busy && <Button key="memories" label={L('mod_btn_memories', 'sync memories')}
155 onPress={() => void runSync($, 'sync-memories')} />}
156 <Button key="auto" dimColor={!auto}
157 label={auto ? L('mod_auto_on', 'auto: on') : L('mod_auto_off', 'auto: off')}
158 onPress={() => void setAuto($, !auto)} />
159 </Box>
160 )
161 })
162}
163types/index.d.ts 27 lines1/** What `sto mod status` prints, or the reason it could not. */
2export type StoStatus = {
3 project: string
4 up: number
5 down: number
6 pathSaved: boolean
7 ahead: number
8 behind: number
9 fetchError: string | null
10 accent: string
11 labels: Record<string, string>
12}
13
14export type Usage = { fiveHour?: number; week?: number; ctx?: number }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'sto-band': {
19 status: StoStatus | null
20 error: string | null
21 usage: Usage
22 isBusy: boolean
23 isAuto: boolean
24 }
25 }
26}
27