Skip to main content

peer_report

The morning report: what the peers did while the user was away.

In work together mode, sessions work while the user is away. This module reads the log of messages and tells the user what happened.

The table, the glossary and the action items that cannot wait come from code. The simple examples come from one summary model call. If that call fails, the report is still saved, with one line that says the examples are missing.

The module loads no model code until it needs it, so ramabana report stays light.

Threads

threads joins the log rows of one conversation. A thread starts with an ask. Its state tells the user how it ended:

  • replied: a reply with proof.
  • not proven: a reply with no proof.
  • refused: a reply that starts with refused:.
  • waiting: no reply yet.
  • stopped: a limit ended the thread.

source

threads

def threads(
    rows, known:NoneType=None
):

One dict per thread in log rows: id, asker, worker, ask, state, why, folders, changes, proof; known maps keys to sessions that rows do not show.

A small log with an ask that got a reply with proof, one that got none, and an ask with no reply. The last thread was stopped by the hop limit.

test_eq([t['state'] for t in threads(LOG)], ['replied', 'not proven', 'waiting', 'stopped'])

The state not proven is the one to watch: the worker says it is done and shows no proof.

test_eq(threads(LOG)[1]['proof'], [])
test_eq(threads(LOG)[0]['worker'], 'Ramabana')

Only a reply that starts with refused: is a refusal. An ask from before the report, or a stop with no ask, gets no made-up asker.

test_eq(threads([LOG[0], dict(LOG[1], text='refused: not my repo')])[0]['state'], 'refused')
test_eq(threads([LOG[0], dict(LOG[1], text="I can't reproduce it, but I fixed X")])[0]['state'], 'replied')
t, = threads([LOG[1]])
test_eq((t['asker'], t['worker'], t['ask']), ('c1', 'Ramabana', NO_ASK))
t, = threads([LOG[-1]])
test_eq((t['asker'], t['worker'], t['ask']), ('?', '', 'hop limit: 6 of 6 hops used'))

Checked changes

A worker lists the changes it made. checked_changes looks in git log of the worker’s folders since the mode started. An item that git does not show gets (not in git). Without folders, nothing can be checked, so the items stay as they are.


source

checked_changes

def checked_changes(
    t, since
):

The changes of thread t, each marked (not in git) when no commit or file of the worker’s repos since since matches it; no repo, no marks.

A temporary repo with one commit. The real commit and file pass; the others are marked.

with tempfile.TemporaryDirectory() as d:
    run = lambda *a: subprocess.run(['git', '-C', d, '-c', 'user.name=t', '-c', 'user.email=t@t', *a], capture_output=True, text=True).stdout.strip()
    run('init', '-q'); (Path(d)/'a.py').write_text('x'); run('add', '.'); run('commit', '-q', '-m', 'one')
    sha = run('rev-parse', 'HEAD')[:8]
    t = dict(folders=[d], changes=[sha, 'deadbeef1', 'a.py', 'gone.py'])
    test_eq(checked_changes(t, time.time() - 3600), [sha, 'deadbeef1 (not in git)', 'a.py', 'gone.py (not in git)'])
    test_eq(checked_changes(dict(folders=[], changes=['x.py']), 0), ['x.py'])
with tempfile.TemporaryDirectory() as d: test_eq(checked_changes(dict(folders=[d], changes=['x.py']), 0), ['x.py'])

The table

table makes one Markdown row per thread. A | in the text is escaped, and a long ask is cut short.


source

table

def table(
    ts
):

A Markdown table of the threads ts, one row each.

test_eq(table(threads(LOG)).splitlines()[0], '| Asked by | Done by | What | Changed | Proof | State |')
test_eq(len(table(threads(LOG)).splitlines()), 6)
assert '| Leela | Ramabana | Fix card |' in table(threads(LOG))

The glossary

The report uses a few terms that a new reader may not know. glossary gives the meaning of only the terms in a text.


source

glossary

def glossary(
    text
):

The lines of GLOSSARY for the terms that text uses.

g = glossary('One ask, one reply, one thread.')
test_eq(g.count('\n'), 2)
assert 'budget' not in g and '**thread**' in g

Actions and examples

A waiting ask, a not proven thread, a refusal and a stopped thread always need the user. derived_actions writes each one as “what to do: where”. The model adds more items and one plain sentence for each change.


source

summarise

def summarise(
    ts, agent:NoneType=None
):

The simple examples and the action list for threads ts, from one summary model call.


source

derived_actions

def derived_actions(
    ts
):

The action lines that the table alone shows: each one is what to do: where.

The action lines of the small log. No model is called here.

test_eq(derived_actions(threads(LOG)), ["- Ask for proof of 'Fix card': Ramabana", "- Get an answer to 'Wait': Ramabana", "- Restart 'Loop' (hop limit: 6 of 6 hops used): Ramabana"])

A stub agent shows how the model reply is split. Its oneshot is the only part of the agent that summarise uses.

class _Agent:
    def oneshot(self, prompt, sp, job): return 'EXAMPLES\nRamabana added tell.\nACTIONS\nRead the diff: cli.py'
test_eq(summarise(threads(LOG), _Agent()), ('- Ramabana added tell.', '- Read the diff: cli.py'))

The report

report joins the parts. The action list has the derived lines first, then the model’s own lines without repeats. If the summary fails, the report says why in place of the examples. With no thread, the model is not called.


source

report

def report(
    since, cfg:NoneType=None, agent:NoneType=None, summary:NoneType=None
):

The Markdown report of the log since time since; summary is summarise unless given.

A log in a temporary folder and a model that fails. The table, the actions and the glossary are still there, with the reason.

from unittest.mock import patch
from ramabana.peers import log
def _down(ts, agent=None): raise RuntimeError('down')
with tempfile.TemporaryDirectory() as d, patch.dict(os.environ, RAMABANA_PEERS=d):
    for r in LOG: log(d, r)
    out = report(0, summary=_down)
    assert out.startswith('# Night of ') and f'{MISSING}: the summary model failed: RuntimeError: down' in out
    assert "Restart 'Loop'" in out and '**proof**' in out

Saving it

save_report writes the report to reports/<date>-<HHMM>.md in the peers folder, named by since, so two modes on one date do not share a file. report_path gives that name, so a hook can find the file. It builds the file again when the log has newer rows, or when the saved file lacks the examples.


source

save_report

def save_report(
    since, cfg:NoneType=None, summary:NoneType=None
):

Write the report since since to the reports folder; a file with examples and newer than the whole log is kept.


source

report_path

def report_path(
    since, cfg:NoneType=None
):

The file in the reports folder for the report since since.

A file with the examples is kept. A file that lacks them, a new log row, or another since makes a new file.

ok = lambda ts, agent=None: ('- ok', '')
with tempfile.TemporaryDirectory() as d, patch.dict(os.environ, RAMABANA_PEERS=d):
    for r in LOG: log(d, r)
    p = save_report(0, summary=_down)
    assert MISSING in p.read_text() and p == report_path(0)
    test_eq(save_report(0, summary=ok), p)
    assert MISSING not in p.read_text()
    p.write_text('kept'); os.utime(p, (time.time(), time.time()))
    test_eq(save_report(0, summary=ok).read_text(), 'kept')
    log(d, dict(LOG[0], id='new', thread='new', at=time.time() + 5))
    assert save_report(0, summary=ok).read_text().startswith('# Night of')
    assert save_report(3600, summary=ok) != p

The command

ramabana report [--since T] [--save] prints the report, or saves it and prints the file name. T is a number of seconds since 1970 or a date such as 2026-10-28 22:00. Without --since, the report starts when the last work together mode started.


source

report_cli

def report_cli(
    argv, cfg:NoneType=None
):

Run ramabana report with the arguments argv; the exit code.

with tempfile.TemporaryDirectory() as d, patch.dict(os.environ, RAMABANA_PEERS=d):
    test_eq(report_cli(['--since', 'soon']), 2)

python -m ramabana.peer_report runs the same command. The peer hooks run it with their own Python, so an older ramabana first on PATH does not matter.