Cmech
                 irc pipeline logic engine






#000 Index

#001 Introduction
#002 Architecture
#003 Pipeline
#004 Output protocol
#005 Token expansion
#006 Session model
#007 Registry
#008 Security
#009 System user
#010 Configuration
#011 bin/ scripts
#012 Operation
#013 Troubleshooting
#014 Log file
#015 Configuration files


#001 Introduction

 cmech is the logic engine for the cmech bot system.
 It reads IRC messages from a named pipe written by
 cyb, runs them through a configurable pipeline of
 shell scripts and executables in bin/, and sends
 replies back through a second named pipe for cyb to
 deliver to IRC.

 cmech holds no sockets. It cannot initiate or receive
 network connections. This is not an accident     it is
 the containment boundary. A compromised cmech script
 or pipeline entry cannot exfiltrate data because there
 is no network to reach. The only output channel is
 the named pipe back to cyb, and cyb only accepts
 PRIVMSG-style commands through it.

 The pipeline is the central design decision. Rather
 than hardcoding bot behaviour in C, cmech executes
 programs. Any executable in bin/ participates in the
 pipeline without recompilation. The bot is extended
 by adding scripts. The routing rules are changed by
 modifying the registry in cmech.c. The pipeline
 protocol is a small set of output directives that any
 program can emit on stdout.

 Session processes extend this to stateful exchanges.
 A session is a persistent child process that stays
 alive across many messages, accumulating state inside
 the child. cmech writes one line per exchange to the
 session stdin and reads responses asynchronously from
 its stdout in the select loop. The LLM and Eliza
 integrations are sessions.

 The decoupling of write and read is deliberate. A
 session running a slow LLM inference does not block
 IRC processing. cmech writes the message to the
 session and immediately returns to the select loop.
 When the session produces output minutes later, the
 select loop picks it up and delivers it to IRC.


#002 Architecture

 cmech is a single-threaded select loop. Every
 iteration checks periodic tasks, reaps idle sessions,
 then dispatches readable file descriptors. Three fd
 sets are watched: the IRC input pipe from cyb, stdin
 for operator injection, and the read_fd of every
 active session.

 Pipe repair is transparent. If the input or output
 pipe breaks (peer closes, file removed), pipe_repair()
 re   creates the named pipe with mkfifo(2) and reopens
 it, falling back to O_RDWR when no peer is present.
 This allows cmech to survive a cyb restart without
 exiting.

 Call flow:
 ___________________________________________________________
 main()
   |
   v
 run()
   |
   v
 select() loop <-----------------------------------.
   |                                               |
   +-- periodic timer                              |
   |     -> check_periodic_tasks()                 |
   |                                               |
   +-- session reap                                |
   |     -> session_reap_idle()                    |
   |                                               |
   +-- session read_fd readable  (per active slot) |
   |     -> session_handle_readable()              |
   |          read 1 byte at a time until \n       |
   |          build _msg, set origin=command_name  |
   |          -> pipeline_run()                    |
   |                                               |
   +-- stdin readable                              |
   |     -> handle_stdin_input()                   |
   |          write line to out_fd (cyb.in)        |
   |                                               |
   +-- in_fd readable (IRC message from cyb)       |
         -> handle_bot_message()                   |
              parse PRIVMSG, set target            |
              -> pipeline_run()                    |
                   pre group                       |
                   process group                   |
                   post group                      |
                   -> pipeline_send_output()       |
                        write :m lines to out_fd   |
                                                   |
   '-----------------------------------------------'
 ___________________________________________________________

 State is held in two structs. _robot_conf holds all
 configuration: paths, nick, debug flag. _robot holds
 the open file descriptors for the current run. Both
 are declared in main and passed by pointer. No global
 mutable state exists outside progname and version.

 The sessions array is a fixed-size array of _session
 structs at file scope. SESSION_MAX = 16 slots. Each
 slot is keyed by context (channel or nick) plus
 command name. The array is zero-initialised at
 program start; active slots have active=1.


#003 Pipeline

 Every addressed PRIVMSG and every line from an active
 session fd passes through the pipeline. The pipeline
 runs three groups in order: pre, process, post.

 pre     All entries in registry order. Used for
         routing decisions and input rewriting.
         Runs to completion unless a terminal
         output directive is emitted.

 process First matching entry runs. If that entry
         emits route:, execution jumps to the named
         entry and continues. Otherwise post runs.

 post    All entries in registry order. Used for
         output transformation and logging.

 pipeline_run() initialises a _pipeline_run struct,
 copies the incoming _msg with msg_cpy(), sets origin,
 txt, and par, then iterates the registry executing
 each matching entry. It maintains a process_done
 flag to enforce the one-entry rule for the process
 group. route: resets this by pointing gi to the
 target group index.

 The help built-in is intercepted before the pipeline.
 If msg.txt equals "help", cmech iterates registry[]
 and sends one PRIVMSG per entry showing group, mode,
 command name, and description. No pipeline runs.

 At pipeline completion:
   if !run.done || run.send -> pipeline_send_output()
   if run.done && !run.send -> nothing sent

 run.send defaults to 1. end sets done=1, send=0.
 final: sets done=1, send=1. A pipeline that exhausts
 without any terminal directive sends whatever is in
 run.output if non-empty.

 _pipeline_run fields:
 ___________________________________________________________
 msg          working _msg copy (mutable by set:)
 output       accumulated reply, newline-separated
 origin       "irc" or session command name (read-only)
 jump         1 if route: was parsed this step
 jump_group   group portion of route: target
 jump_cmd     command portion of route: target
 done         1 if end or final: was parsed
 send         1 to send output on done (default 1)
 jump_count   loop guard, max PIPELINE_JUMP_MAX (32)
 ___________________________________________________________

 Invariant: origin is set once from internal state
 before the pipeline runs and is never modified by
 any output directive. Scripts receive it as %ORIGIN%
 but set: origin is not a valid directive and is
 silently ignored. This is the basis of the trust
 model: a script cannot forge its own origin.


#004 Output protocol

 Executables in bin/ communicate with cmech by writing
 lines to stdout. stderr is merged into stdout via
 2>&1 in the constructed command string. The protocol
 applies identically in all groups and for both
 oneshot and session modes.

 ___________________________________________________________
 Line             Effect
 ___________________________________________________________
 (empty)          skip, continue
 plain text       append to run.output + newline
 set: field val   mutate run.msg field, continue
 route: grp:cmd   jump to named registry entry
 end              done=1, send=0, stop pipeline
 final:           done=1, send=1, stop pipeline
 final: message   append message, done=1, send=1, stop
 ___________________________________________________________

 set: field must be one of: usr, cmd, par, txt.
 The value follows after a single space. The field is
 set by calling the corresponding msg_set_*() function.
 On success the mutation is visible to all subsequent
 pipeline steps via token expansion. On failure
 (overflow) the field is unchanged and a debug message
 is emitted.

 route: format is "route: group:command" where group
 is pre, process, or post and command is the binary
 name as it appears in the registry command field.
 The match is against the binary name only, not the
 full command+args string. If no matching entry is
 found the route is logged in debug and execution
 continues. PIPELINE_JUMP_MAX = 32 guards against
 loops.

 end and final: are terminal. Once either is parsed,
 pipeline_apply_output returns 1 and the calling loop
 stops reading stdout. Remaining output is discarded.

 Normal pipeline exhaustion without any terminal
 directive behaves as send=1: pipeline_send_output
 runs if run.output is non-empty.


#005 Token expansion

 The args field of each registry entry may contain
 tokens expanded at command execution time by
 pipeline_token_expand() before the command string
 is built.

 ___________________________________________________________
 Token       Expands to
 ___________________________________________________________
 %TXT%       run.msg.txt (current input text)
 %PAR%       run.msg.par (reply target)
 %USR%       run.msg.usr (sender nick)
 %OUTPUT%    run.output (accumulated reply so far)
 %BOTNICK%   bot nick from conf.nick
 %ORIGIN%    run.origin ("irc" or session command name)
 %HOME%      conf.home_base (cmech home root)
 ___________________________________________________________

 Every expanded value is wrapped in single quotes
 before insertion into the shell command string.
 Internal single quotes in the value are escaped as
 '\'' (end-quote, backslash-quote, open-quote). This
 prevents IRC input from injecting shell metacharacters
 through token expansion.

 %ORIGIN% is read-only. It reflects internal session
 state set before the pipeline runs and cannot be
 modified by any script output. Scripts that gate
 behaviour on %ORIGIN% can trust the value.

 %HOME% expands to the home base directory. For user
 installs this is $HOME/.cmech (or the path given with
 -r). For system installs using -r it is the path
 passed to -r. Scripts use it to locate per-bot files
 without hardcoding paths.

 Tokens not listed above are passed through literally.


#006 Session model

 A session is a persistent child process keyed by
 context + command name. Two sessions for the same
 command in different channels are independent slots.
 The same command in the same channel reuses the
 existing session.

 session_create() forks the command under /bin/sh -c,
 connects two pipes (to_child, from_child), and stores
 the pid and fds in a _session slot. SESSION_MAX = 16.

 Write and read are fully decoupled.

 Write path: pipeline_exec_session() calls
 session_get() or session_create(), then
 session_write() which writes one line to write_fd.
 Returns immediately without waiting for a response.

 Read path: the select loop watches all active
 read_fds. When readable, session_handle_readable()
 reads one complete line byte-by-byte until '\n',
 strips trailing \r\n, builds a minimal _msg with
 origin=session->command, and calls pipeline_run().

 The byte-by-byte read is deliberate. Session output
 arrives in unpredictable chunk sizes from the OS. A
 single read() would deliver a fragment. Reading until
 '\n' guarantees one complete protocol line per
 pipeline_run() call regardless of child buffering.

 Session lifecycle:
 ___________________________________________________________
 created    session_create(): fork, pipes, active=1
 active     session_write() on each exchange
            select loop reads output asynchronously
 idle reap  now - last_active >= SESSION_IDLE_TIMEOUT
            (1800s): session_cleanup()
 exit reap  waitpid(WNOHANG) > 0: session_cleanup()
 cleanup    session_cleanup(): close fds, SIGTERM,
            waitpid, explicit_bzero slot
 ___________________________________________________________

 _session fields:
 ___________________________________________________________
 context     channel or nick, part of session key
 command     binary name, part of session key
 pid         child process pid
 write_fd    cmech writes one line per exchange here
 read_fd     cmech reads responses (select loop)
 last_active timestamp of last exchange (idle reap)
 active      1 if slot in use
 ___________________________________________________________

 Invariant: active=1 implies pid > 0, write_fd >= 0,
 read_fd >= 0. Both fds are either valid and open or
 -1. session_cleanup() is safe on inactive slots.


#007 Registry

 The registry is a static array of cmd_entry structs
 in cmech.c. It is the single configuration point for
 the pipeline. Changing bot behaviour means changing
 the registry and recompiling.

 cmd_entry fields:
 ___________________________________________________________
 group     "pre", "process", "post", "periodic"
 mode      "oneshot" or "session"
 command   binary name in bin/ with optional static args
 args      token-expanded dynamic args, NULL if none
 target    IRC target for periodic entries, else NULL
 interval  seconds between runs for periodic, else 0
 last_run  internal timestamp, initialise to 0
 desc      one-line description for help built-in
 ___________________________________________________________

 The sentinel entry {NULL,...} terminates the array.
 All registry iteration stops at the NULL group check.

 Current registry:
 ___________________________________________________________
 pre/oneshot    pre_fire       -o %ORIGIN% -b %BOTNICK%
                               -p %PAR% -t %TXT%
 pre/oneshot    pre_session    -o %ORIGIN%
 pre/oneshot    pre_command    (reads stdin only)
 process/session eliza-session -r %HOME% -n %BOTNICK%
 process/session llmcli-session -p %PAR% -u %USR%
                               -b %BOTNICK%
 process/oneshot doc
 process/oneshot man
 process/oneshot uptime
 periodic/oneshot sfeed-news   #main every 900s
 periodic/oneshot uptime       #main every 3600s
 ___________________________________________________________

 Periodic entries bypass the pipeline. They run on
 schedule, receive no stdin, and write output directly
 as :m target line commands to out_fd. The first
 scheduled run is skipped (last_run initialised to 0
 triggers a skip-and-set on first check) to prevent
 a burst on startup.

 pre_fire must be the first pre entry. It receives
 every PRIVMSG and decides whether to proceed or emit
 end. Any entry placed before it would see all channel
 traffic including messages not addressed to the bot.

 The first process entry (eliza-session) is the
 default fallthrough. All messages that pass pre and
 are not routed by pre_command reach it.


#008 Security

 cmech applies OpenBSD privilege restriction after
 path construction in main().

 unveil(2) scope:
 ___________________________________________________________
 conf.log_path             rwc
 conf.in_path              rc
 conf.out_path             wc
 conf.bin_dir              rx
 /bin/sh                   x
 /bin/ksh                  x
 /usr/bin/man              rx
 /usr/bin/col              x
 /usr/bin/finger           x
 /usr/share/man            r
 /usr/local/bin/perl       x
 /usr/local/libdata/perl5  r
 /usr/libdata/perl5        r
 home/.cmech/eliza.txt     r  (ENOENT silently ignored)
 ___________________________________________________________

 pledge(2) scope:
   stdio rpath wpath cpath proc exec

 proc and exec are required for fork()/execl() in
 pipeline_exec_oneshot() and session_create(). inet
 and dns are not pledged. cmech cannot open sockets
 or resolve hostnames.

 Child processes launched by execl() are not bound
 by the parent pledge. A malicious script running as
 a pipeline entry can perform arbitrary actions as the
 user. The unveil list limits filesystem access even
 for child processes on OpenBSD. Scripts that need
 paths not in the unveil list require new unveil()
 entries in main() before unveil(NULL, NULL).

 The origin trust model is described in #003.
 The input surface is the in_path pipe, described
 in #004. An attacker with write access to in_path
 has IRC-level bot access but cannot escalate through
 cmech to host access because cmech ignores all IRC
 commands except PRIVMSG.


#009 System user

 Create system user to run bot as a system daemon
 ___________________________________________________________
 # groupadd -g 697 _cmech
 # useradd -r 100..999 -g _cmech -L daemon \
   -s /sbin/nologin -d /var/cmech \
   -c "cmech bot account" _cmech
 ___________________________________________________________

 Authorise the rc.d script to start both daemons as
 `_cmech` without a password.  Add to /etc/doas.conf:
 ___________________________________________________________
 # Structure: permit [options] user [as target] cmd [args]
 permit nopass root as _cmech cmd /bin/sh args -c "cd /var/cmech && /usr/local/bin/cyb"
 permit nopass root as _cmech cmd /bin/sh args -c "cd /var/cmech && /usr/local/bin/cmech"
 ___________________________________________________________
 These rules allow the root   run rc script to invoke
 `doas -u _cmech    ` while keeping the daemon user
 password   less and shell   less.  After editing
 doas.conf, verify with `doas -C /etc/doas.conf`.

 All data lives under /var/cmech and must be owned
 by _cmech:
 ___________________________________________________________
 # chown -R _cmech:_cmech /var/cmech
 ___________________________________________________________


#010 Configuration

 All configuration passes as command-line flags.

 ___________________________________________________________
 Flag  Default                    Description
 ___________________________________________________________
 -r    (auto)                     base directory (home root)
                                  user: $HOME/.cmech
                                  system: /var/cmech
 -w    <base>/data                data directory override
 -x    <base>/bin                 bin directory override
 -n    $USER                      bot nick
 -i    <base>/<nick>.in           input pipe (from cyb)
 -o    <base>/<nick>.out          output pipe (to cyb)
 -d    off                        debug output to stdout
 -v    off                        print version and exit
 -h    off                        print usage and exit
 ___________________________________________________________

 -r sets the home root. The data directory, bin
 directory, and default pipe paths are all derived
 from it unless explicitly overridden with -w / -x.
 If -r is not given and the effective UID   s home
 directory equals /var/cmech, the base is taken as
 /var/cmech (system user). Otherwise $HOME/.cmech
 is used.

 -w changes the path to the data directory (default
 <base>/data). It is where eliza-session looks for
 a custom script and where eliza.txt is placed.

 -x changes the bin directory (default <base>/bin).
 Pipeline executables are searched here.

 The log file is derived from the input pipe path: it
 replaces the .in suffix with .log, or appends .log
 if the suffix is not present. See #014 for details.

 Compile-time constants in config.h:
 ___________________________________________________________
 SESSION_MAX          16     max concurrent sessions
 SESSION_IDLE_TIMEOUT 1800   seconds before idle reap
 PIPELINE_JUMP_MAX    32     max route: jumps per run
 BUFSIZE              4096   pipe and message buffer
 ___________________________________________________________


#011 bin/ scripts

 pre_fire
   First pre entry. Receives -o %ORIGIN% -b %BOTNICK%
   -p %PAR% -t %TXT%. Exits silently for non-irc
   origins. For IRC: fires on PM, botnick: prefix,
   botnick mention, or TRIGGERS word match. Strips
   botnick prefix from txt via set: for conditions 1
   and 2. Emits end to discard unaddressed messages.

 pre_session
   Second pre entry. Receives -o %ORIGIN%. For irc
   origin: exits silently. For session origin: reads
   reply from stdin. If reply starts with route:,
   emits it as a routing directive. Otherwise emits
   the reply as plain text and emits final:.

 pre_command
   Third pre entry. Reads message text from stdin.
   If first word matches an executable in bin/,
   emits set: txt remainder (if non-empty) and
   route: process:command. Silent on no match.

 eliza-session
   Default process entry. Persistent Perl process.
   Receives -r %HOME% -n %BOTNICK%. Resolves script:
   home/nick.txt, then home/eliza.txt, then internal.
   Stdout is unbuffered ($| = 1).

 llmcli-session
   Session process. Receives -p %PAR% -u %USR%
   -b %BOTNICK%. Queries ollama via llmcli. Emits
   response lines with 0.3s delay between them.

 man, doc, uptime, sfeed-news
   Oneshot and periodic entries. man runs man(1)
   through col -b. sfeed-news fetches RSS via sfeed
   with ID deduplication. Periodic entries for uptime
   and sfeed-news are commented out by default.


#012 Operation

 Start cyb first. cmech opens the pipes cyb creates.
 ___________________________________________________________
 $ ./cyb -n cyb -j "#main"
 $ ./cmech -d -n cyb
 cmech: 1.0.7
 base: /home/punk/.cmech
 nick: cyb
 in:   /home/punk/.cmech/cyb.in
 out:  /home/punk/.cmech/cyb.out
 log:  /home/punk/.cmech/cyb.log
 bin:  /home/punk/.cmech/bin
 ___________________________________________________________

 Debug output for an addressed message:
 ___________________________________________________________
 >> :punk!~punk@sophia.host PRIVMSG #main :cyb: hello
 msg:     context=#main txt=cyb: hello
 cmd:     [pre/oneshot] .../pre_fire -o 'irc' ...
 cmd:     [pre/oneshot] .../pre_session -o 'irc'
 cmd:     [pre/oneshot] .../pre_command
 cmd:     [process/session] .../eliza-session ...
 session: new eliza-session #main pid 8205
 session: readable eliza-session #main: How do you do.
 msg:     context=#main txt=How do you do.
 cmd:     [pre/oneshot] .../pre_fire -o 'eliza-session'
 cmd:     [pre/oneshot] .../pre_session -o 'eliza-session'
 cmd:     final:
 << :m #main How do you do.
 ___________________________________________________________

 Add a command without recompiling:
 ___________________________________________________________
 $ cat > ~/.cmech/bin/weather << 'EOF'
 #!/bin/ksh
 curl -s wttr.in/?format=3
 EOF
 $ chmod +x ~/.cmech/bin/weather
 ___________________________________________________________

 pre_command detects weather as the first word,
 emits route: process:weather, and the script runs.


#013 Troubleshooting

 No response to addressed messages:

   Run with -d. If no cmd: lines appear, pre_fire is
   missing or not executable. Verify:
 ___________________________________________________________
 $ ls -la ~/.cmech/bin/pre_fire
 -rwx------  1 punk  punk  ...  pre_fire
 ___________________________________________________________

   If cmd: lines appear but stop at pre_fire with
   "end", the message was not addressed to the bot.

 Session command not responding:
 ___________________________________________________________
 $ ps aux | grep eliza-session
 ___________________________________________________________

   "session: new" in debug on first use, "session:
   readable" when output arrives. If "session: new"
   appears but no "session: readable" follows, the
   child is buffering stdout. In Perl: $| = 1.

 route: jump not working:

   Target command must exist in registry and binary
   must exist in bin/. Debug emits:
   "cmd: route group:cmd not found" if missing.

 set: directive silently dropped:

   set: requires a space between field name and value.
   Empty value ("set: txt ") is silently dropped.

 LLM session silent:
 ___________________________________________________________
 $ export OLLAMA_HOST=192.168.1.4
 $ ./cmech -d -n cyb
 ___________________________________________________________


#014 Log file

 cmech writes a timestamped line for every message
 received from cyb and every reply sent back. The log
 file is located at <base>/<nick>.log (derived from
 the input pipe path by replacing .in with .log). Each
 entry is prefixed with the date, time, and direction:

 [YYYY-MM-DD HH:MM:SS] RECV: <raw irc line>
 [YYYY-MM-DD HH:MM:SS] SEND: <reply line written to pipe>

 The log is line-buffered, opened once at startup, and
 held open for the lifetime of the process. Failures to
 open the log are fatal.


#015 Configuration files

 ~/.cmech/cyb.out      input pipe (from cyb)
 ~/.cmech/cyb.in       output pipe (to cyb)
 ~/.cmech/cyb.log      traffic log
 ~/.cmech/bin/         pipeline scripts
 ~/.cmech/data/        data files (eliza.txt, nick.txt)
 ~/.cmech/eliza.txt   shared eliza script (optional)
 ~/.cmech/nick.txt     per-bot eliza script (optional)


                         *   *   *


[doc:cyb.txt]         gopher://sophia.host/0/dev/tools/cmech/cyb.txt
[std:posix.1-2017]    IEEE Std 1003.1-2017, Shell and
                      Utilities volume

____________________________________________________________
rev-1.0.7
gopher://sophia.host/0/dev/tools/cmech/cmech.txt
