@c4t4/heyamigo 0.1.18 → 0.5.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 +162 -21
- package/dist/gateway/commands.js +4 -0
- package/dist/gateway/outgoing.js +39 -0
- package/dist/memory/digest-flag.js +67 -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 +306 -0
- package/dist/memory/preamble.js +33 -1
- package/dist/memory/scheduler.js +36 -1
- package/dist/queue/async-tasks.js +215 -0
- package/dist/queue/worker.js +60 -5
- package/dist/wa/whitelist.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
}
|
package/dist/memory/preamble.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
+
import { listAsyncTasks } from '../queue/async-tasks.js';
|
|
5
|
+
import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
|
|
4
6
|
import { masterIndexPath, treeIndexPath } from './paths.js';
|
|
5
7
|
import { routeIndexes } from './router.js';
|
|
6
8
|
import { ensureScaffold } from './store.js';
|
|
7
9
|
import { getRoleForContext } from '../wa/whitelist.js';
|
|
8
10
|
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.`;
|
|
11
|
+
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
12
|
function buildCriticalSection(params) {
|
|
10
13
|
const { senderNumber, roleName, role, userName } = params;
|
|
11
14
|
const who = userName
|
|
@@ -34,6 +37,7 @@ function buildCriticalSection(params) {
|
|
|
34
37
|
}
|
|
35
38
|
export function buildMemoryPreamble(params) {
|
|
36
39
|
ensureScaffold();
|
|
40
|
+
ensureJournalsScaffold();
|
|
37
41
|
const { name: roleName, role, userName } = getRoleForContext(params.senderNumber, params.isGroup ?? params.jid.endsWith('@g.us'));
|
|
38
42
|
const sections = [];
|
|
39
43
|
// Identity — tell Claude its name
|
|
@@ -108,7 +112,28 @@ export function buildMemoryPreamble(params) {
|
|
|
108
112
|
if (entityBlocks.length) {
|
|
109
113
|
sections.push(`${label}\n${entityBlocks.join('\n\n')}`);
|
|
110
114
|
}
|
|
111
|
-
|
|
115
|
+
// Journals — owner-scoped, shown globally across all chats.
|
|
116
|
+
const isOwner = !!config.owner.number && params.senderNumber === config.owner.number;
|
|
117
|
+
const journalsBlock = isOwner ? buildJournalsPreambleBlock() : null;
|
|
118
|
+
const instructions = [DIGEST_REMINDER];
|
|
119
|
+
if (journalsBlock) {
|
|
120
|
+
sections.push(`[Journals: active]\n${journalsBlock}`);
|
|
121
|
+
instructions.push(JOURNAL_REMINDER);
|
|
122
|
+
}
|
|
123
|
+
// Async tasks in progress for this chat — so Claude doesn't re-promise or
|
|
124
|
+
// contradict work already running in the background.
|
|
125
|
+
const asyncTasks = listAsyncTasks(params.jid);
|
|
126
|
+
if (asyncTasks.length > 0) {
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
const lines = ['You have background tasks currently running for this chat:'];
|
|
129
|
+
for (const t of asyncTasks) {
|
|
130
|
+
const ageSec = Math.max(0, now - t.startedAt);
|
|
131
|
+
lines.push(`- "${t.description}" (started ${formatAge(ageSec)} ago)`);
|
|
132
|
+
}
|
|
133
|
+
lines.push('', 'Do NOT re-start or re-promise these. Reply referencing that they are in progress if relevant, but do not emit another [ASYNC:...] for the same work.');
|
|
134
|
+
sections.push(`[Async tasks in progress]\n${lines.join('\n')}`);
|
|
135
|
+
}
|
|
136
|
+
sections.push(`[Instruction]\n${instructions.join('\n\n')}`);
|
|
112
137
|
return sections.join('\n\n');
|
|
113
138
|
}
|
|
114
139
|
function readIfExists(path) {
|
|
@@ -116,6 +141,13 @@ function readIfExists(path) {
|
|
|
116
141
|
return null;
|
|
117
142
|
return readFileSync(path, 'utf-8');
|
|
118
143
|
}
|
|
144
|
+
function formatAge(seconds) {
|
|
145
|
+
if (seconds < 60)
|
|
146
|
+
return `${seconds}s`;
|
|
147
|
+
if (seconds < 3600)
|
|
148
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
149
|
+
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`;
|
|
150
|
+
}
|
|
119
151
|
function buildTimeLine(timezone) {
|
|
120
152
|
const now = new Date();
|
|
121
153
|
const fmt = new Intl.DateTimeFormat('en-GB', {
|
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();
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import fastq from 'fastq';
|
|
6
|
+
import { initiate } from '../gateway/outgoing.js';
|
|
7
|
+
import { logger } from '../logger.js';
|
|
8
|
+
import { logPrompt } from '../promptlog.js';
|
|
9
|
+
// Concurrency: how many async Claude workers can run simultaneously.
|
|
10
|
+
// Start conservative — each process is expensive (Playwright, multi-minute runs).
|
|
11
|
+
// Tune via config.asyncTasks.concurrency once we have real usage data.
|
|
12
|
+
const CONCURRENCY = 3;
|
|
13
|
+
// In-memory registry of tasks currently executing. Not persisted across
|
|
14
|
+
// restarts — on reboot, any in-flight async work is silently dropped.
|
|
15
|
+
// We expose listInProgress() so the chat preamble can show "in progress"
|
|
16
|
+
// hints to the main Claude.
|
|
17
|
+
const inProgress = new Map();
|
|
18
|
+
const queue = fastq.promise(async (task) => {
|
|
19
|
+
inProgress.set(task.id, task);
|
|
20
|
+
try {
|
|
21
|
+
await runTask(task);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
logger.error({ err, id: task.id, jid: task.jid }, 'async task failed unexpectedly');
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
inProgress.delete(task.id);
|
|
28
|
+
}
|
|
29
|
+
}, CONCURRENCY);
|
|
30
|
+
export function enqueueAsyncTask(input) {
|
|
31
|
+
const task = {
|
|
32
|
+
...input,
|
|
33
|
+
id: `async-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
34
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
35
|
+
};
|
|
36
|
+
logger.info({
|
|
37
|
+
id: task.id,
|
|
38
|
+
jid: task.jid,
|
|
39
|
+
description: task.description.slice(0, 200),
|
|
40
|
+
}, 'async task enqueued');
|
|
41
|
+
queue.push(task).catch((err) => logger.error({ err, id: task.id }, 'async queue push failed'));
|
|
42
|
+
return task;
|
|
43
|
+
}
|
|
44
|
+
export function listAsyncTasks(jid) {
|
|
45
|
+
const all = Array.from(inProgress.values());
|
|
46
|
+
if (!jid)
|
|
47
|
+
return all;
|
|
48
|
+
return all.filter((t) => t.jid === jid);
|
|
49
|
+
}
|
|
50
|
+
// ---------- task runner ----------
|
|
51
|
+
let cachedSystemPrompt = null;
|
|
52
|
+
function systemPrompt() {
|
|
53
|
+
if (cachedSystemPrompt !== null)
|
|
54
|
+
return cachedSystemPrompt;
|
|
55
|
+
const personality = readFileSync(resolve(process.cwd(), config.claude.personalityFile), 'utf-8');
|
|
56
|
+
let memoryInstructions = '';
|
|
57
|
+
try {
|
|
58
|
+
memoryInstructions = readFileSync(resolve(process.cwd(), config.memory.instructionsFile), 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// optional
|
|
62
|
+
}
|
|
63
|
+
cachedSystemPrompt = memoryInstructions
|
|
64
|
+
? `${personality}\n\n---\n\n${memoryInstructions}`
|
|
65
|
+
: personality;
|
|
66
|
+
return cachedSystemPrompt;
|
|
67
|
+
}
|
|
68
|
+
export function reloadAsyncSystemPrompt() {
|
|
69
|
+
cachedSystemPrompt = null;
|
|
70
|
+
}
|
|
71
|
+
function buildPrompt(task) {
|
|
72
|
+
const lines = [
|
|
73
|
+
`You are running a BACKGROUND TASK for the owner. The chat already got your ack reply. Your only job now is to do the work and output the final message to send them.`,
|
|
74
|
+
``,
|
|
75
|
+
`TASK:`,
|
|
76
|
+
task.description,
|
|
77
|
+
``,
|
|
78
|
+
`ORIGINAL USER MESSAGE (for reference):`,
|
|
79
|
+
task.originatingMessage,
|
|
80
|
+
``,
|
|
81
|
+
`Sender: ${task.senderName ?? task.senderNumber}`,
|
|
82
|
+
``,
|
|
83
|
+
`RULES:`,
|
|
84
|
+
`- Stay fully in character (personality file). This is not customer service.`,
|
|
85
|
+
`- Do the real work. Use tools (browser, etc.) as needed.`,
|
|
86
|
+
`- When done, output ONLY the message to send the user. No preamble, no "here's what I found:" framing unless that's the message itself.`,
|
|
87
|
+
`- Do NOT emit any [DIGEST:...], [JOURNAL:...], [ASYNC:...], or other markers. This is the final output.`,
|
|
88
|
+
`- Start the message with a short reference to what you were working on so the user knows which task this is about (e.g. "About the TikTok scrape: ..."). They may have asked for multiple things.`,
|
|
89
|
+
`- If the task is impossible or the tools failed, say so honestly and briefly. Don't fabricate.`,
|
|
90
|
+
``,
|
|
91
|
+
`Output the final user-facing message now.`,
|
|
92
|
+
];
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
function buildArgs(task) {
|
|
96
|
+
const args = [
|
|
97
|
+
'-p',
|
|
98
|
+
'--output-format',
|
|
99
|
+
'json',
|
|
100
|
+
'--model',
|
|
101
|
+
config.claude.model,
|
|
102
|
+
'--permission-mode',
|
|
103
|
+
'acceptEdits',
|
|
104
|
+
'--append-system-prompt',
|
|
105
|
+
systemPrompt(),
|
|
106
|
+
];
|
|
107
|
+
for (const dir of config.claude.addDirs) {
|
|
108
|
+
args.push('--add-dir', resolve(process.cwd(), dir));
|
|
109
|
+
}
|
|
110
|
+
args.push('--add-dir', resolve(process.cwd(), config.memory.dir));
|
|
111
|
+
args.push('--add-dir', resolve(process.cwd(), config.storage.mediaDir));
|
|
112
|
+
if (task.allowedTools &&
|
|
113
|
+
task.allowedTools !== 'all' &&
|
|
114
|
+
task.allowedTools.length > 0) {
|
|
115
|
+
args.push('--allowedTools', task.allowedTools.join(','));
|
|
116
|
+
}
|
|
117
|
+
return args;
|
|
118
|
+
}
|
|
119
|
+
async function spawnClaudeForTask(task, prompt) {
|
|
120
|
+
const args = buildArgs(task);
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
123
|
+
const child = spawn('claude', args, {
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
});
|
|
127
|
+
let stdout = '';
|
|
128
|
+
let stderr = '';
|
|
129
|
+
child.stdout.on('data', (c) => {
|
|
130
|
+
stdout += c.toString('utf-8');
|
|
131
|
+
});
|
|
132
|
+
child.stderr.on('data', (c) => {
|
|
133
|
+
stderr += c.toString('utf-8');
|
|
134
|
+
});
|
|
135
|
+
const logFail = (error) => void logPrompt({
|
|
136
|
+
ts: Math.floor(startedAt / 1000),
|
|
137
|
+
caller: 'async-task',
|
|
138
|
+
args,
|
|
139
|
+
input: prompt,
|
|
140
|
+
error,
|
|
141
|
+
durationMs: Date.now() - startedAt,
|
|
142
|
+
});
|
|
143
|
+
child.on('error', (err) => {
|
|
144
|
+
logFail(`spawn failed: ${err.message}`);
|
|
145
|
+
rejectPromise(err);
|
|
146
|
+
});
|
|
147
|
+
child.on('close', (code) => {
|
|
148
|
+
if (code !== 0) {
|
|
149
|
+
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
150
|
+
return rejectPromise(new Error(`async task exit ${code}`));
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(stdout);
|
|
154
|
+
if (parsed.is_error ||
|
|
155
|
+
parsed.subtype !== 'success' ||
|
|
156
|
+
!parsed.result) {
|
|
157
|
+
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
158
|
+
return rejectPromise(new Error('async task bad output'));
|
|
159
|
+
}
|
|
160
|
+
const output = parsed.result.trim();
|
|
161
|
+
void logPrompt({
|
|
162
|
+
ts: Math.floor(startedAt / 1000),
|
|
163
|
+
caller: 'async-task',
|
|
164
|
+
args,
|
|
165
|
+
input: prompt,
|
|
166
|
+
output,
|
|
167
|
+
durationMs: Date.now() - startedAt,
|
|
168
|
+
});
|
|
169
|
+
resolvePromise(output);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
logFail(`parse failed: ${err.message}`);
|
|
173
|
+
rejectPromise(err);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
child.stdin.write(prompt);
|
|
177
|
+
child.stdin.end();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
async function runTask(task) {
|
|
181
|
+
const prompt = buildPrompt(task);
|
|
182
|
+
const elapsedLog = () => `${Math.round((Date.now() - task.startedAt * 1000) / 1000)}s`;
|
|
183
|
+
let output;
|
|
184
|
+
try {
|
|
185
|
+
output = await spawnClaudeForTask(task, prompt);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
logger.error({ err, id: task.id, jid: task.jid, elapsed: elapsedLog() }, 'async task claude call failed');
|
|
189
|
+
await initiate({
|
|
190
|
+
jid: task.jid,
|
|
191
|
+
text: `Heads up: the background task "${truncate(task.description, 80)}" failed. Ask me again and I'll retry.`,
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Strip any accidental trailing markers Claude emitted despite instructions.
|
|
196
|
+
// Import lazily to avoid an import cycle (digest-flag already stands alone,
|
|
197
|
+
// but being explicit here keeps this module independent).
|
|
198
|
+
const { extractFlags } = await import('../memory/digest-flag.js');
|
|
199
|
+
const { clean } = extractFlags(output);
|
|
200
|
+
if (!clean.trim()) {
|
|
201
|
+
logger.warn({ id: task.id, jid: task.jid }, 'async task produced empty output after flag strip');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const sent = await initiate({ jid: task.jid, text: clean });
|
|
205
|
+
logger.info({
|
|
206
|
+
id: task.id,
|
|
207
|
+
jid: task.jid,
|
|
208
|
+
sent,
|
|
209
|
+
elapsed: elapsedLog(),
|
|
210
|
+
chars: clean.length,
|
|
211
|
+
}, 'async task completed');
|
|
212
|
+
}
|
|
213
|
+
function truncate(s, n) {
|
|
214
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
215
|
+
}
|