@c4t4/heyamigo 0.9.25 → 0.10.1

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/README.md CHANGED
@@ -1,284 +1,71 @@
1
1
  # heyamigo
2
2
 
3
- > It remembers. It learns. It gets better while you sleep.
3
+ A WhatsApp-resident assistant. Claude or Codex under the hood, durable SQLite queues, per-sender timezone scheduling, two-track architecture so browser work never blocks the chat.
4
4
 
5
- heyamigo lives in your WhatsApp. It meets people and builds a mental model of each one. It picks up on what matters and forgets what doesn't. It organizes what it knows the way you would, by person, by project, by topic. It browses the web, reads what you send it, sees images, and connects the dots across conversations you had weeks apart.
6
-
7
- Between chats, it processes. Background workers compress raw experience into understanding. Short-term becomes long-term. Noise becomes signal. The next time you talk, it's not starting from scratch. It's starting from everything it's learned.
8
-
9
- It runs on your Claude subscription. No API keys, no third-party services. One command to set up.
10
-
11
- ---
12
-
13
- ## Why this exists
14
-
15
- Most AI tools try to be everything. heyamigo tries to be one thing well: **the AI you actually want in your group chat.**
16
-
17
- Something simple you can talk to, ask things, share stuff with, and that gets better the more you use it.
18
-
19
- Anthropic changed how third-party apps consume your Claude usage. Third-party tools now draw from extra usage, not your plan limits. heyamigo is **first-party**: it runs through Claude CLI, your direct Claude subscription. No middlemen, no extra costs.
20
-
21
- ---
5
+ ```
6
+ WhatsApp ─► inbound ─► chat workers ─► outbound ─► WhatsApp
7
+ │ ▲
8
+ ├──────► async / browser ─┤
9
+ └──────► memory_writes ───┘
10
+ ```
22
11
 
23
12
  ## What it does
24
13
 
25
- ### Talks on WhatsApp
26
- Groups and DMs. Mention its name to get a reply: "amigo what do you think?" or "claude check this". Stays quiet when not mentioned. Replying to one of its messages also triggers a response. Handles text, images, videos, documents, voice messages.
27
-
28
- ### Builds a profile for every person it talks to
29
- Not a chat log. A structured profile.
30
-
31
- After 20 conversations it knows your partner prefers short replies, your coworker only responds to direct questions, and your friend is lactose intolerant. Facts, preferences, patterns, accumulated over time. Each person's profile grows independently, whether they talk in a group or a DM.
14
+ - **Long-term memory per person, per chat, per topic.** Files on disk. The agent decides what's worth keeping; background workers consolidate while you're not chatting.
15
+ - **A relevance watchlist.** Open loops the agent tracks on your behalf questions you'd forget, things you're waiting on surfaced naturally when the moment matches. Built like external working memory for the user.
16
+ - **Scheduling in the sender's timezone.** Natural language → `[REMIND: 2026-05-26 09:00 — ...]` or `[CRON: 0 9 * * 1 PROMPT — ...]`. Fires at the user's wall-clock 9am, not the server's. Cron variants: deliver text, run AI, kick off async work, or drive a browser.
17
+ - **A real Chrome.** Browser delegation via `[ASYNC-BROWSER: ...]` to a parallel Claude session on a shared logged-in Chrome over CDP. TikTok, Instagram, anywhere the owner is logged in. SSH-tunneled noVNC for setup.
18
+ - **Per-reply footer with confirmation tags.** Every side effect from the turn is visible: `_9.9s · 465k↑ 169↓ · +remind · +thread-new · +digest_`. No guessing whether a schedule actually got created.
19
+ - **Default-deny proactive messaging.** Groups stay silent unless explicitly opted in. Per-role token quotas, file-size caps, tool restrictions.
32
20
 
33
- ### Organizes what it knows into buckets
34
- People, projects, topics, each in their own folder with an index.
35
-
36
- Ask about a project and it pulls that project's brief. Ask about a person and it pulls their profile. It doesn't shove everything into one giant prompt. Only the relevant buckets load per message. The rest stays on disk, accessible when needed.
37
-
38
- ### Amigo decides what's worth remembering
39
- Most bots store everything or nothing.
40
-
41
- This one lets the AI flag moments during conversation. You mention you're moving to Berlin next month, it flags it. Your profile gets updated. You send "lol", nothing happens. The signal-to-noise ratio improves over time because the AI itself is curating what matters.
42
-
43
- ### Processes what happened while you're not chatting
44
- Raw conversations sit in short-term memory. Between chats, background workers compress them into long-term profiles and topic summaries.
45
-
46
- Like how your brain consolidates memories during sleep. The bot processes while idle, so the next conversation starts with better context than the last one ended with.
47
-
48
- ### Imports what you already have
49
- Got a messy folder of notes, project docs, or an existing AI workspace? Point amigo at it. It reads through everything, distills the useful stuff, and organizes it into structured buckets (people, projects, topics). Your unstructured knowledge becomes searchable context that amigo references in every conversation. One command: `heyamigo import ~/my-notes`
50
-
51
- ### Browses the web
52
- Controls a real Chrome browser. Navigates pages, takes screenshots, sends them back to WhatsApp. You can watch it browse via SSH tunnel.
53
-
54
- ---
21
+ For the why behind these claim primitives, tag-as-side-effect channel, per-category learning, provider abstraction, the trade-offs that didn't survive the first revision — see [`docs/architecture.md`](docs/architecture.md).
55
22
 
56
23
  ## Quick start
57
24
 
58
- ### 1. Install Claude CLI and log in
59
-
60
25
  ```bash
61
26
  npm install -g @anthropic-ai/claude-code
62
- claude
63
- ```
64
-
65
- Run `claude` and follow the login instructions. You need an [Anthropic account](https://console.anthropic.com). After logging in, exit claude.
27
+ claude # log in once, then exit
66
28
 
67
- ### 2. Run the setup wizard
68
-
69
- ```bash
70
- npx @c4t4/heyamigo setup
71
- ```
72
-
73
- That's it. The wizard handles everything:
74
- - WhatsApp pairing (QR code + pairing code)
75
- - Browser setup (optional)
76
- - Personality selection
77
-
78
- ### 3. Start the bot
79
-
80
- ```bash
81
- npx @c4t4/heyamigo start
29
+ npx @c4t4/heyamigo setup # wizard: pair WhatsApp, pick personality
30
+ npx @c4t4/heyamigo start # background, auto-restart
31
+ npx @c4t4/heyamigo logs # tail
82
32
  ```
83
33
 
84
- Runs in the background, auto-restarts on crash, survives SSH disconnect.
85
-
86
- ---
87
-
88
- ## Commands
89
-
90
- ```
91
- npx @c4t4/heyamigo setup # setup wizard
92
- npx @c4t4/heyamigo start # start (background, auto-restart)
93
- npx @c4t4/heyamigo stop # stop
94
- npx @c4t4/heyamigo restart # restart
95
- npx @c4t4/heyamigo logs # tail live logs
96
- npx @c4t4/heyamigo status # check if running
97
- npx @c4t4/heyamigo update # update to latest version
98
- npx @c4t4/heyamigo import <path> # import knowledge folder
99
- npx @c4t4/heyamigo dev # foreground (development)
100
- ```
34
+ Codex instead of Claude: install `@openai/codex` and set `ai.provider: "codex"` in `config/config.json`.
101
35
 
102
- ### In-chat commands
36
+ ## In-chat commands
103
37
 
104
38
  | Command | What it does |
105
- |---------|-------------|
106
- | `/reset` | Fresh Claude session |
107
- | `/status` | Session info + context usage % |
108
- | `/reload` | Re-read personality |
109
- | `/digest` | Force memory update |
110
-
111
- ---
112
-
113
- ## Memory
114
-
115
- Three layers, inspired by how brains work:
116
-
117
- ```
118
- Short-term raw messages (JSONL per chat)
119
- Working memory Claude session (--resume)
120
- Long-term profiles, topics, project briefs
121
- ```
122
-
123
- ```
124
- storage/memory/
125
- buckets/ projects, topics (imported or auto-created)
126
- persons/ per-person profiles (grow over time)
127
- chats/ per-chat briefs
128
- ```
129
-
130
- ### How memory updates
131
-
132
- The bot updates memory in two ways:
133
-
134
- **Real-time (DIGEST flag):** During a conversation, Claude decides if something is worth remembering (a preference, fact, life event). It appends a hidden `[DIGEST: reason]` tag to its reply, which gets stripped before sending. This triggers a background digest within 2 minutes that updates the person's profile and the chat brief.
135
-
136
- **Background sweep:** Every 3 hours, the bot checks all active chats for new messages that weren't flagged. This catches anything Claude missed. You can also force an immediate update with the `/digest` command in chat.
137
-
138
- Memory is stored as plain markdown files. You can read, edit, or delete them directly.
139
-
140
- ---
39
+ |---|---|
40
+ | `/reset` | Fresh AI session for this chat |
41
+ | `/status` | Session info, context utilization |
42
+ | `/queues` | Live queue depths |
43
+ | `/crons` · `/reminders` | List recurring schedules + one-shots (token cost included) |
44
+ | `/threads` | List the relevance watchlist; resolve / drop / pause / weight |
45
+ | `/digest` | Force a memory consolidation now |
141
46
 
142
47
  ## Roles
143
48
 
144
- Defined in `config/access.json`.
145
-
146
- | Role | Memory | Tools | Boundary |
147
- |------|--------|-------|----------|
148
- | **admin** | everything | all | unrestricted |
149
- | **user** | own profile | web search | can't see other users or internals |
150
- | **guest** | own profile | none | locked down, prompt-injection resistant |
151
-
152
- ---
153
-
154
- ## Browser
155
-
156
- Optional. Chrome via CDP. You watch via noVNC over SSH tunnel. Setup wizard handles install.
157
-
158
- ```
159
- You (SSH tunnel) -> noVNC -> Chrome <- Claude (CDP)
160
- ```
161
-
162
- All localhost. Nothing public.
49
+ `config/access.json`. Three default roles, easily extended.
163
50
 
164
- ---
51
+ | Role | Memory | Tools | Notes |
52
+ |---|---|---|---|
53
+ | admin | everything | all | unrestricted |
54
+ | user | own profile | web search | can't see other users or internals |
55
+ | guest | none | none | prompt-injection resistant |
165
56
 
166
57
  ## Personalities
167
58
 
168
- Three built-in:
169
-
170
- [**Sharp**](config/personalities/sharp.md) (default)
171
- Talks like a smart friend at dinner. Specific, confident, never vague. Won't hedge, won't lecture, won't sound like a brochure. Calls things out when they're obvious, meets people where they actually are. Checks every reply against: would I be embarrassed saying this out loud?
172
-
173
- [**Casual**](config/personalities/casual.md)
174
- Warm, relaxed, friend-over-coffee energy. Short messages, matches your vibe.
175
-
176
- [**Professional**](config/personalities/professional.md)
177
- Clear, efficient, business-appropriate. Gets to the answer fast.
178
-
179
- Create your own: add a `.md` file to `config/personalities/`, point `config.json` at it.
180
-
181
- ---
182
-
183
- ## Configuration
184
-
185
- ### config/config.json
186
-
187
- Core settings. The wizard sets these up, but you can edit anytime.
188
-
189
- ```json
190
- {
191
- "owner": { "number": "17861234567" },
192
- "triggers": { "aliases": ["heyamigo", "amigo", "claude"], "groupMode": "mention" },
193
- "claude": { "model": "claude-opus-4-7", "timeoutMs": 60000 },
194
- "reply": { "quoteInGroups": true, "typingIndicator": true }
195
- }
196
- ```
197
-
198
- ### config/access.json
199
-
200
- Who can use the bot and what they can do. See `access.example.json` for all options.
201
-
202
- ```json
203
- {
204
- "users": {
205
- "17861234567": { "role": "admin", "name": "Alice" },
206
- "491701234567": { "role": "user", "name": "Carlos" }
207
- },
208
- "groups": [
209
- { "jid": "120363xxx@g.us", "name": "Family", "mode": "active", "allowedSenders": "*" },
210
- { "jid": "120363yyy@g.us", "name": "Work", "mode": "active", "allowedSenders": ["17861234567"] }
211
- ],
212
- "dms": {
213
- "defaultMode": "off",
214
- "allowed": [{ "number": "491701234567", "mode": "active" }]
215
- }
216
- }
217
- ```
218
-
219
- Groups auto-discover with `mode: "off"` when the bot first sees a message. Flip to `"active"` to enable.
220
-
221
- ### Other files
222
-
223
- | File | Purpose |
224
- |------|---------|
225
- | `config/personalities/*.md` | System prompts (sharp, casual, professional) |
226
- | `.claude/settings.json` | Tool permissions for Claude CLI |
227
-
228
- ---
59
+ `config/personalities/*.md` — system-prompt fragments that define the bot's voice. The default (`sharp.md`) is opinionated about not people-pleasing. Swap or write your own.
229
60
 
230
61
  ## Where to run it
231
62
 
232
- Needs a persistent filesystem and a long-running process.
233
-
234
- | Option | Cost | Notes |
235
- |--------|------|-------|
236
- | **VPS** (Hetzner, DigitalOcean) | ~$5/mo | Recommended. Setup wizard just works. |
237
- | **Home server / Raspberry Pi** | One-time | Always-on device at home. |
238
- | **Your laptop** | Free | For testing. Bot stops when laptop sleeps. |
239
- | **Cloud** (Railway, Fly) | Varies | Needs persistent volumes. No interactive setup. |
240
-
241
- Not compatible with serverless (Lambda, Vercel). Needs a persistent WebSocket connection.
242
-
243
- ---
244
-
245
- ## Requirements
246
-
247
- - Node.js 18+
248
- - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) (`npm install -g @anthropic-ai/claude-code`) + Anthropic account
249
- - A WhatsApp account
250
- - **macOS or Linux** (Windows: use WSL)
251
-
252
- ---
63
+ A VPS (Hetzner, DO) at ~$5/mo is the path of least resistance. Home server or Raspberry Pi also fine. Needs Node 18+, a persistent filesystem, and one outbound WebSocket to WhatsApp. Not serverless-compatible.
253
64
 
254
65
  ## Tracking memory with git
255
66
 
256
- The bot updates files in `storage/memory/` over time as it learns. We recommend tracking your project with git so you can see what changed and roll back if needed.
257
-
258
- ```bash
259
- cd ~/heyamigo
260
- git init
261
- echo "storage/auth/" >> .gitignore
262
- echo "storage/logs/" >> .gitignore
263
- git add -A && git commit -m "initial setup"
264
- ```
265
-
266
- Never commit `storage/auth/` — it contains your WhatsApp session keys.
267
-
268
- ---
269
-
270
- ## Security
271
-
272
- - `storage/auth/` contains your WhatsApp session keys. Guard them.
273
- - All ports bind to localhost. Nothing exposed publicly.
274
- - Baileys is an unofficial WhatsApp protocol. Use at your own risk.
275
- - Role restrictions are prompt-enforced. Strong but not bulletproof.
276
- - Outgoing media auto-deleted after sending.
277
-
278
- ---
67
+ The bot writes markdown files under `storage/memory/` as it learns. `git init` in your project root and commit periodically gives you a readable diff of what the assistant has come to believe about people and topics. Skip `storage/auth/` (WhatsApp keys) and `storage/logs/`.
279
68
 
280
69
  ## License
281
70
 
282
- MIT - Built by [Catalin Waack](https://github.com/C4T4) · [LinkedIn](https://www.linkedin.com/in/catalinwaack/)
283
-
284
- If you use heyamigo in your project or build something on top of it, a mention or link back is appreciated.
71
+ MIT. Built by [Catalin Waack](https://github.com/C4T4) · [LinkedIn](https://www.linkedin.com/in/catalinwaack/).
@@ -15,10 +15,9 @@ storage/memory/
15
15
  journals/<slug>/index.md # journal spec (frontmatter+body)
16
16
  journals/<slug>/entries.jsonl # append-only (do NOT edit)
17
17
  journals/<slug>/observer-state.json
18
- journals/<slug>/nudge-state.json
19
18
  ```
20
19
 
21
- Relevant blocks appear in `[State]`, `[Map]`, `[Trees]`, `[Entities]`, `[Journals]` at top of each turn. Don't re-Read what's already in your preamble.
20
+ Relevant blocks appear in `[State]`, `[Map]`, `[Trees]`, `[Entities]`, `[Journals]`, `[Live threads]` at top of each turn. Don't re-Read what's already in your preamble.
22
21
 
23
22
  ## State + dig-deeper
24
23
 
@@ -51,11 +50,61 @@ Don't cross-log subjects (Dani's health ≠ Cata's). Ask if ambiguous.
51
50
 
52
51
  ### Create: `[JOURNAL-NEW:<slug> — <purpose>]`
53
52
 
54
- When owner asks to track something no existing journal covers: propose purpose in one message, wait for confirmation, then emit the tag. Slug: lowercase letters/digits/hyphens, max 48 chars, starts with letter/digit. Creates `journals/<slug>/index.md` with defaults (`status=active`, `nudge_if_silent=3d`). Can flag first entry in same reply with a separate `[JOURNAL:<slug> — ...]` tag.
53
+ When owner asks to track something no existing journal covers: propose purpose in one message, wait for confirmation, then emit the tag. Slug: lowercase letters/digits/hyphens, max 48 chars, starts with letter/digit. Creates `journals/<slug>/index.md` with defaults. Can flag first entry in same reply with a separate `[JOURNAL:<slug> — ...]` tag.
55
54
 
56
- ### Edit (pause/archive/cadence)
55
+ ### Edit (pause/archive)
57
56
 
58
- No marker — Edit `journals/<slug>/index.md` directly. Frontmatter fields: `status` (active|paused|archived), `purpose`, `fields`, `checkin`, `nudge_if_silent`, `quiet_hours`. Never touch `entries.jsonl`, `observer-state.json`, `nudge-state.json` unless the owner asked you to fix a specific bug. Confirm the change in reply.
57
+ No marker — Edit `journals/<slug>/index.md` directly. Frontmatter fields: `status` (active|paused|archived), `purpose`, `fields`. Never touch `entries.jsonl` or `observer-state.json` unless the owner asked you to fix a specific bug. Confirm the change in reply.
58
+
59
+ For recurring proactive check-ins on a journal, use a CRON (`[CRON: 0 9 * * 1 PROMPT — ...]`) or open a thread (`[THREAD-NEW: ...]`) — not journal frontmatter.
60
+
61
+ ## Threads — your watchlist
62
+
63
+ A *thread* is an open loop you're tracking: a question, a waiting-on, an intent the owner mentioned in passing. Threads sit between cold memory (journals/profiles/buckets) and live conversation. You curate them.
64
+
65
+ You can see live threads for this chat in the `[Live threads]` preamble block. Bring them up naturally if relevant; don't force them. User voice always wins — if they drop a thread, learn from it.
66
+
67
+ ### Lifecycle tags (all end-of-reply)
68
+
69
+ | Tag | When |
70
+ |---|---|
71
+ | `[THREAD-NEW: title="..." summary="..." hotness=70 linked_memory=... category=...]` | open a new loop |
72
+ | `[THREAD-UPDATE:<id> summary="..." hotness=80]` | refine what you know |
73
+ | `[THREAD-TOUCH:<id>]` | you mentioned it naturally in reply |
74
+ | `[THREAD-COOL:<id> — wait Nd]` | not now, check back later |
75
+ | `[THREAD-RESOLVE:<id> — note]` | answer arrived, close it |
76
+ | `[THREAD-DROP:<id> — reason]` | stale, no longer relevant |
77
+ | `[THREAD-COMPRESS:<id> — note]` | stabilized fact — move to journal/profile via [DIGEST:] in same reply |
78
+ | `[THREAD-WEIGHT: <category> <0-100>]` | rare manual override on a category's default hotness |
79
+
80
+ ### Rules
81
+
82
+ - Open threads sparingly. Only when the owner mentioned something with a future resolution point that they'd plausibly want to hear back on. Not for jokes, opinions, or things you'd hear about anyway.
83
+ - Hotness 0-100. AI-curated. Drop hotness with COOL, raise with TOUCH or UPDATE.
84
+ - Always RESOLVE/DROP/COMPRESS when the loop closes — don't leave stale threads accumulating.
85
+ - Compressing = thread stabilized into a durable fact. Pair `[THREAD-COMPRESS:<id> — ...]` with `[DIGEST: ...]` in the same reply so the fact actually gets written somewhere.
86
+ - One thread per loop. Don't open duplicates — check `[Live threads]` first.
87
+
88
+ ### Examples
89
+
90
+ Owner says "waiting on Jana to confirm Tuesday's meeting":
91
+ ```
92
+ Got it, fingers crossed she replies.
93
+ [THREAD-NEW: title="Jana meeting confirm" summary="owner waiting on confirmation for Tue meeting" hotness=70]
94
+ ```
95
+
96
+ Owner mentions Jana confirmed:
97
+ ```
98
+ Nice, locked in for Tuesday 10am.
99
+ [THREAD-RESOLVE:42 — confirmed Tue 10am]
100
+ [DIGEST: Jana meeting confirmed Tue 10am]
101
+ ```
102
+
103
+ Owner brings up something unrelated, but thread #51 ("5 DMs to creators") is still hot and matches the moment:
104
+ ```
105
+ quick reminder, the 5 DMs were on for today — how'd it go?
106
+ [THREAD-TOUCH:51]
107
+ ```
59
108
 
60
109
  ## Two parallel tracks
61
110
 
package/dist/config.js CHANGED
@@ -117,6 +117,29 @@ const ConfigSchema = z.object({
117
117
  level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']),
118
118
  promptRetentionDays: z.number(),
119
119
  }),
120
+ // Threads — AI-curated relevance watchlist. See src/queue/threads.ts.
121
+ // On by default. Reactive surface only in v1: the agent decides
122
+ // when to open loops, brings them up if naturally relevant, never
123
+ // sends unsolicited messages. To turn off, set enabled=false in
124
+ // config.local.json. Proactive review tick (silent-chat check-ins)
125
+ // is the bit that would be default-off if/when it ships.
126
+ threads: z
127
+ .object({
128
+ enabled: z.boolean().default(true),
129
+ preamblePerChat: z.number().int().positive().default(5),
130
+ // Soft caps used by future cleanup jobs; the worker doesn't read
131
+ // these yet but they're here so config.json can be authored once.
132
+ maxActivePerChat: z.number().int().positive().default(10),
133
+ hotnessCapOnCreate: z.number().int().min(0).max(100).default(70),
134
+ decayPerDay: z.number().int().min(0).default(2),
135
+ })
136
+ .default({
137
+ enabled: true,
138
+ preamblePerChat: 5,
139
+ maxActivePerChat: 10,
140
+ hotnessCapOnCreate: 70,
141
+ decayPerDay: 2,
142
+ }),
120
143
  });
121
144
  function loadJsonIfExists(path) {
122
145
  try {
package/dist/db/schema.js CHANGED
@@ -141,6 +141,61 @@ export const crons = sqliteTable('crons', {
141
141
  .where(sql `${t.recurrence} IS NOT NULL`),
142
142
  }));
143
143
  // ──────────────────────────────────────────────────────────────────
144
+ // Threads — AI-curated relevance watchlist
145
+ // ──────────────────────────────────────────────────────────────────
146
+ //
147
+ // A "thread" is an open loop the AI is tracking on behalf of the user
148
+ // — the layer between cold memory (journals/profiles/buckets) and
149
+ // live conversation. Each row is "this is currently load-bearing for
150
+ // this chat." The AI promotes things here, updates hotness as signals
151
+ // arrive, brings them up naturally during conversation, and either
152
+ // resolves them (answer found), drops them (stale), or compresses
153
+ // them into cold memory (stabilized into a fact).
154
+ //
155
+ // Hotness (0-100) is the AI's running judgment of how relevant a
156
+ // thread is right now. The user's voice always wins — explicit
157
+ // resolves/drops + manual /threads commands override anything the
158
+ // AI inferred. Implicit signals (user touching the topic, ignoring
159
+ // surfaced threads) nudge the score by smaller amounts.
160
+ //
161
+ // Status: live (active), resolved (answer arrived), dropped (stale),
162
+ // compressed (moved into a journal/profile/bucket).
163
+ export const threads = sqliteTable('threads', {
164
+ id: integer('id').primaryKey({ autoIncrement: true }),
165
+ targetJid: text('target_jid').notNull(), // WA JID this thread lives in
166
+ title: text('title').notNull(), // short label, AI-written
167
+ summary: text('summary').notNull(), // 1-2 line context
168
+ hotness: integer('hotness').notNull().default(50), // 0-100
169
+ status: text('status').notNull().default('live'), // live|resolved|dropped|compressed
170
+ linkedMemory: text('linked_memory'), // optional path to cold memory anchor
171
+ openedAt: integer('opened_at').notNull(),
172
+ lastTouchedAt: integer('last_touched_at').notNull(),
173
+ nextReviewAt: integer('next_review_at').notNull(),
174
+ resolutionNote: text('resolution_note'),
175
+ // Token cost attribution — when a thread review burns AI inferences
176
+ // (proactive tick, future feature) the cost lands here. Visible via
177
+ // /threads so the user can see which threads are expensive.
178
+ totalInputTokens: integer('total_input_tokens').notNull().default(0),
179
+ totalOutputTokens: integer('total_output_tokens').notNull().default(0),
180
+ enabled: integer('enabled').notNull().default(1),
181
+ createdAt: integer('created_at').notNull(),
182
+ }, t => ({
183
+ byJidHot: index('threads_by_jid_hot').on(t.targetJid, t.status, t.hotness),
184
+ byDue: index('threads_by_due').on(t.enabled, t.status, t.nextReviewAt),
185
+ }));
186
+ // Owner-global learned weights per category. A thread's category is
187
+ // derived from its linked_memory or title (e.g. "health", "jana",
188
+ // "work"). The weight is the AI's prior for "how much does the user
189
+ // care about this category" — used as the starting hotness when
190
+ // creating new threads in the same category, and nudged up/down by
191
+ // implicit signals (user engaging with / dropping threads).
192
+ export const threadCategoryWeights = sqliteTable('thread_category_weights', {
193
+ category: text('category').primaryKey(), // e.g. 'health', 'jana', 'work-dms'
194
+ weight: integer('weight').notNull().default(50), // 0-100
195
+ samples: integer('samples').notNull().default(0), // signal count, for confidence
196
+ updatedAt: integer('updated_at').notNull(),
197
+ });
198
+ // ──────────────────────────────────────────────────────────────────
144
199
  // Inbound queue (Phase 4)
145
200
  // ──────────────────────────────────────────────────────────────────
146
201
  // Messages received on any channel, waiting to be processed by a
@@ -12,7 +12,10 @@ export async function tryCommand(ctx) {
12
12
  const trimmed = ctx.text.trim();
13
13
  if (!trimmed.startsWith(prefix))
14
14
  return false;
15
- const cmd = trimmed.slice(prefix.length).split(/\s+/)[0]?.toLowerCase() ?? '';
15
+ const afterPrefix = trimmed.slice(prefix.length);
16
+ const tokens = afterPrefix.split(/\s+/);
17
+ const cmd = tokens[0]?.toLowerCase() ?? '';
18
+ const args = tokens.slice(1);
16
19
  if (!cmd)
17
20
  return false;
18
21
  if (config.commands.reset.includes(cmd)) {
@@ -79,5 +82,14 @@ export async function tryCommand(ctx) {
79
82
  await sendText(ctx.sock, ctx.jid, formatScheduleList(items, tz, onlyKind), ctx.quoted);
80
83
  return true;
81
84
  }
85
+ if (cmd === 'threads') {
86
+ if (!config.threads?.enabled) {
87
+ await sendText(ctx.sock, ctx.jid, 'threads are disabled in config. Set `threads.enabled: true` to turn on.', ctx.quoted);
88
+ return true;
89
+ }
90
+ const { handleThreadsCommand } = await import('../queue/thread-list.js');
91
+ await sendText(ctx.sock, ctx.jid, handleThreadsCommand(ctx.jid, args), ctx.quoted);
92
+ return true;
93
+ }
82
94
  return false;
83
95
  }
@@ -232,6 +232,18 @@ export function formatStatsFooter(stats, extras) {
232
232
  parts.push('+digest');
233
233
  if (stats.sendTextCount > 0)
234
234
  parts.push(plus('send-text', stats.sendTextCount));
235
+ // Thread watchlist — loud events only. Updates/cools are intentional
236
+ // no-ops in the footer (would clutter normal conversation).
237
+ if (stats.threadNewCount > 0)
238
+ parts.push(plus('thread-new', stats.threadNewCount));
239
+ if (stats.threadTouchCount > 0)
240
+ parts.push(plus('thread-touch', stats.threadTouchCount));
241
+ if (stats.threadResolveCount > 0)
242
+ parts.push(plus('thread-resolve', stats.threadResolveCount));
243
+ if (stats.threadDropCount > 0)
244
+ parts.push(plus('thread-drop', stats.threadDropCount));
245
+ if (stats.threadCompressCount > 0)
246
+ parts.push(plus('thread-compress', stats.threadCompressCount));
235
247
  // Media — counted per-kind from the file list. e.g. +2 image, +video.
236
248
  // 'document' shortened to 'doc' to keep the footer tight.
237
249
  const mediaKinds = extras?.mediaKinds ?? [];