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.
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 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.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.
The state not proven is the one to watch: the worker says it is done and shows no proof.
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'))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.
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'])table makes one Markdown row per thread. A | in the text is escaped, and a long ask is cut short.
The report uses a few terms that a new reader may not know. glossary gives the meaning of only the terms in a text.
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.
The simple examples and the action list for threads ts, from one summary model call.
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.
A stub agent shows how the model reply is split. Its oneshot is the only part of the agent that summarise uses.
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.
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.
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 outsave_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.
Write the report since since to the reports folder; a file with examples and newer than the whole log is kept.
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) != pramabana 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.
Run ramabana report with the arguments argv; the exit code.
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.