@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
|
@@ -15,10 +15,9 @@ storage/memory/
|
|
|
15
15
|
journals/<slug>/index.md # journal spec (frontmatter+body)
|
|
16
16
|
journals/<slug>/entries.jsonl # append-only (do NOT edit)
|
|
17
17
|
journals/<slug>/observer-state.json
|
|
18
|
-
journals/<slug>/nudge-state.json
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
Relevant blocks appear in `[State]`, `[Map]`, `[Trees]`, `[Entities]`, `[Journals]` at top of each turn. Don't re-Read what's already in your preamble.
|
|
20
|
+
Relevant blocks appear in `[State]`, `[Map]`, `[Trees]`, `[Entities]`, `[Journals]`, `[Live threads]` at top of each turn. Don't re-Read what's already in your preamble.
|
|
22
21
|
|
|
23
22
|
## State + dig-deeper
|
|
24
23
|
|
|
@@ -51,11 +50,61 @@ Don't cross-log subjects (Dani's health ≠ Cata's). Ask if ambiguous.
|
|
|
51
50
|
|
|
52
51
|
### Create: `[JOURNAL-NEW:<slug> — <purpose>]`
|
|
53
52
|
|
|
54
|
-
When owner asks to track something no existing journal covers: propose purpose in one message, wait for confirmation, then emit the tag. Slug: lowercase letters/digits/hyphens, max 48 chars, starts with letter/digit. Creates `journals/<slug>/index.md` with defaults
|
|
53
|
+
When owner asks to track something no existing journal covers: propose purpose in one message, wait for confirmation, then emit the tag. Slug: lowercase letters/digits/hyphens, max 48 chars, starts with letter/digit. Creates `journals/<slug>/index.md` with defaults. Can flag first entry in same reply with a separate `[JOURNAL:<slug> — ...]` tag.
|
|
55
54
|
|
|
56
|
-
### Edit (pause/archive
|
|
55
|
+
### Edit (pause/archive)
|
|
57
56
|
|
|
58
|
-
No marker — Edit `journals/<slug>/index.md` directly. Frontmatter fields: `status` (active|paused|archived), `purpose`, `fields
|
|
57
|
+
No marker — Edit `journals/<slug>/index.md` directly. Frontmatter fields: `status` (active|paused|archived), `purpose`, `fields`. Never touch `entries.jsonl` or `observer-state.json` unless the owner asked you to fix a specific bug. Confirm the change in reply.
|
|
58
|
+
|
|
59
|
+
For recurring proactive check-ins on a journal, use a CRON (`[CRON: 0 9 * * 1 PROMPT — ...]`) or open a thread (`[THREAD-NEW: ...]`) — not journal frontmatter.
|
|
60
|
+
|
|
61
|
+
## Threads — your watchlist
|
|
62
|
+
|
|
63
|
+
A *thread* is an open loop you're tracking: a question, a waiting-on, an intent the owner mentioned in passing. Threads sit between cold memory (journals/profiles/buckets) and live conversation. You curate them.
|
|
64
|
+
|
|
65
|
+
You can see live threads for this chat in the `[Live threads]` preamble block. Bring them up naturally if relevant; don't force them. User voice always wins — if they drop a thread, learn from it.
|
|
66
|
+
|
|
67
|
+
### Lifecycle tags (all end-of-reply)
|
|
68
|
+
|
|
69
|
+
| Tag | When |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `[THREAD-NEW: title="..." summary="..." hotness=70 linked_memory=... category=...]` | open a new loop |
|
|
72
|
+
| `[THREAD-UPDATE:<id> summary="..." hotness=80]` | refine what you know |
|
|
73
|
+
| `[THREAD-TOUCH:<id>]` | you mentioned it naturally in reply |
|
|
74
|
+
| `[THREAD-COOL:<id> — wait Nd]` | not now, check back later |
|
|
75
|
+
| `[THREAD-RESOLVE:<id> — note]` | answer arrived, close it |
|
|
76
|
+
| `[THREAD-DROP:<id> — reason]` | stale, no longer relevant |
|
|
77
|
+
| `[THREAD-COMPRESS:<id> — note]` | stabilized fact — move to journal/profile via [DIGEST:] in same reply |
|
|
78
|
+
| `[THREAD-WEIGHT: <category> <0-100>]` | rare manual override on a category's default hotness |
|
|
79
|
+
|
|
80
|
+
### Rules
|
|
81
|
+
|
|
82
|
+
- Open threads sparingly. Only when the owner mentioned something with a future resolution point that they'd plausibly want to hear back on. Not for jokes, opinions, or things you'd hear about anyway.
|
|
83
|
+
- Hotness 0-100. AI-curated. Drop hotness with COOL, raise with TOUCH or UPDATE.
|
|
84
|
+
- Always RESOLVE/DROP/COMPRESS when the loop closes — don't leave stale threads accumulating.
|
|
85
|
+
- Compressing = thread stabilized into a durable fact. Pair `[THREAD-COMPRESS:<id> — ...]` with `[DIGEST: ...]` in the same reply so the fact actually gets written somewhere.
|
|
86
|
+
- One thread per loop. Don't open duplicates — check `[Live threads]` first.
|
|
87
|
+
|
|
88
|
+
### Examples
|
|
89
|
+
|
|
90
|
+
Owner says "waiting on Jana to confirm Tuesday's meeting":
|
|
91
|
+
```
|
|
92
|
+
Got it, fingers crossed she replies.
|
|
93
|
+
[THREAD-NEW: title="Jana meeting confirm" summary="owner waiting on confirmation for Tue meeting" hotness=70]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Owner mentions Jana confirmed:
|
|
97
|
+
```
|
|
98
|
+
Nice, locked in for Tuesday 10am.
|
|
99
|
+
[THREAD-RESOLVE:42 — confirmed Tue 10am]
|
|
100
|
+
[DIGEST: Jana meeting confirmed Tue 10am]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Owner brings up something unrelated, but thread #51 ("5 DMs to creators") is still hot and matches the moment:
|
|
104
|
+
```
|
|
105
|
+
quick reminder, the 5 DMs were on for today — how'd it go?
|
|
106
|
+
[THREAD-TOUCH:51]
|
|
107
|
+
```
|
|
59
108
|
|
|
60
109
|
## Two parallel tracks
|
|
61
110
|
|
package/dist/config.js
CHANGED
|
@@ -117,6 +117,27 @@ const ConfigSchema = z.object({
|
|
|
117
117
|
level: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']),
|
|
118
118
|
promptRetentionDays: z.number(),
|
|
119
119
|
}),
|
|
120
|
+
// Threads — AI-curated relevance watchlist. See src/queue/threads.ts.
|
|
121
|
+
// Off by default; flip enabled=true to allow the AI to open/track
|
|
122
|
+
// loops via [THREAD-*:] tags. Reactive surface only in v1 — proactive
|
|
123
|
+
// review tick (silent-chat check-ins) deferred.
|
|
124
|
+
threads: z
|
|
125
|
+
.object({
|
|
126
|
+
enabled: z.boolean().default(false),
|
|
127
|
+
preamblePerChat: z.number().int().positive().default(5),
|
|
128
|
+
// Soft caps used by future cleanup jobs; the worker doesn't read
|
|
129
|
+
// these yet but they're here so config.json can be authored once.
|
|
130
|
+
maxActivePerChat: z.number().int().positive().default(10),
|
|
131
|
+
hotnessCapOnCreate: z.number().int().min(0).max(100).default(70),
|
|
132
|
+
decayPerDay: z.number().int().min(0).default(2),
|
|
133
|
+
})
|
|
134
|
+
.default({
|
|
135
|
+
enabled: false,
|
|
136
|
+
preamblePerChat: 5,
|
|
137
|
+
maxActivePerChat: 10,
|
|
138
|
+
hotnessCapOnCreate: 70,
|
|
139
|
+
decayPerDay: 2,
|
|
140
|
+
}),
|
|
120
141
|
});
|
|
121
142
|
function loadJsonIfExists(path) {
|
|
122
143
|
try {
|
package/dist/db/schema.js
CHANGED
|
@@ -141,6 +141,61 @@ export const crons = sqliteTable('crons', {
|
|
|
141
141
|
.where(sql `${t.recurrence} IS NOT NULL`),
|
|
142
142
|
}));
|
|
143
143
|
// ──────────────────────────────────────────────────────────────────
|
|
144
|
+
// Threads — AI-curated relevance watchlist
|
|
145
|
+
// ──────────────────────────────────────────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// A "thread" is an open loop the AI is tracking on behalf of the user
|
|
148
|
+
// — the layer between cold memory (journals/profiles/buckets) and
|
|
149
|
+
// live conversation. Each row is "this is currently load-bearing for
|
|
150
|
+
// this chat." The AI promotes things here, updates hotness as signals
|
|
151
|
+
// arrive, brings them up naturally during conversation, and either
|
|
152
|
+
// resolves them (answer found), drops them (stale), or compresses
|
|
153
|
+
// them into cold memory (stabilized into a fact).
|
|
154
|
+
//
|
|
155
|
+
// Hotness (0-100) is the AI's running judgment of how relevant a
|
|
156
|
+
// thread is right now. The user's voice always wins — explicit
|
|
157
|
+
// resolves/drops + manual /threads commands override anything the
|
|
158
|
+
// AI inferred. Implicit signals (user touching the topic, ignoring
|
|
159
|
+
// surfaced threads) nudge the score by smaller amounts.
|
|
160
|
+
//
|
|
161
|
+
// Status: live (active), resolved (answer arrived), dropped (stale),
|
|
162
|
+
// compressed (moved into a journal/profile/bucket).
|
|
163
|
+
export const threads = sqliteTable('threads', {
|
|
164
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
165
|
+
targetJid: text('target_jid').notNull(), // WA JID this thread lives in
|
|
166
|
+
title: text('title').notNull(), // short label, AI-written
|
|
167
|
+
summary: text('summary').notNull(), // 1-2 line context
|
|
168
|
+
hotness: integer('hotness').notNull().default(50), // 0-100
|
|
169
|
+
status: text('status').notNull().default('live'), // live|resolved|dropped|compressed
|
|
170
|
+
linkedMemory: text('linked_memory'), // optional path to cold memory anchor
|
|
171
|
+
openedAt: integer('opened_at').notNull(),
|
|
172
|
+
lastTouchedAt: integer('last_touched_at').notNull(),
|
|
173
|
+
nextReviewAt: integer('next_review_at').notNull(),
|
|
174
|
+
resolutionNote: text('resolution_note'),
|
|
175
|
+
// Token cost attribution — when a thread review burns AI inferences
|
|
176
|
+
// (proactive tick, future feature) the cost lands here. Visible via
|
|
177
|
+
// /threads so the user can see which threads are expensive.
|
|
178
|
+
totalInputTokens: integer('total_input_tokens').notNull().default(0),
|
|
179
|
+
totalOutputTokens: integer('total_output_tokens').notNull().default(0),
|
|
180
|
+
enabled: integer('enabled').notNull().default(1),
|
|
181
|
+
createdAt: integer('created_at').notNull(),
|
|
182
|
+
}, t => ({
|
|
183
|
+
byJidHot: index('threads_by_jid_hot').on(t.targetJid, t.status, t.hotness),
|
|
184
|
+
byDue: index('threads_by_due').on(t.enabled, t.status, t.nextReviewAt),
|
|
185
|
+
}));
|
|
186
|
+
// Owner-global learned weights per category. A thread's category is
|
|
187
|
+
// derived from its linked_memory or title (e.g. "health", "jana",
|
|
188
|
+
// "work"). The weight is the AI's prior for "how much does the user
|
|
189
|
+
// care about this category" — used as the starting hotness when
|
|
190
|
+
// creating new threads in the same category, and nudged up/down by
|
|
191
|
+
// implicit signals (user engaging with / dropping threads).
|
|
192
|
+
export const threadCategoryWeights = sqliteTable('thread_category_weights', {
|
|
193
|
+
category: text('category').primaryKey(), // e.g. 'health', 'jana', 'work-dms'
|
|
194
|
+
weight: integer('weight').notNull().default(50), // 0-100
|
|
195
|
+
samples: integer('samples').notNull().default(0), // signal count, for confidence
|
|
196
|
+
updatedAt: integer('updated_at').notNull(),
|
|
197
|
+
});
|
|
198
|
+
// ──────────────────────────────────────────────────────────────────
|
|
144
199
|
// Inbound queue (Phase 4)
|
|
145
200
|
// ──────────────────────────────────────────────────────────────────
|
|
146
201
|
// Messages received on any channel, waiting to be processed by a
|
package/dist/gateway/commands.js
CHANGED
|
@@ -12,7 +12,10 @@ export async function tryCommand(ctx) {
|
|
|
12
12
|
const trimmed = ctx.text.trim();
|
|
13
13
|
if (!trimmed.startsWith(prefix))
|
|
14
14
|
return false;
|
|
15
|
-
const
|
|
15
|
+
const afterPrefix = trimmed.slice(prefix.length);
|
|
16
|
+
const tokens = afterPrefix.split(/\s+/);
|
|
17
|
+
const cmd = tokens[0]?.toLowerCase() ?? '';
|
|
18
|
+
const args = tokens.slice(1);
|
|
16
19
|
if (!cmd)
|
|
17
20
|
return false;
|
|
18
21
|
if (config.commands.reset.includes(cmd)) {
|
|
@@ -79,5 +82,14 @@ export async function tryCommand(ctx) {
|
|
|
79
82
|
await sendText(ctx.sock, ctx.jid, formatScheduleList(items, tz, onlyKind), ctx.quoted);
|
|
80
83
|
return true;
|
|
81
84
|
}
|
|
85
|
+
if (cmd === 'threads') {
|
|
86
|
+
if (!config.threads?.enabled) {
|
|
87
|
+
await sendText(ctx.sock, ctx.jid, 'threads are disabled in config. Set `threads.enabled: true` to turn on.', ctx.quoted);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const { handleThreadsCommand } = await import('../queue/thread-list.js');
|
|
91
|
+
await sendText(ctx.sock, ctx.jid, handleThreadsCommand(ctx.jid, args), ctx.quoted);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
82
94
|
return false;
|
|
83
95
|
}
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -232,6 +232,18 @@ export function formatStatsFooter(stats, extras) {
|
|
|
232
232
|
parts.push('+digest');
|
|
233
233
|
if (stats.sendTextCount > 0)
|
|
234
234
|
parts.push(plus('send-text', stats.sendTextCount));
|
|
235
|
+
// Thread watchlist — loud events only. Updates/cools are intentional
|
|
236
|
+
// no-ops in the footer (would clutter normal conversation).
|
|
237
|
+
if (stats.threadNewCount > 0)
|
|
238
|
+
parts.push(plus('thread-new', stats.threadNewCount));
|
|
239
|
+
if (stats.threadTouchCount > 0)
|
|
240
|
+
parts.push(plus('thread-touch', stats.threadTouchCount));
|
|
241
|
+
if (stats.threadResolveCount > 0)
|
|
242
|
+
parts.push(plus('thread-resolve', stats.threadResolveCount));
|
|
243
|
+
if (stats.threadDropCount > 0)
|
|
244
|
+
parts.push(plus('thread-drop', stats.threadDropCount));
|
|
245
|
+
if (stats.threadCompressCount > 0)
|
|
246
|
+
parts.push(plus('thread-compress', stats.threadCompressCount));
|
|
235
247
|
// Media — counted per-kind from the file list. e.g. +2 image, +video.
|
|
236
248
|
// 'document' shortened to 'doc' to keep the footer tight.
|
|
237
249
|
const mediaKinds = extras?.mediaKinds ?? [];
|
|
@@ -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
|
-
}
|