@c4t4/heyamigo 0.9.25 → 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.
@@ -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
+ }
@@ -0,0 +1,142 @@
1
+ // Owner-global learned weights for thread categories. A category is
2
+ // the AI's coarse classification of what a thread is about — derived
3
+ // from linked_memory (e.g. 'journals/health' → 'health') or set
4
+ // explicitly by the AI when opening the thread.
5
+ //
6
+ // The weight (0-100) is used as the starting hotness when the AI
7
+ // creates a new thread in this category. Implicit signals nudge it
8
+ // over time:
9
+ // - user engages with a surfaced thread → weight up
10
+ // - user explicitly drops a thread → weight down
11
+ // - user manually overrides via /threads weight → set absolute
12
+ //
13
+ // Aggregate, not per-thread. The point is the AI learns that "Jana
14
+ // stuff" matters to this owner, so future Jana-related threads start
15
+ // hot. Or "general work threads" don't matter much, so they start
16
+ // quiet and need real signal to climb.
17
+ //
18
+ // User voice always wins — the manual override (/threads weight) sets
19
+ // an absolute value and bumps samples high so subsequent implicit
20
+ // nudges have less effect (already-confident weight is harder to
21
+ // move).
22
+ import { eq, sql } from 'drizzle-orm';
23
+ import { getDb } from '../db/index.js';
24
+ import { threadCategoryWeights } from '../db/schema.js';
25
+ const DEFAULT_WEIGHT = 50;
26
+ const MAX_WEIGHT = 100;
27
+ const MIN_WEIGHT = 0;
28
+ export function getCategoryWeight(category) {
29
+ if (!category)
30
+ return DEFAULT_WEIGHT;
31
+ const db = getDb();
32
+ const row = db
33
+ .select({ weight: threadCategoryWeights.weight })
34
+ .from(threadCategoryWeights)
35
+ .where(eq(threadCategoryWeights.category, category))
36
+ .get();
37
+ return row?.weight ?? DEFAULT_WEIGHT;
38
+ }
39
+ // Implicit signal — small delta, learns over many samples. Caller
40
+ // passes a delta like +5 (engagement) or -10 (explicit drop). Clamps
41
+ // to [0,100]. Insert-or-update so a brand-new category starts at
42
+ // DEFAULT_WEIGHT before the first nudge.
43
+ export function nudgeCategoryWeight(category, delta) {
44
+ if (!category || delta === 0)
45
+ return;
46
+ const db = getDb();
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const existing = db
49
+ .select()
50
+ .from(threadCategoryWeights)
51
+ .where(eq(threadCategoryWeights.category, category))
52
+ .get();
53
+ if (existing) {
54
+ const next = clamp(existing.weight + delta);
55
+ db.update(threadCategoryWeights)
56
+ .set({
57
+ weight: next,
58
+ samples: sql `${threadCategoryWeights.samples} + 1`,
59
+ updatedAt: now,
60
+ })
61
+ .where(eq(threadCategoryWeights.category, category))
62
+ .run();
63
+ return;
64
+ }
65
+ db.insert(threadCategoryWeights)
66
+ .values({
67
+ category,
68
+ weight: clamp(DEFAULT_WEIGHT + delta),
69
+ samples: 1,
70
+ updatedAt: now,
71
+ })
72
+ .run();
73
+ }
74
+ // Manual override from /threads weight <category> <0-100>. Sets the
75
+ // absolute value, bumps samples so this manual value carries
76
+ // confidence and isn't immediately drowned out.
77
+ export function setCategoryWeight(category, weight) {
78
+ if (!category)
79
+ return;
80
+ const w = clamp(weight);
81
+ const db = getDb();
82
+ const now = Math.floor(Date.now() / 1000);
83
+ const existing = db
84
+ .select()
85
+ .from(threadCategoryWeights)
86
+ .where(eq(threadCategoryWeights.category, category))
87
+ .get();
88
+ if (existing) {
89
+ db.update(threadCategoryWeights)
90
+ .set({
91
+ weight: w,
92
+ samples: sql `${threadCategoryWeights.samples} + 10`, // manual override = high confidence
93
+ updatedAt: now,
94
+ })
95
+ .where(eq(threadCategoryWeights.category, category))
96
+ .run();
97
+ return;
98
+ }
99
+ db.insert(threadCategoryWeights)
100
+ .values({ category, weight: w, samples: 10, updatedAt: now })
101
+ .run();
102
+ }
103
+ export function listCategoryWeights() {
104
+ const db = getDb();
105
+ return db
106
+ .select()
107
+ .from(threadCategoryWeights)
108
+ .all()
109
+ .map((r) => ({ category: r.category, weight: r.weight, samples: r.samples }));
110
+ }
111
+ // Derive a coarse category from a linked_memory path or fall back to
112
+ // extracting the first word of the title. Used when the AI doesn't
113
+ // pass a category explicitly.
114
+ // 'journals/health/entries.jsonl' → 'health'
115
+ // 'persons/5491234567890/profile.md' → 'jana' (no — we'd need
116
+ // identity resolution; for now → 'persons')
117
+ // 'buckets/work-dms/index.md' → 'work-dms'
118
+ // no link → first word of title, lowercased
119
+ export function deriveCategory(linkedMemory, title) {
120
+ if (linkedMemory) {
121
+ const segments = linkedMemory.split('/').filter(Boolean);
122
+ if (segments.length >= 2) {
123
+ // 'journals/health/...' → 'health'
124
+ // 'buckets/work-dms/...' → 'work-dms'
125
+ // 'persons/<phone>/...' → 'persons' (don't leak phone numbers
126
+ // into category names; the AI can pass an explicit category
127
+ // for per-person tracking)
128
+ if (segments[0] === 'persons')
129
+ return 'persons';
130
+ return segments[1];
131
+ }
132
+ return segments[0] ?? 'general';
133
+ }
134
+ // Fall back: first token of the title, lowercased.
135
+ const firstWord = title.trim().split(/\s+/)[0]?.toLowerCase();
136
+ return firstWord || 'general';
137
+ }
138
+ function clamp(n) {
139
+ if (!Number.isFinite(n))
140
+ return DEFAULT_WEIGHT;
141
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, Math.round(n)));
142
+ }
@@ -0,0 +1,271 @@
1
+ // Threads — the AI-curated relevance watchlist. See schema.ts header
2
+ // comment for the conceptual model. This module is the CRUD surface
3
+ // the worker calls when processing THREAD-* tags.
4
+ //
5
+ // Hotness invariants:
6
+ // - 0-100, clamped on every write
7
+ // - new threads start at min(category_weight, hotnessCapOnCreate)
8
+ // (default cap 70) so the AI can't open a brand-new thread at
9
+ // full hotness without an established category prior
10
+ // - explicit user signals (resolve/drop, /threads commands) win
11
+ // over implicit AI-emitted updates
12
+ //
13
+ // Status transitions are one-way: live → resolved | dropped |
14
+ // compressed. Once non-live, the row stays for /threads history but
15
+ // is filtered out of the live preamble.
16
+ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
17
+ import { getDb } from '../db/index.js';
18
+ import { logger } from '../logger.js';
19
+ import { threads } from '../db/schema.js';
20
+ import { deriveCategory, getCategoryWeight, nudgeCategoryWeight, } from './thread-weights.js';
21
+ const HOTNESS_MIN = 0;
22
+ const HOTNESS_MAX = 100;
23
+ const HOTNESS_CAP_ON_CREATE = 70;
24
+ const DEFAULT_NEXT_REVIEW_DAYS = 1;
25
+ // Implicit signal deltas — small per-event, AI/system-triggered.
26
+ const DELTA_TOUCH = +5;
27
+ const DELTA_COOL = -10;
28
+ const CATEGORY_NUDGE_TOUCH = +3;
29
+ const CATEGORY_NUDGE_DROP = -5;
30
+ export function createThread(input) {
31
+ const db = getDb();
32
+ const now = Math.floor(Date.now() / 1000);
33
+ const category = input.category ?? deriveCategory(input.linkedMemory, input.title);
34
+ // Default starting hotness = category weight, capped. AI can pass a
35
+ // higher value but it's clamped to the cap so brand-new threads
36
+ // can't open at 95 without history.
37
+ const proposed = input.hotness ?? getCategoryWeight(category);
38
+ const hotness = clampHotness(Math.min(proposed, HOTNESS_CAP_ON_CREATE));
39
+ const nextReview = input.nextReviewAt ?? now + DEFAULT_NEXT_REVIEW_DAYS * 86400;
40
+ const row = db
41
+ .insert(threads)
42
+ .values({
43
+ targetJid: input.targetJid,
44
+ title: input.title,
45
+ summary: input.summary,
46
+ hotness,
47
+ status: 'live',
48
+ linkedMemory: input.linkedMemory ?? null,
49
+ openedAt: now,
50
+ lastTouchedAt: now,
51
+ nextReviewAt: nextReview,
52
+ createdAt: now,
53
+ })
54
+ .returning()
55
+ .get();
56
+ logger.info({ id: row.id, jid: row.targetJid, title: row.title, hotness, category }, 'thread opened');
57
+ return row;
58
+ }
59
+ export function updateThread(input) {
60
+ const db = getDb();
61
+ const existing = db.select().from(threads).where(eq(threads.id, input.id)).get();
62
+ if (!existing)
63
+ return null;
64
+ const now = Math.floor(Date.now() / 1000);
65
+ const next = {
66
+ title: input.title ?? existing.title,
67
+ summary: input.summary ?? existing.summary,
68
+ hotness: input.hotness !== undefined ? clampHotness(input.hotness) : existing.hotness,
69
+ linkedMemory: input.linkedMemory ?? existing.linkedMemory,
70
+ nextReviewAt: input.nextReviewAt ?? existing.nextReviewAt,
71
+ lastTouchedAt: now,
72
+ };
73
+ const row = db
74
+ .update(threads)
75
+ .set(next)
76
+ .where(eq(threads.id, input.id))
77
+ .returning()
78
+ .get();
79
+ return row ?? null;
80
+ }
81
+ // TOUCH — small hotness bump + category-weight nudge. Called when the
82
+ // AI brings up a thread naturally or the user references its topic.
83
+ export function touchThread(id) {
84
+ const db = getDb();
85
+ const existing = db.select().from(threads).where(eq(threads.id, id)).get();
86
+ if (!existing)
87
+ return null;
88
+ const now = Math.floor(Date.now() / 1000);
89
+ const next = clampHotness(existing.hotness + DELTA_TOUCH);
90
+ const row = db
91
+ .update(threads)
92
+ .set({ hotness: next, lastTouchedAt: now })
93
+ .where(eq(threads.id, id))
94
+ .returning()
95
+ .get();
96
+ const category = deriveCategory(existing.linkedMemory, existing.title);
97
+ nudgeCategoryWeight(category, CATEGORY_NUDGE_TOUCH);
98
+ return row ?? null;
99
+ }
100
+ // COOL — drop hotness + push next review out. Tag form:
101
+ // [THREAD-COOL:<id> — wait 3d]
102
+ // Pass deferDays explicitly when the AI specifies a wait; otherwise
103
+ // just lowers hotness without rescheduling.
104
+ export function coolThread(id, deferDays) {
105
+ const db = getDb();
106
+ const existing = db.select().from(threads).where(eq(threads.id, id)).get();
107
+ if (!existing)
108
+ return null;
109
+ const now = Math.floor(Date.now() / 1000);
110
+ const next = clampHotness(existing.hotness + DELTA_COOL);
111
+ const review = deferDays && deferDays > 0
112
+ ? now + deferDays * 86400
113
+ : existing.nextReviewAt;
114
+ const row = db
115
+ .update(threads)
116
+ .set({ hotness: next, nextReviewAt: review, lastTouchedAt: now })
117
+ .where(eq(threads.id, id))
118
+ .returning()
119
+ .get();
120
+ return row ?? null;
121
+ }
122
+ export function resolveThread(id, note) {
123
+ const db = getDb();
124
+ const existing = db.select().from(threads).where(eq(threads.id, id)).get();
125
+ if (!existing)
126
+ return null;
127
+ const now = Math.floor(Date.now() / 1000);
128
+ const row = db
129
+ .update(threads)
130
+ .set({
131
+ status: 'resolved',
132
+ resolutionNote: note,
133
+ lastTouchedAt: now,
134
+ })
135
+ .where(eq(threads.id, id))
136
+ .returning()
137
+ .get();
138
+ // Strong positive signal — user got an answer / closed the loop.
139
+ const category = deriveCategory(existing.linkedMemory, existing.title);
140
+ nudgeCategoryWeight(category, CATEGORY_NUDGE_TOUCH);
141
+ logger.info({ id, note }, 'thread resolved');
142
+ return row ?? null;
143
+ }
144
+ export function dropThread(id, reason) {
145
+ const db = getDb();
146
+ const existing = db.select().from(threads).where(eq(threads.id, id)).get();
147
+ if (!existing)
148
+ return null;
149
+ const now = Math.floor(Date.now() / 1000);
150
+ const row = db
151
+ .update(threads)
152
+ .set({
153
+ status: 'dropped',
154
+ resolutionNote: reason,
155
+ lastTouchedAt: now,
156
+ })
157
+ .where(eq(threads.id, id))
158
+ .returning()
159
+ .get();
160
+ // Negative signal — AI thought this mattered, user (or AI realizing
161
+ // it doesn't) decided otherwise. Pull the category down so future
162
+ // threads in this area start cooler.
163
+ const category = deriveCategory(existing.linkedMemory, existing.title);
164
+ nudgeCategoryWeight(category, CATEGORY_NUDGE_DROP);
165
+ logger.info({ id, reason }, 'thread dropped');
166
+ return row ?? null;
167
+ }
168
+ // COMPRESS — thread has stabilized into a fact and the AI is moving
169
+ // it into cold memory (typically via a [DIGEST:] or direct write in
170
+ // the same reply). We just flip the status here; the actual file
171
+ // write is the caller's responsibility.
172
+ export function compressThread(id, note) {
173
+ const db = getDb();
174
+ const existing = db.select().from(threads).where(eq(threads.id, id)).get();
175
+ if (!existing)
176
+ return null;
177
+ const now = Math.floor(Date.now() / 1000);
178
+ const row = db
179
+ .update(threads)
180
+ .set({
181
+ status: 'compressed',
182
+ resolutionNote: note,
183
+ lastTouchedAt: now,
184
+ })
185
+ .where(eq(threads.id, id))
186
+ .returning()
187
+ .get();
188
+ logger.info({ id, note }, 'thread compressed to cold memory');
189
+ return row ?? null;
190
+ }
191
+ // Preamble loader — live threads in this chat, ordered hot-first.
192
+ // `limit` caps the preamble size (default 5). Threads below
193
+ // hotness `minHotness` are excluded (so cold threads don't clutter).
194
+ export function listLiveThreads(targetJid, limit = 5, minHotness = 10) {
195
+ const db = getDb();
196
+ return db
197
+ .select()
198
+ .from(threads)
199
+ .where(and(eq(threads.targetJid, targetJid), eq(threads.status, 'live'), eq(threads.enabled, 1)))
200
+ .orderBy(desc(threads.hotness), desc(threads.lastTouchedAt))
201
+ .limit(limit)
202
+ .all()
203
+ .filter((r) => r.hotness >= minHotness);
204
+ }
205
+ // /threads — all threads (any status) in a chat. Used by the chat
206
+ // command for full listing.
207
+ export function listAllThreads(targetJid) {
208
+ const db = getDb();
209
+ return db
210
+ .select()
211
+ .from(threads)
212
+ .where(eq(threads.targetJid, targetJid))
213
+ .orderBy(asc(threads.status), desc(threads.hotness))
214
+ .all();
215
+ }
216
+ export function getThread(id) {
217
+ const db = getDb();
218
+ return db.select().from(threads).where(eq(threads.id, id)).get() ?? null;
219
+ }
220
+ export function setThreadEnabled(id, enabled) {
221
+ const db = getDb();
222
+ const result = db
223
+ .update(threads)
224
+ .set({ enabled: enabled ? 1 : 0 })
225
+ .where(eq(threads.id, id))
226
+ .returning({ id: threads.id })
227
+ .all();
228
+ return result.length > 0;
229
+ }
230
+ export function deleteThread(id) {
231
+ const db = getDb();
232
+ const result = db
233
+ .delete(threads)
234
+ .where(eq(threads.id, id))
235
+ .returning({ id: threads.id })
236
+ .all();
237
+ return result.length > 0;
238
+ }
239
+ // Cost attribution — called when a future proactive review tick burns
240
+ // AI inferences. Mirrors addCronUsage in crons.ts.
241
+ export function addThreadUsage(id, inputTokens, outputTokens) {
242
+ if (!id || (inputTokens <= 0 && outputTokens <= 0))
243
+ return;
244
+ const db = getDb();
245
+ db.update(threads)
246
+ .set({
247
+ totalInputTokens: sql `${threads.totalInputTokens} + ${inputTokens}`,
248
+ totalOutputTokens: sql `${threads.totalOutputTokens} + ${outputTokens}`,
249
+ })
250
+ .where(eq(threads.id, id))
251
+ .run();
252
+ }
253
+ function clampHotness(n) {
254
+ if (!Number.isFinite(n))
255
+ return 0;
256
+ return Math.max(HOTNESS_MIN, Math.min(HOTNESS_MAX, Math.round(n)));
257
+ }
258
+ // Helper for the worker to filter out tags targeting threads in
259
+ // other chats (which shouldn't happen — AI shouldn't have IDs from
260
+ // other chats in preamble — but defense-in-depth).
261
+ export function threadsBelongToJid(ids, targetJid) {
262
+ if (ids.length === 0)
263
+ return new Set();
264
+ const db = getDb();
265
+ const rows = db
266
+ .select({ id: threads.id })
267
+ .from(threads)
268
+ .where(and(inArray(threads.id, ids), eq(threads.targetJid, targetJid)))
269
+ .all();
270
+ return new Set(rows.map((r) => r.id));
271
+ }