@c4t4/heyamigo 0.9.24 → 0.10.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.
@@ -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,27 @@ 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
+ // Off by default; flip enabled=true to allow the AI to open/track
122
+ // loops via [THREAD-*:] tags. Reactive surface only in v1 — proactive
123
+ // review tick (silent-chat check-ins) deferred.
124
+ threads: z
125
+ .object({
126
+ enabled: z.boolean().default(false),
127
+ preamblePerChat: z.number().int().positive().default(5),
128
+ // Soft caps used by future cleanup jobs; the worker doesn't read
129
+ // these yet but they're here so config.json can be authored once.
130
+ maxActivePerChat: z.number().int().positive().default(10),
131
+ hotnessCapOnCreate: z.number().int().min(0).max(100).default(70),
132
+ decayPerDay: z.number().int().min(0).default(2),
133
+ })
134
+ .default({
135
+ enabled: false,
136
+ preamblePerChat: 5,
137
+ maxActivePerChat: 10,
138
+ hotnessCapOnCreate: 70,
139
+ decayPerDay: 2,
140
+ }),
120
141
  });
121
142
  function loadJsonIfExists(path) {
122
143
  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
  }
@@ -63,8 +63,11 @@ export async function handleReply(job, result, _originalMsg) {
63
63
  const isGroup = isJidGroup(job.jid) === true;
64
64
  void isGroup; // quoting deferred; see comment above
65
65
  const address = formatAddress(jidToAddress(job.jid));
66
+ // Surface media tags in the footer too. Files already parsed above
67
+ // — just map each to its kind so the footer reads e.g. "+2 image".
68
+ const mediaKinds = files.map(kindForFile);
66
69
  const footer = result.stats && config.reply.showStats
67
- ? formatStatsFooter(result.stats)
70
+ ? formatStatsFooter(result.stats, { mediaKinds })
68
71
  : '';
69
72
  let pieceIdx = 0;
70
73
  const baseKey = `reply-${job.jid}-${Date.now()}`;
@@ -177,7 +180,10 @@ export async function initiate(params) {
177
180
  }
178
181
  // Append-only-at-send footer. Never stored, never in Claude's recent-context
179
182
  // feedback loop. Adaptive: shows only what's interesting for this reply.
180
- export function formatStatsFooter(stats) {
183
+ // `mediaKinds` is the array of [IMAGE/VIDEO/AUDIO/DOCUMENT] tags the agent
184
+ // emitted in this reply — they're parsed out of the text in handleReply so
185
+ // we forward them here for footer rendering.
186
+ export function formatStatsFooter(stats, extras) {
181
187
  const parts = [];
182
188
  // Duration — always
183
189
  const secs = stats.durationMs / 1000;
@@ -207,12 +213,48 @@ export function formatStatsFooter(stats) {
207
213
  }
208
214
  if (stats.fresh)
209
215
  parts.push('fresh');
216
+ // Side-effect tags. Order: scheduling first (most "did it work?"
217
+ // value for the user), then delegations, then content side effects.
218
+ const plus = (label, n) => n === 1 ? `+${label}` : `+${n} ${label}`;
219
+ if (stats.remindCount > 0)
220
+ parts.push(plus('remind', stats.remindCount));
221
+ if (stats.cronCount > 0)
222
+ parts.push(plus('cron', stats.cronCount));
223
+ if (stats.asyncBrowserCount > 0)
224
+ parts.push(plus('browser', stats.asyncBrowserCount));
225
+ if (stats.asyncCount > 0)
226
+ parts.push(plus('async', stats.asyncCount));
210
227
  for (const slug of stats.journalSlugs)
211
228
  parts.push(`+journal:${slug}`);
229
+ if (stats.journalCreateCount > 0)
230
+ parts.push(plus('journal-new', stats.journalCreateCount));
212
231
  if (stats.hasDigest)
213
232
  parts.push('+digest');
214
- if (stats.asyncCount > 0) {
215
- parts.push(stats.asyncCount === 1 ? '+async' : `+${stats.asyncCount} async`);
233
+ if (stats.sendTextCount > 0)
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));
247
+ // Media — counted per-kind from the file list. e.g. +2 image, +video.
248
+ // 'document' shortened to 'doc' to keep the footer tight.
249
+ const mediaKinds = extras?.mediaKinds ?? [];
250
+ if (mediaKinds.length > 0) {
251
+ const byKind = new Map();
252
+ for (const k of mediaKinds) {
253
+ const short = k === 'document' ? 'doc' : k;
254
+ byKind.set(short, (byKind.get(short) ?? 0) + 1);
255
+ }
256
+ for (const [kind, n] of byKind)
257
+ parts.push(plus(kind, n));
216
258
  }
217
259
  return `_${parts.join(' · ')}_`;
218
260
  }
@@ -19,6 +19,14 @@ const KINDS = [
19
19
  'SEND-TEXT',
20
20
  'CRON',
21
21
  'REMIND',
22
+ 'THREAD-NEW',
23
+ 'THREAD-UPDATE',
24
+ 'THREAD-TOUCH',
25
+ 'THREAD-COOL',
26
+ 'THREAD-RESOLVE',
27
+ 'THREAD-DROP',
28
+ 'THREAD-COMPRESS',
29
+ 'THREAD-WEIGHT',
22
30
  ];
23
31
  // Walk backwards from the end of the string, tracking bracket depth, to find
24
32
  // the `[` that matches the final `]`. Returns the tag kind, its payload, and
@@ -82,6 +90,14 @@ export function extractFlags(reply) {
82
90
  const sendTexts = [];
83
91
  const crons = [];
84
92
  const reminds = [];
93
+ const threadNews = [];
94
+ const threadUpdates = [];
95
+ const threadTouches = [];
96
+ const threadCools = [];
97
+ const threadResolves = [];
98
+ const threadDrops = [];
99
+ const threadCompresses = [];
100
+ const threadWeights = [];
85
101
  while (true) {
86
102
  const peeled = peelTrailingTag(current);
87
103
  if (!peeled)
@@ -134,6 +150,62 @@ export function extractFlags(reply) {
134
150
  else
135
151
  logger.warn({ payload }, 'REMIND tag dropped: unparseable payload');
136
152
  }
153
+ else if (kind === 'THREAD-NEW') {
154
+ const parsed = parseThreadNewPayload(payload);
155
+ if (parsed)
156
+ threadNews.unshift(parsed);
157
+ else
158
+ logger.warn({ payload }, 'THREAD-NEW tag dropped: unparseable payload');
159
+ }
160
+ else if (kind === 'THREAD-UPDATE') {
161
+ const parsed = parseThreadUpdatePayload(payload);
162
+ if (parsed)
163
+ threadUpdates.unshift(parsed);
164
+ else
165
+ logger.warn({ payload }, 'THREAD-UPDATE tag dropped: unparseable payload');
166
+ }
167
+ else if (kind === 'THREAD-TOUCH') {
168
+ const id = parseThreadId(payload);
169
+ if (id !== null)
170
+ threadTouches.unshift({ id });
171
+ else
172
+ logger.warn({ payload }, 'THREAD-TOUCH tag dropped: unparseable id');
173
+ }
174
+ else if (kind === 'THREAD-COOL') {
175
+ const parsed = parseThreadCoolPayload(payload);
176
+ if (parsed)
177
+ threadCools.unshift(parsed);
178
+ else
179
+ logger.warn({ payload }, 'THREAD-COOL tag dropped: unparseable payload');
180
+ }
181
+ else if (kind === 'THREAD-RESOLVE') {
182
+ const parsed = parseThreadIdNotePayload(payload);
183
+ if (parsed)
184
+ threadResolves.unshift(parsed);
185
+ else
186
+ logger.warn({ payload }, 'THREAD-RESOLVE tag dropped: unparseable payload');
187
+ }
188
+ else if (kind === 'THREAD-DROP') {
189
+ const parsed = parseThreadIdNotePayload(payload);
190
+ if (parsed)
191
+ threadDrops.unshift(parsed);
192
+ else
193
+ logger.warn({ payload }, 'THREAD-DROP tag dropped: unparseable payload');
194
+ }
195
+ else if (kind === 'THREAD-COMPRESS') {
196
+ const parsed = parseThreadIdNotePayload(payload);
197
+ if (parsed)
198
+ threadCompresses.unshift(parsed);
199
+ else
200
+ logger.warn({ payload }, 'THREAD-COMPRESS tag dropped: unparseable payload');
201
+ }
202
+ else if (kind === 'THREAD-WEIGHT') {
203
+ const parsed = parseThreadWeightPayload(payload);
204
+ if (parsed)
205
+ threadWeights.unshift(parsed);
206
+ else
207
+ logger.warn({ payload }, 'THREAD-WEIGHT tag dropped: unparseable payload');
208
+ }
137
209
  }
138
210
  return {
139
211
  clean: current,
@@ -145,6 +217,14 @@ export function extractFlags(reply) {
145
217
  sendTexts,
146
218
  crons,
147
219
  reminds,
220
+ threadNews,
221
+ threadUpdates,
222
+ threadTouches,
223
+ threadCools,
224
+ threadResolves,
225
+ threadDrops,
226
+ threadCompresses,
227
+ threadWeights,
148
228
  };
149
229
  }
150
230
  // Strip flags that the sender's role isn't permitted to emit. The
@@ -154,6 +234,9 @@ export function filterFlagsByRole(flags, allowedTags) {
154
234
  if (allowedTags === 'all' || allowedTags === undefined)
155
235
  return flags;
156
236
  const allowed = new Set(allowedTags);
237
+ // 'THREAD' acts as a single allow-all-thread-ops bucket so role
238
+ // configs don't have to list all 8 THREAD-* variants individually.
239
+ const threadOk = allowed.has('THREAD');
157
240
  return {
158
241
  clean: flags.clean,
159
242
  digest: allowed.has('DIGEST') ? flags.digest : null,
@@ -164,6 +247,14 @@ export function filterFlagsByRole(flags, allowedTags) {
164
247
  sendTexts: allowed.has('SEND-TEXT') ? flags.sendTexts : [],
165
248
  crons: allowed.has('CRON') ? flags.crons : [],
166
249
  reminds: allowed.has('REMIND') ? flags.reminds : [],
250
+ threadNews: threadOk ? flags.threadNews : [],
251
+ threadUpdates: threadOk ? flags.threadUpdates : [],
252
+ threadTouches: threadOk ? flags.threadTouches : [],
253
+ threadCools: threadOk ? flags.threadCools : [],
254
+ threadResolves: threadOk ? flags.threadResolves : [],
255
+ threadDrops: threadOk ? flags.threadDrops : [],
256
+ threadCompresses: threadOk ? flags.threadCompresses : [],
257
+ threadWeights: threadOk ? flags.threadWeights : [],
167
258
  };
168
259
  }
169
260
  // Legacy helper kept so existing callers still compile.
@@ -251,3 +342,134 @@ function parseJournalPayload(payload) {
251
342
  return null;
252
343
  return { slug, note };
253
344
  }
345
+ // ──────────────────────────────────────────────────────────────────
346
+ // THREAD-* payload parsers
347
+ // ──────────────────────────────────────────────────────────────────
348
+ //
349
+ // Two payload shapes:
350
+ // [THREAD-NEW: key="quoted" key=value] key/value form
351
+ // [THREAD-RESOLVE:42 — note] id-and-note form
352
+ // Pull `key="quoted-value"` and `key=word-value` pairs out of a
353
+ // payload. Returns a map. Supports backslash-escaped quotes inside
354
+ // quoted values.
355
+ function parseKeyValuePayload(payload) {
356
+ const out = {};
357
+ // Quoted values first (greedy enough to capture spaces, escaped quotes)
358
+ const quotedRe = /\b([a-z_]+)\s*=\s*"((?:[^"\\]|\\.)*)"/gi;
359
+ let rest = payload;
360
+ for (const m of payload.matchAll(quotedRe)) {
361
+ const key = m[1].toLowerCase();
362
+ const val = m[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
363
+ out[key] = val;
364
+ rest = rest.replace(m[0], '');
365
+ }
366
+ // Then unquoted single-word values
367
+ const wordRe = /\b([a-z_]+)\s*=\s*(\S+)/gi;
368
+ for (const m of rest.matchAll(wordRe)) {
369
+ const key = m[1].toLowerCase();
370
+ if (key in out)
371
+ continue;
372
+ out[key] = m[2];
373
+ }
374
+ return out;
375
+ }
376
+ function parseThreadNewPayload(payload) {
377
+ const kv = parseKeyValuePayload(payload);
378
+ const title = kv['title']?.trim();
379
+ const summary = kv['summary']?.trim();
380
+ if (!title || !summary)
381
+ return null;
382
+ const out = { title, summary };
383
+ if (kv['hotness'] !== undefined) {
384
+ const n = parseInt(kv['hotness'], 10);
385
+ if (Number.isFinite(n))
386
+ out.hotness = n;
387
+ }
388
+ if (kv['linked_memory'])
389
+ out.linkedMemory = kv['linked_memory'];
390
+ if (kv['category'])
391
+ out.category = kv['category'].toLowerCase();
392
+ return out;
393
+ }
394
+ function parseThreadUpdatePayload(payload) {
395
+ // Leading id, then key=value pairs
396
+ const idMatch = payload.match(/^\s*(\d+)\b/);
397
+ if (!idMatch)
398
+ return null;
399
+ const id = parseInt(idMatch[1], 10);
400
+ if (!Number.isFinite(id) || id <= 0)
401
+ return null;
402
+ const rest = payload.slice(idMatch[0].length);
403
+ const kv = parseKeyValuePayload(rest);
404
+ const out = { id };
405
+ if (kv['title'])
406
+ out.title = kv['title'].trim();
407
+ if (kv['summary'])
408
+ out.summary = kv['summary'].trim();
409
+ if (kv['hotness'] !== undefined) {
410
+ const n = parseInt(kv['hotness'], 10);
411
+ if (Number.isFinite(n))
412
+ out.hotness = n;
413
+ }
414
+ if (kv['linked_memory'])
415
+ out.linkedMemory = kv['linked_memory'];
416
+ return out;
417
+ }
418
+ // `<id>` alone — for TOUCH.
419
+ function parseThreadId(payload) {
420
+ const m = payload.match(/^\s*(\d+)\s*$/);
421
+ if (!m)
422
+ return null;
423
+ const id = parseInt(m[1], 10);
424
+ return Number.isFinite(id) && id > 0 ? id : null;
425
+ }
426
+ // `<id> — <note>` shape used by RESOLVE / DROP / COMPRESS. Note is
427
+ // the rest of the payload after the first em/en/hyphen separator.
428
+ const ID_NOTE_SEP_RE = /\s+[—–-]\s+/;
429
+ function parseThreadIdNotePayload(payload) {
430
+ const idMatch = payload.match(/^\s*(\d+)\b/);
431
+ if (!idMatch)
432
+ return null;
433
+ const id = parseInt(idMatch[1], 10);
434
+ if (!Number.isFinite(id) || id <= 0)
435
+ return null;
436
+ const rest = payload.slice(idMatch[0].length);
437
+ const sep = rest.match(ID_NOTE_SEP_RE);
438
+ if (!sep || sep.index === undefined)
439
+ return { id, note: '' };
440
+ const note = rest.slice(sep.index + sep[0].length).trim();
441
+ return { id, note };
442
+ }
443
+ // `<id>` or `<id> — wait Nd|Nh` for COOL.
444
+ function parseThreadCoolPayload(payload) {
445
+ const idMatch = payload.match(/^\s*(\d+)\b/);
446
+ if (!idMatch)
447
+ return null;
448
+ const id = parseInt(idMatch[1], 10);
449
+ if (!Number.isFinite(id) || id <= 0)
450
+ return null;
451
+ const rest = payload.slice(idMatch[0].length).trim();
452
+ if (!rest)
453
+ return { id };
454
+ // Look for "wait <N><d|h>" anywhere in the rest
455
+ const waitMatch = rest.match(/wait\s+(\d+)\s*([dh])/i);
456
+ if (!waitMatch)
457
+ return { id };
458
+ const n = parseInt(waitMatch[1], 10);
459
+ const unit = waitMatch[2].toLowerCase();
460
+ if (!Number.isFinite(n) || n <= 0)
461
+ return { id };
462
+ const deferDays = unit === 'h' ? n / 24 : n;
463
+ return { id, deferDays };
464
+ }
465
+ // `<category> <weight>` for WEIGHT.
466
+ function parseThreadWeightPayload(payload) {
467
+ const m = payload.match(/^\s*([a-z0-9][a-z0-9_-]*)\s+(\d+)\s*$/i);
468
+ if (!m)
469
+ return null;
470
+ const category = m[1].toLowerCase();
471
+ const weight = parseInt(m[2], 10);
472
+ if (!Number.isFinite(weight))
473
+ return null;
474
+ return { category, weight };
475
+ }
@@ -22,9 +22,6 @@ function journalsIndexPath() {
22
22
  function journalObserverStatePath(slug) {
23
23
  return resolve(journalDir(slug), 'observer-state.json');
24
24
  }
25
- function journalNudgeStatePath(slug) {
26
- return resolve(journalDir(slug), 'nudge-state.json');
27
- }
28
25
  // ---------- low-level fs ----------
29
26
  function ensureDirFor(path) {
30
27
  mkdirSync(dirname(path), { recursive: true });
@@ -283,24 +280,6 @@ export function setLastScannedTs(slug, jid, ts) {
283
280
  state.jids[jid] = { lastScannedTs: ts };
284
281
  saveObserverState(slug, state);
285
282
  }
286
- export function loadNudgeState(slug) {
287
- const raw = readIfExists(journalNudgeStatePath(slug));
288
- if (!raw)
289
- return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
290
- try {
291
- const parsed = JSON.parse(raw);
292
- return {
293
- lastCheckinTs: parsed.lastCheckinTs ?? 0,
294
- lastSilentNudgeTs: parsed.lastSilentNudgeTs ?? 0,
295
- snoozedUntilTs: parsed.snoozedUntilTs ?? 0,
296
- };
297
- }
298
- catch {
299
- return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
300
- }
301
- }
302
- export function saveNudgeState(slug, state) {
303
- const path = journalNudgeStatePath(slug);
304
- ensureDirFor(path);
305
- writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
306
- }
283
+ // Nudge-state APIs removed — replaced by the threads watchlist
284
+ // (see src/queue/threads.ts). Existing storage/memory/journals/*/
285
+ // nudge-state.json files become orphaned and can be safely deleted.
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'fs';
2
2
  import { config } from '../config.js';
3
3
  import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
4
4
  import { listAsyncTasks } from '../queue/async-tasks.js';
5
+ import { listLiveThreads } from '../queue/threads.js';
5
6
  import { readCompressed } from './compressed.js';
6
7
  import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
7
8
  import { masterIndexPath, treeIndexPath } from './paths.js';
@@ -14,6 +15,7 @@ import { getRoleForContext } from '../wa/whitelist.js';
14
15
  const DIGEST_REMINDER = `[DIGEST: <reason>] at end of reply for durable facts. Sparingly.`;
15
16
  const JOURNAL_REMINDER = `[JOURNAL:<slug> — <note>] at end of reply when content fits an active journal. Use listed slugs only.`;
16
17
  const ASYNC_REMINDER = `Never call browser_* / mcp__*playwright* tools. Delegate via [ASYNC-BROWSER: <task>]. Non-browser long work → [ASYNC: <task>]. Irreversible writes: gather → confirm → act.`;
18
+ const THREADS_REMINDER = `Threads = your active watchlist. Open new ones with [THREAD-NEW: title="..." summary="..."]. Close with [THREAD-RESOLVE:<id> — note] / [THREAD-DROP:<id> — reason] / [THREAD-COMPRESS:<id> — note]. Touch (mention naturally) with [THREAD-TOUCH:<id>]. Cool/defer with [THREAD-COOL:<id> — wait Nd]. User voice always wins.`;
17
19
  // Buildable per-turn so the agent always sees the SENDER's current
18
20
  // time. Grammar reference is in cached memory-instructions.md;
19
21
  // this is just the live time + format pointer.
@@ -139,6 +141,25 @@ export function buildMemoryPreamble(params) {
139
141
  hour12: false,
140
142
  }).format(new Date());
141
143
  instructions.push(buildSchedulingReminder(nowLocal, senderTz));
144
+ // Threads — AI-curated relevance watchlist. Off by default; turn on
145
+ // via config.threads.enabled. Loads up to N hottest live threads
146
+ // for this chat (default 5) plus a terse pointer to the grammar
147
+ // (full docs are in cached memory-instructions.md).
148
+ if (config.threads?.enabled) {
149
+ const cap = config.threads.preamblePerChat ?? 5;
150
+ const live = listLiveThreads(params.jid, cap);
151
+ if (live.length > 0) {
152
+ const now = Math.floor(Date.now() / 1000);
153
+ const lines = ['[Live threads — bring up if naturally relevant; don\'t force]'];
154
+ for (const t of live) {
155
+ const age = formatAge(Math.max(0, now - t.openedAt));
156
+ lines.push(`- #${t.id} (hot ${t.hotness}, ${age} ago): ${t.title}`);
157
+ lines.push(` ${t.summary}`);
158
+ }
159
+ sections.push(lines.join('\n'));
160
+ instructions.push(THREADS_REMINDER);
161
+ }
162
+ }
142
163
  // Async tasks in progress for this chat — so the agent doesn't re-promise
143
164
  // or contradict work already running. Don't emit another [ASYNC:] for
144
165
  // these.