@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.
@@ -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.
@@ -75,7 +75,6 @@ async function sweep() {
75
75
  logger.error({ err }, 'journal observer sweep failed');
76
76
  }
77
77
  }
78
- const NUDGE_TICK_MS = 5 * 60 * 1000; // 5 minutes
79
78
  let started = false;
80
79
  export function startScheduler() {
81
80
  if (started)
@@ -111,30 +110,12 @@ export function startScheduler() {
111
110
  payload: { handler: 'memory-sweep' },
112
111
  recurrence: `@every ${Math.floor(config.memory.sweepIntervalMs / 1000)}s`,
113
112
  });
114
- // Proactive journal nudges (check-ins, silent-nudges). Migrated from
115
- // setInterval to a cron row orchestrator. Same cadence, same body;
116
- // benefits are: survives restarts, visible in `crons` table, can be
117
- // paused via control row without code change.
118
- registerInternalCronHandler('journal-nudge-tick', runNudgeTickSafe);
119
- enqueueCron({
120
- name: 'journal-nudge-tick',
121
- enqueueInto: 'internal',
122
- payload: { handler: 'journal-nudge-tick' },
123
- recurrence: `@every ${Math.floor(NUDGE_TICK_MS / 1000)}s`,
124
- });
125
- logger.info({
126
- intervalMs: config.memory.sweepIntervalMs,
127
- nudgeTickMs: NUDGE_TICK_MS,
128
- }, 'memory scheduler started');
129
- }
130
- async function runNudgeTickSafe() {
131
- try {
132
- const { runNudgeTick } = await import('./journal-nudger.js');
133
- await runNudgeTick();
134
- }
135
- catch (err) {
136
- logger.error({ err }, 'nudge tick failed');
137
- }
113
+ // Drop the legacy journal-nudge-tick cron row if it survives from a
114
+ // pre-threads install. The handler is no longer registered, so any
115
+ // row left in the table would log warnings every tick. Safe to call
116
+ // even if the row doesn't exist.
117
+ deleteCron('journal-nudge-tick');
118
+ logger.info({ intervalMs: config.memory.sweepIntervalMs }, 'memory scheduler started');
138
119
  }
139
120
  export function stopScheduler() {
140
121
  // All recurring work is now in the crons table; orchestrator handles
@@ -145,10 +126,3 @@ export function stopScheduler() {
145
126
  pendingTimers.clear();
146
127
  started = false;
147
128
  }
148
- // Exported for callers (CLI, /nudge command) that want to surgically
149
- // disable nudges without editing config. Use `setCronEnabled` from
150
- // crons.ts for the on/off switch; this is a hard delete (regenerated
151
- // on next startScheduler call).
152
- export function deleteNudgeCron() {
153
- return deleteCron('journal-nudge-tick');
154
- }
@@ -0,0 +1,167 @@
1
+ // /threads chat command handler. Formats listings + handles
2
+ // subcommands: delete, pause, resume, resolve, drop, compress,
3
+ // touch, weight.
4
+ //
5
+ // Phone-readable plain text — same shape as schedule-list.ts.
6
+ import { compressThread, deleteThread, dropThread, getThread, listAllThreads, resolveThread, setThreadEnabled, touchThread, } from './threads.js';
7
+ import { listCategoryWeights, setCategoryWeight, } from './thread-weights.js';
8
+ export function handleThreadsCommand(jid, args) {
9
+ const sub = args[0]?.toLowerCase();
10
+ if (!sub) {
11
+ return formatList(listAllThreads(jid));
12
+ }
13
+ // Subcommand: weight <category> <0-100>
14
+ if (sub === 'weight') {
15
+ if (args.length === 1)
16
+ return formatWeights(listCategoryWeights());
17
+ const category = args[1]?.toLowerCase();
18
+ const weight = parseInt(args[2] ?? '', 10);
19
+ if (!category || !Number.isFinite(weight) || weight < 0 || weight > 100) {
20
+ return 'Usage: /threads weight <category> <0-100>';
21
+ }
22
+ setCategoryWeight(category, weight);
23
+ return `Category "${category}" weight set to ${weight}.`;
24
+ }
25
+ // All remaining subcommands take an id as args[1] and optional note as
26
+ // the rest joined.
27
+ const id = parseInt(args[1] ?? '', 10);
28
+ if (!Number.isFinite(id) || id <= 0) {
29
+ return `Usage: /threads ${sub} <id> [note]`;
30
+ }
31
+ const noteTokens = args.slice(2);
32
+ const note = noteTokens.join(' ').trim();
33
+ if (sub === 'delete') {
34
+ return deleteThread(id) ? `Thread #${id} deleted.` : `Thread #${id} not found.`;
35
+ }
36
+ if (sub === 'pause') {
37
+ return setThreadEnabled(id, false)
38
+ ? `Thread #${id} paused.`
39
+ : `Thread #${id} not found.`;
40
+ }
41
+ if (sub === 'resume') {
42
+ return setThreadEnabled(id, true)
43
+ ? `Thread #${id} resumed.`
44
+ : `Thread #${id} not found.`;
45
+ }
46
+ if (sub === 'resolve') {
47
+ const row = resolveThread(id, note || 'manual');
48
+ return row
49
+ ? `Thread #${id} resolved: ${row.resolutionNote}`
50
+ : `Thread #${id} not found.`;
51
+ }
52
+ if (sub === 'drop') {
53
+ const row = dropThread(id, note || 'manual');
54
+ return row
55
+ ? `Thread #${id} dropped: ${row.resolutionNote}`
56
+ : `Thread #${id} not found.`;
57
+ }
58
+ if (sub === 'compress') {
59
+ const row = compressThread(id, note || 'manual');
60
+ return row
61
+ ? `Thread #${id} compressed: ${row.resolutionNote}`
62
+ : `Thread #${id} not found.`;
63
+ }
64
+ if (sub === 'touch') {
65
+ const row = touchThread(id);
66
+ return row
67
+ ? `Thread #${id} touched (hotness now ${row.hotness}).`
68
+ : `Thread #${id} not found.`;
69
+ }
70
+ if (sub === 'show') {
71
+ const row = getThread(id);
72
+ return row ? formatOne(row) : `Thread #${id} not found.`;
73
+ }
74
+ return [
75
+ 'Usage:',
76
+ ' /threads list all threads in this chat',
77
+ ' /threads show <id> show one thread in detail',
78
+ ' /threads resolve <id> <note> mark resolved (answer found)',
79
+ ' /threads drop <id> <reason> mark dropped (stale)',
80
+ ' /threads compress <id> <note> mark moved into cold memory',
81
+ ' /threads touch <id> bump hotness manually',
82
+ ' /threads pause <id> disable (hide from preamble)',
83
+ ' /threads resume <id> re-enable',
84
+ ' /threads delete <id> permanent delete',
85
+ ' /threads weight list category weights',
86
+ ' /threads weight <category> <0-100> override category weight',
87
+ ].join('\n');
88
+ }
89
+ function formatList(rows) {
90
+ if (rows.length === 0)
91
+ return 'No threads in this chat yet.';
92
+ const live = rows.filter((r) => r.status === 'live');
93
+ const closed = rows.filter((r) => r.status !== 'live');
94
+ const lines = [];
95
+ if (live.length > 0) {
96
+ lines.push('*Live threads*');
97
+ for (const r of live)
98
+ lines.push(formatRow(r));
99
+ }
100
+ if (closed.length > 0) {
101
+ lines.push('');
102
+ lines.push('*Closed*');
103
+ for (const r of closed.slice(0, 10))
104
+ lines.push(formatRow(r));
105
+ if (closed.length > 10)
106
+ lines.push(` …and ${closed.length - 10} older`);
107
+ }
108
+ return lines.join('\n');
109
+ }
110
+ function formatRow(r) {
111
+ const age = formatAge(Math.floor(Date.now() / 1000) - r.openedAt);
112
+ const status = r.status === 'live' ? `hot ${r.hotness}` : r.status;
113
+ const en = r.enabled ? '' : ' [paused]';
114
+ const cost = formatCost(r.totalInputTokens, r.totalOutputTokens);
115
+ const costSuffix = cost ? ` · ${cost}` : '';
116
+ const lines = [` #${r.id} ${status} ${age} ago${en} ${r.title}${costSuffix}`];
117
+ if (r.summary)
118
+ lines.push(` ${r.summary}`);
119
+ if (r.resolutionNote)
120
+ lines.push(` → ${r.resolutionNote}`);
121
+ return lines.join('\n');
122
+ }
123
+ function formatOne(r) {
124
+ const age = formatAge(Math.floor(Date.now() / 1000) - r.openedAt);
125
+ const lines = [
126
+ `*Thread #${r.id}* — ${r.title}`,
127
+ `Status: ${r.status}${r.enabled ? '' : ' (paused)'}`,
128
+ `Hotness: ${r.hotness}`,
129
+ `Opened: ${age} ago`,
130
+ `Last touched: ${formatAge(Math.floor(Date.now() / 1000) - r.lastTouchedAt)} ago`,
131
+ `Summary: ${r.summary}`,
132
+ ];
133
+ if (r.linkedMemory)
134
+ lines.push(`Linked: ${r.linkedMemory}`);
135
+ if (r.resolutionNote)
136
+ lines.push(`Resolution: ${r.resolutionNote}`);
137
+ const cost = formatCost(r.totalInputTokens, r.totalOutputTokens);
138
+ if (cost)
139
+ lines.push(`Cost: ${cost}`);
140
+ return lines.join('\n');
141
+ }
142
+ function formatWeights(rows) {
143
+ if (rows.length === 0)
144
+ return 'No category weights learned yet.';
145
+ const sorted = [...rows].sort((a, b) => b.weight - a.weight);
146
+ const lines = ['*Category weights* (higher = AI surfaces more)'];
147
+ for (const r of sorted) {
148
+ lines.push(` ${r.category.padEnd(20)} ${r.weight} (${r.samples} samples)`);
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+ function formatAge(seconds) {
153
+ if (seconds < 60)
154
+ return `${seconds}s`;
155
+ if (seconds < 3600)
156
+ return `${Math.floor(seconds / 60)}m`;
157
+ if (seconds < 86400)
158
+ return `${Math.floor(seconds / 3600)}h`;
159
+ return `${Math.floor(seconds / 86400)}d`;
160
+ }
161
+ function formatCost(input, output) {
162
+ const total = input + output;
163
+ if (total === 0)
164
+ return '';
165
+ const compact = (n) => n < 1000 ? `${n}` : n < 10_000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
166
+ return `${compact(input)}↑ ${compact(output)}↓`;
167
+ }