@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.
- package/config/memory-instructions.md +54 -5
- package/dist/config.js +21 -0
- package/dist/db/schema.js +55 -0
- package/dist/gateway/commands.js +13 -1
- package/dist/gateway/outgoing.js +12 -0
- package/dist/memory/digest-flag.js +222 -0
- package/dist/memory/journals.js +3 -24
- package/dist/memory/preamble.js +21 -0
- package/dist/memory/scheduler.js +6 -32
- package/dist/queue/thread-list.js +167 -0
- package/dist/queue/thread-weights.js +142 -0
- package/dist/queue/threads.js +271 -0
- package/dist/queue/worker.js +107 -1
- package/migrations/0010_create_threads.sql +27 -0
- package/migrations/meta/0010_snapshot.json +1134 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/dist/memory/journal-cadence.js +0 -120
- package/dist/memory/journal-nudger.js +0 -221
|
@@ -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
|
+
}
|