@c4t4/heyamigo 0.7.2 → 0.7.4

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.
@@ -1,15 +1,64 @@
1
- const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|ASYNC):\s*([^\]]+)\]\s*$/i;
1
+ // Trailing-marker parser. Handles tags like [DIGEST: ...], [JOURNAL:...],
2
+ // [JOURNAL-NEW:...], [ASYNC: ...] at the end of a reply.
3
+ //
4
+ // Uses a bracket-depth walk rather than a regex because payloads routinely
5
+ // contain nested square brackets (e.g. [ASYNC: read a [config] file] or
6
+ // [DIGEST: noted the [JOURNAL:x] pattern]). A naive /\[.*?\]/-style regex
7
+ // terminates at the FIRST inner `]` and either misidentifies the tag or
8
+ // drops it entirely. That bug leaked two real markers into user-facing
9
+ // replies today (DIGEST ~morning, ASYNC later). This parser closes that
10
+ // whole class of failure.
11
+ const KINDS = ['DIGEST', 'JOURNAL', 'JOURNAL-NEW', 'ASYNC'];
12
+ // Walk backwards from the end of the string, tracking bracket depth, to find
13
+ // the `[` that matches the final `]`. Returns the tag kind, its payload, and
14
+ // everything before the tag. Returns null if the tail doesn't cleanly look
15
+ // like a supported tag — caller should stop peeling.
16
+ function peelTrailingTag(raw) {
17
+ const trimmed = raw.replace(/\s+$/, '');
18
+ if (trimmed.length === 0)
19
+ return null;
20
+ if (trimmed[trimmed.length - 1] !== ']')
21
+ return null;
22
+ // Walk right-to-left counting depth. `]` pushes, `[` pops. The opening `[`
23
+ // that brings depth back to 0 is the match for the trailing `]`.
24
+ let depth = 0;
25
+ let openIdx = -1;
26
+ for (let i = trimmed.length - 1; i >= 0; i--) {
27
+ const c = trimmed[i];
28
+ if (c === ']') {
29
+ depth++;
30
+ }
31
+ else if (c === '[') {
32
+ depth--;
33
+ if (depth === 0) {
34
+ openIdx = i;
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ if (openIdx < 0)
40
+ return null; // unbalanced — don't try to interpret
41
+ const inside = trimmed.slice(openIdx + 1, trimmed.length - 1);
42
+ const colonIdx = inside.indexOf(':');
43
+ if (colonIdx < 0)
44
+ return null;
45
+ const tagCandidate = inside.slice(0, colonIdx).trim().toUpperCase();
46
+ if (!KINDS.includes(tagCandidate))
47
+ return null;
48
+ const payload = inside.slice(colonIdx + 1).trim();
49
+ const remaining = trimmed.slice(0, openIdx).replace(/\s+$/, '');
50
+ return { kind: tagCandidate, payload, remaining };
51
+ }
2
52
  // Peel trailing tags off the end of a reply. Supported:
3
53
  // [DIGEST: <reason>]
4
54
  // [JOURNAL:<slug> — <note>] (append entry)
5
55
  // [JOURNAL-NEW:<slug> — <purpose>] (create journal)
6
56
  // [ASYNC: <self-sufficient task description>]
7
- // Multiple tags supported, any order at the tail. Tags must be the LAST
57
+ // Multiple tags supported in any order at the tail. Tags must be the LAST
8
58
  // thing in the reply (after trimming trailing whitespace).
9
59
  //
10
- // Journal pause/resume/archive is intentionally NOT a marker. If the owner
11
- // wants those, Claude edits the journal's index.md frontmatter directly.
12
- // Keeping the marker vocabulary small keeps Claude's context tight.
60
+ // Payload can contain arbitrary characters including `[` and `]` as long as
61
+ // the brackets are balanced within the payload.
13
62
  export function extractFlags(reply) {
14
63
  let current = reply;
15
64
  let digest = null;
@@ -17,14 +66,13 @@ export function extractFlags(reply) {
17
66
  const journalCreates = [];
18
67
  const asyncTasks = [];
19
68
  while (true) {
20
- const trimmed = current.replace(/\s+$/, '');
21
- const match = trimmed.match(TRAILING_TAG_RE);
22
- if (!match)
69
+ const peeled = peelTrailingTag(current);
70
+ if (!peeled)
23
71
  break;
24
- const kind = match[1].toUpperCase();
25
- const payload = (match[2] ?? '').trim();
72
+ const { kind, payload, remaining } = peeled;
73
+ current = remaining;
26
74
  if (kind === 'DIGEST') {
27
- if (digest === null)
75
+ if (digest === null && payload.length > 0)
28
76
  digest = payload;
29
77
  }
30
78
  else if (kind === 'JOURNAL') {
@@ -43,7 +91,6 @@ export function extractFlags(reply) {
43
91
  asyncTasks.unshift({ description: payload });
44
92
  }
45
93
  }
46
- current = trimmed.slice(0, match.index).trimEnd();
47
94
  }
48
95
  return { clean: current, digest, journals, journalCreates, asyncTasks };
49
96
  }
@@ -70,7 +70,7 @@ export function reloadAsyncSystemPrompt() {
70
70
  }
71
71
  function buildPrompt(task) {
72
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.`,
73
+ `You are a BACKGROUND WORKER. The chat already got its ack ("on it, will report back"). Your output does NOT go to chat by default it routes through markers to memory files, same way the main chat Claude routes things.`,
74
74
  ``,
75
75
  `TASK:`,
76
76
  task.description,
@@ -80,15 +80,27 @@ function buildPrompt(task) {
80
80
  ``,
81
81
  `Sender: ${task.senderName ?? task.senderNumber}`,
82
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.`,
83
+ `HOW TO ROUTE YOUR FINDINGS (markers, at the END of your output, one per line):`,
84
+ `- [JOURNAL:<slug> <one-line finding>] for each distinct finding that belongs in a journal. ONE marker per finding — ten findings = ten markers, not one long paragraph. Only use slugs that already exist (check the Journals list in your preamble), or emit [JOURNAL-NEW:<slug> <purpose>] first to create one in the same output.`,
85
+ `- [JOURNAL-NEW:<slug> — <one-line purpose>] to create a new journal when the task clearly needs tracking but no journal covers it yet. Propose the slug yourself, conservatively.`,
86
+ `- [DIGEST: <one-line reason>] if you learned something durable about the owner or chat that should update the profile/brief.`,
90
87
  ``,
91
- `Output the final user-facing message now.`,
88
+ `CONSTRAINTS:`,
89
+ `- Do NOT emit [ASYNC:...]. No recursive delegation.`,
90
+ `- Do NOT frame your output as a chat message. No "here's what I found:", no "About the task:". The markers ARE the output.`,
91
+ `- Keep any pre-marker text SHORT — one sentence max, or empty. Long pre-marker prose is suppressed and not sent to chat. Put the real content inside markers.`,
92
+ `- If the task failed or the tools didn't produce a usable result (login wall, empty page, bot-detection, timeout), output a short clean message (no markers) explaining what happened. That short text IS sent to chat so the owner knows. Do not fabricate findings.`,
93
+ `- Stay fully in character.`,
94
+ ``,
95
+ `EXAMPLE for an IG scrape task:`,
96
+ `[JOURNAL:rivoara-spy — IG bio: "Premium shower filter for HT aftercare"]`,
97
+ `[JOURNAL:rivoara-spy — IG post: day-5 routine angle live]`,
98
+ `[JOURNAL:rivoara-spy — IG post: Turkey clinic partnership visible in post 3]`,
99
+ ``,
100
+ `EXAMPLE for a failure:`,
101
+ `Instagram hit login wall on @rivoara_official after 2 navigation attempts. No public data accessible. Auth needs refreshing.`,
102
+ ``,
103
+ `Do the work now. Then emit your markers.`,
92
104
  ];
93
105
  return lines.join('\n');
94
106
  }
@@ -161,24 +173,101 @@ async function runTask(task) {
161
173
  });
162
174
  return;
163
175
  }
164
- // Strip any accidental trailing markers Claude emitted despite instructions.
165
- // Import lazily to avoid an import cycle (digest-flag already stands alone,
166
- // but being explicit here keeps this module independent).
176
+ // Parse markers from the worker's output and route them through the same
177
+ // handlers the main chat uses. The async worker's job is to emit findings
178
+ // as markers; clean pre-marker text is only sent to chat when short (a
179
+ // failure explanation or tight ack) or when no markers fired at all.
167
180
  const { extractFlags } = await import('../memory/digest-flag.js');
168
- const { clean } = extractFlags(output);
169
- if (!clean.trim()) {
170
- logger.warn({ id: task.id, jid: task.jid }, 'async task produced empty output after flag strip');
171
- return;
181
+ const { clean, digest, journals, journalCreates } = extractFlags(output);
182
+ // Journal creates run first so an entry flagged in the same output against
183
+ // a new slug lands correctly.
184
+ const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
185
+ for (const op of journalCreates) {
186
+ if (!isValidSlug(op.slug)) {
187
+ logger.warn({ op, id: task.id }, 'async JOURNAL-NEW: invalid slug, dropped');
188
+ continue;
189
+ }
190
+ if (getJournal(op.slug))
191
+ continue;
192
+ try {
193
+ createJournal({
194
+ slug: op.slug,
195
+ name: titleCaseSlug(op.slug),
196
+ purpose: op.purpose,
197
+ });
198
+ logger.info({ slug: op.slug, id: task.id }, 'journal created via async marker');
199
+ }
200
+ catch (err) {
201
+ logger.error({ err, op, id: task.id }, 'async JOURNAL-NEW failed');
202
+ }
203
+ }
204
+ let appendedCount = 0;
205
+ for (const j of journals) {
206
+ const ok = appendEntry(j.slug, {
207
+ source: 'async',
208
+ jid: task.jid,
209
+ senderNumber: task.senderNumber,
210
+ note: j.note,
211
+ });
212
+ if (ok)
213
+ appendedCount++;
214
+ else {
215
+ logger.warn({ slug: j.slug, id: task.id }, 'async JOURNAL marker pointed at unknown slug, dropped');
216
+ }
217
+ }
218
+ if (digest) {
219
+ const { scheduleDigest } = await import('../memory/scheduler.js');
220
+ scheduleDigest({
221
+ jid: task.jid,
222
+ number: task.senderNumber,
223
+ reason: digest,
224
+ });
225
+ }
226
+ // Decide what to send to chat.
227
+ const leftover = clean.trim();
228
+ const anyMarkerFired = appendedCount > 0 || journalCreates.length > 0 || digest !== null;
229
+ let chatText = null;
230
+ if (!anyMarkerFired) {
231
+ // No markers — fall back to sending the output as a chat message so the
232
+ // owner isn't left with silence. Covers both "Claude ignored the marker
233
+ // rule" and legitimate "short failure explanation" cases.
234
+ chatText = leftover || null;
235
+ }
236
+ else if (leftover.length > 0 && leftover.length <= 400) {
237
+ // Markers fired AND a short pre-marker line — likely an intentional
238
+ // failure explanation or completion note. Send it.
239
+ chatText = leftover;
240
+ }
241
+ else if (leftover.length > 400) {
242
+ // Long pre-marker prose despite markers firing — Claude didn't follow
243
+ // the routing contract. Suppress the prose; log for inspection.
244
+ logger.warn({
245
+ id: task.id,
246
+ jid: task.jid,
247
+ chars: leftover.length,
248
+ }, 'async task produced long pre-marker prose, suppressing chat send');
249
+ }
250
+ // Otherwise: markers fired, no leftover — success, silent. Findings live
251
+ // in the journal files now.
252
+ if (chatText) {
253
+ await initiate({ jid: task.jid, text: chatText });
172
254
  }
173
- const sent = await initiate({ jid: task.jid, text: clean });
174
255
  logger.info({
175
256
  id: task.id,
176
257
  jid: task.jid,
177
- sent,
178
258
  elapsed: elapsedLog(),
179
- chars: clean.length,
259
+ appended: appendedCount,
260
+ createdJournals: journalCreates.length,
261
+ digestFired: !!digest,
262
+ chatSent: chatText ? chatText.length : 0,
180
263
  }, 'async task completed');
181
264
  }
265
+ function titleCaseSlug(slug) {
266
+ return slug
267
+ .split('-')
268
+ .map((p) => (p ? p[0].toUpperCase() + p.slice(1) : p))
269
+ .join(' ');
270
+ }
182
271
  function truncate(s, n) {
183
272
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
184
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",