@c4t4/heyamigo 0.9.25 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -250
- package/config/memory-instructions.md +54 -5
- package/dist/config.js +23 -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
|
@@ -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
|
+
}
|
package/dist/memory/journals.js
CHANGED
|
@@ -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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
package/dist/memory/preamble.js
CHANGED
|
@@ -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.
|
package/dist/memory/scheduler.js
CHANGED
|
@@ -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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|