Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ sync:
enabled: true
accounts: [] # Set in config.local.yaml
schedule: "*/15 * * * *"
# Plain IMAP mailboxes — for providers the Gmail source can't reach.
# One source per account. Passwords go in config.local.yaml under
# sync.imap.passwords, keyed by username. See docs/sources.md for the
# optional pass that reads mail whose payload is only legible as an image.
# imap:
# enabled: false
# accounts:
# - host: imap.example.net
# username: me@example.net
# label: mailbox # source name becomes imap:mailbox
# schedule: "*/30 * * * *"
github:
enabled: true
schedule: "*/15 * * * *"
Expand Down
17 changes: 17 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,23 @@ Sources pull data from external services on a schedule. See [sources.md](sources
| `sync.gmail.schedule` | cron | `*/15 * * * *` | Fetch frequency |
| `sync.gmail.keyring_password` | string | - | gog keyring password |

**IMAP-specific** (see [sources.md](sources.md) for the image pass):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `sync.imap.enabled` | bool | `false` | Enable IMAP mailboxes |
| `sync.imap.accounts` | list | `[]` | Mailboxes: `host`, `username`, `label`, `port`, `mailbox` |
| `sync.imap.passwords` | dict | `{}` | Password per username — keep in `config.local.yaml` |
| `sync.imap.schedule` | cron | `*/30 * * * *` | Fetch frequency |
| `sync.imap.initial_lookback_days` | int | `1` | `SINCE` window on first run |
| `sync.imap.match.sender_contains` | list | `[]` | Substrings matched against `From:` |
| `sync.imap.match.attachment_contains` | list | `[]` | Substrings matched against an image's Content-ID / filename |
| `sync.imap.match.only_matched` | bool | `false` | Drop everything that did not match |
| `sync.imap.vision.enabled` | bool | `false` | Run a multimodal pass over a matched message's image |
| `sync.imap.vision.model` | string | *(empty)* | Falls back to `memory.fast_model` |
| `sync.imap.vision.prompt` | string | *(empty)* | What to ask about the image — required when vision is enabled |
| `sync.imap.vision.answer_key` | string | *(empty)* | Label the prompt asks the model to emit; empty = first non-empty line |

**GitHub-specific:**

| Key | Type | Default | Description |
Expand Down
81 changes: 81 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,70 @@ A persistent cron job (`inbox-processor`) runs every 15 minutes:
- **Two-step fetch:** Search returns metadata only; body + `internalDate` are fetched per-message via `gog gmail get` (up to 5 concurrent)
- **Default schedule:** `*/15 * * * *` (every 15 min)

### IMAP
- **Adapter:** `nerve/sources/imap.py` — plain `imaplib`, no CLI or provider API
- **Scope:** Any IMAP mailbox the Gmail source cannot reach (that one goes through the `gog` CLI / Gmail API), e.g. GMX, Fastmail, a company server
- **Instances:** One source per configured account (`imap:<label>`), each with an independent cursor
- **Cursor:** `<UIDVALIDITY>:<max_uid>` — UIDs are only stable within a UIDVALIDITY, so a server-side reset is detected and re-baselined instead of trusted
- **First run:** `SINCE` window of `initial_lookback_days` (default 1)
- **Subsequent runs:** `UID SEARCH <last+1>:*`, filtered client-side (the `N:*` range always returns at least the highest UID)
- **Blocking I/O:** the whole IMAP conversation runs in a worker thread via `asyncio.to_thread`
- **Credentials:** passwords live in `sync.imap.passwords` keyed by username (put them in `config.local.yaml`); an account with no password is skipped with a warning instead of failing the sync
- **Optional image pass:** see below
- **Default schedule:** `*/30 * * * *` (every 30 min)
- **Disabled by default**

#### Reading mail that is only legible as an image

Some senders put the part you actually care about in an inline image — a
scan, a photo, a rendered document — so the body text is useless to an
agent. `sync.imap.match` singles those messages out and `sync.imap.vision`
runs a multimodal model over the image at ingest time, so the inbox
consumer gets plain text.

Both are inert by default: with no match rules, this is a plain mailbox
source and no model is ever called.

`vision.prompt` and `vision.answer_key` are a **matched pair** — the prompt
tells the model which label to emit, and the parser reads the line after
exactly that label. Change one without the other and every message silently
comes back as `unknown_answer`, which looks like the model degrading rather
than a config mistake. Leave `answer_key` empty to just take the first
non-empty line.

```yaml
sync:
imap:
enabled: true
accounts:
- host: imap.example.net
username: me@example.net
label: scans # source name becomes imap:scans
passwords: # config.local.yaml
me@example.net: "..."
match:
sender_contains: ["scans@example.net"] # substrings of From:
attachment_contains: ["scan"] # substrings of Content-ID / filename
only_matched: true # drop everything else at the source
vision:
enabled: true
model: "" # empty = memory.fast_model
prompt: |
Read the scan. Answer with EXACTLY one line:
Sender: <company or person, or "unreadable">
answer_key: "Sender:"
unknown_answer: "unreadable"
summary: "[{label}] scan from {answer}"
content: "{vision}\n\nSubject: {subject}\nDate: {date}"
summary_unknown: "[{label}] unreadable scan"
content_unknown: "The image could not be read.\n\nSubject: {subject}"
```

Wording templates accept `{label} {answer} {vision} {subject} {sender}
{date} {body}`, where `{answer}` is the parsed line and `{vision}` the full
model reply. An unknown placeholder logs a warning and leaves the template
visible rather than failing the fetch.

### GitHub
- **Adapter:** `nerve/sources/github.py` — uses `gh api notifications` CLI
- **Cursor:** ISO 8601 timestamp of the newest notification's `updated_at`
Expand Down Expand Up @@ -131,6 +195,23 @@ sync:
batch_size: 20
condense: true # Strip boilerplate + Haiku extraction for long emails

imap:
enabled: false # Off by default
accounts: # One source per mailbox, each with own cursor
- host: imap.example.net
username: me@example.net
label: mailbox # Source name becomes imap:mailbox
port: 993
mailbox: INBOX
passwords: # Keyed by username (in config.local.yaml)
me@example.net: "..."
schedule: "*/30 * * * *"
batch_size: 20
initial_lookback_days: 1
condense: false
match: {} # See "Reading mail that is only legible as an image"
vision: {}

github:
enabled: true
schedule: "*/15 * * * *"
Expand Down
135 changes: 135 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,139 @@ def from_dict(cls, d: dict) -> GmailSyncConfig:
)


@dataclass
class ImapMatchConfig:
"""Which IMAP messages are singled out for the optional image pass.

Both lists are case-insensitive substrings and both default to empty, so
an unconfigured source never singles anything out and stays a plain
mailbox reader.
"""

# Matched against the decoded From: header.
sender_contains: list[str] = field(default_factory=list)
# Matched against an inline image's Content-ID / filename. A hit also
# picks that image over the merely largest one.
attachment_contains: list[str] = field(default_factory=list)
# Drop everything that did not match, turning the mailbox into a
# single-purpose notifier instead of a second inbox.
only_matched: bool = False

@classmethod
def from_dict(cls, d: dict) -> ImapMatchConfig:
return cls(
sender_contains=[str(s) for s in d.get("sender_contains", [])],
attachment_contains=[str(s) for s in d.get("attachment_contains", [])],
only_matched=bool(d.get("only_matched", False)),
)


@dataclass
class ImapVisionConfig:
"""Multimodal pass over an inline image in matched messages.

For mail whose payload is only legible in an image — a scan, a photo, a
rendered document — the model reads it at ingest time so downstream
consumers get plain text.

``prompt`` and ``answer_key`` are a matched pair: the prompt tells the
model which label to emit, and the parser reads the line after exactly
that label. Changing one without the other silently yields
``unknown_answer`` every time, so they live together here. Leave
``answer_key`` empty to take the first non-empty line of the answer.

Wording templates accept ``{label} {answer} {vision} {subject} {sender}
{date} {body}``; ``{answer}`` is the parsed line and ``{vision}`` the full
model reply.
"""

enabled: bool = False
# Defaults to memory.fast_model when left empty.
model: str = ""
prompt: str = ""
answer_key: str = ""
unknown_answer: str = "unreadable"
summary: str = "[{label}] {answer}"
summary_unknown: str = "[{label}] {subject}"
content: str = (
"{vision}\n\n"
"Subject: {subject}\n"
"From: {sender}\n"
"Date: {date}"
)
content_unknown: str = (
"The image could not be read (missing or unreadable).\n\n"
"Subject: {subject}\n"
"From: {sender}\n"
"Date: {date}"
)

@classmethod
def from_dict(cls, d: dict) -> ImapVisionConfig:
base = cls()
return cls(
enabled=bool(d.get("enabled", base.enabled)),
model=str(d.get("model", base.model)),
prompt=str(d.get("prompt", base.prompt)),
answer_key=str(d.get("answer_key", base.answer_key)),
unknown_answer=str(d.get("unknown_answer", base.unknown_answer)),
summary=str(d.get("summary", base.summary)),
summary_unknown=str(d.get("summary_unknown", base.summary_unknown)),
content=str(d.get("content", base.content)),
content_unknown=str(d.get("content_unknown", base.content_unknown)),
)


@dataclass
class ImapAccountConfig:
"""One IMAP mailbox. The password lives in config.local.yaml under
``sync.imap.passwords[<username>]``, never here."""
host: str
username: str
label: str
port: int = 993
mailbox: str = "INBOX"

@classmethod
def from_dict(cls, d: dict) -> ImapAccountConfig:
return cls(
host=str(d["host"]),
username=str(d["username"]),
label=str(d.get("label") or d["username"].split("@")[0]),
port=int(d.get("port", 993)),
mailbox=str(d.get("mailbox", "INBOX")),
)


@dataclass
class ImapSyncConfig:
enabled: bool = False
accounts: list[ImapAccountConfig] = field(default_factory=list)
passwords: dict[str, str] = field(default_factory=dict)
schedule: str = "*/30 * * * *"
batch_size: int = 20
initial_lookback_days: int = 1
condense: bool = False
condense_prompt: str = ""
match: ImapMatchConfig = field(default_factory=ImapMatchConfig)
vision: ImapVisionConfig = field(default_factory=ImapVisionConfig)

@classmethod
def from_dict(cls, d: dict) -> ImapSyncConfig:
return cls(
enabled=bool(d.get("enabled", False)),
accounts=[ImapAccountConfig.from_dict(a) for a in d.get("accounts", [])],
passwords=dict(d.get("passwords", {})),
schedule=str(d.get("schedule", "*/30 * * * *")),
batch_size=int(d.get("batch_size", 20)),
initial_lookback_days=int(d.get("initial_lookback_days", 1)),
condense=bool(d.get("condense", False)),
condense_prompt=str(d.get("condense_prompt", "")),
match=ImapMatchConfig.from_dict(d.get("match", {})),
vision=ImapVisionConfig.from_dict(d.get("vision", {})),
)


@dataclass
class GitHubSyncConfig:
enabled: bool = True
Expand Down Expand Up @@ -562,6 +695,7 @@ def from_dict(cls, d: dict) -> CodexSyncConfig:
class SyncConfig:
telegram: TelegramSyncConfig = field(default_factory=TelegramSyncConfig)
gmail: GmailSyncConfig = field(default_factory=GmailSyncConfig)
imap: ImapSyncConfig = field(default_factory=ImapSyncConfig)
github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
github_events: GitHubEventsSyncConfig = field(default_factory=GitHubEventsSyncConfig)
github_repos: GitHubReposSyncConfig = field(default_factory=GitHubReposSyncConfig)
Expand All @@ -574,6 +708,7 @@ def from_dict(cls, d: dict) -> SyncConfig:
return cls(
telegram=TelegramSyncConfig.from_dict(d.get("telegram", {})),
gmail=GmailSyncConfig.from_dict(d.get("gmail", {})),
imap=ImapSyncConfig.from_dict(d.get("imap", {})),
github=GitHubSyncConfig.from_dict(d.get("github", {})),
github_events=GitHubEventsSyncConfig.from_dict(d.get("github_events", {})),
github_repos=GitHubReposSyncConfig.from_dict(d.get("github_repos", {})),
Expand Down
Loading
Loading