@c4t4/heyamigo 0.7.1 → 0.7.3

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
  }
@@ -10,6 +10,7 @@ import { ensureScaffold } from './store.js';
10
10
  import { getRoleForContext } from '../wa/whitelist.js';
11
11
  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.`;
12
12
  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.`;
13
+ const ASYNC_REMINDER = `BROWSER / SCRAPE / MULTI-STEP RESEARCH = always async. Never call browser tools (browser_navigate, browser_click, browser_take_screenshot, browser_evaluate, any mcp__*playwright*) inline — they block this chat for minutes when pages hang. Instead: send a short ack ("On it, will report back.") AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access and will report back here. Even for "just one URL" — always async.`;
13
14
  function buildCriticalSection(params) {
14
15
  const { senderNumber, roleName, role, userName } = params;
15
16
  const who = userName
@@ -58,10 +59,9 @@ export function buildMemoryPreamble(params) {
58
59
  ' [AUDIO: /absolute/path/to/file.mp3]\n' +
59
60
  ' [DOCUMENT: /absolute/path/to/file.pdf]\n' +
60
61
  'The tag will be stripped from the message. Use absolute paths only.\n\n' +
61
- 'Browser: you have a real Chrome browser available via Playwright.\n' +
62
- 'You can navigate to URLs, click, fill forms, take screenshots, and read page content.\n' +
63
- 'Use the browser tools (mcp__playwright__*) when asked to visit websites, look something up, or take a screenshot.\n' +
64
- 'To send a screenshot back, take one with the browser tool, then include [IMAGE: /path/to/screenshot.png] in your reply.\n\n' +
62
+ 'Browser (Playwright MCP): a real Chrome browser is available for navigation, clicks, forms, screenshots, page content. ' +
63
+ 'DO NOT call browser tools inline from this main chat lane — they block the chat queue for minutes when pages stall (login walls, anti-bot, rate limits). ' +
64
+ 'ALL browser work goes through the async lane. When a request needs browser: send a short ack AND append [ASYNC: <self-sufficient task description>] at the END of your reply. The async worker has full browser access; it will send the result back to this chat as a new message. Even a single URL check goes async. No exceptions.\n\n' +
65
65
  'File storage: if you need to save any files (screenshots, research, notes), always save them to storage/temp/. Never save files to the project root.');
66
66
  // Critical section
67
67
  sections.push(buildCriticalSection({
@@ -124,7 +124,10 @@ export function buildMemoryPreamble(params) {
124
124
  // Journals — owner-scoped, shown globally across all chats.
125
125
  const isOwner = !!config.owner.number && params.senderNumber === config.owner.number;
126
126
  const journalsBlock = isOwner ? buildJournalsPreambleBlock() : null;
127
- const instructions = [DIGEST_REMINDER];
127
+ // ASYNC reminder goes first so it's the most prominent rule — it's the
128
+ // one that prevents the main chat queue from jamming on browser work.
129
+ // The preamble's Capabilities section also reinforces it.
130
+ const instructions = [ASYNC_REMINDER, DIGEST_REMINDER];
128
131
  if (journalsBlock) {
129
132
  sections.push(`[Journals: active]\n${journalsBlock}`);
130
133
  instructions.push(JOURNAL_REMINDER);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",