]> git.giacomolli.uk - onechat/blob - README.rst
Deliver routed WhatsApp batches to children
[onechat] / README.rst
1 #########
2  OneChat
3 #########
4
5 OneChat is one AI agent in a single chat that never ends. A daemon owns
6 the agent and its files, and a terminal client and a Telegram bot talk
7 to it. It is in early development. Linux is supported.
8
9 ****************
10  Data directory
11 ****************
12
13 OneChat keeps its data and its token file in one directory. It uses the
14 first of these:
15
16 #. ``ONECHAT_DIR``, when it is set.
17 #. ``$XDG_DATA_HOME/onechat``, when ``XDG_DATA_HOME`` is an absolute
18    path. A relative value is ignored.
19 #. ``~/.local/share/onechat``. With no home directory, OneChat exits
20    with an error.
21
22 OneChat creates the directory with mode 0700.
23
24 ***************
25  Configuration
26 ***************
27
28 ``config.json`` in the data directory holds the settings. OneChat does
29 not create it. A missing file or field takes the default, and an unknown
30 field is an error.
31
32 .. code:: json
33
34    {
35      "endpoint": "chatgpt",
36      "compactor": {"model": "gpt-6.1-sol", "effort": "high"}
37    }
38
39 ``endpoint``
40    ``chatgpt`` (the default) sends model calls to the ChatGPT backend
41    with the subscription login. ``api`` sends them to the OpenAI API
42    with the key in ``OPENAI_API_KEY``, which must then be set. With
43    ``chatgpt``, OneChat ignores ``OPENAI_API_KEY``.
44
45 ``compactor``
46    The model and the reasoning effort of the summaries. The defaults are
47    ``gpt-6.1-sol`` and ``high``.
48
49 *******
50  Login
51 *******
52
53 The ``chatgpt`` endpoint needs a login with a ChatGPT account:
54
55 .. code:: console
56
57    onechat login
58
59 The command shows a link and a code. Open the link on any device, enter
60 the code, and approve the login. Device code login must be allowed in
61 the ChatGPT security settings. OneChat writes the tokens to
62 ``auth.json`` in the data directory, with mode 0600, and refreshes them
63 before they expire. The file belongs to OneChat only. Do not share it
64 with the Codex CLI, because each program rotates the refresh token and
65 logs the other out.
66
67 *******
68  Agent
69 *******
70
71 Start the daemon, and then the client in another terminal:
72
73 .. code:: console
74
75    onechat daemon
76    onechat
77
78 ``onechat`` with no command is the same as ``onechat chat``. ``onechat
79 help`` lists the commands.
80
81 The daemon owns the root agent in ``agent/`` in the data directory and
82 listens on the Unix socket ``onechat.sock`` there. In the client, each
83 line is a message to the agent, the line ``/stop`` interrupts the
84 running turn, the line ``/stop NAME`` stops the subagent NAME, and
85 Ctrl-D ends the client. On terminal output, the client shows ``> ``
86 after each event while idle and clears it before the next event. An
87 event can interrupt a line while the user types. The client shows the
88 replies, the tool calls, and the first lines of each tool result as they
89 happen. Replies and subagent reports render Markdown with styles and
90 wrapped tables only when the output is a terminal, and stay unchanged in
91 a pipe or file. It also shows each message that the user sends from
92 another client, with the client as a mark, such as ``[telegram] text``.
93 When a subagent starts, the client shows ``[NAME] started``, and it
94 shows the whole report of the subagent, which starts with ``[NAME]``,
95 but not the tool calls of the subagent. The daemon is the only process
96 that uses the token file while it runs, so ``onechat login`` and
97 ``onechat probe`` refuse to start until it stops.
98
99 The agent runs commands with bash as the user in its working directory,
100 with no sandbox. It has the rights of the user, also on the token file.
101
102 When the user asks for it, the agent starts subagents for long tasks.
103 Each subagent has a name, such as ``GreenOtter``, and runs in the
104 background while the agent answers other messages. It gets the view of
105 the agent and its task, and it runs its tools as the agent does, in
106 ``agent/subagents/NAME/work``. A task in a repository gives the path of
107 the repository. The steps of a subagent go to its own log in
108 ``agent/subagents/NAME``, not to the chat, and its final reply comes
109 back to the agent as one message that starts with ``[NAME]``. The agent
110 can send a subagent more messages: a running subagent reads them between
111 its tool calls, and a finished one goes on from its log, and its reply
112 comes back in the same way. When the user stops a subagent, the chat
113 gets a note, and the report of the subagent is logged, but it starts no
114 turn. Each model call of a subagent has the name in the ``agent`` field
115 of ``agent/usage.jsonl``. ``agent/work.json`` holds the names of the
116 running subagents. A subagent that runs when the daemon stops goes on at
117 the next start: the chat gets the note that the daemon restarted, and
118 the subagent gets the same note and goes on from its log.
119
120 ``agent/config.json`` holds the settings of the agent. The first start
121 writes it with the defaults and a random ID. Do not change the ID: the
122 prompt cache key comes from it.
123
124 .. code:: json
125
126    {
127      "name": "OneChat",
128      "id": "0123456789abcdef0123456789abcdef",
129      "model": "gpt-6.1-sol",
130      "effort": "medium",
131      "workdir": "/home/user"
132    }
133
134 ``workdir`` is an absolute path, and its default is the home directory.
135 ``agent/instructions.md`` holds the user's instructions: who they are,
136 how their files are organized, and how they want work done. The prompt
137 ends with them, and a change applies at the next call. The agent can
138 propose a change to the file, and it makes the change only after the
139 user agrees.
140
141 A permanent child agent lives in ``agent/children/NAME/`` with the same
142 files as the root. The root's ``agent_create(name, responsibility)``
143 tool creates and starts it with the root's model, effort, and working
144 directory, and a separate random ID. A name describes the area, such as
145 ``House`` or ``Calendar``. It is one word of 2 to 32 ASCII letters,
146 starts with a capital letter, and cannot name the root, an existing
147 child, or a subagent. The daemon opens all children at startup. A
148 missing or invalid child config stops startup. Only one level of
149 children is supported. Remove a child by hand while the daemon is
150 stopped. The root's turn state lists each child and its responsibility.
151 The root passes area messages from mixed chats with sender, date, and
152 words, and asks the user before it sends a child's draft to WhatsApp.
153
154 Each child has its own memory, model client, usage file, subagents,
155 schedules, and turn loop. Its prompt limits work to its area, asks for
156 only reports that need the parent or the user, and permits an empty
157 reply. Children have no WhatsApp tools and draft replies instead of
158 sending them. Clients address only the root. A child's saved schedules
159 and subagents can resume at startup. The ready log line lists the child
160 names.
161
162 A message from a parent to its own child enters the child's log as
163 ``user`` and wakes it. A child's final reply reaches the parent as
164 ``work`` with the prefix ``[NAME] `` and wakes it. An empty reply sends
165 nothing. A busy recipient takes the message between tool calls or in its
166 next turn. ``agent/messages.json``, with mode 0600, keeps messages until
167 their recipient finishes its turn. A daemon stop preserves queued
168 messages and reports for the next start without a second log entry. An
169 interrupted turn can repeat its model calls and tools after restart.
170 Backups lock this queue with the agent files, including children made
171 during the daemon run. The root uses ``tell`` for both children and
172 subagents. ``zoom("Name")`` gives a child's view or a subagent's whole
173 log. ``spawn`` starts short-lived subagents, not permanent children.
174
175 **************
176  User service
177 **************
178
179 The Nix package includes ``share/systemd/user/onechat.service``. It runs
180 ``onechat daemon`` from that package, starts at login when enabled, and
181 restarts 5 seconds after a failure. A stop sends SIGTERM to the daemon
182 and allows up to 120 seconds for cleanup. Its output goes to the user
183 journal. The service has the rights of the user, as the terminal daemon
184 does.
185
186 Build a package with a separate result link. Keep this link so that Nix
187 does not collect the package while the service uses it:
188
189 .. code:: console
190
191    nix build --out-link result-service
192    systemctl --user link --force "$PWD/result-service/share/systemd/user/onechat.service"
193    systemctl --user edit onechat.service
194
195 In the editor, set the data directory with this drop-in. Replace
196 ``DATA_DIR`` with an absolute path to the data directory. Set the same
197 ``ONECHAT_DIR`` for each terminal client. Without a drop-in, the data
198 directory rules above apply to the environment of the user manager, not
199 to the development shell.
200
201 .. code:: ini
202
203    [Service]
204    Environment="ONECHAT_DIR=DATA_DIR"
205
206 The service does not read the environment of an open terminal. If the
207 agent needs tools from a shell, set ``Environment="PATH=TOOLS_PATH"`` in
208 the same drop-in, with ``TOOLS_PATH`` replaced by the complete search
209 path, including bash. For the API endpoint, use
210 ``EnvironmentFile=ENV_FILE``, with ``ENV_FILE`` replaced by an absolute
211 path to a mode 0600 file that sets ``OPENAI_API_KEY``. Keep secrets out
212 of the unit and Nix sources.
213
214 Use the existing data directory, or copy the development data before the
215 first service start. For a copy, first stop the terminal daemon with
216 Ctrl-C and wait for it to exit. Replace ``DATA_DIR`` below with a new,
217 empty destination. If the development shell uses a custom
218 ``ONECHAT_DIR``, replace ``.devenv/onechat`` with that source. Copy the
219 whole directory so that ``auth.json``, ``telegram.json``,
220 ``config.json``, ``agent/``, and ``whatsapp/`` stay together, including
221 the WhatsApp session and its database files. The copy preserves the file
222 modes and the source as a backup. Do not copy or use the same data from
223 two running daemons.
224
225 .. code:: console
226
227    install -d -m 0700 DATA_DIR
228    cp -a .devenv/onechat/. DATA_DIR/
229
230 After the terminal daemon exits and the data is ready, enable and start
231 the service:
232
233 .. code:: console
234
235    systemctl --user daemon-reload
236    systemctl --user enable --now onechat.service
237    systemctl --user status onechat.service
238    ONECHAT_DIR=DATA_DIR ./result-service/bin/onechat
239    journalctl --user -u onechat.service -f
240
241 After a pull, update the package and unit with:
242
243 .. code:: console
244
245    git pull
246    bin/service-update
247
248 The script builds with the same ``result-service`` link, reloads the
249 user manager, and restarts the service only if it is active. The unit
250 link follows ``result-service``, so no new link is needed.
251
252 The service starts with the user manager at login, not at boot without a
253 login. To stop it, or also remove its start at login:
254
255 .. code:: console
256
257    systemctl --user stop onechat.service
258    systemctl --user disable onechat.service
259
260 ********************
261  Backup and restore
262 ********************
263
264 Back up the running daemon to one mode 0600 ``.tar.gz`` archive. The
265 command prints its path and size in bytes. With no daemon, it fails.
266
267 .. code:: console
268
269    onechat backup BACKUP_FILE.tar.gz
270    ONECHAT_DIR=EMPTY_DATA_DIR onechat restore BACKUP_FILE.tar.gz
271
272 The daemon flushes memory and holds its write locks during the agent
273 copy. It also flushes and locks the child agents and all subagent logs.
274 It copies the WhatsApp session with SQLite ``VACUUM INTO``, not as raw
275 database files. It then compresses the copy and installs the archive
276 through a temporary file and a rename. Use a backup path outside the
277 data directory.
278
279 The archive includes configuration, instructions, the log, tree,
280 indexes, views, layout, schedules, subagents, children, Telegram offset,
281 tokens, and WhatsApp state and session. It excludes sockets, lock files,
282 ``probe/``, and the ``agent-backup-*`` import copies. Links and special
283 files fail, rather than enter the archive. **The archive holds
284 secrets**, including login tokens, the Telegram bot token, and the
285 WhatsApp link. Keep it private. There is no encryption or remote copy.
286
287 Restore needs a stopped daemon and an empty destination. It checks the
288 token lock and validates the archive before it installs files, with
289 their modes. It rejects unsafe paths, links, and special files. A failed
290 restore can leave ``auth.lock`` in the destination. Use another empty
291 directory for a retry. Do not run the source and restored WhatsApp links
292 at the same time. Check the restored log and view before you use it in
293 place of the source.
294
295 For a daily local archive with the last 14 kept, save this script as
296 ``BACKUP_SCRIPT``. Replace ``BACKUP_DIR`` with a private archive
297 directory outside the data directory. Replace ``ONECHAT_BINARY`` with
298 the absolute path through the stable ``result-service/bin/onechat``
299 package link. Make the archive directory with mode 0700 before you
300 enable the timer.
301
302 .. code:: sh
303
304    #!/bin/sh
305    set -eu
306    umask 077
307    cd BACKUP_DIR
308    ONECHAT_BINARY backup "onechat-$(date +%F).tar.gz"
309    find . -maxdepth 1 -type f -name 'onechat-????-??-??.tar.gz' -printf '%f\n' |
310      sort -r | tail -n +15 |
311      while IFS= read -r file; do
312        rm -- "$file"
313      done
314
315 Save these units as ``onechat-backup.service`` and
316 ``onechat-backup.timer`` in the systemd user unit directory. Replace
317 ``DATA_DIR`` and ``BACKUP_SCRIPT`` with absolute paths. The service uses
318 the daemon unit from User service above.
319
320 .. code:: ini
321
322    [Unit]
323    Description=Back up OneChat data
324    Requires=onechat.service
325    After=onechat.service
326
327    [Service]
328    Type=oneshot
329    Environment="ONECHAT_DIR=DATA_DIR"
330    UMask=0077
331    ExecStart=/bin/sh BACKUP_SCRIPT
332
333 .. code:: ini
334
335    [Unit]
336    Description=Back up OneChat each day
337
338    [Timer]
339    OnCalendar=daily
340    Persistent=true
341
342    [Install]
343    WantedBy=timers.target
344
345 Enable the timer with the user manager. A failed backup leaves the
346 existing archives in place and skips removal of old archives.
347
348 .. code:: console
349
350    systemctl --user daemon-reload
351    systemctl --user enable --now onechat-backup.timer
352    systemctl --user list-timers onechat-backup.timer
353
354 ************
355  Daemon log
356 ************
357
358 The daemon writes short events with the part prefix and ordered
359 ``key=value`` attributes. An error cause comes last. Normal events use
360 info, failures that can be retried or lose nothing use warning, and
361 failures that lose data or stop work use error.
362
363 With ``JOURNAL_STREAM`` set, each line carries a syslog priority without
364 a time stamp or level word. The user journal can show only warnings and
365 errors:
366
367 .. code:: console
368
369    journalctl --user -u onechat -p warning
370
371 The summary warning names its node. A schedule run has ``late`` only
372 when it ran late:
373
374 .. code:: text
375
376    <4>onechat: summary failed level=0 index=0: fake failure
377    <6>onechat: schedule ran id=1
378    <6>onechat: schedule ran id=2 late=1m0s
379
380 Outside the journal, each line starts with the time stamp and level.
381
382 **********
383  Telegram
384 **********
385
386 The daemon also connects the agent to a Telegram bot when
387 ``telegram.json`` exists in the data directory. It polls the Bot API for
388 new messages, so it needs no open port.
389
390 #. Make a bot with BotFather and keep its token.
391
392 #. Find your numeric Telegram user ID.
393
394 #. Write the token and the ID to ``telegram.json``, and give the file
395    mode 0600, because the token is a secret. The daemon refuses the file
396    with any other mode.
397
398 #. Start the daemon, open a private chat with the bot, and write.
399
400 .. code:: json
401
402    {"token": "123456:ABC-token-from-BotFather", "owner": 123456789}
403
404 The bot reads only the private chat of the owner and ignores all other
405 updates. Each text message goes to the agent as a user message, the line
406 ``/stop`` interrupts the running turn, and the line ``/stop NAME`` stops
407 the subagent NAME. The bot sends the replies in parts of at most 4096
408 characters, with their Markdown as Telegram formats: bold, italic,
409 strikethrough, code, code blocks, links, and headings as bold text. When
410 Telegram refuses the formats of a part, the bot sends the part again as
411 plain text. Each error goes as a short message in plain text. It shows
412 the typing action while a turn runs. It does not send the tool calls.
413 For a subagent it sends only ``[NAME] started`` and ``[NAME] finished``,
414 ``[NAME] stopped``, or ``[NAME] failed``, because the reply of the agent
415 carries the content of the report. It sends each message that the user
416 types in the terminal client, with the mark ``[terminal]``.
417
418 ``agent/telegram-offset.json`` holds the ID of the next update. The bot
419 moves it past an update only after the message is durable in the log, so
420 a restart of the daemon does not lose a message. A crash just after the
421 write of a message can make the message repeat once. A network failure
422 or a rate limit makes the bot wait and try again, and does not stop the
423 daemon.
424
425 **********
426  WhatsApp
427 **********
428
429 The daemon connects to the WhatsApp account of the user as a linked
430 device. The terminal client and Telegram take three commands:
431
432 ``/whatsapp pair PHONE``
433    Links the account of PHONE, the number with its country code, such as
434    ``+15550001111``. The daemon answers with the steps and then with a
435    pairing code in its own message: in WhatsApp on the phone, open
436    Linked devices, tap Link a device, then tap Link with phone number
437    instead, and enter the code. The code expires in about 2 minutes, and
438    the same command gives a new code. When the link succeeds, the daemon
439    answers with the linked number. Linked devices on the phone shows the
440    device as OneChat. The name applies at the pairing, so an existing
441    link keeps its old name until ``/whatsapp unlink`` and a new
442    ``/whatsapp pair PHONE``.
443
444 ``/whatsapp status``
445    Shows the link: not linked, linked and connected, or linked and
446    disconnected, with the times of the connection and the WhatsApp
447    routes.
448
449 ``/whatsapp unlink``
450    Ends the link and deletes the session. When WhatsApp is disconnected,
451    the phone does not get the logout, so remove the device in Linked
452    devices on the phone.
453
454 The answers go only to the client that asked, and the commands do not go
455 to the agent, so the agent never gets the phone number or the code. The
456 chat gets only the notes that WhatsApp is linked or unlinked.
457
458 The session is ``whatsapp/session.db`` in the data directory, with mode
459 0600 in a directory with mode 0700. It gives full access to the WhatsApp
460 account, so the daemon refuses the file when its mode is not 0600. The
461 shell of the agent runs as the user, so it can read the session. If the
462 session leaks, remove the linked device in WhatsApp on the phone, and
463 the session stops working. Without the file, WhatsApp is not linked.
464
465 The daemon reads the text messages of the private chats and the groups,
466 from other people and from the user. It does not read media, reactions,
467 edits, deletions, view-once and disappearing messages, or the history
468 from before the link. The messages of one chat form one batch until the
469 chat is quiet for 20 seconds, for 2 minutes or 50 messages at the most.
470 Each batch goes to the chat of the agent as one work message that starts
471 with ``[WhatsApp]``, with the name and the ID of the chat, and the time,
472 the sender, and the text of each message. The messages of the user show
473 as ``You``. ``whatsapp/state.json``, with mode 0600, keeps the batches
474 that are not in the chat yet, so they survive a restart, and the records
475 of the sends. A crash just after a batch goes to the chat can make the
476 batch repeat once.
477
478 A batch of a private chat with a message of another person starts a
479 turn, and so does a batch of a group with a message that mentions the
480 user or replies to a message of the user. The other batches go to the
481 chat with no turn, and the agent sees them at its next turn. A batch
482 that starts a turn ends with a line that tells the agent to end its turn
483 with an empty reply when nothing in the batch needs the user. No client
484 shows an empty reply, so such a batch gives no message in Telegram. The
485 terminal client shows each batch, and Telegram shows none.
486
487 The phone number of the user never goes to the chat: a mention of the
488 user shows as ``@you``, the number shows as ``OWN``, and the chat of the
489 user with itself is not read. But the IDs of the other chats go to the
490 chat and to the model, because the agent needs them to send. The ID of a
491 private chat holds the phone number of the contact, such as
492 ``15550002222@s.whatsapp.net``, and the ID of a group can hold the
493 number of the person who made it.
494
495 While the daemon is stopped or disconnected, the WhatsApp servers keep
496 the messages for the linked device and send them at the next connection.
497 They keep them only for a limited time, so a long stop can lose
498 messages. These messages form batches by their original times, as live
499 messages do. The daemon waits for the end of this catch-up, or one
500 minute at the most, and then gives all its unrouted batches to the root
501 in one turn, not one turn for each chat. Routed batches wake their
502 child. The batches of ``whatsapp/state.json`` at the start wait for the
503 first catch-up in the same way.
504
505 The agent finds a chat with the tool ``whatsapp_find``: it gives the
506 contacts and the groups whose name holds a text, with no regard to case
507 and accents, with their IDs. The names of the contacts come from the
508 session, and the names of the groups from WhatsApp, kept in memory for
509 one hour. The names arrive from the phone some minutes after the link,
510 and until then the tool says that they are not ready. The agent sends a
511 text message with the tool ``whatsapp_send``, only when the user asks
512 for it. Each send has an ID that the agent gives: a repeated call with
513 the same ID gives the answer of the first call and sends nothing. The
514 state file keeps the last 1000 send records. When the daemon stops
515 during a send, the result is not known, and the tool tells the agent to
516 ask the user to look at the chat in WhatsApp. Without a link, both tools
517 tell the agent to ask the user to run ``/whatsapp pair PHONE``. Only the
518 agent has the WhatsApp tools, not its subagents.
519
520 Each batch of a group goes to the chat, also with no turn, and the
521 memory tree summarizes it with model calls, which use the quota.
522
523 The root manages source routes with ``route(source, agent)``,
524 ``unroute(source)``, and ``routes()``, only after the user agrees. A
525 source is ``whatsapp:CHAT-ID``, with the ID from ``whatsapp_find`` or a
526 ``[WhatsApp]`` message, and the agent must be its child. The root keeps
527 the map in ``agent/routes.json`` with mode 0600. Children and subagents
528 have no route tools. There is no command to change routes. Each routed
529 batch enters only its child's log and wakes it, including a group batch
530 with no mention. The durable agent queue prevents a second log entry
531 after a restart. An unrouted source or a missing child uses the root. A
532 queued batch whose child is removed while stopped goes to the root.
533
534 When the session holds a linked device, the daemon connects at its
535 start. If the first connection fails, it tries again after 2 seconds,
536 then doubles the wait up to 5 minutes. The inbox and commands stay
537 active during each wait. An unlink or a logout stops these retries.
538 After a network failure of an open connection, the library connects
539 again by itself. When a disconnection lasts 10 minutes, all clients get
540 one notice, and one more when the connection comes back. When WhatsApp
541 ends the connection for good, for example because another program uses
542 the same session, all clients get a notice at once. When the user
543 removes the device in the phone, the daemon deletes the session and
544 tells all clients one time.
545
546 *************************
547  Import WhatsApp history
548 *************************
549
550 Stop the daemon and finish all subagents before an import. Export each
551 chat from WhatsApp as a ZIP with ``_chat.txt``. Keep the chat name in
552 the ZIP name, such as ``WhatsApp Chat - Example.zip``. Read the plan
553 first:
554
555 .. code:: console
556
557    onechat import -n CHAT.zip OTHER.zip
558    onechat import CHAT.zip OTHER.zip
559
560 ``-n`` makes no model call and prints the chats, chat-day batches, local
561 dates, cutoff, text bytes, merged message count, and estimated calls.
562 The import keeps multiline text, system lines, and omitted-media
563 markers. It maps the session's profile name to ``You``. Without that
564 name, it keeps the senders as written and says so in the plan. An exact,
565 unambiguous local contact or batch name supplies the chat ID when known.
566 No network lookup is made.
567
568 Each chat and local day becomes one work message marked ``(imported)``.
569 Only messages before the first message of the earliest live WhatsApp
570 batch enter the import. Live times have minute precision, so the cutoff
571 excludes that whole minute. A chat-day already imported is skipped.
572 Overlapping chat-days in the supplied ZIPs are refused. Review the
573 exports before import: their text goes to the model as chat history.
574
575 The import merges the full log in date order and builds its tree with
576 real compactor calls, the agent prompt, and the agent cache key. It uses
577 a limit of 1,500 calls and the probe's 15-quota-point limit, and prints
578 the same usage report. Its estimate is about 1.3 calls per merged
579 message, not a cost guarantee. A stopped or failed run leaves the agent
580 unchanged. A new run starts over. Success keeps the other agent files
581 and renames the old directory to ``agent-backup-TIMESTAMP`` before it
582 installs the new one. Keep the backup until the new memory is checked,
583 then start the daemon. Message IDs change to their positions in the
584 merged log.
585
586 ***********
587  Schedules
588 ***********
589
590 The agent sets reminders and timed tasks when the user asks for them,
591 such as "remind me tomorrow at 9" or "every weekday at 8, tell me what
592 my WhatsApp groups talked about". A schedule has a text, the task for
593 the agent, and either one time or a cron expression. The agent also
594 lists the schedules with their next times, and removes one when the user
595 asks. The root and its children have the schedule tools, not their
596 subagents. Each schedule records its owner and wakes only that agent.
597
598 A cron expression has five fields: minute, hour, day of month, month,
599 and day of week, where 0 and 7 are Sunday. A field is a comma list of
600 ``*``, ``N``, and ``N-M``, and ``*`` and ``N-M`` can have a step ``/S``,
601 as in ``0 8 * * 1-5`` or ``*/15 9-17 * * *``. When both day fields start
602 with something other than ``*``, a day matches when either field
603 matches. Names such as ``MON``, macros such as ``@daily``, the
604 characters ``? L W #``, and ``N/S`` are refused. The runs must be at
605 least 5 minutes apart, because each run is a full turn that uses the
606 quota, so ``*/5 * * * *`` is the shortest schedule.
607
608 The times use the time zone of the machine. A time that the clock skips
609 at the start of daylight saving time runs later by the length of the
610 skip. A time that the clock repeats at its end runs once, at its first
611 occurrence.
612
613 At its time, a schedule goes to the chat as a work message that starts
614 with ``[Schedule ID]``, with its time and its text, and it starts a
615 turn. The message ends with a line that tells the agent to end its turn
616 with an empty reply when nothing needs the user. The terminal client
617 shows the message, and Telegram shows only the reply. After its run, a
618 one-time schedule goes, and a repeating schedule gets its next time.
619
620 ``agent/schedules.json``, with mode 0600, holds the schedules. Each
621 change writes it before the tool answers, so the schedules survive a
622 restart. A schedule that came due while the daemon was stopped runs once
623 at the next start, with its original time and a line that says that the
624 run is late. A repeating schedule that missed several times runs once. A
625 crash just after a run goes to the chat can make the run repeat once.
626
627 *******
628  Probe
629 *******
630
631 ``onechat probe LOG COUNT [BUILDS]`` measures the compactor on a log. It
632 appends the first COUNT messages of LOG, one JSON object ``{"kind",
633 "text"}`` on each line, to a new chat in ``probe/`` in the data
634 directory, and builds its tree with real model calls. The calls use the
635 quota of the account. The run stops at the first failed call, after 660
636 calls, or when the used percent of a quota window grows by more than 15
637 points. At the end it reports the calls per message, the cached share of
638 the input tokens, the retries, the line sizes, and the quota at the
639 first and the last call. BUILDS, from 1 to 8, limits the model calls at
640 once in the run.
641
642 A test writes a generated log of readable made-up messages:
643
644 .. code:: console
645
646    ONECHAT_PROBE_LOG=probe.jsonl go test -run TestProbeLogHasTheSyntheticMix
647
648 *************
649  Development
650 *************
651
652 Nix supplies the Go toolchain and all tools. Enter the development shell
653 with direnv:
654
655 .. code:: console
656
657    direnv allow
658
659 Or enter it directly:
660
661 .. code:: console
662
663    nix develop
664
665 The shell sets ``ONECHAT_DIR`` to ``.devenv/onechat`` in the checkout,
666 unless it is already set. Development data goes there, and Git ignores
667 ``.devenv``.
668
669 Build and run:
670
671 .. code:: console
672
673    nix build
674    ./result/bin/onechat
675
676 ``onechat version`` prints ``yyyy-mm-dd-gitshort`` for a Nix build, adds
677 ``-dirty`` for a dirty tree, and prints ``dev`` for a direct Go build.
678
679 Format and check all files:
680
681 .. code:: console
682
683    bin/format
684    bin/check
685    nix flake check
686
687 ``bin/check`` and ``nix build`` run a short replay of the synthetic log.
688 ``nix flake check`` also runs the full replay of 100,000 messages.