Add encrypted SQLite message logging and an always-on daemon

ober

e758e145d590461f8d26750f3110d9f755ca2a7c

diff --git a/.gitignore b/.gitignore
index 1cf45fb..a07850e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,6 @@ program_boot.h
 vendor/
 signal_tui_shim.dylib
 signal_tui_shim.so
+signal_log_shim.dylib
+signal_log_shim.so
+*.db
diff --git a/Makefile b/Makefile
index 448671b..99960b7 100644
--- a/Makefile
+++ b/Makefile
@@ -19,8 +19,10 @@ else
 TUI_SHIM_EXT := so
 endif
 TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
+LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT)
+SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null)
 
-.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim
+.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim log-shim
 .DEFAULT_GOAL := help
 
 all: binary
@@ -34,7 +36,7 @@ build: binary
 run: binary
 	./$(BIN) $(ARGS)
 
-run-tui: binary tui-shim
+run-tui: binary tui-shim log-shim
 	./$(BIN) tui $(ARGS)
 
 test: binary
@@ -44,11 +46,13 @@ install: binary
 	mkdir -p $(BIN_DIR)
 	install -m 0755 $(BIN) $(BIN_DIR)/$(BIN)
 	test ! -f signal_tui_shim.$(TUI_SHIM_EXT) || install -m 0755 signal_tui_shim.$(TUI_SHIM_EXT) $(BIN_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
+	test ! -f $(LOG_SHIM) || install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM)
 	@echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)"
 
 clean:
 	rm -f $(BIN)
 	rm -f signal_tui_shim.dylib signal_tui_shim.so
+	rm -f signal_log_shim.dylib signal_log_shim.so
 	find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true
 
 vendor-deps: vendor/termbox2
@@ -65,6 +69,19 @@ tui-shim: vendor/termbox2
 	    signal/tui/signal_tui_shim.c
 	cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT)
 
+# Encrypted-logging shim (SQLCipher). Skipped with a notice if sqlcipher is
+# absent, so building/running still works without message logging.
+log-shim:
+	@if [ -z "$(SQLCIPHER_PREFIX)" ]; then \
+	  echo "log-shim: sqlcipher not found; skipping (encrypted logging disabled)."; \
+	  echo "          enable it with: brew install sqlcipher && make log-shim"; \
+	else \
+	  cc -shared -fPIC \
+	    -I$(SQLCIPHER_PREFIX)/include/sqlcipher \
+	    -L$(SQLCIPHER_PREFIX)/lib -lsqlcipher \
+	    -o $(LOG_SHIM) signal/log_shim.c && echo "Built $(LOG_SHIM)"; \
+	fi
+
 help:
 	@echo "jerboa-signal -- Signal client over signal-cli"
 	@echo ""
@@ -75,6 +92,7 @@ help:
 	@echo "  test                Run tests"
 	@echo "  install             Install ./jerboa-signal to ~/.local/bin"
 	@echo "  tui-shim            Build the termbox2 TUI shim"
+	@echo "  log-shim            Build the SQLCipher encrypted-logging shim"
 	@echo "  clean               Remove build artifacts"
 	@echo ""
 	@echo "Prerequisite: signal-cli must be linked to your Signal account."
diff --git a/README.md b/README.md
index 113bb8c..9964a20 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Bidirectional bridge between Signal and your shell:
 jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE   # send one message, exit
 jerboa-signal listen [-a +PHONE]                   # stream inbound as NDJSON
 jerboa-signal tui [-a +PHONE]                      # terminal UI shell
+jerboa-signal log [-a +PHONE]                      # headless: capture all to an encrypted DB
 ```
 
 `-a` is required only if multiple Signal accounts are linked to signal-cli.
@@ -21,6 +22,8 @@ jerboa-signal tui [-a +PHONE]                      # terminal UI shell
 
 - `signal-cli` on `PATH` (Homebrew: `brew install signal-cli`)
 - A Jerboa checkout next to this repo at `../jerboa`, or `JERBOA_HOME` set
+- Optional, for the encrypted message log: `brew install sqlcipher`, then
+  `make log-shim` (without it the TUI runs fine, just without logging)
 
 ## One-time setup: link as a secondary device
 
@@ -90,6 +93,54 @@ phone's contacts. If a removed person messages you again the thread reappears;
 to remove someone from your Signal account everywhere, delete them on your phone
 too. Use q, Esc, or Ctrl-C to quit.
 
+## Encrypted message log
+
+The TUI can log every message to an encrypted SQLite database so you keep a copy
+even when the sender later deletes it ("delete for everyone"). It uses
+[SQLCipher](https://www.zetetic.net/sqlcipher/) (AES-256) — the whole file,
+contents and metadata, is unreadable without your passphrase.
+
+Enable it:
+
+```sh
+brew install sqlcipher
+make log-shim                 # builds signal_log_shim.dylib
+./jerboa-signal tui           # prompts: passphrase for encrypted message log
+```
+
+At startup the TUI asks for a passphrase (echo off). Enter one to log; leave it
+blank to skip logging for that session. To run unattended, set
+`JERBOA_SIGNAL_DB_KEY` instead and the prompt is skipped.
+
+The database lives at `~/.local/share/jerboa-signal/messages-<account>.db`. Each
+inbound notification and outbound send is one append-only row (with the full raw
+JSON); remote-deletes and edits are logged as new rows and never overwrite the
+original. Read it back with the matching key:
+
+```sh
+sqlcipher ~/.local/share/jerboa-signal/messages-+15551234567.db \
+  "PRAGMA key='your passphrase'; SELECT timestamp,direction,sender,kind,body FROM messages;"
+```
+
+### Always-on logging daemon
+
+The TUI only logs while it is open. To capture messages around the clock, run
+the headless logger, which connects to signal-cli in receive mode and appends
+every event to the same encrypted database until stopped:
+
+```sh
+export JERBOA_SIGNAL_DB_KEY='your passphrase'   # required when headless
+jerboa-signal log                               # prints a line per captured event
+```
+
+Run it under launchd/`nohup`/tmux for 24/7 capture. It reads the passphrase from
+`JERBOA_SIGNAL_DB_KEY`, or prompts if started attached to a terminal. Each event
+is committed immediately, so killing the daemon never loses logged messages.
+
+Note: signal-cli allows only one connection per account, so the daemon and the
+TUI cannot run at the same time for the same account — use the daemon for
+unattended capture and the TUI for reading/replying.
+
 ## Architecture
 
 ```
@@ -109,6 +160,10 @@ Modules:
 - `signal/cmd-send.ss` — `send` subcommand
 - `signal/cmd-listen.ss` — `listen` subcommand
 - `signal/cmd-tui.ss` — TUI command entry point; see `docs/TUI_PLAN.md`
+- `signal/cmd-log.ss` — always-on headless logging daemon (`log` subcommand)
+- `signal/store.ss` — persistent local state (deleted-conversation list)
+- `signal/capture.ss` — notification → encrypted log row (shared by TUI + daemon)
+- `signal/logdb.ss` + `signal/log_shim.c` — FFI to the SQLCipher encrypted log
 - `signal/tui/` — termbox2 FFI and the first terminal shell
 
 ## License
diff --git a/signal/capture.ss b/signal/capture.ss
new file mode 100644
index 0000000..f9a7bb4
--- /dev/null
+++ b/signal/capture.ss
@@ -0,0 +1,173 @@
+#!chezscheme
+;;; signal/capture -- normalize one signal-cli notification into a single
+;;; encrypted log row. Shared by the TUI and the `log` daemon so both capture
+;;; identically. The full raw JSON is always stored; the other columns are a
+;;; best-effort index for querying. Remote-deletes and edits are logged as their
+;;; own rows (kind = "remote-delete"/"edit") and never overwrite the original.
+
+(library (signal capture)
+  (export capture-notification! capture-outbound!)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (std text json)
+          (signal logdb))
+
+  ;; Log one notification. Returns a short summary string (for daemon output)
+  ;; or #f when nothing is written. Never raises -- logging must not break
+  ;; the caller's event loop.
+  (def (capture-notification! logdb account notif)
+    (and logdb
+         (hashtable? notif)
+         (guard (e [#t #f])
+           (let ([raw (event->json notif)]
+                 [env (notif-envelope notif)])
+             (if (hashtable? env)
+               (let ([row (envelope->row env)])
+                 (logdb-put logdb (safe account)
+                            (list-ref row 0)            ;; direction
+                            (safe (list-ref row 1))     ;; conversation
+                            (safe (list-ref row 2))     ;; sender
+                            (as-int (list-ref row 3))   ;; timestamp
+                            (list-ref row 4)            ;; kind
+                            (safe (list-ref row 5))     ;; body
+                            raw)
+                 (string-append (list-ref row 0) " "
+                                (safe (list-ref row 1)) " "
+                                (list-ref row 4)))
+               (begin
+                 (logdb-put logdb (safe account) "event" "" "" 0
+                            (or (notif-method notif) "event") "" raw)
+                 "event"))))))
+
+  ;; Log a locally-sent message (TUI composer). Returns a summary or #f.
+  (def (capture-outbound! logdb account conversation text timestamp)
+    (and logdb
+         (guard (e [#t #f])
+           (let ([raw (make-hashtable equal-hash equal?)])
+             (hashtable-set! raw "type" "sent")
+             (hashtable-set! raw "conversation" (safe conversation))
+             (hashtable-set! raw "message" (safe text))
+             (when (number? timestamp)
+               (hashtable-set! raw "timestamp" timestamp))
+             (logdb-put logdb (safe account) "out" (safe conversation) "You"
+                        (as-int timestamp) "data" (safe text)
+                        (event->json raw))
+             "out sent"))))
+
+  ;; --- extraction: envelope -> (direction conversation sender ts kind body) ---
+
+  (def (envelope->row env)
+    (let ([data (htref env "dataMessage")]
+          [sync (htref env "syncMessage")]
+          [receipt (htref env "receiptMessage")]
+          [typing (htref env "typingMessage")]
+          [src-name (first-string (htref env "sourceName")
+                                  (htref env "sourceNumber")
+                                  (htref env "source")
+                                  "unknown")]
+          [src-target (first-string (htref env "sourceNumber")
+                                    (htref env "source"))]
+          [env-ts (htref env "timestamp")])
+      (cond
+        [(hashtable? data)
+         (let ([gid (data-group-id data)])
+           (list "in"
+                 (conv-id gid (or gid src-target src-name))
+                 src-name
+                 (or (htref data "timestamp") env-ts)
+                 (data-kind data)
+                 (or (htref data "message") (body-marker data))))]
+        [(hashtable? sync)
+         (let ([sent (htref sync "sentMessage")])
+           (if (hashtable? sent)
+             (let ([gid (data-group-id sent)]
+                   [dest (first-string (htref sent "destinationNumber")
+                                       (htref sent "destination")
+                                       (htref sent "destinationUuid"))])
+               (list "out"
+                     (conv-id gid (or gid dest "unknown"))
+                     "You"
+                     (or (htref sent "timestamp") env-ts)
+                     (data-kind sent)
+                     (or (htref sent "message") (body-marker sent))))
+             (list "out" "" "You" env-ts "sync" "")))]
+        [(hashtable? receipt)
+         (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "receipt" "")]
+        [(hashtable? typing)
+         (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "typing" "")]
+        [else
+         (list "in" (conv-id #f (or src-target src-name)) src-name env-ts "event" "")])))
+
+  (def (data-kind msg)
+    (cond
+      [(htref msg "remoteDelete") "remote-delete"]
+      [(htref msg "editMessage") "edit"]
+      [else "data"]))
+
+  (def (body-marker msg)
+    (if (htref msg "attachments") "[attachment]" ""))
+
+  (def (conv-id gid target)
+    (string-append (if gid "group:" "direct:")
+                   (safe-display (or target "unknown"))))
+
+  (def (data-group-id msg)
+    (or (nonempty (htref msg "groupId"))
+        (let ([gi (htref msg "groupInfo")])
+          (and (hashtable? gi)
+               (or (nonempty (htref gi "groupId"))
+                   (nonempty (htref gi "groupIdBase64"))
+                   (nonempty (htref gi "id")))))
+        (let ([g2 (htref msg "groupV2")])
+          (and (hashtable? g2)
+               (or (nonempty (htref g2 "id"))
+                   (nonempty (htref g2 "masterKey")))))))
+
+  (def (notif-envelope notif)
+    (let ([params (htref notif "params")])
+      (and (hashtable? params)
+           (let ([payload (or (htref params "result") params)])
+             (and (hashtable? payload) (htref payload "envelope"))))))
+
+  (def (notif-method notif)
+    (let ([m (htref notif "method")]) (and (string? m) m)))
+
+  ;; --- small helpers ---
+
+  (def (htref ht key)
+    (and (hashtable? ht) (hashtable-ref ht key #f)))
+
+  (def (nonempty s) (and (string? s) (not (string=? s "")) s))
+
+  (def (first-string . xs)
+    (cond
+      [(null? xs) #f]
+      [(nonempty (car xs)) (car xs)]
+      [else (apply first-string (cdr xs))]))
+
+  (def (safe x) (cond [(string? x) x] [x (safe-display x)] [else ""]))
+
+  (def (as-int x) (if (number? x) x 0))
+
+  (def (safe-display x)
+    (if (string? x)
+      x
+      (let ([p (open-output-string)]) (display x p) (get-output-string p))))
+
+  (def (event->json x)
+    (guard (e [#t ""])
+      (cond
+        [(hashtable? x) (json-object->string x)]
+        [(string? x) x]
+        [else (safe-display x)])))
+
+  ) ;; end library
diff --git a/signal/cmd-log.ss b/signal/cmd-log.ss
new file mode 100644
index 0000000..67752fc
--- /dev/null
+++ b/signal/cmd-log.ss
@@ -0,0 +1,145 @@
+#!chezscheme
+;;; signal/cmd-log -- always-on headless message logger.
+;;;
+;;; Connects to signal-cli in receive mode and appends every event to the
+;;; encrypted SQLCipher log, so messages are kept even if the sender later
+;;; deletes them ("delete for everyone"). Runs until the stream closes or it is
+;;; interrupted. Each insert is its own committed transaction, so an abrupt kill
+;;; never loses already-logged messages.
+;;;
+;;; Passphrase: JERBOA_SIGNAL_DB_KEY if set (for unattended/launchd use),
+;;; otherwise prompt when attached to a terminal.
+
+(library (signal cmd-log)
+  (export cmd-log)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (std misc process)
+          (signal rpc-actor)
+          (signal store)
+          (signal logdb)
+          (signal capture))
+
+  (def (cmd-log account)
+    (let* ([acct (or account "default")]
+           [logdb (open-log-or-exit acct)]
+           [actor (start-signal-actor account "on-connection")])
+      (dynamic-wind
+        (lambda () (void))
+        (lambda ()
+          (announce acct)
+          (confirm-backend actor)
+          (log-loop logdb acct actor 0))
+        (lambda ()
+          (stop-signal-actor actor)
+          (logdb-close logdb)
+          (display "jerboa-signal log: stopped.\n")))))
+
+  (def (open-log-or-exit acct)
+    (unless (logdb-available?)
+      (die "encrypted logging unavailable; build it with: brew install sqlcipher && make log-shim"))
+    (let ([key (resolve-key)])
+      (unless (and key (not (string=? key "")))
+        (die "no passphrase; set JERBOA_SIGNAL_DB_KEY or run attached to a terminal"))
+      (ensure-store-dir!)
+      (let ([db (logdb-open (messages-store-path acct) key)])
+        (unless db
+          (die "could not open encrypted log (wrong passphrase, or corrupt file)"))
+        db)))
+
+  (def (resolve-key)
+    (let ([env (getenv "JERBOA_SIGNAL_DB_KEY")])
+      (cond
+        [(and env (not (string=? env ""))) env]
+        [(tty? (current-input-port))
+         (logdb-prompt-passphrase
+           "jerboa-signal log: passphrase for encrypted message log: ")]
+        [else #f])))
+
+  (def (announce acct)
+    (display "jerboa-signal log: capturing to ")
+    (display (messages-store-path acct))
+    (newline)
+    (display "  receive mode on-connection; press Ctrl-C to stop.\n")
+    (flush-output-port (current-output-port)))
+
+  (def (confirm-backend actor)
+    (guard (e [#t (void)])
+      (let ([v (actor-call actor "version" #f)])
+        (when (hashtable? v)
+          (display "  signal-cli ")
+          (display (hashtable-ref v "version" "unknown"))
+          (newline)
+          (flush-output-port (current-output-port))))))
+
+  ;; Poll the actor's event stream, log every notification, and keep a running
+  ;; total. Exits when signal-cli closes the stream.
+  (def (log-loop logdb acct actor total)
+    (cond
+      [(signal-actor-closed? actor)
+       (display "jerboa-signal log: signal-cli stream closed.\n")
+       total]
+      [else
+       (let ([n (handle-events! logdb acct (actor-drain-events actor) total)])
+         (sleep-ms 150)
+         (log-loop logdb acct actor n))]))
+
+  (def (handle-events! logdb acct events total)
+    (let loop ([evs events] [count total])
+      (cond
+        [(null? evs) count]
+        [else
+         (let ([ev (car evs)]
+               [rest (cdr evs)])
+           (cond
+             [(notification-event? ev)
+              (let ([summary (capture-notification! logdb acct (cadr ev))]
+                    [n (+ count 1)])
+                (report n summary)
+                (loop rest n))]
+             [(tagged? ev 'error)
+              (display "jerboa-signal log: signal-cli error: ")
+              (display (event-detail ev))
+              (newline)
+              (flush-output-port (current-output-port))
+              (loop rest count)]
+             [else (loop rest count)]))])))
+
+  (def (notification-event? ev)
+    (and (tagged? ev 'notification) (pair? (cdr ev))))
+
+  (def (tagged? ev tag)
+    (and (pair? ev) (eq? (car ev) tag)))
+
+  (def (report n summary)
+    (display "[")
+    (display n)
+    (display "] ")
+    (display (if (string? summary) summary "event"))
+    (newline)
+    (flush-output-port (current-output-port)))
+
+  (def (event-detail ev)
+    (if (pair? (cdr ev))
+      (let ([d (cadr ev)]) (if (string? d) d (object->string d)))
+      "?"))
+
+  (def (object->string x)
+    (let ([p (open-output-string)]) (display x p) (get-output-string p)))
+
+  (def (die msg)
+    (display "jerboa-signal log: " (current-error-port))
+    (display msg (current-error-port))
+    (newline (current-error-port))
+    (exit 1))
+
+  ) ;; end library
diff --git a/signal/log_shim.c b/signal/log_shim.c
new file mode 100644
index 0000000..b82a27a
--- /dev/null
+++ b/signal/log_shim.c
@@ -0,0 +1,170 @@
+/* signal_log_shim.c -- SQLCipher-backed encrypted message log for jerboa-signal.
+ *
+ * Captures every Signal event as an append-only row so messages survive even
+ * when the sender later deletes them ("delete for everyone" / remote delete):
+ * a remote-delete is just another row and never touches the original.
+ *
+ * The database is a real encrypted SQLite file (SQLCipher, AES-256) -- contents
+ * AND metadata are unreadable without the key.
+ *
+ * Build:
+ *   cc -shared -fPIC \
+ *      -I$(brew --prefix sqlcipher)/include/sqlcipher \
+ *      -L$(brew --prefix sqlcipher)/lib -lsqlcipher \
+ *      -o signal_log_shim.dylib signal/log_shim.c
+ *
+ * Self-test:
+ *   cc -DSIGNAL_LOG_TEST -I... -L... -lsqlcipher signal/log_shim.c -o /tmp/logtest
+ */
+
+#include <sqlite3.h>   /* SQLCipher's sqlite3.h (note the -I path) */
+#include <string.h>
+#include <unistd.h>    /* getpass */
+
+/* SQLCipher exports these, but sqlite3.h only declares them under
+ * SQLITE_HAS_CODEC. Declare directly so we link against libsqlcipher's
+ * symbols without depending on the header guard. */
+extern int sqlite3_key(sqlite3 *db, const void *pKey, int nKey);
+
+static const char *SCHEMA =
+    "CREATE TABLE IF NOT EXISTS messages ("
+    "  id           INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  logged_at    INTEGER DEFAULT (strftime('%s','now')),"
+    "  account      TEXT,"
+    "  direction    TEXT,"   /* in | out | system | event */
+    "  conversation TEXT,"   /* direct:<target> | group:<id> */
+    "  sender       TEXT,"
+    "  timestamp    INTEGER,"/* Signal message timestamp (ms), if any */
+    "  kind         TEXT,"   /* data | remote-delete | edit | receipt | typing | ... */
+    "  body         TEXT,"
+    "  raw          TEXT NOT NULL"  /* full raw JSON of the event */
+    ");"
+    "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation);"
+    "CREATE INDEX IF NOT EXISTS idx_messages_ts   ON messages(timestamp);";
+
+/* Open (creating if needed) the encrypted DB at `path`, keyed with `key`.
+ * Returns a sqlite3* (as void*) or NULL on failure (including a wrong key). */
+void *signal_log_open(const char *path, const char *key) {
+    sqlite3 *db = NULL;
+    char *err = NULL;
+
+    if (sqlite3_open(path, &db) != SQLITE_OK) {
+        if (db) sqlite3_close(db);
+        return NULL;
+    }
+    /* The key must be applied before any other database access. */
+    if (key && key[0] != '\0') {
+        if (sqlite3_key(db, key, (int)strlen(key)) != SQLITE_OK) {
+            sqlite3_close(db);
+            return NULL;
+        }
+    }
+    /* Touch the schema so a wrong key fails here, not mid-insert. */
+    if (sqlite3_exec(db, "SELECT count(*) FROM sqlite_master;",
+                     NULL, NULL, &err) != SQLITE_OK) {
+        if (err) sqlite3_free(err);
+        sqlite3_close(db);
+        return NULL;
+    }
+    if (sqlite3_exec(db, SCHEMA, NULL, NULL, &err) != SQLITE_OK) {
+        if (err) sqlite3_free(err);
+        sqlite3_close(db);
+        return NULL;
+    }
+    (void)sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
+    return (void *)db;
+}
+
+int signal_log_close(void *handle) {
+    if (!handle) return 0;
+    return sqlite3_close((sqlite3 *)handle);
+}
+
+static void bind_text_or_null(sqlite3_stmt *st, int idx, const char *s) {
+    if (s && s[0] != '\0')
+        sqlite3_bind_text(st, idx, s, -1, SQLITE_TRANSIENT);
+    else
+        sqlite3_bind_null(st, idx);
+}
+
+/* Append one row. Text args may be empty (stored as NULL). Returns 0 on success. */
+int signal_log_put(void *handle,
+                   const char *account, const char *direction,
+                   const char *conversation, const char *sender,
+                   long long timestamp, const char *kind,
+                   const char *body, const char *raw) {
+    if (!handle) return -1;
+    sqlite3 *db = (sqlite3 *)handle;
+    static const char *SQL =
+        "INSERT INTO messages"
+        " (account,direction,conversation,sender,timestamp,kind,body,raw)"
+        " VALUES (?,?,?,?,?,?,?,?);";
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK)
+        return sqlite3_errcode(db);
+    bind_text_or_null(st, 1, account);
+    bind_text_or_null(st, 2, direction);
+    bind_text_or_null(st, 3, conversation);
+    bind_text_or_null(st, 4, sender);
+    sqlite3_bind_int64(st, 5, (sqlite3_int64)timestamp);
+    bind_text_or_null(st, 6, kind);
+    bind_text_or_null(st, 7, body);
+    sqlite3_bind_text(st, 8, raw ? raw : "", -1, SQLITE_TRANSIENT);
+    int rc = sqlite3_step(st);
+    sqlite3_finalize(st);
+    return (rc == SQLITE_DONE) ? 0 : rc;
+}
+
+/* Total rows logged (verification helper). Returns -1 on error. */
+long long signal_log_count(void *handle) {
+    if (!handle) return -1;
+    sqlite3 *db = (sqlite3 *)handle;
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, "SELECT count(*) FROM messages;", -1, &st, NULL)
+            != SQLITE_OK)
+        return -1;
+    long long n = -1;
+    if (sqlite3_step(st) == SQLITE_ROW)
+        n = (long long)sqlite3_column_int64(st, 0);
+    sqlite3_finalize(st);
+    return n;
+}
+
+/* Read a passphrase from the controlling terminal with echo disabled.
+ * Returns a pointer to a static buffer (copied by the FFI into a Scheme string). */
+const char *signal_log_getpass(const char *prompt) {
+    char *p = getpass(prompt ? prompt : "Passphrase: ");
+    return p ? p : "";
+}
+
+#ifdef SIGNAL_LOG_TEST
+#include <stdio.h>
+int main(void) {
+    const char *path = "/tmp/jerboa-signal-logtest.db";
+    unlink(path);
+    void *db = signal_log_open(path, "correct horse battery staple");
+    if (!db) { printf("FAIL open\n"); return 1; }
+    signal_log_put(db, "+15550001111", "in", "direct:+15550002222",
+                   "Alice", 1717000000000LL, "data", "hello world",
+                   "{\"method\":\"receive\"}");
+    signal_log_put(db, "+15550001111", "in", "direct:+15550002222",
+                   "Alice", 1717000005000LL, "remote-delete", "",
+                   "{\"remoteDelete\":{\"timestamp\":1717000000000}}");
+    long long n = signal_log_count(db);
+    signal_log_close(db);
+
+    /* Reopen with correct key: both rows (incl. the original) still present. */
+    void *db2 = signal_log_open(path, "correct horse battery staple");
+    long long n2 = db2 ? signal_log_count(db2) : -1;
+    if (db2) signal_log_close(db2);
+
+    /* Wrong key must fail. */
+    void *db3 = signal_log_open(path, "wrong key");
+    int wrong_rejected = (db3 == NULL);
+    if (db3) signal_log_close(db3);
+
+    printf("rows=%lld reopened=%lld wrong_key_rejected=%d\n",
+           n, n2, wrong_rejected);
+    return (n == 2 && n2 == 2 && wrong_rejected) ? 0 : 2;
+}
+#endif
diff --git a/signal/logdb.ss b/signal/logdb.ss
new file mode 100644
index 0000000..70668bf
--- /dev/null
+++ b/signal/logdb.ss
@@ -0,0 +1,93 @@
+#!chezscheme
+;;; signal/logdb -- FFI to the SQLCipher encrypted message-log shim.
+;;;
+;;; Loads signal_log_shim.<dylib|so> (built by `make log-shim`) which links
+;;; libsqlcipher. If the shim or sqlcipher is missing, logdb-available? is #f
+;;; and every operation is a no-op, so the TUI runs fine without logging.
+
+(library (signal logdb)
+  (export logdb-available?
+          logdb-open logdb-close logdb-put logdb-count
+          logdb-prompt-passphrase)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (std os path))
+
+  (def *shim-load-state* (box 'untried))
+
+  (def (try-load-shim path)
+    (and (file-exists? path) (load-shared-object path)))
+
+  (def (ensure-shim-loaded!)
+    (cond
+      [(eq? (unbox *shim-load-state*) 'loaded) #t]
+      [(eq? (unbox *shim-load-state*) 'missing) #f]
+      [else
+       (let* ([bin-dir (path-directory (car (command-line)))]
+              [home (or (getenv "HOME") ".")]
+              [loaded?
+               (guard (e [#t #f])
+                 (or (try-load-shim "signal_log_shim.dylib")
+                     (try-load-shim "signal_log_shim.so")
+                     (try-load-shim (path-join bin-dir "signal_log_shim.dylib"))
+                     (try-load-shim (path-join bin-dir "signal_log_shim.so"))
+                     (try-load-shim (path-join home ".local" "bin"
+                                               "signal_log_shim.dylib"))
+                     (try-load-shim (path-join home ".local" "bin"
+                                               "signal_log_shim.so"))))])
+         (set-box! *shim-load-state* (if loaded? 'loaded 'missing))
+         loaded?)]))
+
+  (def (entry? name)
+    (and (ensure-shim-loaded!) (foreign-entry? name)))
+
+  (def (logdb-available?)
+    (entry? "signal_log_open"))
+
+  ;; Open (creating if needed) the encrypted DB at `path` with passphrase `key`.
+  ;; Returns an opaque handle (nonzero integer) or #f on failure / wrong key.
+  (def (logdb-open path key)
+    (and (entry? "signal_log_open")
+         (let ([h ((foreign-procedure "signal_log_open" (string string) uptr)
+                   path key)])
+           (and (not (= h 0)) h))))
+
+  (def (logdb-close handle)
+    (when (and handle (entry? "signal_log_close"))
+      ((foreign-procedure "signal_log_close" (uptr) int) handle)
+      (void)))
+
+  ;; Append one event. Strings should be non-#f (use "" for absent). Returns #t
+  ;; on success. Best-effort: callers should still guard, but this never raises
+  ;; for a #f handle.
+  (def (logdb-put handle account direction conversation sender
+                  timestamp kind body raw)
+    (and handle
+         (entry? "signal_log_put")
+         (= 0 ((foreign-procedure "signal_log_put"
+                  (uptr string string string string integer-64 string string string)
+                  int)
+               handle account direction conversation sender
+               timestamp kind body raw))))
+
+  (def (logdb-count handle)
+    (if (and handle (entry? "signal_log_count"))
+      ((foreign-procedure "signal_log_count" (uptr) integer-64) handle)
+      -1))
+
+  ;; Read a passphrase from the controlling terminal with echo off.
+  (def (logdb-prompt-passphrase prompt)
+    (if (entry? "signal_log_getpass")
+      ((foreign-procedure "signal_log_getpass" (string) string) prompt)
+      ""))
+
+  ) ;; end library
diff --git a/signal/main.ss b/signal/main.ss
index 4585eac..75631fb 100644
--- a/signal/main.ss
+++ b/signal/main.ss
@@ -35,7 +35,8 @@
 (import (except (jerboa prelude) meta atom?)
         (signal cmd-send)
         (signal cmd-listen)
-        (signal cmd-tui))
+        (signal cmd-tui)
+        (signal cmd-log))
 
 (def *version* "0.1.0")
 
@@ -47,6 +48,7 @@ Subcommands:
   send [-a +PHONE] RECIPIENT MESSAGE   Send a Signal message
   listen [-a +PHONE]                   Stream inbound envelopes as NDJSON
   tui [-a +PHONE]                      Start the terminal UI
+  log [-a +PHONE]                      Capture all messages to an encrypted log
   version                              Print version
   help                                 Print this help
 
@@ -101,6 +103,15 @@ linking once before using this tool:
                (exit 2)]
               [else
                (cmd-tui account)]))]
+         [(string=? sub "log")
+          (let-values ([(account args) (parse-account-flag rest)])
+            (cond
+              [(not (null? args))
+               (display "jerboa-signal: log takes no positional arguments\n"
+                        (current-error-port))
+               (exit 2)]
+              [else
+               (cmd-log account)]))]
          [else
           (display "jerboa-signal: unknown subcommand: " (current-error-port))
           (display sub (current-error-port))
diff --git a/signal/store.ss b/signal/store.ss
index 90b4413..36d1052 100644
--- a/signal/store.ss
+++ b/signal/store.ss
@@ -11,7 +11,8 @@
 ;;; conversation id per line. It holds no message content.
 
 (library (signal store)
-  (export load-removed-ids save-removed-ids removed-store-path)
+  (export load-removed-ids save-removed-ids removed-store-path
+          messages-store-path ensure-store-dir!)
 
   (import (except (chezscheme)
                   make-hash-table hash-table?
@@ -30,6 +31,9 @@
   (def (removed-store-path account)
     (string-append (store-dir) "/removed-" (sanitize account) ".txt"))
 
+  (def (messages-store-path account)
+    (string-append (store-dir) "/messages-" (sanitize account) ".db"))
+
   ;; Keep the filename safe: phone numbers (+, digits), uuids (-) and ordinary
   ;; word characters pass through; anything else collapses to _.
   (def (sanitize s)
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 636782d..34900d1 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -16,6 +16,8 @@
           (except (jerboa prelude) meta atom?)
           (signal rpc-actor)
           (signal store)
+          (signal logdb)
+          (signal capture)
           (signal tui ffi))
 
   (defstruct chat-message (direction sender text timestamp kind))
@@ -23,38 +25,67 @@
   (defstruct search-hit (conv-id conv-title message))
   (defstruct tui-state
     (account version receive-mode width height input status quit? event-count
-     conversations selected-index mode picker-query picker-index removed))
+     conversations selected-index mode picker-query picker-index removed logdb))
 
   (def (run-tui-terminal account actor version receive-mode)
-    (with-tui
-      (tb-set-input-mode! (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE))
-      (tb-set-output-mode! TB_OUTPUT_TRUECOLOR)
-      (let* ([acct (or account "default")]
-             [removed (load-removed-set acct)]
-             [state (make-tui-state acct
-                                   version
-                                   receive-mode
-                                   (tb-width)
-                                   (tb-height)
-                                   ""
-                                   "Backend connected."
-                                   #f
-                                   0
-                                   (list
-                                     (make-system-conversation
-                                       (list
-                                         "TUI connected. Waiting for Signal receive notifications."
-                                         "Session history is in memory only.")))
-                                   0
-                                   'chat
-                                   ""
-                                   0
-                                   removed)])
-        (tb-hide-cursor!)
-        (draw! state)
-        (tb-present!)
-        (seed-known-conversations! state actor)
-        (event-loop state actor))))
+    ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the
+    ;; terminal (getpass needs the normal tty), and close it on the way out.
+    (let* ([acct (or account "default")]
+           [removed (load-removed-set acct)]
+           [logdb (open-message-log acct)])
+      (dynamic-wind
+        (lambda () (void))
+        (lambda ()
+          (with-tui
+            (tb-set-input-mode! (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE))
+            (tb-set-output-mode! TB_OUTPUT_TRUECOLOR)
+            (let ([state (make-tui-state acct
+                                         version
+                                         receive-mode
+                                         (tb-width)
+                                         (tb-height)
+                                         ""
+                                         "Backend connected."
+                                         #f
+                                         0
+                                         (list
+                                           (make-system-conversation
+                                             (list
+                                               "TUI connected. Waiting for Signal receive notifications."
+                                               "Session history is in memory only.")))
+                                         0
+                                         'chat
+                                         ""
+                                         0
+                                         removed
+                                         logdb)])
+              (append-system-message!
+                state
+                (if logdb
+                  "Encrypted logging ON: messages are saved even if deleted later."
+                  "Encrypted logging OFF."))
+              (tb-hide-cursor!)
+              (draw! state)
+              (tb-present!)
+              (seed-known-conversations! state actor)
+              (event-loop state actor))))
+        (lambda () (when logdb (logdb-close logdb))))))
+
+  ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY
+  ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a
+  ;; missing shim disables logging and the TUI runs normally.
+  (def (open-message-log acct)
+    (and (logdb-available?)
+         (let* ([env (getenv "JERBOA_SIGNAL_DB_KEY")]
+                [key (if (and env (not (string=? env "")))
+                       env
+                       (logdb-prompt-passphrase
+                         "jerboa-signal: passphrase for encrypted message log (blank to skip): "))])
+           (and key
+                (not (string=? key ""))
+                (begin
+                  (ensure-store-dir!)
+                  (logdb-open (messages-store-path acct) key))))))
 
   (def (event-loop state actor)
     (let loop ()
@@ -77,12 +108,16 @@
           (lambda (ev)
             (cond
               [(and (pair? ev) (eq? (car ev) 'notification))
-               (let ([chat-event (notification->chat-event (cadr ev))])
+               (let* ([notif (cadr ev)]
+                      [chat-event (notification->chat-event notif)])
+                 (capture-notification! (tui-state-logdb state)
+                                        (tui-state-account state)
+                                        notif)
                  (if chat-event
                    (begin
                      (apply-chat-event! state chat-event)
                      (tui-state-status-set! state "New Signal message received."))
-                   (let ([line (notification->line (cadr ev))])
+                   (let ([line (notification->line notif)])
                      (when line
                        (append-system-message! state line)
                        (tui-state-status-set! state "New Signal event received.")))))]
@@ -633,14 +668,22 @@
                     (tui-state-status-set!
                       state
                       (string-append "Send failed: " (safe-display e)))])
-           (actor-call actor "send" (make-send-params-for-conversation conv text))
-           (append-message-to-conversation!
-             conv
-             (make-chat-message 'out "You" text #f 'data))
+           (let* ([result (actor-call actor "send"
+                                      (make-send-params-for-conversation conv text))]
+                  [ts (send-result-timestamp result)])
+             (append-message-to-conversation!
+               conv
+               (make-chat-message 'out "You" text ts 'data))
+             (capture-outbound! (tui-state-logdb state)
+                                (tui-state-account state)
+                                (conversation-id conv) text ts))
            (tui-state-input-set! state "")
            (conversation-unread-set! conv 0)
            (tui-state-status-set! state "Sent."))])))
 
+  (def (send-result-timestamp result)
+    (and (hashtable? result) (hashtable-ref result "timestamp" #f)))
+
   (def (make-send-params-for-conversation conv text)
     (let ([p (make-hashtable equal-hash equal?)]
           [target (conversation-target conv)])