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.
- package/CHANGELOG.md +13 -0
- package/README.md +36 -29
- package/defaults/skills/lcm/SKILL.md +43 -8
- package/dist/agent.d.ts +8 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +178 -53
- package/dist/agent.js.map +1 -1
- package/dist/channels/imessage.d.ts +39 -0
- package/dist/channels/imessage.d.ts.map +1 -0
- package/dist/channels/imessage.js +387 -0
- package/dist/channels/imessage.js.map +1 -0
- package/dist/channels/index.d.ts +1 -0
- package/dist/channels/index.d.ts.map +1 -1
- package/dist/channels/index.js +1 -0
- package/dist/channels/index.js.map +1 -1
- package/dist/cli/config.d.ts +3 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +579 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +50 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/lcm.js +3 -3
- package/dist/cli/lcm.js.map +1 -1
- package/dist/cli/sessions.js +7 -0
- package/dist/cli/sessions.js.map +1 -1
- package/dist/cli/start.js +11 -1
- package/dist/cli/start.js.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +40 -5
- package/dist/config.js.map +1 -1
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +133 -0
- package/dist/router.js.map +1 -0
- package/dist/sessions/store.d.ts +11 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +46 -2
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +13 -1
- package/dist/sessions/types.d.ts.map +1 -1
- 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
|
|
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
|
-
-
|
|
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
|
-
##
|
|
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
|
-
-
|
|
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
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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;
|
package/dist/agent.d.ts.map
CHANGED
|
@@ -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;
|
|
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:
|
|
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: `$${
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
437
|
+
channel: replyChannel.name,
|
|
364
438
|
timestamp: Date.now(),
|
|
365
439
|
});
|
|
366
|
-
log.info({ channel:
|
|
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
|
|
378
|
-
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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:
|
|
490
|
-
const stopTyping =
|
|
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:
|
|
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:
|
|
619
|
+
channel: deliveryChannel.name,
|
|
503
620
|
timestamp: Date.now(),
|
|
504
621
|
});
|
|
505
|
-
await
|
|
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
|
-
//
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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");
|