SLOPSHOPPER

sto-band

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

newbandcommandtoastprocesstimer
v0.2.0MITupdated 2026-10-23SimonOcampo1/braingent-sto/mod/sto-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sto-band
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────╮ │ sto-band │ ⏺ Read(src/auth.ts) │ sto: JSON Parse error: Unexpected EOF │ ⎿ Read 6 lines ╰───────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sto ⎿ sto-band: sto: JSON Parse error: Unexpected EOF STO sto: JSON Parse error: Unexpected EOF 5h 31% ctx 49% [ sync chats ][ sync memories ][ auto: on ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
STO sto: JSON Parse error: Unexpected EOF 5h 31% ctx 49% [ sync chats ][ sync memories ][ auto:
README

<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> &nbsp;•&nbsp; <a href="#features">Features</a> &nbsp;•&nbsp; <a href="#stack">Stack</a> &nbsp;•&nbsp; <a href="#structure">Structure</a> &nbsp;•&nbsp; <a href="#setup">Setup</a> &nbsp;•&nbsp; <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.


Why it exists

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:

NeedExisting toolsWhat they leave out
Persistent memoryclaude-mem, engram, agentmemoryConfig, skills and plugins stay on one machine; memory lives in a SQLite/vector store you can't read or diff
Config syncclaude-sync, claude-code-dotfilesNo sessions, no usage, no UI — sync and nothing else
Session browsingclaude-code-log, claude-code-trace, claude-historyLocal-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.


Features

  • Built on the native memory, not beside it. No second memory store, no protocol to paste into CLAUDE.md, nothing new to teach the model: Claude Code writes its auto memory as markdown, and STO mirrors those exact files between machines.
  • One repo, everything in it. 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.
  • Modular sync. Pick exactly which parts of ~/.claude travel: claude-md, settings, keybindings, skills, agents, hooks, plugins. Memories, sessions and vault always travel.
  • Redaction on export. Session transcripts are trimmed to what a reader needs (prompts, tool names, errors) and scrubbed of API-key-shaped strings before they ever hit a commit.
  • Resumable conversations. Every session another machine pushed can be brought here with 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.
  • Terminal UI (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.
  • Live push/pull progress. Every step of a sync reports itself (exporting sessions → staging → committing → pushing to origin) with a spinner, instead of freezing the screen until git returns.
  • Memory graph. A real graph of your memories: one node per memory, one per project, edges from [[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.
  • Config parity at a glance. The Home dashboard shows what is local-only, what is in the repo but not installed, and which plugins are missing on this machine.
  • Plan usage. Live limit bars and the last days of token spend, straight from ccusage when it is installed.
  • Bilingual chrome. English by default, Spanish one keystroke away — commands never change, only what they print.
  • Optional web app. A React dashboard over the same backend, for when a terminal is not enough.

Structure

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

Stack

  • Backend — Python 3.11+, standard library only. No pip install, no virtualenv, no server to keep alive for the CLI: cli.py imports the engine directly.
  • Sync — plain git. Your repo, your remote, your history. Conflicts are git conflicts, and you already know how to resolve those.
  • TUI — Textual. Same screens on Windows and Linux; uv borrows it in a throwaway environment when it is not installed.
  • Graph — one HTML file, canvas 2D, hand-rolled force layout. Opens offline in a Chromium --app window.
  • App — React 19, Vite 8, Tailwind 4, react-force-graph for the 2D/3D views.

Setup

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.

  1. Go to github.com/new and create an empty private repo, no README. Copy its URL.
  2. The clone came from the public repo, so that one becomes upstream: ``bash git remote rename origin upstream ``
  3. Your new repo becomes origin — the only remote that ever sees your knowledge: ``bash git remote add origin git@github.com:<you>/<your-repo>.git ``
  4. Push the code to your repo, which also sets the branch to track it: ``bash git push -u origin main ``
  5. Push your knowledge — 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.

remotewhat lives therewho writes to it
originthe engine plus your sessions, memories and configonly you, with sto push
upstreamthe engine, nothing elsewhoever publishes the OS

On every other machine

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.

Getting updates to the OS itself

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.


Commands

CommandWhat it does
sto / sto statuswhere sync stands: branch, ahead/behind, dirty
sto push / sto pullexport and upload / download and apply
sto uithe 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 configwhich ~/.claude modules are syncing
sto machinesthe machines feeding the repo
sto usageplan 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

Inside Claude Code: the sto-band mod

A 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.


What STO does not do

Honesty beats a feature matrix:

  • No embeddings, no semantic recall. Search is lexical. If you want vector recall inside the agent loop, run claude-mem or engram alongside it — they solve a different problem.
  • No MCP server. The agent does not query STO at runtime; STO moves the files Claude Code already reads.
  • No memory system of its own. Claude Code's auto memory writes the files; STO makes them travel. Nothing is distilled by a background job.
  • Windows-first by history, not by design. Engine, CLI, TUI, installer and launcher all run on Windows, Linux and macOS. What Windows still has alone is the double click: start.cmd opens from the file manager, start.sh expects a terminal.
  • Single user. It syncs your machines. It is not a team knowledge base.

What is next

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.

Tests

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.

Source 2 files
hooks/register.tsx 163 lines
1import { 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}
163
types/index.d.ts 27 lines
1/** 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