A routed batch enters the durable agent message queue as work and wakes
only its child. Saved log IDs prevent a second entry on restart, and a
receipt remains until inbox removal even if the child finishes first.
Receipts use batch numbers rather than a watermark because chats can
close out of number order.
Unrouted batches keep the root path. A missing child sends new inputs
and its queued external batches to the root. Routed events keep their
WhatsApp source without a subagent completion status, and daemon logs
name the destination without message text or chat IDs.
The root keeps a private source-to-child map and manages it through
route, unroute, and routes. Only direct children can receive a route,
and only WhatsApp chat sources are supported. The prompt requires user
agreement, and WhatsApp status lists the configured routes. Route logs
omit source IDs because they can contain phone numbers.
The root creates permanent children with its model and effort, sends
them durable messages with tell, and reads their views with zoom. A
child has a short capitalized name for its area, such as House, which no
child, subagent, or the root uses. spawn keeps its short tasks. Children
have no creation or WhatsApp tools.
The root sees each child name and area in its turn state, while its
shared prompt stays stable. The prompt directs mixed-chat area messages
to the right child with their sender, date, and words, and requires user
agreement before the root sends a child draft to WhatsApp.
A new child is prepared outside the children directory and published in
one step, so a stop never leaves a half-made child that blocks the next
start, and a failed creation frees its name. Runtime children join the
daemon lifetime and backup locks before they receive tasks.
Schedules record the agent that sets them and wake only its loop. Saved
schedules without an owner take the name of their agent, so root tasks
keep their behavior across a restart. A different stored owner stops
startup instead of sending the task to the wrong agent.
A parent can queue a user message for its own child through the daemon.
The child's final reply queues a work message for the parent with the
child's name in brackets. Each delivery wakes its recipient, including
one that is busy, and an empty final reply sends nothing.
The durable queue keeps inputs until their turn completes and saves a
child report with its input acknowledgments. Saved log positions prevent
repeat entries after restart. Interrupted turns can repeat model calls
and tools. Queue failures stop the loops, and backups lock the queue
before the agent files. Turn logs name their agent. Tools and prompts
stay unchanged.
Memory saves a queued message's position before it appends under the
write lock. Recovery reads that position or completes its append, so a
stop between the queue save and the log write cannot repeat the message.
The queue owns the saved position and stops delivery after a failure.
The agent guide in the README describes manual child configuration,
separate state and calls, startup checks, and backup coverage. It makes
clear that clients still address the root and that children have no
WhatsApp tools or parent message transport.
The daemon opens the root and each direct child with separate memory,
compaction, usage records, schedules, and subagents. Invalid child
configuration stops startup, and the ready event names the children.
Each child uses its own cache key and has no WhatsApp tools. Its turn
and compaction calls share the same prompt and tools.
Backups flush and lock every agent and its subagent logs during the
copy, so the archive holds the full state of the children as well as the
root. Clients still address only the root.
Child configuration names its parent and responsibility together. Its
prompt limits work to that area, reports only what needs attention,
allows an empty report, and requires drafts instead of WhatsApp sends.
Each child reads its own instructions file at each call.
The bot writes the end of a send outage to the log after the send
returns, so the fake server can hold the message before the line exists.
The test now waits for the line instead of reading the log once, which
made it fail now and then, also inside nix build.
The README explains backup consistency, secret archive contents, safe
restore, and the empty destination requirement. A user timer example
makes one local archive each day and keeps the last 14 after a
successful backup.
The backup command asks the running daemon for an archive. A flushed
memory snapshot holds the write locks during the agent copy, and SQLite
VACUUM INTO saves the WhatsApp session without raw database files.
Compression runs after the write locks are released.
Restore validates the archive in a staging directory before it installs
the files. The token lock excludes a running daemon, and a nonempty
destination fails without replacement of its data.
The archive writer installs a complete mode 0600 tar.gz file through a
temporary file and a rename. Extraction preserves regular file and
directory modes and rejects unsafe paths, links, and special files.
bin/format and bin/check give gofumpt only the Go files that Git tracks
or would add, as they do for the other formatters. gofumpt walked into
the ignored .devenv directory, so a format in the main checkout changed
the files of a worktree there, and a check failed on its unfinished
files.
This reverts 0a98ccb. A compaction sends the view, its closing tag, a
newline, and the task as one user message with the same joined bytes.
Each retry adds its note as one separate user message. The prompt path,
names, style, and summary-failure log stay unchanged.
After the split, compactions read 32.8% of input from the cache across
493 calls, against 31.9% across 183 calls before it. Imports read 30.7%.
The split adds code and messages for no measured gain.
The terminal client ends shown events with a prompt while no turn runs.
Busy and idle events track the turn, and the next shown event clears the
prompt before it prints. Pipes and files keep the same output. The small
prompt does not keep a typed line separate from incoming events.
Replies and work reports use the terminal width for Markdown rendering.
Other event kinds and output to files or pipes keep their text. Each
event reads the width again, with 80 columns when a terminal reports
zero. Tests use isolated terminal descriptors and fake events, including
single-column and very narrow table headers.
The terminal renderer wraps styled text and table cells to the display
width. It uses bold, dim, italic, and reverse without colors, and keeps
unclosed marks as text through the shared parser. Wide characters take
two columns. The existing text module supplies width data, so vendoring
includes that package without a new dependency.
The inline and block parser supplies the same closed marks to each
renderer. Telegram keeps its existing text and entities while terminal
rendering can use the same rules for unclosed marks and code fences.
The go-code skill gets the rules for log lines: a short event as the
message, names, IDs and durations as attributes, the cause in err, a
level for each kind of event, and no secrets or personal data. The rules
were only in issue comments, so workers missed them, and every Go task
reads this skill.
The README gives the short log form and the meaning of each level. The
journal example selects warnings and errors without the normal events,
so a user can find the failures that need attention.
Each daemon part gives its event, level, and raw attributes to the
shared handler. Normal events use info, recoverable failures use
warning, and lost data or stopped work uses error. Outages still log
only their start and end, and probe and rebuild runs discard their
summary log output.
A shared handler writes the event and ordered attributes, with the error
cause last. Journal lines carry a syslog priority and terminal lines
carry the time and level, so each output keeps short, readable events.
The Nix package and the version command use the commit date and short
revision, with a dirty suffix for a changed tree. Direct Go builds keep
the dev version. The install check compares the binary version with the
package version so a missing linker value fails the build.
A rebuild summarizes the whole merged log, so it can need more calls
than a probe sample. The import of two chats estimates 647 calls, close
to the 660 calls of the probe, and a stop at the limit swaps nothing and
wastes the calls. The rebuild now stops after 1,500 calls, and the quota
stop still guards the account. The probe keeps its own limit of 660
calls.
The skills identify the linters that enforce error, assertion, name, and
spacing rules. They keep manual review for logical groups, local error
paths, and the config exceptions because passing lint does not prove
those rules.
Wsl v5 checks empty lines around blocks and multiline declarations and
calls. Spacing keeps immediate error checks and short declarations with
their uses. Checks that split those groups are off because they conflict
with the Go rule. The cleanup changes only spacing and groups of
variable declarations.
Nlreturn checks spacing before returns and branch statements. Its short-
block allowance keeps a declaration with its immediate use, as the Go
rules require. Longer blocks have an empty line before they return or
continue.
Wrapcheck requires operation context on errors from package and
interface calls. Returned errors keep their causes for errors.Is and
errors.As. Context cancellation and the listed functions with one error
path keep their direct returns, as the Go rules allow.
The lint check applies revive to Go declarations and common style rules.
Unused callback and interface parameters have blank names, and the
command has a package comment for its entry points.
Test assertions use the specific require helpers for errors, lengths,
JSON, and numeric values. Test setup completes before its server starts
so that a failed assertion stops the test goroutine, not a worker.
The lint config keeps the standard linters and adds errorlint and
gocritic. Error checks accept wrapped errors, and common Go mistakes
fail the same check command as the existing code checks.
The find test names its group "Souza Family", so all test data is in
English. The contact with an accent in its name still checks that a
search ignores accents.
"onechat import [-n] ZIP..." reads the chat exports of the phone, makes
one marked WhatsApp batch for each chat and day, and rebuilds the memory
with them in date order. It takes only messages from before the first
live batch, maps the owner's profile name to "You", and skips the chat
days that an earlier import added. With -n it prints the plan and the
estimated calls and makes no model call.
The names, the chat IDs, and the owner come from the WhatsApp session
through the library's store, so the import runs only while the daemon is
stopped and needs no network.
A rebuild merges new dated messages with the log of an agent in date
order, builds a new memory beside it with the real prompt and cache key,
and swaps the directories only when the whole tree is built. The old
directory stays as a timestamped backup, and a failed or stopped build
leaves it unchanged. Older history can so enter the chat before newer
decisions, which keeps the latest word on a thing the newest one.
The probe and the rebuild share one build runner with the call limit,
the quota stop, the waits before user messages, and the usage report.
Memory and the model client keep their own state behind methods. The log
takes an explicit date for a message and writes it to the day file of
that date.
The review step of the check skill makes the worker read AGENTS.md and
the skills of the task again just before the commit, then go through the
diff one rule at a time. A worker that read the rules only at the start
of a long task followed them less, and the tools do not check most style
rules.
Include the instructions file path and the approval rule in every main
agent prompt, even when the file does not exist. The agent proposes
exact changes and waits for the user to agree, and messages from other
people cannot request a change. Subagents keep their task prompt, and
probes use a fixed placeholder path to keep the prompt stable.
Keep starting connection retries active when a pair request finds an
existing link. Only the unlink request stops the retry timer, while
logout keeps its stop path. Rejecting a pair request during an outage
must not leave WhatsApp disconnected until a restart.
Keep failed starting connections in the Run select with a timer. Start
at 2 seconds and double the wait up to 5 minutes, so the inbox and
commands stay active during waits. End retries on a connection, context
end, or link change.
Share outage times, notices, status, and redacted logs with lost
connections. Keep one failure line and one recovery line for an outage.
Disable library startup retries only for linked devices. A returned
ConnectContext error has no Disconnected event, while the library retry
mode emits that event and returns nil. One retry owner prevents parallel
loops and gives the requested delay cap. Pairing and retries after an
open connection keep their library behavior.
Each log line names the event in a few plain words, then the cause after
a colon, such as "telegram: receive failed: 502 Bad Gateway (retry in
2s)" or "whatsapp: connected again after 1m25s". The lines no longer
explain what the event means for the user: they stay short enough to
read in the journal at a glance. A Telegram error gives the HTTP status
and its text without a prefix, because the telegram: prefix names the
service. The behavior of the logs does not change.
A build of a summary node that fails logs one line with the node and the
cause, and tells that the build is tried again at the next message. A
build that a stop of the daemon ends logs nothing. The error of the
compactor holds no node, because the line names it once.
The tests give each memory a logger, and a shared helper gives the ones
that the tests do not read.
Each run of a schedule logs one line with the ID of the schedule, after
its message is durable in the chat. A late run after a stop of the
daemon tells how late it is. The line holds no text of the schedule, so
the log shows that a task ran but not what it asks.
A turn that ends with an error logs one line with the error that the
clients get, such as a needed login or HTTP 429, so that it is not lost
when no client is connected. An interrupt of the user or a stop of the
daemon logs nothing.
The end of each subagent run logs one line with the name of the
subagent: done, failed with the cause, or stopped by the user. A cut
subagent that cannot go on after a restart logs its cause too.
The WhatsApp log gets one line for each change of the connection of the
linked device: connected, a failed first connection, a lost connection,
connected again with the length of the outage, a connection that does
not come back by itself, a link, an unlink, and a logout from the phone.
The retries of the library between them log nothing, and the logger of
the library stays off.
Each line says first what happens to the WhatsApp messages, and then the
cause. An unlink clears the time of the last disconnection, so the first
connection of a new link does not count as the end of an outage.
The daemon makes the logger of each part in one place. Under the
journal, which sets JOURNAL_STREAM and adds its own time, the lines have
no time stamp. Otherwise every line has the standard time stamp, the
onechat lines too, so that no line has two time stamps and none has
none.
The start line names each value: the socket, the model and its effort,
the directory where the commands run, whether WhatsApp is linked, and
the number of active schedules.
A Telegram outage logs one line at its start and one at its end, with
its length, and the retries between them log nothing. Each line says
first what the user loses or gets, such as "cannot get messages from
Telegram" or "replies go to Telegram again, after 1m25s", and then the
cause in plain words, such as "no answer in 1m10s" or "Telegram answered
502 Bad Gateway".
The errors of the Bot API client hold no method name and no Go deadline
text, so that the log line can say what the call was for. A refused
format, a dropped reply, and an ignored message use the same plain
words.
onechat with no command opens the terminal chat, as onechat chat does,
because the chat is the command of daily use once the daemon runs as a
service. A start of the daemon stays explicit, so a second daemon does
not start by accident. onechat help, --help, and -h print the commands,
and an unknown command prints the error and the same help. The install
check runs onechat help in place of the bare command.
A compaction sends each whole block of four view lines as its own user
message, the first one with the opening tag, and one last user message
with the rest of the lines, the closing tag, and the task. The note of a
retry is one more user message after them. The blocks count from the
first line of the view, so two compactions whose views begin with the
same lines send the same block messages. Joined, the messages are the
same bytes as the one message before.
The backend reads a cache entry only at a message end of a request. With
the view in one message, a compaction read only the shared prompt and
tools, about 1,920 of 10,000 tokens. The first request of a turn sends
its view as messages that only grow and reads 74% to 85% from the cache,
so the compactions get the same kind of split.
The user unit links through result-service, so each build replaces its
package without another unit link. The update command builds with that
out-link, reloads the user manager, and uses try-restart to restart only
an active service. A stopped service stays stopped.
The README gives the first link and the two update commands. The checks
include shell lint for the update script.
The README gives the update commands after a git pull. The same result
link keeps the new package available, and linking the unit again makes
the user manager load the unit from that package before the service
restarts.
The flake package supplies a user unit that runs its own daemon binary
at login, restarts failures after five seconds, and sends output to the
journal. SIGTERM starts cleanup, with two minutes before a forced stop.
The unit keeps the rights of the user and a private file creation mask.
A unit in the existing package is the smallest flake interface for this
service. It needs no extra module interface or dependency and leaves the
data directory and tool environment in a standard user drop-in.
The README covers the data copy with the daemon stopped, the shared
client data setting, the package result link, and service controls. A
flake check verifies the built unit without a running user manager.
The daemon runs the schedules next to WhatsApp. At its time, a schedule
goes to the chat as a work message that starts with [Schedule ID] and
starts a turn of the root agent, which does the task or spawns a
subagent for long work. The message ends with the empty reply line, so a
task that finds nothing for the user gives no message.
The runner waits until the first due time, at most one minute at a time,
so it sees a suspend of the machine or a change of the clock within a
minute. A set or a remove wakes it. It logs all due schedules in due
order, flushes the log, ends each run in the file, and wakes the turn
loop once, as the WhatsApp batches do. A crash between the flush and the
file write makes a run repeat once. So a schedule that came due while
the daemon was stopped runs once at the next start, with the line that
the run is late.
Telegram does not send the work message, because the reply of the agent
carries its content. The terminal client shows it. The README describes
the schedules, the cron subset, the limit of 5 minutes, and the rules
for daylight saving time.
The root agent gets three tools. schedule_set adds a schedule with a
task text and either a local time or a cron expression, and answers with
its ID and next time. schedule_list gives each schedule with its ID,
next time, cron expression or once, and text. schedule_remove removes
one by its ID.
The descriptions tell the agent to set a schedule only when the user
asks, to write the text as an order to itself, and to turn a time such
as "tomorrow at 9" into a local time from the Now line. The cron
description gives the limit of 5 minutes between runs. The prompt text
does not change, so it stays near the reference design.
The daemon opens agent/schedules.json at its start. The toolbox of a
subagent has no schedules, so the tools are unknown there, as the
WhatsApp tools are. Compactions send the new tools too, so they keep the
shared prompt cache.
The agent gets schedules: a task with one local time or a cron
expression. agent/schedules.json holds them, and each set and remove
writes it whole with mode 0600 before it returns, so a schedule survives
a restart. The file keeps the last ID, so that an ID is never used
again. A mutex protects the schedules, because the tools change them
from a turn while the runs read them.
A one-time time is a local wall time after now. A time in the hour that
the clock repeats is its first occurrence, as in cron. The cron
expressions are checked at a set and at the start.
A run ends with Done: a one-time schedule goes, and a repeating one gets
the next time after now, so a repeating schedule that missed several
times while the daemon was stopped runs once. The message of a run names
the schedule, gives its time and the task, says when the run is more
than one minute late, and ends with the line about the empty reply, as a
WhatsApp batch does.
The schedules of the agent repeat by a five-field cron expression, so
OneChat parses one with a small parser and no new dependency. A field is
a comma list of *, N, and N-M, where * and N-M can have a step. When
both day fields are restricted, a day matches when either one matches,
as in classic cron. The parser refuses names, macros, the characters ? L
W #, a seconds field, N/S, values out of range, and an expression that
matches no day in 8 years, each with an error that names the problem.
It also refuses an expression whose runs can be less than 5 minutes
apart, also across the end of an hour and across midnight. Each run is a
full turn, so a run every minute would use the weekly quota in hours.
The error gives the limit, so that the agent can ask the user.
The next time is the first matching wall time in the local zone. A wall
time that the clock skips runs later by the length of the skip, and a
wall time that the clock repeats runs once, at its first occurrence.
The daemon sets the device properties of the library once before any
client exists, because they are global values of the library. The
properties have the OS name OneChat and the desktop platform type.
Without a known platform type, the phone ignores the name and shows the
device as "Other device".
The display name of the phone code pairing stays "Chrome (Linux)": the
server accepts only the form "Browser (OS)" with a common browser and
OS, and refuses the pairing otherwise. A comment says so.
The name applies at the pairing, so an existing link keeps its old name
until the user unlinks and pairs again.
The send of whatsapp_send is now the function whatsappSend in
whatsapp_send.go, with its check of the arguments and its constants. It
owns no state: it gets a WhatsAppLink for the device, the client, the
connection, and the own IDs, the WhatsAppGroups for the name of a group
in the answer, and the state file for the send records. WhatsApp.Send
stays the entry point of the tool and gives the current link. The tests
of the tool move to whatsapp_send_test.go. The behavior does not change.
The search of whatsapp_find is now the function whatsappFind in
whatsapp_find.go, with its matches, its fold of the names, and its
answer. It owns no state: it gets a WhatsAppLink for the device, the
client, and the own IDs, and the WhatsAppGroups for the names of the
groups. WhatsApp.Find stays the entry point of the tool and gives the
current link. The tests of the tool move to whatsapp_find_test.go. The
behavior does not change.
WhatsAppInbox in whatsapp_inbox.go puts the incoming text messages into
batches, logs the closed batches, and holds the catch-up after each
connection. It owns the wake and failures channels of Run, the limit of
the catch-up, and, under its own mutex, the state of the catch-up and
its timer. The conversion of an event to a batch message goes with it.
WhatsApp keeps the session and the connection. Its event handler gives
each message to the inbox with a WhatsAppLink, and starts and ends the
catch-up there. The state file stays a field of WhatsApp too, because it
also holds the send records, and it has a mutex of its own. The tests of
the batches and the catch-up move to whatsapp_inbox_test.go, and the
helpers that more test files use move to whatsapp_fake_helper_test.go.
The behavior does not change.
WhatsAppGroups in whatsapp_groups.go holds the names of the groups, the
time of the last read of the groups from WhatsApp, and the time of the
last read that succeeded, under its own mutex. The batches, find, and
send use it, and the event handler of WhatsApp sets the names of the
group events in it.
WhatsAppLink is a copy of the link of the device: whether it is linked,
the device, the client, whether it is connected, and the own IDs.
WhatsApp keeps these fields under its mutex and gives a copy to each
call of the groups, so the groups read with the current client and never
keep it. No type calls another type while it holds its mutex. The
behavior does not change.
The conversion of a message event to a batch message and the removal of
the own IDs from a text are now the functions whatsappMessage,
whatsappText, and whatsappRedact. They get the device and the own IDs as
arguments and read no state of WhatsApp, so the batches, find, and send
can use them after the split of WhatsApp by responsibility. The method
receive copies the device and the own IDs under the mutex, as message
did, and the method redact calls whatsappRedact. The behavior does not
change.
After a pairing, the linked device never connected again. The pairing
client connected with the context of the pairing, and the library keeps
the context of ConnectContext for its keep-alive loop, its event handler
loop, and its reconnections. After PairSuccess the server ends the
stream and the library connects again with that context, but the end of
the pairing canceled it, so the reconnection stopped. The session got no
prekeys, no Signal sessions, and no contacts, and no message came.
The pairing client now connects with the context of Run, which lives
until the daemon stops, as the connection at the start of the daemon
does. The context of the pairing still limits the QR channel, the wait
for the code, and the wait for the link, and a pairing that does not
link disconnects its client.
The fake client keeps the context of each connection, and the pairing
test checks that the context stays alive after the link. The test fails
without the fix.
The WhatsApp changes left two doc comments with uneven lines: the
comment of promptTools had a line of 100 characters, and the comment of
terminal had a line that ended early. Both are reflowed with bin/reflow,
with no change of their words.
The agent gets the tool whatsapp_send, which sends a text message to a
private chat or a group by its ID, only when the user asks for it. Each
call has an ID that the agent gives. The state file keeps a record of
each send by its ID, so a repeated call gives the answer of the first
call and sends nothing, and the same ID with another chat or text is an
error. The record "sending" is durable before the send, so a stop of the
daemon during a send gives an answer that the result is unknown and that
the agent must ask the user, and never a second send. A disconnected
WhatsApp sends nothing and makes no record.
The state file keeps the last 1000 send records with the batches. Its
type is now WhatsAppState in whatsapp_state.go, because it holds more
than the batches, and whatsapp_batch.go keeps the batch, its text, and
its turn rule.
The send tool takes only the ID of a private chat or a group, and not
the chat of the user with itself, and the own number in an error shows
as OWN. Only the root agent has the tool, and its new schema makes the
prompt cache miss once.
The test of a private batch gave its two messages with 50 ms of real
quiet time between them, so on a loaded machine the first message closed
its batch alone and the test failed. The messages now come during a
catch-up, which holds the batch open until its end, so the test no
longer depends on the speed of the machine.
The test of a long disconnection waits 300 ms before the notice and 400
ms for a notice that must not come, so that a slow machine does not give
a notice between two events of the test.
The agent gets the tool whatsapp_find, which gives the contacts and
groups of the user whose name holds a text, with no regard to case and
accents, as lines "ID | name | contact or group", at most 20 and the
number of the others. The send tool of the next step takes these IDs.
The contacts come from the session database of the library, with the
full, first, business, and push names, and with no copy. The groups come
from WhatsApp and stay in memory for one hour; the batches use the same
names. When the groups cannot be read and none are known, the answer
says so. Before the names arrive from the phone, the tool says that they
are not ready, and without a link it tells the agent to ask the user to
run /whatsapp pair PHONE.
Only the root agent has the tool: the toolbox of a subagent has no
WhatsApp, so it is an unknown tool there. The new tool changes the tool
list, so the prompt cache misses once. The match uses golang.org/x/text,
which the library needs already, so the vendor hash changes only for its
packages.
After each connection, the WhatsApp servers send the messages that came
while the daemon was away, and then the event OfflineSyncCompleted.
Until that event, or one minute at the most, the daemon keeps the closed
batches in the state file and logs none. At the end it closes all
batches, logs them in order, and wakes the turn loop once with the
batches that need the user, so the whole catch-up gives one turn and not
one turn for each chat. The batches that the state file holds at the
start wait for the first catch-up in the same way.
TurnLoop.Work now only logs and notifies, and the new TurnLoop.Wake adds
several logged messages to the waiting messages at once. A report of a
subagent uses both, and the WhatsApp batches of one delivery join one
turn through one Wake.
The daemon reads the text messages of the private chats and the groups
and gives them to the agent in batches. The messages of one chat form
one batch while they are at most 20 seconds apart, for 2 minutes or 50
messages at the most, and a batch also closes after 20 quiet seconds in
real time. The rule uses the WhatsApp times of the messages, so the
catch-up after a stop gives the same batches as live messages.
Each batch goes to the log as one work message that starts with
[WhatsApp], with the chat, its ID for the later send tool, and the time,
sender, and text of each message. It takes the path of the subagent
reports: TurnLoop.Work logs it and, for a batch that needs the user,
wakes the loop, so a batch that comes during a turn joins its next
request. A batch starts a turn when it is private with a message of
another person, or from a group with a message that mentions the user or
replies to the user. Such a batch ends with a line that lets the agent
end the turn with an empty reply. The chain already drops an empty
reply, and Telegram drops the batches, so a batch that does not need the
user gives no Telegram message.
whatsapp/state.json keeps the batches that are not in the log. The
library acknowledges a message only after the file holds it, and a batch
leaves the file only after the log is durable, so a crash loses nothing
and can repeat a batch once. A failed write stops the daemon.
The own number never goes to the log: the messages of the user show as
You, a mention of the user as @you, the number as OWN, and the chat of
the user with itself is not read. The batches do not take media,
reactions, edits, view-once and disappearing messages, or old history.
The line /whatsapp pair PHONE in the terminal client or in Telegram
links the WhatsApp account of PHONE as a linked device. The daemon
answers with the steps and then with the pairing code in its own
message, so that it is easy to copy, and later with the linked number,
an expired code, or the failure. A new pairing ends the one before.
/whatsapp unlink logs the device out and deletes the session, and tells
the user to remove the device in the phone when the logout cannot reach
WhatsApp.
The phone number and the code go only to the client that asked. The log
of the agent gets only the notes that WhatsApp is linked or unlinked,
and the errors in the daemon log show the own number and the own LID as
OWN.
When the user removes the device in the phone, the daemon deletes the
session, notes it, and tells all clients one time. A disconnection that
lasts 10 minutes gives all clients one notice, not one for each retry of
the library, and one more when the connection comes back. A
disconnection that the library does not retry gives a notice at once.
The pair and unlink commands run one at a time in the WhatsApp goroutine
of the daemon, which ends a running pairing at the stop of the daemon.
The daemon opens the WhatsApp session in whatsapp/session.db and keeps a
linked device connected, so the next steps can pair, read, and send. The
line /whatsapp status in the terminal client or in Telegram tells
whether WhatsApp is not linked, linked and connected, or linked and
disconnected, with the times of the connection.
The clients handle a /whatsapp line before it reaches the agent, and the
daemon answers only the client that asked, with a notice event that the
terminal client now shows. So the lines of the later pairing, which hold
the phone number and the code, never reach the log, the model, or the
other client.
The session gives full access to the account, so the daemon refuses a
session file whose mode is not 0600, and the library logs nothing,
because its log lines hold the own phone number. The daemon opens the
session only after it holds the lock of the data directory. The library
and its SQLite driver, modernc.org/sqlite, are pure Go.
A call of a subagent sends the cache key of the agent with "-" and the
name of the subagent, in the body and in the session_id header. With the
key of the agent, the second request of a subagent in a live check read
0 of 4,435 input tokens from the prompt cache, although it starts with
the whole first request, while the agent made calls with the same key at
the same time. With its own key, the second request of a subagent in the
same check read 4,608 of 4,761 tokens from the cache. The calls of the
agent keep the key of the agent.
A subagent that runs when the daemon stops goes on at the next start.
work.json in the agent directory holds the names of the running
subagents: each start and each end of a run writes it whole, but the
stop of the daemon does not, so it names the runs that the stop cut. The
logs alone cannot tell this, because a stop can cut a run just after its
final reply.
At the start, the chat gets the note "The daemon restarted, cutting
NAMES; nothing was lost: go on.", and each cut subagent gets the same
text as a told message, so it goes on from its log with the view of now.
The note starts no turn, but the reports of the subagents do. A subagent
that cannot go on gets a failed report, and work.json then no longer
names it, so the next start does not try it again. A failed write of
work.json stops the agent, as a failed log write does.
When a subagent starts a run, the terminal client and Telegram show
"[NAME] started". The terminal shows the whole report, which starts with
the name. Telegram shows only "[NAME] finished", "[NAME] stopped", or
"[NAME] failed", because the reply of the agent in the turn of the
report carries its content, and the phone then gets one message for each
result. Neither client shows the tool calls of a subagent.
The work event of a report gets the name of the subagent in its source
and the end of the run in a new status field, so that a client needs no
parse of the report text.
The line "/stop NAME" in the terminal client and in Telegram stops the
running subagent NAME: its model call ends, and a running command is
killed with its process group. The chat gets the note "Stopped by the
user: NAME.", and then the report of the subagent with the reply
"stopped", which is logged but starts no turn, so the agent does not act
on a subagent that the user stopped. A name that does not run gives an
answer to the client. The bare "/stop" still interrupts only the turn of
the agent.
The note is logged under the mutex of the runs, which the run takes
before its report, so the note always comes before the report.
The agent gets the tell tool: tell(name, message) logs the message as a
user message of the subagent. A running subagent reads it between its
tool calls, and when it comes after a final answer, the run goes on with
one more request chain that reads the log again. The report of the run
then holds the final answer of each chain. A finished subagent goes on
from its log with a new run, with the view of now and the effort of the
note at the start of its log, and its reply comes back as a new report.
The log holds every message of a tell, so a told subagent needs no state
outside its log, and a restart can resume it in the same way.
The agent gets the spawn tool: spawn(tasks, effort) starts one subagent
for each task and returns their names at once. It waits until the view
has no pending summary, and gives that view to each subagent. Each
subagent runs in its own goroutine with its own request chain, its own
system prompt with the user's instructions at the end, and the tools
zoom, date, shell, read, write, and edit, in subagents/NAME/work. It
cannot start a subagent. Each request starts with the view, the state,
and the whole log of the subagent as "i|kind: text" lines, and the log
starts with the note "subagent" and the task.
At the end of a run, its final answer, or "failed: ERROR" after a failed
call such as a full context, goes to the agent as one work message:
"[Name]", the reply, and the zoom that gives its whole chat. The report
joins a running turn between its tool calls, or starts a new turn. A
report of a stopped subagent is logged but starts no turn. When the loop
ends at the stop of the daemon, the runs end with no report, and the
loop waits for them. The prompt gets the lines on subagents.
The efforts of spawn are low, medium, high, and xhigh: the efforts of
the reference design that the Responses API also takes.
Each subagent has a directory in subagents/ of the agent directory,
named for it, with its own message log. A name is an adjective and a
noun from two word lists, each capitalized and joined, such as
GreenOtter, and a new subagent draws again when the directory of its
name exists. Only such a name can be a directory, so a name never leaves
subagents/.
The zoom tool gets a nullable name, and zoom("Name") gives the whole log
of that subagent, each message as "i|kind: text". The prompt gets the
tool line for it. The log of a name opens once and stays open until the
daemon stops, because its lock allows one open log, and its mutex keeps
the run of the subagent and a zoom of the agent apart.
Memory.Settle returns when every line of the chat view is built, so a
subagent that starts gets a view with no pending summary, and the view
holds every message so far. A pending line with no running build belongs
to a failed build, which waits for the next message, so Settle returns
an error in place of a wait that never ends. It also ends with its
context, so an interrupted turn does not wait.
The request chain moves out of the turn loop into Chain: it sends a
request, logs the replies, runs the tool calls, and sends their results
back until a response has no tool call. A turn builds a chain with the
prompt, the tools, and the input of the turn, so its requests do not
change. The chain also returns the replies of the final answer, because
a subagent needs that text for its report and runs the same chain with
its own prompt, toolbox, and log.
A model request names the subagent that makes it, and the usage record
of the call holds that name in the agent field. A call of the agent
itself has no such field. The name goes only to the usage file, never to
the endpoint, so the usage records show the cost of each subagent in the
shared usage file of the agent.
The replay of the synthetic log runs 5,000 messages with -short and
100,000 messages without it. bin/check and the test run of nix build
pass -short, so bin/check takes about 20 seconds instead of more than
two minutes under the race detector. A new flake check runs only
TestReplaySyntheticLog without -short, so nix flake check still runs the
full replay and does not run the other tests a second time.
The test uses testing.Short, because -short is the standard Go flag to
make long tests shorter, and go test and buildGoModule pass it without
new code. An environment variable would need its own name and parsing
for the same result. The short replay gives the chat view 26 batches,
and the test still requires at least one batch.
The bot writes each < of a reply outside inline code and fenced code
blocks as < and each & as & before it sends the reply with
sendRichMessage. Rich Markdown does not read a backslash before < as an
escape: it shows the backslash and still reads the tag as HTML. It reads
HTML entities, so < shows as <. The escape of & makes an entity that
the model writes in prose, such as <, show as written.
The entity fallback reads no HTML, so a rich part that Telegram refuses
goes to the fallback with the escape reversed, and the user sees the
text as the model wrote it. The reverse is exact, because after the
escape each & outside code starts an entity of the escape. The escape
and its reverse find the code of a text in the same way, so the reverse
also works on each part of the split.
The split still counts the escaped text against the rich limit, and the
fallback splits the text without the escape at its own limit. When the
split cuts a line longer than the limit, it cuts before an & in the last
four bytes, so that it does not cut an entity.
Each helper that more than one test file uses moves out of the test file
that holds it into a file without tests, named for what it holds: the
fake Responses endpoint, the fake ChatGPT login service, the fake Bot
API, the terminal test client, the daemon start with a bot, and the tree
node helpers. A helper that only one test file uses stays in that file.
A reader then finds a shared helper from the file name, and a test file
holds only the tests and the helpers of its own feature.
The test files that hold no tests and only helpers for other test files
get names that end in _helper_test.go and say what they hold: the sync
buffer, the clock, and the summarizer. A reader then finds a shared
helper from the file name and does not look for tests in these files.
The content of the files does not change.
The bot puts a backslash before each < of a reply outside inline code
and fenced code blocks before it sends the reply with sendRichMessage.
Rich Markdown reads a tag in prose, such as <details>, as raw HTML: the
tag name is lost and the rest of the paragraph is in a collapsed
section. With the escape, Telegram shows the tag as written, and a reply
cannot carry raw HTML.
Inline code and code blocks keep their text, also a code block that a
split opens again, because the escape runs before the split. The split
thus counts the escaped text against the length limit. A < that a
backslash already makes plain stays as it is. The pipe, the dollar sign,
and the equals sign are not escaped: the tables use the pipe, and the
live check showed no problem with the dollar sign.
The entity fallback reads the backslash as an escape, so it shows the
same text as before. Other events go as plain text and do not change.
A helper that one test file uses stays in that file. A helper that more
than one test file uses goes into a file without tests, whose name says
what it holds and ends with _helper_test.go. The name then shows that
the file has no tests, and a file named for one type does not seem to
test a feature of that name.
The doc comment of TelegramAPI names its sendTimeout field, which limits
each send call, and not SendTimeout, which is no name in the code.
The doc comment of the test helper viewPush describes what the push does
to the rollback state list, and it does not name a file of the reference
design.
The names of the memory core that only one of its parts uses start with
that part: the day helpers of the journal, the record decoder of the
tree, the message kinds and check of the log, and the view file of the
memory. The types of the core start with a capital letter: Journal,
Position, Records, Syncer, and NodeKey.
The fake summarizer of the tests is in summarizer_test.go, because most
tests use it. The test helpers of the tree, the log, the syncer, and the
replay test start with their part. The declarations of the core files
follow the order of the go-code skill. The behavior does not change.
The names that only the agent or only the config uses start with agent
or config: the instructions file of the agent and the endpoint values of
config.json. Config and ModelConfig stay generic, because the daemon,
the probe, the model client, and the compactor read them.
The data directory is in datadir.go, with its tests in datadir_test.go,
because the login, the terminal client, the daemon, and the probe use
it, and it is not a part of config.json. The declarations of these files
follow the order of the go-code skill. The behavior does not change.
Every package-level name that only the compactor uses starts with
compactor: its limit of tries, the ruler, the task text, the note on a
long line, and the helpers that cut a line and pick the shortest one.
Names such as ruler and shortest read as helpers of the whole program,
but only the compactor uses them.
The declarations of the compactor files follow the order of the go-code
skill. The behavior does not change.
Every package-level name of the tools starts with tool: the Toolbox, its
interfaces ToolLog and ToolChat, the argument types, the head and tail
buffer, and the clip helpers. The interfaces chatLog and chatReader read
as parts of the chat, but only the tools use them.
The declarations of tools.go follow the order of the go-code skill. The
behavior does not change.
The function tools that every model call sends are promptTools, and
their schema helpers start with prompt too. The turns and the
compactions send the same prompt and tools, so the tools are a part of
the prompt, and agentTools read as the tools of an agent next to the
agent config. The parts of the prompt text, such as the task sentence
and the clause on the instructions, start with prompt as well.
The declarations of prompt.go already follow the order of the go-code
skill. The behavior does not change.
Every package-level name of the probe starts with probe: its run
ProbeRun, the summarizer ProbeSlotSummarizer that limits the builds at
once, the reader of its log and of the usage file, and the parts of its
report. Names such as ratio, timeText, and modelLines read as helpers of
the whole program, but only the probe uses them.
The declarations of the probe files follow the order of the go-code
skill. The behavior does not change.
The queue of the node builds is BuildQueue in build_queue.go, and its
other names start with build: buildMax and buildLeafWindow are its
limits, and BuildMergeHeap holds its merges. The Telegram bot has a
queue of events too, so a bare queue does not tell which one it is.
The declarations of the queue file follow the order of the go-code
skill. The behavior does not change.
Every package-level name of the view starts with view: its size limits,
the pending mark, the saved state ViewState, and the interface
ViewNodeReader. The layout of the view messages in the turn requests is
in view_layout.go, as ViewLayout. The limits chatHigh and compactionHigh
read as limits of the chat and of the compactor, but they are limits of
the two views.
The declarations of the view files follow the order of the go-code
skill. The behavior does not change.
Every package-level name that only the turn loop uses starts with turn:
the loop is TurnLoop, its function call items are TurnFunctionCall and
TurnFunctionCallOutput, and its error of a log write is errTurnLogWrite.
Event stays generic, because the daemon, the terminal client, and the
Telegram bot read the events too.
The declarations of the turn files follow the order of the go-code
skill. The behavior does not change.
Every package-level name that only the daemon uses starts with daemon:
its hub of socket clients is DaemonHub, a connected client is
DaemonClient, and the loop that serves the agent is daemonServe. So a
client of the socket does not read as the model client or the Bot API
client.
What the daemon and the terminal client share is in socket.go: the
socket file, the size limits, the request type SocketRequest, and the
check of the socket path. The file locks that the log, the daemon, the
login, and the probe share are in lock.go, with their test in
lock_test.go. The declarations of these files follow the order of the
go-code skill. The behavior does not change.