DigitableCourses
Portal settings
Show me around

Local and account-free. No account, no sign-up: whatever you type into the tools stays in this browser's localStorage and never reaches a server.

Our own counter records page opens and finishes: the address leaves, plus one tenth-of-the-text figure. No cookies, no outside trackers, your IP is not stored, Do Not Track is honoured. How to check that

The portal's own repository is not published, so we do not call it open source. What is open:

The portal lives on donations, paid consultations and requested write-ups, and on Workbench sales.

There are no plans to make the courses paid.

Ouroboros

A record of every call a running program makes: which functions ran, what they were called with, what they returned and what they threw.

A real run: fifteen lines that crash

$ ouroboros wrap-snippet -l python < discount.py

A frame of the output of ouroboros wrap-snippet -l python < discount.py: 24 lines of monospaced text on a dark background. The same output as text sits next to it, under "the whole output as text".
The whole subject program and what was written into it: the runtime import and @_ouro_log above each of the three functions. They compute nothing and only record. wrap-file does the same in place.
The whole output as text
from ouroboros_runtime import log as _ouro_log
@_ouro_log
def discount_rate(total, member):
    if member:
        return 0.15
    if total >= 10000:
        return 0.10
    return 0.0


@_ouro_log
def apply_discount(total, member=False):
    rate = discount_rate(total, member)
    return round(total * (1 - rate), 2)


@_ouro_log
def main():
    for total, member in [(9999, False), (10000, False), (500, True)]:
        print(total, member, apply_discount(total, member=member))
    apply_discount("free")


main()

$ OUROBOROS_DEBUG_INFO=./debug.info python3 discount.py

A frame of the output of OUROBOROS_DEBUG_INFO=./debug.info python3 discount.py: 22 lines of monospaced text on a dark background. The same output as text sits next to it, under "the whole output as text".
The program runs as usual and prints its own output. On the fourth input it crashes, and the ordinary traceback says where, but not what it was called with.
The whole output as text
9999 False 9999.0
10000 False 9000.0
500 True 425.0
Traceback (most recent call last):
  File "/home/user/shop/discount.py", line 24, in <module>
    main()
    ~~~~^^
  File "/home/user/shop/ouroboros_runtime.py", line 234, in wrapper
    result = fn(*args, **kwargs)
  File "/home/user/shop/discount.py", line 21, in main
    apply_discount("free")
    ~~~~~~~~~~~~~~^^^^^^^^
  File "/home/user/shop/ouroboros_runtime.py", line 234, in wrapper
    result = fn(*args, **kwargs)
  File "/home/user/shop/discount.py", line 13, in apply_discount
    rate = discount_rate(total, member)
  File "/home/user/shop/ouroboros_runtime.py", line 234, in wrapper
    result = fn(*args, **kwargs)
  File "/home/user/shop/discount.py", line 6, in discount_rate
    if total >= 10000:
       ^^^^^^^^^^^^^^
TypeError: '>=' not supported between instances of 'str' and 'int'

$ grep fb17367a debug.info

A frame of the output of grep fb17367a debug.info: 2 lines of monospaced text on a dark background. The same output as text sits next to it, under "the whole output as text".
Two records of one call, tied by a shared id: on entry the name and the arguments, on exit what came back or was thrown and how long it took. This is what debug.info looks like.
The whole output as text
{"p":"in","t":"2026-09-10T19:51:47.402","id":"fb17367a-f596-4198-9565-add097cefa0e","ci":-1,"th":"2.136045302235648","fn":"discount_rate","a":"'free', False","k":""}
{"p":"out","id":"fb17367a-f596-4198-9565-add097cefa0e","fn":"discount_rate","x":"TypeError: '>=' not supported between instances of 'str' and 'int'","d":3e-06}

$ ouroboros trace ./debug.info --outcome raised --function discount_rate

A frame of the output of ouroboros trace ./debug.info --outcome raised --function discount_rate: 26 lines of monospaced text on a dark background. The same output as text sits next to it, under "the whole output as text".
The same call, found by outcome and name. Here you see what the traceback does not have: it was called with the string "free" instead of a number.
The whole output as text
{
  "ok": true,
  "path": "debug.info",
  "calls_parsed": 9,
  "malformed": 0,
  "matched": 1,
  "returned": 1,
  "next_cursor": null,
  "in_flight": [],
  "in_flight_truncated": false,
  "records": [
    {
      "index": 6,
      "started": "2026-09-10T19:51:47.402",
      "call_id": "fb17367a-f596-4198-9565-add097cefa0e",
      "name": "discount_rate",
      "args": "'free', False",
      "kwargs": "",
      "outcome_kind": "raised",
      "outcome": "TypeError: '>=' not supported between instances of 'str' and 'int'",
      "duration": 3e-06,
      "cpu": null,
      "thread": "2.136045302235648"
    }
  ]
}

$ ouroboros trace-stats ./debug.info

A frame of the output of ouroboros trace-stats ./debug.info: 75 lines of monospaced text on a dark background. The same output as text sits next to it, under "the whole output as text".
The whole run in counters: how many times each function was called, how many returned and how many threw, and the real duration of every call.
The whole output as text
{
  "ok": true,
  "path": "debug.info",
  "calls_parsed": 9,
  "malformed": 0,
  "total_calls": 9,
  "in_flight": [],
  "by_function": [
    {
      "name": "apply_discount",
      "count": 4,
      "result": 3,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 7e-05,
        "max": 0.000103,
        "mean": 8.7e-05,
        "total": 0.000347,
        "count": 4
      }
    },
    {
      "name": "discount_rate",
      "count": 4,
      "result": 3,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 1e-06,
        "max": 3e-06,
        "mean": 2e-06,
        "total": 7e-06,
        "count": 4
      }
    },
    {
      "name": "main",
      "count": 1,
      "result": 0,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 0.000693,
        "max": 0.000693,
        "mean": 0.000693,
        "total": 0.000693,
        "count": 1
      }
    }
  ],
  "by_thread": [
    {
      "thread": "2.136045302235648",
      "count": 9,
      "functions": 3,
      "cpus": []
    }
  ],
  "duration_seconds": {
    "min": 1e-06,
    "max": 0.000693,
    "mean": 0.000116,
    "total": 0.001047,
    "count": 9
  },
  "timespan": {
    "first": "2026-09-10T19:51:47.402",
    "last": "2026-09-10T19:51:47.402",
    "seconds": 0.0,
    "timestamps_parsed": 9,
    "timestamps_unparsed": 0
  },
  "note": "counts/durations are over completed calls; `duration_seconds` are REAL per-call durations (exit−entry) from each call's `d`. `by_thread` groups calls by the `th` token (CPUs each thread ran on); empty for traces with no thread field. `in_flight` = entered (`p:in`) but never completed. `timespan` is first→last entry time."
}

This is the real output of release v0.6.1, captured by a run on 2026-09-10 — not text typed by hand. The characters come from the run; the font and the highlighting are added, and long lines are wrapped to the window. The whole output is below as text.

A record of calls, not a debugger

The tool writes lines into your source that compute nothing and only record what happened. The program then runs as usual, and every call leaves two records: one on entry — the function name and the arguments — and one on exit: the result or the exception, and the duration. Both carry the same call id and pile up in a debug.info file, one JSON object per line.

Eight languages: Python, JavaScript/TypeScript, C, C++, Elixir, Go, Java, C#. The record schema is the same for all of them; what differs is the dialect each language prints its arguments in.

There are two ways to read the records. For a person, the command line: filter calls by name, outcome or duration, and aggregate a whole run into counters. For an AI agent, the MCP server: the same operations handed over as tools.

It is not a profiler — the records change the timing of the run. Not a debugger — the program never stops. And not coverage: coverage says a line ran, a record says what it was called with and what came out.

Install it and call it

Python 3.12 or newer is required. Any of the three ways puts two commands on PATH — ouroboros and ouroboros-mcp.

brew install digitable-lol/tap/ouroboros
asdf plugin add ouroboros https://github.com/digitable-lol/ouroboros.git
uv tool install git+https://github.com/digitable-lol/ouroboros
wrap-file <file>

Instrument a whole file, in place. A small runtime helper appears next to it and does the writing; nothing else is added.

wrap-functions <file> <names>

Instrument only the named functions. That is what you do when the trace of the whole file runs hundreds of times longer than the source.

trace <debug.info>

Filter calls: by name, by outcome — returned, raised, never came back — by duration, by thread.

trace-stats <debug.info>

Aggregate a run into counters: how many calls each function had, how they ended, how long they took and which never returned.

There are 17 commands; these four are where people start. The same operations are handed to an AI agent by the MCP server — 17 tools, with the order of work stated in the server greeting. Release 0.6.1 is backed by 1257 checks and 100.00 % statement and branch coverage.

Where the tool stops

  • It records how the code behaved, not how it should: "10000 in, 9000.0 out" is an observation, not a rule. An off-by-one at a boundary lands in the trace as an ordinary record.
  • About what did not happen the trace is silent, and it does not warn you: a branch never entered leaves no line. Silence means "we were not here", not "all is well here".
  • Not a profiler: instrumentation changes the timing of the run. The added cost per call runs from 15.6 microseconds in C# to 190.7 in Elixir, and the writing itself costs more than the language does.
  • Instrumenting rewrites the source, and an instrumented file stays instrumented — it must not reach a shared branch. The tool cannot take the instrumentation back out: getting to clean code again is a job for version control.

A course in seven chapters: install, refusals, choosing what to record, working with an agent, eight languages and the limits

Source and releases