@c4t4/heyamigo 0.1.18 → 0.3.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 +81 -0
- package/dist/gateway/commands.js +195 -0
- package/dist/gateway/outgoing.js +39 -0
- package/dist/memory/digest-flag.js +76 -5
- package/dist/memory/journal-cadence.js +120 -0
- package/dist/memory/journal-nudger.js +282 -0
- package/dist/memory/journal-observer.js +225 -0
- package/dist/memory/journals.js +314 -0
- package/dist/memory/preamble.js +12 -1
- package/dist/memory/scheduler.js +36 -1
- package/dist/queue/worker.js +62 -5
- package/dist/wa/whitelist.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { logger } from '../logger.js';
|
|
4
|
+
import { parseFrontmatter, serializeFrontmatter, } from './frontmatter.js';
|
|
5
|
+
import { memoryRoot } from './paths.js';
|
|
6
|
+
// ---------- paths ----------
|
|
7
|
+
function journalsRoot() {
|
|
8
|
+
return resolve(memoryRoot(), 'journals');
|
|
9
|
+
}
|
|
10
|
+
function journalDir(slug) {
|
|
11
|
+
return resolve(journalsRoot(), slug);
|
|
12
|
+
}
|
|
13
|
+
function journalIndexPath(slug) {
|
|
14
|
+
return resolve(journalDir(slug), 'index.md');
|
|
15
|
+
}
|
|
16
|
+
function journalEntriesPath(slug) {
|
|
17
|
+
return resolve(journalDir(slug), 'entries.jsonl');
|
|
18
|
+
}
|
|
19
|
+
function journalsIndexPath() {
|
|
20
|
+
return resolve(journalsRoot(), 'index.md');
|
|
21
|
+
}
|
|
22
|
+
function journalObserverStatePath(slug) {
|
|
23
|
+
return resolve(journalDir(slug), 'observer-state.json');
|
|
24
|
+
}
|
|
25
|
+
function journalNudgeStatePath(slug) {
|
|
26
|
+
return resolve(journalDir(slug), 'nudge-state.json');
|
|
27
|
+
}
|
|
28
|
+
// ---------- low-level fs ----------
|
|
29
|
+
function ensureDirFor(path) {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
function readIfExists(path) {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return null;
|
|
35
|
+
return readFileSync(path, 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
// ---------- scaffold ----------
|
|
38
|
+
export function ensureJournalsScaffold() {
|
|
39
|
+
mkdirSync(journalsRoot(), { recursive: true });
|
|
40
|
+
if (!existsSync(journalsIndexPath())) {
|
|
41
|
+
writeFileSync(journalsIndexPath(), '# journals\n\n(empty)\n', 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ---------- slug rules ----------
|
|
45
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
46
|
+
export function isValidSlug(slug) {
|
|
47
|
+
return SLUG_RE.test(slug);
|
|
48
|
+
}
|
|
49
|
+
// ---------- parse / serialize ----------
|
|
50
|
+
function parseJournal(slug, raw) {
|
|
51
|
+
const { data, body } = parseFrontmatter(raw);
|
|
52
|
+
const name = typeof data.name === 'string' ? data.name : slug;
|
|
53
|
+
const purpose = typeof data.purpose === 'string' ? data.purpose : '';
|
|
54
|
+
const fields = Array.isArray(data.fields)
|
|
55
|
+
? data.fields.map(String)
|
|
56
|
+
: [];
|
|
57
|
+
const status = data.status === 'paused' || data.status === 'archived'
|
|
58
|
+
? data.status
|
|
59
|
+
: 'active';
|
|
60
|
+
const cadence = {
|
|
61
|
+
checkin: pickString(data.checkin),
|
|
62
|
+
followup_after: pickString(data.followup_after),
|
|
63
|
+
nudge_if_silent: pickString(data.nudge_if_silent),
|
|
64
|
+
};
|
|
65
|
+
const created_at = typeof data.created_at === 'string' ? data.created_at : '';
|
|
66
|
+
const updated_at = typeof data.updated_at === 'string' ? data.updated_at : created_at;
|
|
67
|
+
return {
|
|
68
|
+
slug,
|
|
69
|
+
name,
|
|
70
|
+
purpose,
|
|
71
|
+
fields,
|
|
72
|
+
cadence,
|
|
73
|
+
status,
|
|
74
|
+
quiet_hours: pickString(data.quiet_hours),
|
|
75
|
+
created_at,
|
|
76
|
+
updated_at,
|
|
77
|
+
body,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function pickString(v) {
|
|
81
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
82
|
+
}
|
|
83
|
+
function journalToFrontmatter(j) {
|
|
84
|
+
const fm = {
|
|
85
|
+
slug: j.slug,
|
|
86
|
+
name: j.name,
|
|
87
|
+
purpose: j.purpose,
|
|
88
|
+
fields: j.fields,
|
|
89
|
+
status: j.status,
|
|
90
|
+
created_at: j.created_at,
|
|
91
|
+
updated_at: j.updated_at,
|
|
92
|
+
};
|
|
93
|
+
if (j.cadence.checkin)
|
|
94
|
+
fm.checkin = j.cadence.checkin;
|
|
95
|
+
if (j.cadence.followup_after)
|
|
96
|
+
fm.followup_after = j.cadence.followup_after;
|
|
97
|
+
if (j.cadence.nudge_if_silent)
|
|
98
|
+
fm.nudge_if_silent = j.cadence.nudge_if_silent;
|
|
99
|
+
if (j.quiet_hours)
|
|
100
|
+
fm.quiet_hours = j.quiet_hours;
|
|
101
|
+
return fm;
|
|
102
|
+
}
|
|
103
|
+
// ---------- CRUD ----------
|
|
104
|
+
export function listJournals() {
|
|
105
|
+
const root = journalsRoot();
|
|
106
|
+
if (!existsSync(root))
|
|
107
|
+
return [];
|
|
108
|
+
const slugs = readdirSync(root, { withFileTypes: true })
|
|
109
|
+
.filter((d) => d.isDirectory())
|
|
110
|
+
.map((d) => d.name)
|
|
111
|
+
.sort();
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const slug of slugs) {
|
|
114
|
+
const raw = readIfExists(journalIndexPath(slug));
|
|
115
|
+
if (!raw)
|
|
116
|
+
continue;
|
|
117
|
+
const j = parseJournal(slug, raw);
|
|
118
|
+
if (j)
|
|
119
|
+
out.push(j);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
export function getJournal(slug) {
|
|
124
|
+
if (!isValidSlug(slug))
|
|
125
|
+
return null;
|
|
126
|
+
const raw = readIfExists(journalIndexPath(slug));
|
|
127
|
+
if (!raw)
|
|
128
|
+
return null;
|
|
129
|
+
return parseJournal(slug, raw);
|
|
130
|
+
}
|
|
131
|
+
export function journalExists(slug) {
|
|
132
|
+
return isValidSlug(slug) && existsSync(journalIndexPath(slug));
|
|
133
|
+
}
|
|
134
|
+
export function createJournal(input) {
|
|
135
|
+
if (!isValidSlug(input.slug)) {
|
|
136
|
+
throw new Error(`Invalid journal slug "${input.slug}". Use lowercase letters, digits, and hyphens (max 48 chars, must start with letter/digit).`);
|
|
137
|
+
}
|
|
138
|
+
if (journalExists(input.slug)) {
|
|
139
|
+
throw new Error(`Journal "${input.slug}" already exists.`);
|
|
140
|
+
}
|
|
141
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
142
|
+
// Default cadence: nudge after 3 days of silence on this topic. No daily
|
|
143
|
+
// check-in by default — that would be too pushy for most journals. Owner
|
|
144
|
+
// can tune by editing the journal's index.md frontmatter directly.
|
|
145
|
+
const cadence = input.cadence ?? {
|
|
146
|
+
nudge_if_silent: '3d',
|
|
147
|
+
};
|
|
148
|
+
const journal = {
|
|
149
|
+
slug: input.slug,
|
|
150
|
+
name: input.name,
|
|
151
|
+
purpose: input.purpose,
|
|
152
|
+
fields: input.fields ?? [],
|
|
153
|
+
cadence,
|
|
154
|
+
status: 'active',
|
|
155
|
+
quiet_hours: input.quiet_hours,
|
|
156
|
+
created_at: now,
|
|
157
|
+
updated_at: now,
|
|
158
|
+
body: input.body ??
|
|
159
|
+
`# ${input.name}\n\n${input.purpose}\n\n## How this journal is used\n\nEntries are captured by the assistant when topics relevant to this journal come up. See entries.jsonl for the log.\n`,
|
|
160
|
+
};
|
|
161
|
+
writeJournal(journal);
|
|
162
|
+
refreshJournalsIndex();
|
|
163
|
+
logger.info({ slug: journal.slug }, 'journal created');
|
|
164
|
+
return journal;
|
|
165
|
+
}
|
|
166
|
+
export function writeJournal(j) {
|
|
167
|
+
const content = serializeFrontmatter(journalToFrontmatter(j), j.body);
|
|
168
|
+
const path = journalIndexPath(j.slug);
|
|
169
|
+
ensureDirFor(path);
|
|
170
|
+
writeFileSync(path, content, 'utf-8');
|
|
171
|
+
}
|
|
172
|
+
export function updateJournalStatus(slug, status) {
|
|
173
|
+
const j = getJournal(slug);
|
|
174
|
+
if (!j)
|
|
175
|
+
return null;
|
|
176
|
+
j.status = status;
|
|
177
|
+
j.updated_at = new Date().toISOString().slice(0, 10);
|
|
178
|
+
writeJournal(j);
|
|
179
|
+
refreshJournalsIndex();
|
|
180
|
+
return j;
|
|
181
|
+
}
|
|
182
|
+
// ---------- entries ----------
|
|
183
|
+
export function appendEntry(slug, entry) {
|
|
184
|
+
if (!journalExists(slug)) {
|
|
185
|
+
logger.warn({ slug }, 'journal append ignored: unknown slug');
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const full = {
|
|
189
|
+
ts: entry.ts ?? Math.floor(Date.now() / 1000),
|
|
190
|
+
source: entry.source,
|
|
191
|
+
jid: entry.jid,
|
|
192
|
+
senderNumber: entry.senderNumber,
|
|
193
|
+
note: entry.note,
|
|
194
|
+
};
|
|
195
|
+
const path = journalEntriesPath(slug);
|
|
196
|
+
ensureDirFor(path);
|
|
197
|
+
appendFileSync(path, JSON.stringify(full) + '\n', 'utf-8');
|
|
198
|
+
logger.info({ slug, source: full.source, jid: full.jid }, 'journal entry appended');
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
export function readEntries(slug, limit = 100) {
|
|
202
|
+
const raw = readIfExists(journalEntriesPath(slug));
|
|
203
|
+
if (!raw)
|
|
204
|
+
return [];
|
|
205
|
+
const lines = raw.trim().split(/\r?\n/).filter(Boolean);
|
|
206
|
+
const tail = limit > 0 ? lines.slice(-limit) : lines;
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const line of tail) {
|
|
209
|
+
try {
|
|
210
|
+
out.push(JSON.parse(line));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// skip malformed line
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
// ---------- index ----------
|
|
219
|
+
export function refreshJournalsIndex() {
|
|
220
|
+
const journals = listJournals();
|
|
221
|
+
const lines = ['# journals', ''];
|
|
222
|
+
if (journals.length === 0) {
|
|
223
|
+
lines.push('(empty)');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
for (const j of journals) {
|
|
227
|
+
lines.push(`- ${j.slug}/ [${j.status}] — ${j.purpose || j.name}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
lines.push('');
|
|
231
|
+
const path = journalsIndexPath();
|
|
232
|
+
ensureDirFor(path);
|
|
233
|
+
writeFileSync(path, lines.join('\n'), 'utf-8');
|
|
234
|
+
}
|
|
235
|
+
// ---------- preamble helper ----------
|
|
236
|
+
// Short one-liner per active journal for the [Journals: active] preamble block.
|
|
237
|
+
// Only returns active journals (not paused/archived).
|
|
238
|
+
export function buildJournalsPreambleBlock() {
|
|
239
|
+
const journals = listJournals().filter((j) => j.status === 'active');
|
|
240
|
+
if (journals.length === 0)
|
|
241
|
+
return null;
|
|
242
|
+
const lines = [];
|
|
243
|
+
for (const j of journals) {
|
|
244
|
+
const cadence = summarizeCadence(j.cadence);
|
|
245
|
+
const cadenceSuffix = cadence ? ` (${cadence})` : '';
|
|
246
|
+
lines.push(`- ${j.slug}: ${j.purpose || j.name}${cadenceSuffix}`);
|
|
247
|
+
}
|
|
248
|
+
return lines.join('\n');
|
|
249
|
+
}
|
|
250
|
+
function summarizeCadence(c) {
|
|
251
|
+
const bits = [];
|
|
252
|
+
if (c.checkin)
|
|
253
|
+
bits.push(`check-in ${c.checkin}`);
|
|
254
|
+
if (c.followup_after)
|
|
255
|
+
bits.push(`follow-up ${c.followup_after}`);
|
|
256
|
+
if (c.nudge_if_silent)
|
|
257
|
+
bits.push(`nudge if silent ${c.nudge_if_silent}`);
|
|
258
|
+
return bits.join('; ');
|
|
259
|
+
}
|
|
260
|
+
export function loadObserverState(slug) {
|
|
261
|
+
const raw = readIfExists(journalObserverStatePath(slug));
|
|
262
|
+
if (!raw)
|
|
263
|
+
return { jids: {} };
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(raw);
|
|
266
|
+
return { jids: parsed.jids ?? {} };
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return { jids: {} };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export function saveObserverState(slug, state) {
|
|
273
|
+
const path = journalObserverStatePath(slug);
|
|
274
|
+
ensureDirFor(path);
|
|
275
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
276
|
+
}
|
|
277
|
+
export function getLastScannedTs(slug, jid) {
|
|
278
|
+
const state = loadObserverState(slug);
|
|
279
|
+
return state.jids[jid]?.lastScannedTs ?? 0;
|
|
280
|
+
}
|
|
281
|
+
export function setLastScannedTs(slug, jid, ts) {
|
|
282
|
+
const state = loadObserverState(slug);
|
|
283
|
+
state.jids[jid] = { lastScannedTs: ts };
|
|
284
|
+
saveObserverState(slug, state);
|
|
285
|
+
}
|
|
286
|
+
export function loadNudgeState(slug) {
|
|
287
|
+
const raw = readIfExists(journalNudgeStatePath(slug));
|
|
288
|
+
if (!raw)
|
|
289
|
+
return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(raw);
|
|
292
|
+
return {
|
|
293
|
+
lastCheckinTs: parsed.lastCheckinTs ?? 0,
|
|
294
|
+
lastSilentNudgeTs: parsed.lastSilentNudgeTs ?? 0,
|
|
295
|
+
snoozedUntilTs: parsed.snoozedUntilTs ?? 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { lastCheckinTs: 0, lastSilentNudgeTs: 0, snoozedUntilTs: 0 };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
export function saveNudgeState(slug, state) {
|
|
303
|
+
const path = journalNudgeStatePath(slug);
|
|
304
|
+
ensureDirFor(path);
|
|
305
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
306
|
+
}
|
|
307
|
+
export function snoozeJournal(slug, untilTs) {
|
|
308
|
+
if (!journalExists(slug))
|
|
309
|
+
return false;
|
|
310
|
+
const state = loadNudgeState(slug);
|
|
311
|
+
state.snoozedUntilTs = untilTs;
|
|
312
|
+
saveNudgeState(slug, state);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
package/dist/memory/preamble.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
+
import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
|
|
4
5
|
import { masterIndexPath, treeIndexPath } from './paths.js';
|
|
5
6
|
import { routeIndexes } from './router.js';
|
|
6
7
|
import { ensureScaffold } from './store.js';
|
|
7
8
|
import { getRoleForContext } from '../wa/whitelist.js';
|
|
8
9
|
const DIGEST_REMINDER = `When something worth remembering happens (new preference, key fact, life event, changed plan), append [DIGEST: <one-line reason>] to the END of your reply. It will be stripped before sending. Flag sparingly.`;
|
|
10
|
+
const JOURNAL_REMINDER = `When a message contains info for one of the journals above, append [JOURNAL:<slug> — <one-line note>] to the END of your reply. Multiple tags OK. Only use slugs listed; never invent. Full rules are in your memory instructions.`;
|
|
9
11
|
function buildCriticalSection(params) {
|
|
10
12
|
const { senderNumber, roleName, role, userName } = params;
|
|
11
13
|
const who = userName
|
|
@@ -34,6 +36,7 @@ function buildCriticalSection(params) {
|
|
|
34
36
|
}
|
|
35
37
|
export function buildMemoryPreamble(params) {
|
|
36
38
|
ensureScaffold();
|
|
39
|
+
ensureJournalsScaffold();
|
|
37
40
|
const { name: roleName, role, userName } = getRoleForContext(params.senderNumber, params.isGroup ?? params.jid.endsWith('@g.us'));
|
|
38
41
|
const sections = [];
|
|
39
42
|
// Identity — tell Claude its name
|
|
@@ -108,7 +111,15 @@ export function buildMemoryPreamble(params) {
|
|
|
108
111
|
if (entityBlocks.length) {
|
|
109
112
|
sections.push(`${label}\n${entityBlocks.join('\n\n')}`);
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
// Journals — owner-scoped, shown globally across all chats.
|
|
115
|
+
const isOwner = !!config.owner.number && params.senderNumber === config.owner.number;
|
|
116
|
+
const journalsBlock = isOwner ? buildJournalsPreambleBlock() : null;
|
|
117
|
+
const instructions = [DIGEST_REMINDER];
|
|
118
|
+
if (journalsBlock) {
|
|
119
|
+
sections.push(`[Journals: active]\n${journalsBlock}`);
|
|
120
|
+
instructions.push(JOURNAL_REMINDER);
|
|
121
|
+
}
|
|
122
|
+
sections.push(`[Instruction]\n${instructions.join('\n\n')}`);
|
|
112
123
|
return sections.join('\n\n');
|
|
113
124
|
}
|
|
114
125
|
function readIfExists(path) {
|
package/dist/memory/scheduler.js
CHANGED
|
@@ -62,8 +62,20 @@ async function sweep() {
|
|
|
62
62
|
.catch((err) => logger.error({ err, jid }, 'sweep digest push failed'));
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
// Journal observer pass: scans recent messages per active journal for
|
|
66
|
+
// entries Claude missed (i.e. when the bot wasn't mentioned). Runs once
|
|
67
|
+
// per sweep cycle; each journal maintains its own last-scanned-ts.
|
|
68
|
+
try {
|
|
69
|
+
const { runJournalObserverSweep } = await import('./journal-observer.js');
|
|
70
|
+
await runJournalObserverSweep();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
logger.error({ err }, 'journal observer sweep failed');
|
|
74
|
+
}
|
|
65
75
|
}
|
|
66
76
|
let sweepTimer = null;
|
|
77
|
+
let nudgeTimer = null;
|
|
78
|
+
const NUDGE_TICK_MS = 5 * 60 * 1000; // 5 minutes
|
|
67
79
|
export function startScheduler() {
|
|
68
80
|
if (sweepTimer)
|
|
69
81
|
return;
|
|
@@ -72,13 +84,36 @@ export function startScheduler() {
|
|
|
72
84
|
sweepTimer = setInterval(() => {
|
|
73
85
|
void sweep().catch((err) => logger.error({ err }, 'sweep failed'));
|
|
74
86
|
}, config.memory.sweepIntervalMs);
|
|
75
|
-
|
|
87
|
+
// Faster tick just for proactive journal nudges (check-ins, silent-nudges).
|
|
88
|
+
// The memory-sweep cycle (default 3h) is too coarse for a "daily 21:00"
|
|
89
|
+
// check-in. This tick is cheap: it only spawns Claude when something is
|
|
90
|
+
// actually due for a journal.
|
|
91
|
+
nudgeTimer = setInterval(() => {
|
|
92
|
+
void runNudgeTickSafe();
|
|
93
|
+
}, NUDGE_TICK_MS);
|
|
94
|
+
logger.info({
|
|
95
|
+
intervalMs: config.memory.sweepIntervalMs,
|
|
96
|
+
nudgeTickMs: NUDGE_TICK_MS,
|
|
97
|
+
}, 'memory scheduler started');
|
|
98
|
+
}
|
|
99
|
+
async function runNudgeTickSafe() {
|
|
100
|
+
try {
|
|
101
|
+
const { runNudgeTick } = await import('./journal-nudger.js');
|
|
102
|
+
await runNudgeTick();
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.error({ err }, 'nudge tick failed');
|
|
106
|
+
}
|
|
76
107
|
}
|
|
77
108
|
export function stopScheduler() {
|
|
78
109
|
if (sweepTimer) {
|
|
79
110
|
clearInterval(sweepTimer);
|
|
80
111
|
sweepTimer = null;
|
|
81
112
|
}
|
|
113
|
+
if (nudgeTimer) {
|
|
114
|
+
clearInterval(nudgeTimer);
|
|
115
|
+
nudgeTimer = null;
|
|
116
|
+
}
|
|
82
117
|
for (const t of pendingTimers.values())
|
|
83
118
|
clearTimeout(t);
|
|
84
119
|
pendingTimers.clear();
|
package/dist/queue/worker.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { askClaude } from '../ai/claude.js';
|
|
2
2
|
import { clearSession, setSession, setUsage } from '../ai/sessions.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
|
-
import {
|
|
4
|
+
import { extractFlags } from '../memory/digest-flag.js';
|
|
5
|
+
import { appendEntry, createJournal, getJournal, isValidSlug, updateJournalStatus, } from '../memory/journals.js';
|
|
5
6
|
import { scheduleDigest } from '../memory/scheduler.js';
|
|
6
7
|
function isStaleSessionError(err) {
|
|
7
8
|
return (err instanceof Error &&
|
|
@@ -25,17 +26,73 @@ async function callClaude(job) {
|
|
|
25
26
|
totalContextTokens,
|
|
26
27
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
27
28
|
});
|
|
28
|
-
const { clean,
|
|
29
|
-
if (
|
|
30
|
-
logger.info({ jid: job.jid, number: job.senderNumber, reason:
|
|
29
|
+
const { clean, digest, journals, lifecycleOps } = extractFlags(reply);
|
|
30
|
+
if (digest) {
|
|
31
|
+
logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
|
|
31
32
|
scheduleDigest({
|
|
32
33
|
jid: job.jid,
|
|
33
34
|
number: job.senderNumber,
|
|
34
|
-
reason:
|
|
35
|
+
reason: digest,
|
|
35
36
|
});
|
|
36
37
|
}
|
|
38
|
+
// Lifecycle ops run BEFORE entry appends so that a reply creating a new
|
|
39
|
+
// journal AND flagging its first entry in the same turn works correctly.
|
|
40
|
+
for (const op of lifecycleOps) {
|
|
41
|
+
if (!isValidSlug(op.slug)) {
|
|
42
|
+
logger.warn({ op, jid: job.jid }, 'journal lifecycle op: invalid slug, dropped');
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
if (op.kind === 'new') {
|
|
47
|
+
if (getJournal(op.slug)) {
|
|
48
|
+
logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
createJournal({
|
|
52
|
+
slug: op.slug,
|
|
53
|
+
name: titleCase(op.slug),
|
|
54
|
+
purpose: op.purpose,
|
|
55
|
+
});
|
|
56
|
+
logger.info({ slug: op.slug, jid: job.jid }, 'journal created via bot marker');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const status = op.kind === 'pause'
|
|
60
|
+
? 'paused'
|
|
61
|
+
: op.kind === 'archive'
|
|
62
|
+
? 'archived'
|
|
63
|
+
: 'active';
|
|
64
|
+
const updated = updateJournalStatus(op.slug, status);
|
|
65
|
+
if (updated) {
|
|
66
|
+
logger.info({ slug: op.slug, status, jid: job.jid }, 'journal status updated via bot marker');
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
logger.warn({ op, jid: job.jid }, 'journal lifecycle op: unknown slug, dropped');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
logger.error({ err, op, jid: job.jid }, 'journal lifecycle op failed');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const j of journals) {
|
|
78
|
+
const ok = appendEntry(j.slug, {
|
|
79
|
+
source: 'reactive',
|
|
80
|
+
jid: job.jid,
|
|
81
|
+
senderNumber: job.senderNumber,
|
|
82
|
+
note: j.note,
|
|
83
|
+
});
|
|
84
|
+
if (!ok) {
|
|
85
|
+
logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
37
88
|
return { reply: clean };
|
|
38
89
|
}
|
|
90
|
+
function titleCase(slug) {
|
|
91
|
+
return slug
|
|
92
|
+
.split('-')
|
|
93
|
+
.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : p))
|
|
94
|
+
.join(' ');
|
|
95
|
+
}
|
|
39
96
|
export async function processJob(job) {
|
|
40
97
|
try {
|
|
41
98
|
return await callClaude(job);
|
package/dist/wa/whitelist.js
CHANGED
|
@@ -96,9 +96,11 @@ function save(next) {
|
|
|
96
96
|
export function getAccess() {
|
|
97
97
|
return current;
|
|
98
98
|
}
|
|
99
|
-
// Guardrail for proactive (unsolicited) messaging.
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Guardrail for proactive (unsolicited) messaging. Default deny.
|
|
100
|
+
//
|
|
101
|
+
// Exception: the owner's own self-DM is always allowed — the owner implicitly
|
|
102
|
+
// consents to the bot nudging them in their own DM. Other DMs and groups
|
|
103
|
+
// require an explicit `proactive: true` entry in access.json.
|
|
102
104
|
export function canSendProactive(jid) {
|
|
103
105
|
const isGroup = jid.endsWith('@g.us');
|
|
104
106
|
if (isGroup) {
|
|
@@ -108,6 +110,9 @@ export function canSendProactive(jid) {
|
|
|
108
110
|
const number = jidDecode(jid)?.user;
|
|
109
111
|
if (!number)
|
|
110
112
|
return false;
|
|
113
|
+
// Owner's self-DM is always allowed.
|
|
114
|
+
if (config.owner.number && number === config.owner.number)
|
|
115
|
+
return true;
|
|
111
116
|
const entry = current.dms.allowed.find((d) => d.number === number);
|
|
112
117
|
return entry?.proactive === true;
|
|
113
118
|
}
|