openloops
What your Claude Code sessions were doing — kept after the transcripts are gone.
Claude Code writes a JSONL transcript for every session and deletes it after about a month. openloops reads those transcripts, writes one short dated markdown digest per session, and keeps the digests. Nothing here calls a model, reaches the network, or needs an account: it reads files you already have and writes files you already own.
>>> import openloops
>>> report = openloops.sync()
>>> for row in openloops.ls(state='open'):
... print(row['session'], row['ai_title'] or row['title'])
The whole design rests on one sentence, and everything else follows from it:
> A digest says what a session said, dated. It never says what is true now.
So a digest is filed under open/ or archive/ by what the session’s own last
turn read like — not by whether a process is running, which would make this a session
dashboard rather than a record of open loops. Nothing is asserted to be still
outstanding, because openloops has checked nothing against the world.
The two modules whose primary export shares their name — the sync engine and the
classifier — are private (openloops._sync, openloops._classify), because a
module and a function cannot both answer to openloops.sync. Everything they export
is re-exported here, including the classifier’s cue tables.
Two seams, both one keyword argument, both defaulting to something that works out of
the box: transcript_source= (a reader of Claude Code’s on-disk layout) and
digests_store= (a directory of markdown files under ~/.local/share/openloops/).
Swap either for a test fixture, a git-synced directory, or blob storage without
touching anything else.
Not in this release, and deliberately: anything that tracks what you owe or are owed. That is the project’s headline claim and it is withheld pending a measurement — see the README.
- class openloops.ClaudeCodeTranscripts(root: str | Path | None = None, *, since_days: float | None = None, projects: Iterable[str] | str | None = None, skip_scratchpads: bool = True)[source]
Claude Code’s persisted sessions, as a
Mapping[str, Session].since_daysbounds the scan by file modification time (Nonescans everything);projectskeeps only project directories whose name contains one of the given substrings;skip_scratchpadsdrops the throwaway directories the CLI creates under a temp root, which hold agent scratch sessions rather than work.ADR-010’s revision shape lives here:
revision()returns an opaque token — a file’s modification time for one session, a hash over all of them for the collection — andchanged_since()compares against it. mtime is a coarse signal that both misses content-preserving rewrites and fires on touches; that trade is accepted rather than reprocessing several thousand transcripts a tick.>>> src = ClaudeCodeTranscripts(root='/nonexistent-dir-for-doctest') >>> list(src), src.changed_since('0') ([], False)
- changed_since(token: Any, key: str | None = None) bool[source]
Whether the current
revision()differs from token.
- revision(key: str | None = None) str[source]
An opaque change token: for one session, or for the whole collection.
For a single key it is the transcript’s modification time in nanoseconds. For the collection it is a hash over every
(key, mtime)pair, so a token can be compared without holding the whole index.
- scratchpad_marker = '-private-tmp'
Project-directory names starting with this are scratchpads, not work.
- exception openloops.CredentialFound(pattern_name: str, offset: int, *, where: str = '')[source]
Raised when text about to be written matches a credential pattern.
The message names the pattern class and the offset. It never contains the matched text: an exception that quotes a secret has moved the secret into a log file.
- class openloops.Digest(key: str, text: str, session_key: str, source: str, state: str, verdict: Verdict = <factory>)[source]
One rendered digest: its store key and its markdown text.
textis a pure function of theSessionit was built from. It carries no generation timestamp, which is what lets the regeneration test intests/test_sync.pycompare bytes rather than fields.
- class openloops.Locator(type: str, url: str = '', text: str = '', at: str = '')[source]
A typed, human-readable pointer to something outside the digest.
The exportable half of ADR-015’s evidence split: a short typed reference that another tool (or a person) can resolve, never a content hash and never a byte offset into a file that is garbage-collected after thirty days.
>>> Locator("pr", url="https://github.com/o/r/pull/1").as_dict()["type"] 'pr'
- class openloops.Session(key: str, title: str = '', ai_title: str = '', cwd: str = '', project: str = '', git_branches: tuple[str, ...] = (), started_at: str = '', ended_at: str = '', last_turn_at: str = '', last_user_prompt: str = '', last_prompt_at: str = '', last_assistant_text: str = '', recap: str = '', recap_at: str = '', compaction: str = '', compaction_at: str = '', turn_count: int = 0, model: str = '', ended_mid_turn: bool = False, ended_with_error: bool = False, locators: tuple[Locator, ...] = ())[source]
What one Claude Code session’s persisted state says, parsed but not judged.
Every field is a fact read from the transcript, never an inference about the world.
last_assistant_textis what the session said last; it is not a claim that the thing it describes is still true.keyis the session id. It is the only identifier openloops uses, and per ADR-014 nothing in the model hangs off it: a digest is a view keyed by session, not a record owned by one.- compaction: str = ''
The context-compaction summary, a different and much longer thing than the recap, covering only the part of the session that preceded it.
- ended_mid_turn: bool = False
The transcript’s final conversational record is an unanswered human prompt, or an assistant tool call whose result never arrived — i.e. the session stopped mid-turn.
- ended_with_error: bool = False
The last thing the session said is a usage-limit or API-error banner rather than the assistant’s own words: it was cut off, not finished.
- property git_branch: str
The branch the session opened on — the one that names it, when several ran.
A third of sessions touch more than one branch, so a single value is a summary rather than a fact;
git_branchesis what carries the truth.
- property heading: str
its description, else its label.
- Type:
What to call this session in prose
- recap: str = ''
Claude Code’s own end-of-turn recap (its
away_summaryrecord) — one to three sentences it wrote itself, usually naming what it thought came next. Reading it is retention, not duplication: it was generated and billed once already, and it disappears with the transcript.
- class openloops.Verdict(state: str, reason: str, cues: tuple[str, ...] = (), at: str = '', confidence: str = 'high')[source]
A loop-state judgement, with the rule that produced it and the cues it saw.
reasonandcuesexist so a reader can disagree. A classification whose grounds are not shown is an assertion, and openloops does not make assertions about sessions — it reports what they said and why it read them that way.- at: str = ''
When the text this verdict was read from was written. Usually the closing turn, but the recap rule reads text written later, and a heading dated to the wrong moment is exactly the thing ADR-005 forbids.
- confidence: str = 'high'
"high"when a rule actually fired,"low"when the state is the default and nothing in the transcript supported it. A classifier that hides which of the two it did is claiming knowledge it does not have.
- openloops.asks_the_human(text: str, *, ask_cues: Sequence[str] = ('want me to', 'do you want', 'would you like', 'shall i', 'should i', 'let me know', 'say the word', 'your call', 'up to you', 'over to you', 'which would you', 'tell me which', "if you'd prefer", 'if you would prefer', 'needs your input', 'needs your review', 'needs your decision', 'needs your go-ahead', 'needs your call'), chars: int = 1200) tuple[str, ...][source]
The ask cues in the closing lines, if those lines put a question to the reader.
The primitive a retrospective measurement uses to ask “did this session end with a question directed at the human” — defined once, here, rather than twice.
>>> asks_the_human('I fixed it. Want me to open the PR?') ('want me to',) >>> asks_the_human('Was it broken? Yes, and I fixed it.') () >>> asks_the_human('All done.') ()
- openloops.classify(session: Session, *, ask_cues: Sequence[str] = ('want me to', 'do you want', 'would you like', 'shall i', 'should i', 'let me know', 'say the word', 'your call', 'up to you', 'over to you', 'which would you', 'tell me which', "if you'd prefer", 'if you would prefer', 'needs your input', 'needs your review', 'needs your decision', 'needs your go-ahead', 'needs your call'), defer_cues: Sequence[str] = ('still open', 'still owed', 'still outstanding', 'still pending', 'next step', 'next session', 'left off', 'waiting for you', 'waiting on', 'blocked on', 'blocked by', 'handoff', 'hand-off', 'pick it up', 'pick this up', 'pick that up', 'i left it', 'not yet done', 'not yet merged', 'not yet posted', 'open question', 'unresolved', 'i could not', "i couldn't", 'was unable to', 'were unable to', 'did not finish', "didn't finish", 'needs a human', 'needs you', 'still needs', "you'll need to", 'you will need to', 'requires you to'), close_cues: Sequence[str] = ('nothing half-finished', 'nothing pending', 'nothing is pending', 'nothing left', 'nothing is blocking', 'nothing blocking', 'nothing to clean', 'nothing else outstanding', 'nothing outstanding', 'nothing further', 'no next action', 'no further action', 'no action needed', 'safe to close', 'safe to exit', "you're all set", 'you are all set', 'all done', 'everything is done', 'everything is green', 'everything is verified', 'not unfinished business', 'ready to close'), chars: int = 1200) Verdict[source]
Read a session’s loop state from its own last turn.
>>> from openloops.base import Session >>> classify(Session(key='s', ended_with_error=True)).state 'open' >>> v = classify(Session(key='s', last_assistant_text='Blocked on your deploy key.')) >>> v.state, v.cues, v.confidence ('open', ('blocked on',), 'high')
- openloops.data_dir() Path[source]
The project’s data root. Override with
OPENLOOPS_DATA_DIR.Per-kind subdirectories hang off this; nothing is written directly into it, so a later kind of data needs no second migration.
- openloops.default_source(directory: str | Path | None = None) str[source]
This machine’s label for its digest folder. Override with
OPENLOOPS_SOURCE.A short, filename-safe name. It exists so several machines can sync digests into one place without colliding — not to identify anybody, though on a machine whose hostname was never changed it may well do; every
ol syncprints it, andOPENLOOPS_SOURCEoverrides it.It is sticky. The hostname is a seed, written once to a file under the state directory and read thereafter. macOS rewrites the hostname when it joins a network where the name collides — appending
-2,-3— and a label that moved would fork the store into two complete copies with no dedup and no warning.
- openloops.digest_key(source: str, state: str, session_key: str) str[source]
The store key for one session’s digest.
Both segments are validated, not just the state.
transcript_source=is a seam whose documented purpose is “another machine’s synced transcripts”, so a session id can come from a listing this process did not produce — and a key containing..would make a file-backed store write outside its own root whilesyncreported success. Validating here means every backend inherits the check.>>> digest_key('mac', 'archive', 's1') 'mac/archive/s1.md' >>> digest_key('mac', 'open', '../../etc/passwd') Traceback (most recent call last): ... ValueError: session key must be a single safe path segment, got '../../etc/passwd'
- openloops.digests_store(rootdir: str | Path | None = None) MutableMapping[str, str][source]
The default
digests_store: markdown files under the data root.Any
MutableMapping[str, str]works in its place — a plaindictfor tests, a git-synced directory, an S3-backed store. The keys are what carry the layout, so a different backend gets the same{source}/{state}/{session}.mdstructure for free.>>> store = digests_store(rootdir='/tmp/openloops-doctest-store') >>> store['demo/open/s1.md'] = '# hi' >>> sorted(store) ['demo/open/s1.md'] >>> del store['demo/open/s1.md']
- openloops.ls(*, state: str = 'open', source: str | None = None, project: str | None = None, confidence: str | None = None, limit: int = 20, digests_store: MutableMapping[str, str] | None = None) list[dict[str, Any]][source]
The digests in the store, newest last-turn first.
stateisopen,archiveorall.source,projectandconfidencenarrow by the digest’s own header fields. Each row is that front matter plus its key.confidence='high'is the one worth knowing about: most sessions land inopen, and a good few of those are open only because nothing said otherwise. Filtering tohighleaves the ones where the session itself said something.>>> rows = ls(digests_store={'m/open/s1.md': ... '---\nsession: s1\nstate: open\nlast_turn: T9\n---\n'}) >>> rows[0]['session'], rows[0]['key'] ('s1', 'm/open/s1.md')
- openloops.make_digest(session: Session, verdict: Verdict, *, source: str, aliases=None) Digest[source]
Render, scrub, and key one digest.
Raises
CredentialFoundwhen the rendered text matches a credential pattern — deliberately, so the caller skips that session loudly rather than writing a secret into a store that may be synced.>>> from openloops.base import Session, Verdict >>> d = make_digest(Session(key='s1'), Verdict('open', 'why'), source='demo') >>> d.key 'demo/open/s1.md'
- openloops.parse_session(records: list[dict], *, key: str = '') Session[source]
Read one transcript’s records into a
Session.Pure: same records in, same session out. Everything it reports is a fact about the document — no inference, no judgement, and nothing about running processes.
Three details are corpus-driven rather than obvious, and getting them wrong mislabels a large fraction of real sessions:
Start and end come from ``min``/``max`` over every timestamp, not from the first and last line. Transcript lines are not written in timestamp order; on real data the first line holds the earliest timestamp only about four times in five.
The project comes from the *first* record’s ``cwd``. Four sessions in five visit more than one working directory (scratchpads, sibling repos, task dirs), and the last or most-frequent one names a different repository about half the time.
Branches are a tuple. A third of sessions touch more than one.
>>> recs = [ ... {"type": "user", "sessionId": "s1", "cwd": "/w/proj", "timestamp": "T1", ... "gitBranch": "main", ... "message": {"role": "user", "content": [{"type": "text", "text": "do it"}]}}, ... {"type": "assistant", "sessionId": "s1", "timestamp": "T2", ... "message": {"role": "assistant", "model": "m", ... "content": [{"type": "text", "text": "done"}]}}, ... ] >>> s = parse_session(recs) >>> s.key, s.project, s.turn_count, s.last_assistant_text ('s1', 'proj', 1, 'done') >>> s.started_at, s.ended_at, s.git_branch, s.ended_mid_turn ('T1', 'T2', 'main', False)
- openloops.render(session: Session, verdict: Verdict, *, source: str) str[source]
The markdown for one digest. Pure, dated, and bounded.
>>> from openloops.base import Session, Verdict >>> s = Session(key='s1', project='proj', started_at='T0', last_turn_at='T1', ... last_assistant_text='Shipped it.', turn_count=3) >>> verdict = Verdict('archive', 'its closing lines declare it finished') >>> text = render(s, verdict, source='demo') >>> text.splitlines()[0] '---' >>> 'state: archive' in text True >>> render(s, verdict, source='demo') == text # pure: same in, same out True
- openloops.retained(store: Mapping[str, str], sessions: Mapping[str, Session], *, source: str | None = None) list[str][source]
Digest keys whose session no longer has a transcript — the retention surplus.
These are what makes openloops a retention device rather than a view: Claude Code garbage-collects transcripts, and a digest outlives the thing it was derived from.
sync()never removes them, and they are the one part of the store that a from-scratch rebuild does not reproduce.>>> retained({'m/open/a.md': '', 'm/open/b.md': ''}, {'a': None}) ['m/open/b.md']
- openloops.scrub(text: str, *, aliases: Mapping[str, str] | None = None, where: str = '') str[source]
Rewrite paths and raise on credentials. The only way text leaves openloops.
whereis a caller-supplied label (a session id, a file name) carried into the exception so a failed run can say which input tripped, without quoting it.The path check runs on the output, not the input, because
aliasesis caller-supplied: a rewrite can in principle produce a home path as easily as remove one, and a postcondition that is only true of benign inputs is not a postcondition.>>> scrub("built /nowhere/proj/x", aliases={"/nowhere/proj": "$PROJ"}) 'built $PROJ/x'
- openloops.show(session: str, *, source: str | None = None, digests_store: MutableMapping[str, str] | None = None) dict[str, Any][source]
One digest in full, found by session id or by a unique prefix of one.
An exact id always wins over a prefix, so a session whose id happens to be a prefix of another stays reachable.
sourcenarrows a store that several machines write into, where the same session can legitimately appear more than once.>>> store = {'m/open/abcdef.md': '---\nsession: abcdef\n---\nbody'} >>> show('abc', digests_store=store)['key'] 'm/open/abcdef.md'
- openloops.state_dir() Path[source]
Where per-machine caches and job logs go. Override with
OPENLOOPS_STATE_DIR.Deliberately not under
data_dir(): everything here is disposable, and keeping it elsewhere means “clear the cache” can never be mistyped into “delete the digests”.
- openloops.status(*, source: str | None = None, since_days: float | None = None, digests_store: MutableMapping[str, str] | None = None, transcript_source: Mapping[str, Session] | None = None) dict[str, Any][source]
Where everything is, how much of it there is, and how stale the cache is.
Reports the cache’s age because a read served from a cache that nothing has refreshed is the failure openloops is built to avoid — a periodic job that died leaves a confident, months-old answer behind, and the only defence is saying how old the answer is.
- openloops.sync(*, transcript_source: Mapping[str, Session] | None = None, digests_store: MutableMapping[str, str] | None = None, source: str | None = None, since_days: float | None = None, state_dir: str | None = None, force: bool = False) dict[str, Any][source]
Bring the digest store up to date with the sessions, and say what changed.
forceignores the cache and re-derives everything; the result must be identical, which is what makes it safe to suggest when someone suspects a stale digest.>>> from openloops.base import STATES, Session >>> sessions = {'s1': Session(key='s1', ... last_assistant_text='Shipped it. Nothing is pending.')} >>> store = {} >>> r = sync(transcript_source=sessions, digests_store=store, source='demo', ... state_dir='/tmp/openloops-doctest-state', force=True) >>> r['written'], sorted(store) (1, ['demo/archive/s1.md']) >>> sync(transcript_source=sessions, digests_store=store, source='demo', ... state_dir='/tmp/openloops-doctest-state', force=True)['written'] 0