tomo-ai 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +36 -29
  3. package/defaults/skills/lcm/SKILL.md +43 -8
  4. package/dist/agent.d.ts +8 -1
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +178 -53
  7. package/dist/agent.js.map +1 -1
  8. package/dist/channels/imessage.d.ts +39 -0
  9. package/dist/channels/imessage.d.ts.map +1 -0
  10. package/dist/channels/imessage.js +387 -0
  11. package/dist/channels/imessage.js.map +1 -0
  12. package/dist/channels/index.d.ts +1 -0
  13. package/dist/channels/index.d.ts.map +1 -1
  14. package/dist/channels/index.js +1 -0
  15. package/dist/channels/index.js.map +1 -1
  16. package/dist/cli/config.d.ts +3 -0
  17. package/dist/cli/config.d.ts.map +1 -0
  18. package/dist/cli/config.js +579 -0
  19. package/dist/cli/config.js.map +1 -0
  20. package/dist/cli/init.d.ts.map +1 -1
  21. package/dist/cli/init.js +50 -1
  22. package/dist/cli/init.js.map +1 -1
  23. package/dist/cli/lcm.js +3 -3
  24. package/dist/cli/lcm.js.map +1 -1
  25. package/dist/cli/sessions.js +7 -0
  26. package/dist/cli/sessions.js.map +1 -1
  27. package/dist/cli/start.js +11 -1
  28. package/dist/cli/start.js.map +1 -1
  29. package/dist/cli.js +3 -1
  30. package/dist/cli.js.map +1 -1
  31. package/dist/config.d.ts +16 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +40 -5
  34. package/dist/config.js.map +1 -1
  35. package/dist/router.d.ts +28 -0
  36. package/dist/router.d.ts.map +1 -0
  37. package/dist/router.js +133 -0
  38. package/dist/router.js.map +1 -0
  39. package/dist/sessions/store.d.ts +11 -1
  40. package/dist/sessions/store.d.ts.map +1 -1
  41. package/dist/sessions/store.js +46 -2
  42. package/dist/sessions/store.js.map +1 -1
  43. package/dist/sessions/types.d.ts +13 -1
  44. package/dist/sessions/types.d.ts.map +1 -1
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 (2026-04-08)
4
+
5
+ ### Features
6
+
7
+ - Add context window breakdown by category to session metadata
8
+ - Auto-nudge agent to compact when context hits 80%
9
+
10
+ ### Bug fixes
11
+
12
+ - Surface API errors to Telegram instead of silently swallowing
13
+ - Split cost log into per-turn and cumulative session total
14
+ - Fix totalCostUsd double-counting in session stats
15
+
3
16
  ## 0.2.0 (2026-04-08)
4
17
 
5
18
  ### Features
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  Personality system ·
15
15
  Persistent memory ·
16
16
  Scheduled tasks ·
17
- Telegram (more channels coming)
17
+ Telegram · iMessage
18
18
  </p>
19
19
 
20
20
  ---
@@ -33,12 +33,15 @@ That's it. Open Telegram and message your bot.
33
33
 
34
34
  - Node.js 22+
35
35
  - [Claude Code](https://claude.com/claude-code) installed and authenticated (subscription plan — API keys are not currently supported)
36
- - Telegram bot token (from [@BotFather](https://t.me/BotFather))
36
+ - At least one channel:
37
+ - **Telegram** — bot token from [@BotFather](https://t.me/BotFather)
38
+ - **iMessage** — [BlueBubbles](https://bluebubbles.app) server running on a Mac with iMessage signed in
37
39
 
38
40
  ## CLI
39
41
 
40
42
  ```bash
41
43
  tomo init # First-time setup
44
+ tomo config # Interactive settings (model, channels, identities, groups)
42
45
  tomo start # Start in background (daemon)
43
46
  tomo start -f # Start in foreground (for dev)
44
47
  tomo stop # Stop the daemon
@@ -48,16 +51,14 @@ tomo logs # View logs (pretty-printed)
48
51
  tomo logs -f # Follow logs live
49
52
  tomo sessions list # Show active sessions
50
53
  tomo sessions clear # Reset all sessions
51
- tomo cron add # Create a scheduled task
52
- tomo cron list # List all jobs
53
- tomo cron remove <id> # Delete a job
54
54
  ```
55
55
 
56
- ## Telegram Commands
56
+ ## Chat Commands
57
57
 
58
58
  | Command | Description |
59
59
  |---------|-------------|
60
60
  | `/new` | Start a new conversation (resets session) |
61
+ | `/model` | Switch model (sonnet/opus/haiku) |
61
62
 
62
63
  ## Features
63
64
 
@@ -84,7 +85,20 @@ File-based persistent memory at `~/.tomo/workspace/memory/`. The `MEMORY.md` ind
84
85
  - Image/photo support (sends to Claude as vision input)
85
86
  - Group chat: only responds when @mentioned or replied to, tracks participants
86
87
  - Markdown rendering with plain-text fallback
87
- - More channels coming (iMessage, Discord, etc.)
88
+ - **iMessage** via [BlueBubbles](https://bluebubbles.app)
89
+ - DM and group chat support
90
+ - Image attachment support
91
+ - Contact name resolution from Mac contacts
92
+ - Group chat: observes all messages, only responds when relevant (replies `NO_REPLY` to stay silent)
93
+
94
+ ### Multi-Channel Sessions
95
+
96
+ Talk to Tomo from multiple channels using the same session. Configure identities in `tomo config` to bind your Telegram and iMessage accounts — Tomo replies on whichever channel you last used (or a fixed default).
97
+
98
+ - DM sessions are unified across channels per identity
99
+ - Group chats always get their own isolated session
100
+ - Per-channel allowlists control who can message Tomo
101
+ - Group chats require a secret passphrase to activate (configured in `tomo config`)
88
102
 
89
103
  ### Tools
90
104
 
@@ -101,24 +115,7 @@ Tomo has access to Claude's built-in tools:
101
115
 
102
116
  ### Scheduled Tasks
103
117
 
104
- ```bash
105
- # One-shot reminder
106
- tomo cron add --name "standup" --schedule "in 20m" --message "Time for standup!"
107
-
108
- # Recurring task
109
- tomo cron add --name "morning" --schedule "0 9 * * *" --message "Check calendar and weather"
110
-
111
- # Interval
112
- tomo cron add --name "check" --schedule "every 2h" --message "Check email inbox"
113
- ```
114
-
115
- Tomo can also create jobs itself — just ask "remind me in 30 minutes to stretch."
116
-
117
- | Format | Type | Example |
118
- |--------|------|---------|
119
- | `in Xm/h/d` | One-shot | `in 30m`, `in 2h` |
120
- | `every Xm/h` | Recurring interval | `every 30m` |
121
- | Cron expression | Recurring (5-field) | `0 9 * * *` |
118
+ Tomo can create scheduled tasks on its own — just ask "remind me in 30 minutes to stretch" or "check the weather every morning at 9am." Supports one-shot reminders, recurring intervals, and cron expressions.
122
119
 
123
120
  ### Sessions
124
121
 
@@ -138,7 +135,7 @@ Structured logs via [pino](https://github.com/pinojs/pino):
138
135
 
139
136
  ```
140
137
  ~/.tomo/
141
- config.json # Telegram token, model
138
+ config.json # Channels, identities, model, settings
142
139
  tomo.pid # PID file (when running)
143
140
  workspace/
144
141
  SOUL.md # Your personality config
@@ -155,14 +152,23 @@ Structured logs via [pino](https://github.com/pinojs/pino):
155
152
 
156
153
  ## Configuration
157
154
 
158
- Config lives at `~/.tomo/config.json`:
155
+ Run `tomo config` for interactive setup, or edit `~/.tomo/config.json` directly:
159
156
 
160
157
  ```json
161
158
  {
162
159
  "channels": {
163
- "telegram": { "token": "your-bot-token" }
160
+ "telegram": { "token": "your-bot-token", "allowlist": ["123456789"] },
161
+ "imessage": { "url": "http://localhost:1234", "password": "...", "allowlist": ["+15551234567"] }
164
162
  },
165
- "model": "claude-sonnet-4-6"
163
+ "identities": [
164
+ {
165
+ "name": "yourname",
166
+ "channels": { "telegram": "123456789", "imessage": "+15551234567" },
167
+ "replyPolicy": "last-active"
168
+ }
169
+ ],
170
+ "model": "claude-sonnet-4-6",
171
+ "groupSecret": "tomo-xxxxxxxx"
166
172
  }
167
173
  ```
168
174
 
@@ -171,6 +177,7 @@ Environment variables override config file values:
171
177
  | Variable | Description |
172
178
  |----------|-------------|
173
179
  | `TELEGRAM_BOT_TOKEN` | Override Telegram token |
180
+ | `IMESSAGE_URL` | Override BlueBubbles URL |
174
181
  | `CLAUDE_MODEL` | Override model |
175
182
  | `TOMO_WORKSPACE` | Override workspace directory |
176
183
  | `LOG_LEVEL` | Log level (default: `debug`) |
@@ -39,32 +39,67 @@ Replace a heavy section with a summary. Use timestamps to specify the range:
39
39
  tomo lcm compact --session-id SESSION_ID \
40
40
  --from-time "2026-03-28T16:29" \
41
41
  --to-time "2026-03-28T19:09" \
42
- --summary "Refactored auth module: extracted middleware, added JWT validation, updated 12 routes. Tests passing."
42
+ --summary "2026-03-28: Refactored auth module: extracted middleware, added JWT validation, updated 12 routes. Tests passing."
43
43
  ```
44
44
 
45
- - `--from-time` / `--to-time`: ISO timestamps (you already know these from the conversation)
45
+ - `--from-time` / `--to-time`: ISO timestamps you already know these from the conversation, no need to run stats first
46
46
  - `--summary`: Write a concise summary of what happened in that range — you know best since you just did the work
47
47
 
48
48
  **Workflow:**
49
49
  1. After completing a big task, decide what can be compacted
50
- 2. You already know the time range from the timestamps in the conversation
50
+ 2. Read the time range directly from conversation timestamps no need to run `stats` first
51
51
  3. Write a summary capturing key decisions, outcomes, and anything worth remembering
52
52
  4. Run `tomo lcm compact` with the time range and your summary
53
- 5. Optionally run `tomo lcm stats` first if you want to see the full breakdown
53
+ 5. Optionally run `tomo lcm stats` to verify the result
54
54
 
55
55
  The original messages are archived to the transcript file and can be searched later.
56
56
 
57
+ **Daily memory notes:**
58
+
59
+ When compacting a section, also write a brief note to `memory/YYYY-MM-DD.md` for each date covered. This creates a fast, human-readable index you can read directly — without needing to invoke any tools.
60
+
61
+ ```bash
62
+ # Example: after compacting 2026-03-29
63
+ # Append to memory/2026-03-29.md (create if it doesn't exist)
64
+ ```
65
+
66
+ ```markdown
67
+ ## 2026-03-29 — from LCM compact
68
+
69
+ - Completed auth refactor: JWT middleware extracted, 12 routes updated
70
+ - Discussed deployment strategy with team — decided on blue/green
71
+ - Set up backup cron job
72
+ ```
73
+
74
+ Two-layer recall:
75
+ 1. **`memory/YYYY-MM-DD.md`** — read directly, fast, no tools needed
76
+ 2. **`tomo lcm search`** — when you need the raw original messages
77
+
78
+ **Writing good summaries:**
79
+ - Use your own natural voice — more like a note to your future self than a changelog
80
+ - Always include explicit dates in **YYYY-MM-DD format** for anything date-specific — e.g. "2026-03-29: published first blog post". This makes `tomo lcm search` much more useful later.
81
+ - Record *outcomes* and *key decisions*, not every step taken
82
+ - For tool-heavy sections (browser loops, exec retries): one sentence on what was attempted and whether it worked
83
+ - For conversations: preserve texture — a key quote or specific detail is worth more than a paragraph of abstraction
84
+
57
85
  ## Search past conversations
58
86
 
59
87
  Search the transcript archive for past messages:
60
88
 
61
89
  ```bash
62
- tomo lcm search --channel-key CHANNEL_KEY --query "momo"
90
+ # Search both current transcript AND archive (requires --session-id)
91
+ tomo lcm search --channel-key CHANNEL_KEY --session-id SESSION_ID --query "momo"
92
+
93
+ # Search by sequence range
63
94
  tomo lcm search --channel-key CHANNEL_KEY --from-seq 100 --to-seq 200
64
- tomo lcm search --channel-key CHANNEL_KEY --query "blog" --limit 10
95
+
96
+ # Limit results
97
+ tomo lcm search --channel-key CHANNEL_KEY --session-id SESSION_ID --query "blog" --limit 10
65
98
  ```
66
99
 
67
- Your channel key is in your system prompt under `# SESSION`.
100
+ Your channel key and session ID are in your system prompt under `# SESSION`.
101
+
102
+ **Note:** Always include `--session-id` to search the archive — without it, only the current transcript is searched.
68
103
 
69
104
  Add `--json` for machine-readable output.
70
105
 
@@ -73,4 +108,4 @@ Add `--json` for machine-readable output.
73
108
  - After completing a big task with many tool calls (file operations, debugging, etc.)
74
109
  - When you notice context is above 70% capacity
75
110
  - When the harness warns you about context usage
76
- - Compact `tool_ops` sections firstthey're usually the largest and least important to keep verbatim
111
+ - Prioritize sections with many tool calls (browser, exec, Read/Edit loops) these are usually the largest and least important to keep verbatim
package/dist/agent.d.ts CHANGED
@@ -2,15 +2,22 @@ import type { Channel } from "./channels/types.js";
2
2
  export declare class Agent {
3
3
  private channels;
4
4
  private sessions;
5
+ private router;
5
6
  private liveSessions;
7
+ private messageQueues;
6
8
  private groupParticipants;
7
9
  private modelOverrides;
8
10
  private lastPromptHash;
9
11
  constructor();
12
+ /** Look up a channel by name */
13
+ private getChannel;
14
+ /** Activate a group chat by adding it to the channel's allowlist */
15
+ private activateGroup;
10
16
  addChannel(channel: Channel): void;
17
+ /** Queue messages per session key so they process sequentially */
18
+ private enqueueMessage;
11
19
  private static readonly AVAILABLE_MODELS;
12
20
  private handleCommand;
13
- private sessionKey;
14
21
  private getOrCreateLiveSession;
15
22
  private closeLiveSession;
16
23
  private hashString;
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAmB,MAAM,qBAAqB,CAAC;AAiTpE,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,iBAAiB,CAAkC;IAC3D,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,cAAc,CAAc;;IAMpC,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAMlC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAItC;YAEY,aAAa;IAkC3B,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,sBAAsB;IA4B9B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,UAAU;YAQJ,aAAa;YAsFb,YAAY;YA8CZ,kBAAkB;IA0BhC,OAAO,CAAC,eAAe;IAWvB,sCAAsC;IAChC,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgD9F,0EAA0E;IACpE,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAyBrD,OAAO,CAAC,cAAc;IAShB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAM5B"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAmB,MAAM,qBAAqB,CAAC;AA8TpE,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,QAAQ,CAAe;IAC/B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,iBAAiB,CAAkC;IAC3D,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,cAAc,CAAc;;IAYpC,gCAAgC;IAChC,OAAO,CAAC,UAAU;IAIlB,oEAAoE;YACtD,aAAa;IAsB3B,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAMlC,kEAAkE;YACpD,cAAc;IAS5B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAItC;YAEY,aAAa;IAkC3B,OAAO,CAAC,sBAAsB;IA4B9B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,UAAU;YAQJ,aAAa;YA6Hb,YAAY;YA8CZ,kBAAkB;IA+BhC,OAAO,CAAC,eAAe;IAWvB,sCAAsC;IAChC,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqE9F,0EAA0E;IACpE,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBrD,OAAO,CAAC,cAAc;IAShB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAM5B"}
package/dist/agent.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
- import { config } from "./config.js";
2
+ import { config, CONFIG_PATH } from "./config.js";
3
3
  import { buildSystemPrompt } from "./workspace/index.js";
4
4
  import { SessionStore } from "./sessions/index.js";
5
5
  import { checkAndClearCompactTrigger } from "./lcm/index.js";
6
+ import { IdentityRouter } from "./router.js";
6
7
  import { log } from "./logger.js";
7
8
  function isSilentReply(text) {
8
9
  return /^\s*NO_REPLY\s*$/i.test(text);
@@ -54,6 +55,7 @@ class LiveSession {
54
55
  sessionId = null;
55
56
  alive = true;
56
57
  lastResult = null;
58
+ prevTotalCost = 0;
57
59
  eventLoopDone;
58
60
  constructor(options) {
59
61
  this.q = query({ prompt: this.messageGenerator(), options });
@@ -121,9 +123,13 @@ class LiveSession {
121
123
  const output = u?.output_tokens ?? 0;
122
124
  const cacheRead = u?.cache_read_input_tokens ?? 0;
123
125
  const cacheCreated = u?.cache_creation_input_tokens ?? 0;
126
+ // Compute per-turn cost as delta from cumulative total
127
+ const totalCost = result.total_cost_usd ?? 0;
128
+ const turnCost = totalCost - this.prevTotalCost;
129
+ this.prevTotalCost = totalCost;
124
130
  // Store result stats, get context usage, then resolve
125
131
  this.lastResult = {
126
- costUsd: result.total_cost_usd ?? 0,
132
+ costUsd: totalCost,
127
133
  inputTokens: input,
128
134
  outputTokens: output,
129
135
  cacheReadTokens: cacheRead,
@@ -132,7 +138,7 @@ class LiveSession {
132
138
  contextMax: 0,
133
139
  };
134
140
  // Await context usage before resolving so stats are complete
135
- await this.logContextUsage(result, input, output, cacheRead, cacheCreated);
141
+ await this.logContextUsage(result, turnCost, totalCost, input, output, cacheRead, cacheCreated);
136
142
  const response = this.parts.join("\n").trim() || "I'm not sure how to respond to that.";
137
143
  this.parts = [];
138
144
  this.streamingText = "";
@@ -140,7 +146,7 @@ class LiveSession {
140
146
  this.currentRequest = null;
141
147
  }
142
148
  }
143
- async logContextUsage(result, input, output, cacheRead, cacheCreated) {
149
+ async logContextUsage(result, turnCost, totalCost, input, output, cacheRead, cacheCreated) {
144
150
  const contextInfo = await (async () => {
145
151
  try {
146
152
  const ctx = await this.q.getContextUsage();
@@ -148,6 +154,9 @@ class LiveSession {
148
154
  if (this.lastResult) {
149
155
  this.lastResult.contextUsed = ctx.totalTokens;
150
156
  this.lastResult.contextMax = ctx.maxTokens;
157
+ this.lastResult.contextBreakdown = ctx.categories
158
+ .filter((c) => c.tokens > 0)
159
+ .map((c) => ({ name: c.name, tokens: c.tokens }));
151
160
  }
152
161
  if (pct >= 80) {
153
162
  log.warn({ used: ctx.totalTokens, max: ctx.maxTokens, pct: `${pct}%` }, "Context nearing compaction");
@@ -166,7 +175,8 @@ class LiveSession {
166
175
  log.info({
167
176
  turns: result.num_turns,
168
177
  duration: `${result.duration_ms}ms`,
169
- cost: `$${result.total_cost_usd?.toFixed(4)}`,
178
+ cost: `$${turnCost.toFixed(4)}`,
179
+ totalCost: `$${totalCost.toFixed(4)}`,
170
180
  tokens: `in:${input} out:${output}`,
171
181
  cache: `read:${cacheRead} created:${cacheCreated}`,
172
182
  context: contextInfo,
@@ -239,25 +249,68 @@ function summarizeToolInput(name, input) {
239
249
  export class Agent {
240
250
  channels = [];
241
251
  sessions;
252
+ router;
242
253
  liveSessions = new Map();
254
+ messageQueues = new Map();
243
255
  groupParticipants = new Map();
244
256
  modelOverrides = new Map();
245
257
  lastPromptHash = "";
246
258
  constructor() {
247
259
  this.sessions = new SessionStore(config.sessionsDir, config.historyLimit);
260
+ this.router = new IdentityRouter(config.identities, this.sessions, config.channelAllowlists);
261
+ // Load persistent per-session model overrides
262
+ for (const [key, model] of Object.entries(config.sessionModelOverrides)) {
263
+ this.modelOverrides.set(key, model);
264
+ }
265
+ }
266
+ /** Look up a channel by name */
267
+ getChannel(name) {
268
+ return this.channels.find((ch) => ch.name === name);
269
+ }
270
+ /** Activate a group chat by adding it to the channel's allowlist */
271
+ async activateGroup(channel, chatId) {
272
+ try {
273
+ const { readFileSync, writeFileSync } = await import("node:fs");
274
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
275
+ const channels = (cfg.channels ?? {});
276
+ if (!channels[channel.name])
277
+ channels[channel.name] = {};
278
+ const allowlist = (channels[channel.name].allowlist ?? []);
279
+ if (!allowlist.includes(chatId)) {
280
+ allowlist.push(chatId);
281
+ channels[channel.name].allowlist = allowlist;
282
+ cfg.channels = channels;
283
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
284
+ // Update the router's in-memory allowlist
285
+ this.router.addToAllowlist(channel.name, chatId);
286
+ }
287
+ log.info({ channel: channel.name, chatId }, "Group chat activated via secret");
288
+ await channel.send({ chatId, text: "Tomo activated in this group." });
289
+ }
290
+ catch (err) {
291
+ log.error({ err }, "Failed to activate group");
292
+ }
248
293
  }
249
294
  addChannel(channel) {
250
- channel.onMessage((msg) => this.handleMessage(channel, msg));
295
+ channel.onMessage((msg) => this.enqueueMessage(channel, msg));
251
296
  channel.onCommand((cmd, chatId, senderName, args) => this.handleCommand(channel, cmd, chatId, senderName, args));
252
297
  this.channels.push(channel);
253
298
  }
299
+ /** Queue messages per session key so they process sequentially */
300
+ async enqueueMessage(channel, message) {
301
+ const isGroup = message.isGroup ?? false;
302
+ const { sessionKey } = this.router.resolve(channel.name, message.chatId, isGroup);
303
+ const prev = this.messageQueues.get(sessionKey) ?? Promise.resolve();
304
+ const next = prev.then(() => this.handleMessage(channel, message)).catch(() => { });
305
+ this.messageQueues.set(sessionKey, next);
306
+ }
254
307
  static AVAILABLE_MODELS = {
255
308
  "sonnet": "claude-sonnet-4-6[1m]",
256
309
  "opus": "claude-opus-4-6[1m]",
257
310
  "haiku": "claude-haiku-4-5",
258
311
  };
259
312
  async handleCommand(channel, command, chatId, senderName, args) {
260
- const key = this.sessionKey(channel, chatId);
313
+ const { sessionKey: key } = this.router.resolve(channel.name, chatId, false);
261
314
  if (command === "new") {
262
315
  this.closeLiveSession(key);
263
316
  this.sessions.clearSdkSessionId(key);
@@ -286,9 +339,6 @@ export class Agent {
286
339
  return;
287
340
  }
288
341
  }
289
- sessionKey(channel, chatId) {
290
- return `${channel.name}:${chatId}`;
291
- }
292
342
  getOrCreateLiveSession(key) {
293
343
  let session = this.liveSessions.get(key);
294
344
  if (session?.isAlive())
@@ -333,10 +383,23 @@ export class Agent {
333
383
  const isGroup = message.isGroup ?? false;
334
384
  const isMentioned = message.isMentioned ?? false;
335
385
  log.info({ channel: channel.name, sender: message.senderName, group: isGroup || undefined, mentioned: isMentioned || undefined, images: hasImages ? message.images.length : undefined }, message.text);
336
- const key = this.sessionKey(channel, message.chatId);
386
+ // Group secret activation: if message matches the secret, add group to allowlist
387
+ if (isGroup && config.groupSecret && message.text.trim() === config.groupSecret) {
388
+ await this.activateGroup(channel, message.chatId);
389
+ return;
390
+ }
391
+ // Allowlist check: reject messages from unknown senders
392
+ if (!this.router.isAllowed(channel.name, message.chatId)) {
393
+ log.debug({ channel: channel.name, chatId: message.chatId }, "Message blocked (not in allowlist)");
394
+ return;
395
+ }
396
+ const resolution = this.router.resolve(channel.name, message.chatId, isGroup);
397
+ const key = resolution.sessionKey;
398
+ const replyChannel = this.getChannel(resolution.replyTarget.channelName) ?? channel;
399
+ const replyChatId = resolution.replyTarget.chatId;
337
400
  const textForAgent = isGroup ? `${message.senderName}: ${message.text}` : message.text;
338
401
  if (isGroup) {
339
- await this.updateGroupContext(key, message.senderName, message.chatTitle);
402
+ await this.updateGroupContext(key, message.senderName, channel.name, message.chatTitle);
340
403
  }
341
404
  this.sessions.append(key, {
342
405
  role: "user",
@@ -349,33 +412,50 @@ export class Agent {
349
412
  log.debug("Group message ignored (not mentioned)");
350
413
  return;
351
414
  }
352
- const stopTyping = channel.startTyping(message.chatId);
415
+ // iMessage groups: skip typing indicator (most messages will be NO_REPLY)
416
+ const isImessageGroup = isGroup && channel.name === "imessage";
417
+ const stopTyping = isImessageGroup ? () => { } : replyChannel.startTyping(replyChatId);
353
418
  try {
354
419
  const stampedText = this.injectTimestamp(textForAgent);
355
- const stream = channel.createStreamingMessage(message.chatId, message.id);
420
+ const stream = replyChannel.createStreamingMessage(replyChatId, isGroup ? message.id : undefined);
356
421
  const response = await this.runWithRetry(key, stampedText, (text) => {
357
422
  stream.update(text.replace(MEDIA_RE, "").trim());
358
423
  }, message.images);
359
424
  stopTyping();
425
+ // If context is high, send a system nudge so the agent can compact
426
+ const liveSession = this.liveSessions.get(key);
427
+ const ctx = liveSession?.lastResult;
428
+ if (ctx && ctx.contextMax > 0) {
429
+ const pct = Math.round((ctx.contextUsed / ctx.contextMax) * 100);
430
+ if (pct >= 80) {
431
+ this.runWithRetry(key, `System: Context usage is at ${pct}% (${ctx.contextUsed}/${ctx.contextMax} tokens). Use the lcm compact skill to free up space before the next user message.`).catch(() => { });
432
+ }
433
+ }
360
434
  this.sessions.append(key, {
361
435
  role: "assistant",
362
436
  content: response,
363
- channel: channel.name,
437
+ channel: replyChannel.name,
364
438
  timestamp: Date.now(),
365
439
  });
366
- log.info({ channel: channel.name }, "Tomo: %s", response);
440
+ log.info({ channel: replyChannel.name }, "Tomo: %s", response);
367
441
  if (isSilentReply(response)) {
368
442
  log.info("Silent reply (no message sent)");
369
443
  return;
370
444
  }
445
+ // Surface API errors that the SDK returns as response text
446
+ if (/^API Error: \d+/i.test(response) || /^\{"type":"error"/.test(response)) {
447
+ await stream.finish();
448
+ await replyChannel.send({ chatId: replyChatId, text: `[error] ${response}` });
449
+ return;
450
+ }
371
451
  const { cleanText, mediaPaths } = extractMedia(response);
372
452
  if (mediaPaths.length > 0) {
373
453
  const { existsSync: fileExists } = await import("node:fs");
374
454
  const validPaths = mediaPaths.filter((p) => fileExists(p));
375
455
  if (validPaths.length > 0) {
376
456
  for (let i = 0; i < validPaths.length; i++) {
377
- await channel.send({
378
- chatId: message.chatId,
457
+ await replyChannel.send({
458
+ chatId: replyChatId,
379
459
  photo: validPaths[i],
380
460
  text: i === 0 ? cleanText : "",
381
461
  });
@@ -393,10 +473,13 @@ export class Agent {
393
473
  catch (err) {
394
474
  stopTyping();
395
475
  log.error({ err }, "Error handling message");
396
- await channel.send({
397
- chatId: message.chatId,
398
- text: "Sorry, something went wrong. Please try again.",
399
- replyTo: message.id,
476
+ // iMessage groups: suppress error messages to avoid polluting the chat
477
+ if (isImessageGroup)
478
+ return;
479
+ const detail = err instanceof Error ? err.message : String(err);
480
+ await replyChannel.send({
481
+ chatId: replyChatId,
482
+ text: `[error] ${detail}`,
400
483
  });
401
484
  }
402
485
  }
@@ -438,7 +521,7 @@ export class Agent {
438
521
  throw err;
439
522
  }
440
523
  }
441
- async updateGroupContext(key, senderName, chatTitle) {
524
+ async updateGroupContext(key, senderName, channelName, chatTitle) {
442
525
  let participants = this.groupParticipants.get(key);
443
526
  const isNew = !participants;
444
527
  if (!participants) {
@@ -450,7 +533,11 @@ export class Agent {
450
533
  if (isNew || !wasKnown) {
451
534
  const names = [...participants].join(", ");
452
535
  const title = chatTitle ? `"${chatTitle}"` : "a group chat";
453
- const contextMsg = `System: You are in ${title}. Participants so far: ${names}. Messages are prefixed with sender names.`;
536
+ let contextMsg = `System: You are in ${title}. Participants so far: ${names}. Messages are prefixed with sender names.`;
537
+ // iMessage groups: inject guidance to stay silent unless needed
538
+ if (isNew && channelName === "imessage") {
539
+ contextMsg += " This is an iMessage group chat. You see every message but should only reply when you have something genuinely useful to add. Reply NO_REPLY to stay silent. Do not respond to casual chatter, greetings, or messages not directed at you.";
540
+ }
454
541
  try {
455
542
  await this.runWithRetry(key, contextMsg);
456
543
  log.info({ group: chatTitle, participants: names }, "Group context updated");
@@ -472,26 +559,56 @@ export class Agent {
472
559
  }
473
560
  /** Handle a cron-triggered message */
474
561
  async handleCronMessage(message, channelName, chatId) {
475
- const channel = channelName
476
- ? this.channels.find((ch) => ch.name === channelName)
477
- : this.channels[0];
478
- if (!channel) {
479
- log.warn({ channelName }, "Cron: no channel found for delivery");
480
- return;
481
- }
482
- const targetChatId = chatId ?? this.findLastChatId(channel.name);
483
- if (!targetChatId) {
484
- log.warn({ channel: channel.name }, "Cron: no chatId available for delivery");
485
- return;
562
+ // Resolve delivery target
563
+ let key;
564
+ let deliveryChannel;
565
+ let deliveryChatId;
566
+ if (channelName && chatId) {
567
+ // Explicit channel+chatId from cron job — resolve through router for identity support
568
+ const resolution = this.router.resolve(channelName, chatId, false);
569
+ key = resolution.sessionKey;
570
+ deliveryChannel = this.getChannel(resolution.replyTarget.channelName) ?? this.channels[0];
571
+ deliveryChatId = resolution.replyTarget.chatId;
572
+ }
573
+ else {
574
+ // No explicit target — try unified dm session first, then fall back to channel scan
575
+ const dmKey = this.router.findFirstDmSession();
576
+ if (dmKey) {
577
+ key = dmKey;
578
+ const target = this.router.getReplyTarget(dmKey);
579
+ if (target) {
580
+ deliveryChannel = this.getChannel(target.channelName) ?? this.channels[0];
581
+ deliveryChatId = target.chatId;
582
+ }
583
+ else {
584
+ log.warn("Cron: dm session has no reply target");
585
+ return;
586
+ }
587
+ }
588
+ else {
589
+ // Legacy fallback: find last active chatId on first channel
590
+ const channel = this.channels[0];
591
+ if (!channel) {
592
+ log.warn("Cron: no channel available");
593
+ return;
594
+ }
595
+ const fallbackChatId = this.findLastChatId(channel.name);
596
+ if (!fallbackChatId) {
597
+ log.warn({ channel: channel.name }, "Cron: no chatId available");
598
+ return;
599
+ }
600
+ key = `${channel.name}:${fallbackChatId}`;
601
+ deliveryChannel = channel;
602
+ deliveryChatId = fallbackChatId;
603
+ }
486
604
  }
487
- const key = this.sessionKey(channel, targetChatId);
488
605
  const stampedMessage = this.injectTimestamp(message);
489
- log.info({ channel: channel.name, sender: "cron" }, message);
490
- const stopTyping = channel.startTyping(targetChatId);
606
+ log.info({ channel: deliveryChannel.name, sender: "cron" }, message);
607
+ const stopTyping = deliveryChannel.startTyping(deliveryChatId);
491
608
  try {
492
609
  const response = await this.runWithRetry(key, stampedMessage);
493
610
  stopTyping();
494
- log.info({ channel: channel.name }, "Tomo: %s", response);
611
+ log.info({ channel: deliveryChannel.name }, "Tomo: %s", response);
495
612
  if (isSilentReply(response)) {
496
613
  log.info("Cron completed silently (no reply sent)");
497
614
  return;
@@ -499,34 +616,42 @@ export class Agent {
499
616
  this.sessions.append(key, {
500
617
  role: "assistant",
501
618
  content: response,
502
- channel: channel.name,
619
+ channel: deliveryChannel.name,
503
620
  timestamp: Date.now(),
504
621
  });
505
- await channel.send({ chatId: targetChatId, text: response });
622
+ await deliveryChannel.send({ chatId: deliveryChatId, text: response });
506
623
  }
507
624
  catch (err) {
508
625
  stopTyping();
509
626
  log.error({ err }, "Cron message handling failed");
627
+ const detail = err instanceof Error ? err.message : String(err);
628
+ await deliveryChannel.send({ chatId: deliveryChatId, text: `[error] cron failed: ${detail}` });
510
629
  }
511
630
  }
512
631
  /** Handle a continuity heartbeat — runs on the first active DM session */
513
632
  async handleContinuity(prompt) {
514
- // Find the first active DM session
515
- const channel = this.channels[0];
516
- if (!channel) {
517
- log.warn("Continuity: no channel available");
518
- return;
519
- }
520
- const targetChatId = this.findLastChatId(channel.name);
521
- if (!targetChatId) {
522
- log.debug("Continuity: no active session, skipping");
523
- return;
633
+ // Prefer unified dm session, then fall back to channel-scoped session
634
+ const dmKey = this.router.findFirstDmSession();
635
+ let key;
636
+ if (dmKey) {
637
+ key = dmKey;
638
+ }
639
+ else {
640
+ const channel = this.channels[0];
641
+ if (!channel) {
642
+ log.warn("Continuity: no channel available");
643
+ return;
644
+ }
645
+ const chatId = this.findLastChatId(channel.name);
646
+ if (!chatId) {
647
+ log.debug("Continuity: no active session, skipping");
648
+ return;
649
+ }
650
+ key = `${channel.name}:${chatId}`;
524
651
  }
525
- const key = this.sessionKey(channel, targetChatId);
526
652
  try {
527
653
  const response = await this.runWithRetry(key, prompt);
528
654
  log.info("Continuity response: %s", response.slice(0, 100));
529
- // Continuity responses are always silent — agent should reply NO_REPLY
530
655
  }
531
656
  catch (err) {
532
657
  log.error({ err }, "Continuity heartbeat failed");