@c4t4/heyamigo 0.10.3 → 0.10.5

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/README.md CHANGED
@@ -60,7 +60,7 @@ Other providers:
60
60
  | Role | Memory | Tools | Notes |
61
61
  |---|---|---|---|
62
62
  | admin | everything | all | unrestricted |
63
- | user | own profile | web search | can't see other users or internals |
63
+ | user | own profile | none | can't see other users or internals |
64
64
  | guest | none | none | prompt-injection resistant |
65
65
 
66
66
  ## Personalities
@@ -13,9 +13,9 @@
13
13
  "dailyTokenLimit": null
14
14
  },
15
15
  "user": {
16
- "description": "Can chat and search the web, scoped memory",
16
+ "description": "Can chat with scoped memory",
17
17
  "memory": "self",
18
- "tools": ["WebSearch"],
18
+ "tools": [],
19
19
  "rules": [
20
20
  "Never reveal file paths, directory structure, or system architecture",
21
21
  "Never share personal data about other users",
@@ -27,6 +27,20 @@ Relevant blocks appear in `[State]`, `[Map]`, `[Trees]`, `[Entities]`, `[Journal
27
27
 
28
28
  The system auto-suffixes a stats line (duration, tokens, ctx %). Do NOT write or mimic it. No `_stats_` italic footers.
29
29
 
30
+ ## Core queue contract
31
+
32
+ Final reply is the control surface. Tags queue work, memory, schedules, threads, or media.
33
+ Files/browser work are async. No tag = no side effect.
34
+
35
+ ## Core tag reference
36
+
37
+ Common tags:
38
+ - Work: `[ASYNC: task]`, `[ASYNC-BROWSER: task]`
39
+ - Media: `[IMAGE|VIDEO|AUDIO|DOCUMENT: /absolute/path]`
40
+ - Memory: `[DIGEST: reason]`, `[JOURNAL:slug - note]`, `[JOURNAL-NEW:slug - purpose]`
41
+ - Time: `[REMIND: YYYY-MM-DD HH:MM - text]`, `[CRON: expr SAY|PROMPT|ASYNC|BROWSER - body]`
42
+ - Threads: `THREAD-*` for active open loops shown in `[Live threads]`. Full grammar below.
43
+
30
44
  ## DIGEST
31
45
 
32
46
  Append `[DIGEST: <one-line reason>]` at END of reply when something is worth durable storage: new preference, key life/work fact, relationship/context shift, decision future replies should respect. Stripped before send. Sparingly — a few times per week.
@@ -114,18 +128,20 @@ You = chat track. Browser track = parallel Claude session on shared Chrome at `l
114
128
 
115
129
  Never call `browser_*` / `mcp__*playwright*` inline. Ever. Single URL, "just checking", everything — all via `[ASYNC-BROWSER: <task>]`. The browser worker has persistent session memory.
116
130
 
131
+ Never use AI-internal web tools (`WebSearch`, `WebFetch`, `web_search`) for internet lookup. Search pages, current facts, prices, social profiles, websites, and screenshots are BrowserUse work via `[ASYNC-BROWSER: <task>]`. If BrowserUse is unavailable, say that; do not fall back to AI-internal lookup.
132
+
117
133
  ```
118
134
  On it.
119
135
  [ASYNC-BROWSER: Open instagram.com/rivoara_official on shared Chrome (IG already logged in, do NOT launch new browser). Extract bio + 5 latest captions. If login wall, report and stop. Bail after 3 retries.]
120
136
  ```
121
137
 
122
- ### Non-browser long work → `[ASYNC: <task>]`
138
+ ### File/long non-browser work → `[ASYNC: <task>]`
123
139
 
124
- For >30s reasoning over many files, web_search batches, anything slow. Stateless per task — describe fully.
140
+ File generation/edit/export, >30s reasoning over many files, anything slow that is not browser use. Stateless per task — describe fully.
125
141
 
126
142
  ### Don't delegate
127
143
 
128
- Answerable from your context / memory / `[State]` / recent entries. Short reasoning. Immediate questions. Single quick non-browser tool calls.
144
+ Answerable from your context / memory / `[State]` / recent entries. Short reasoning. Immediate questions. Single quick non-browser tool calls. No browser/file-generation work here.
129
145
 
130
146
  ### Task description rules
131
147
 
@@ -145,7 +161,9 @@ If `[Async running — do NOT re-emit for these]` appears in your preamble, a wo
145
161
  [FILE: /absolute/path] | [IMAGE: ...] | [VIDEO: ...] | [AUDIO: ...] | [DOCUMENT: ...]
146
162
  ```
147
163
 
148
- Save to `storage/outbox/` (auto-deleted after send). Absolute paths only. Media type from extension. Single-file + short text (<1000 chars, non-audio) → text becomes caption.
164
+ Save to `storage/outbox/` (auto-deleted after send). Absolute paths under `storage/outbox/` only. Media type from extension. Single-file + short text (<1000 chars, non-audio) → text becomes caption.
165
+
166
+ Media tags deliver files that already exist. Main chat delegates file generation/edit/export with `[ASYNC: ...]`; the follow-up worker saves final files under `storage/outbox/` and emits one media tag per final file. If the requested file could not be produced, say that explicitly instead of implying delivery.
149
167
 
150
168
  ## Scheduling
151
169
 
package/dist/cli/setup.js CHANGED
@@ -231,9 +231,9 @@ export async function runSetup() {
231
231
  rules: [],
232
232
  },
233
233
  user: {
234
- description: 'Can chat and search the web, scoped memory',
234
+ description: 'Can chat with scoped memory',
235
235
  memory: 'self',
236
- tools: ['WebSearch'],
236
+ tools: [],
237
237
  rules: [
238
238
  'Never reveal file paths, directory structure, or system architecture',
239
239
  'Never share personal data about other users',
@@ -312,10 +312,10 @@ export async function runSetup() {
312
312
  }
313
313
  p.log.success('Claude authenticated');
314
314
  // Tool permissions — write .claude/settings.json in project root.
315
- p.log.info('Claude needs tool permissions to browse the web, read files, and control the browser. ' +
315
+ p.log.info('Claude needs tool permissions to read files and control BrowserUse. ' +
316
316
  'This writes a .claude/settings.json file in the project directory.');
317
317
  const grantPermissions = await p.confirm({
318
- message: 'Grant tool permissions? (WebFetch, WebSearch, Read, Edit, Write, browser)',
318
+ message: 'Grant tool permissions? (Read, Edit, Write, BrowserUse)',
319
319
  initialValue: true,
320
320
  });
321
321
  if (p.isCancel(grantPermissions) || !grantPermissions) {
@@ -335,8 +335,6 @@ export async function runSetup() {
335
335
  ? permissions.allow
336
336
  : [];
337
337
  const required = [
338
- 'WebFetch',
339
- 'WebSearch',
340
338
  'Read',
341
339
  'Edit',
342
340
  'Write',
@@ -1,4 +1,5 @@
1
1
  import { unlink } from 'fs/promises';
2
+ import { resolve } from 'path';
2
3
  import { getProvider } from '../ai/providers.js';
3
4
  import { getSession } from '../ai/sessions.js';
4
5
  import { config } from '../config.js';
@@ -40,6 +41,15 @@ function enqueueTextReply(incoming, text, idempotencyKey) {
40
41
  idempotencyKey,
41
42
  });
42
43
  }
44
+ function buildImageGenRoutingContract() {
45
+ const outboxPath = resolve('storage/outbox');
46
+ return [
47
+ '[Image generation routing]',
48
+ 'This turn is classified as image/file generation.',
49
+ 'Do not perform file work in this foreground reply.',
50
+ `Reply briefly and emit [ASYNC: Generate the requested image using current chat context. Save final files under ${outboxPath}/. Follow-up reply must include one [IMAGE: /absolute/path] tag per final image, or say: Image job failed before producing a file.]`,
51
+ ].join('\n');
52
+ }
43
53
  export async function processIncomingMessage(incoming, opts = {}) {
44
54
  const stored = toStored(incoming);
45
55
  const ageMs = Date.now() - stored.timestamp * 1000;
@@ -181,7 +191,20 @@ export async function processIncomingMessage(incoming, opts = {}) {
181
191
  chat,
182
192
  });
183
193
  }
184
- const input = `${memoryPreamble}\n\n---\n\n${core}`;
194
+ const personId = personIdForAddress(incoming.address);
195
+ const actorPersonId = incoming.actorAddress
196
+ ? personIdForAddress(incoming.actorAddress)
197
+ : null;
198
+ const est = estimateJob({
199
+ description: stored.text,
200
+ attachments: media ? [{ kind: media.mediaType }] : undefined,
201
+ senderPersonId: actorPersonId ?? undefined,
202
+ });
203
+ const jobKind = est?.kind ?? null;
204
+ let input = `${memoryPreamble}\n\n---\n\n${core}`;
205
+ if (est?.kind === 'image-gen') {
206
+ input = `${input}\n\n---\n\n${buildImageGenRoutingContract()}`;
207
+ }
185
208
  logger.info({ ...logCtx, resume: !!existingSession, trigger: triggerReason }, 'message captured, enqueuing');
186
209
  const job = {
187
210
  jid: stored.jid,
@@ -195,16 +218,6 @@ export async function processIncomingMessage(incoming, opts = {}) {
195
218
  allowedTools: role.tools,
196
219
  allowedTags: role.tags,
197
220
  };
198
- const personId = personIdForAddress(incoming.address);
199
- const actorPersonId = incoming.actorAddress
200
- ? personIdForAddress(incoming.actorAddress)
201
- : null;
202
- const est = estimateJob({
203
- description: stored.text,
204
- attachments: media ? [{ kind: media.mediaType }] : undefined,
205
- senderPersonId: actorPersonId ?? undefined,
206
- });
207
- const jobKind = est?.kind ?? null;
208
221
  if (est) {
209
222
  enqueueOutbound({
210
223
  address: incoming.address,
@@ -1,5 +1,5 @@
1
- import { existsSync, statSync } from 'fs';
2
- import { extname } from 'path';
1
+ import { existsSync, realpathSync, statSync } from 'fs';
2
+ import { extname, isAbsolute, relative, resolve } from 'path';
3
3
  import { config } from '../config.js';
4
4
  import { formatAddress, jidToAddress } from '../db/address.js';
5
5
  import { logger } from '../logger.js';
@@ -8,16 +8,50 @@ import { enqueueOutbound } from '../queue/outbound.js';
8
8
  import { detectMediaType } from '../wa/sender.js';
9
9
  // Matches [FILE: path], [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path]
10
10
  const FILE_TAG_RE = /\[(?:FILE|IMAGE|VIDEO|AUDIO|DOCUMENT):\s*([^\]]+)\]/gi;
11
+ function isInsideDir(parent, child) {
12
+ const rel = relative(parent, child);
13
+ return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel));
14
+ }
15
+ function outboxDir() {
16
+ const path = resolve(process.cwd(), 'storage/outbox');
17
+ return existsSync(path) ? realpathSync(path) : path;
18
+ }
19
+ function resolveSendableFile(path) {
20
+ const trimmed = path.trim();
21
+ if (!isAbsolute(trimmed)) {
22
+ logger.warn({ path: trimmed }, 'file tag ignored: path must be absolute');
23
+ return null;
24
+ }
25
+ if (!existsSync(trimmed)) {
26
+ logger.warn({ path: trimmed }, 'file path not found, skipping');
27
+ return null;
28
+ }
29
+ let realFile;
30
+ try {
31
+ const stat = statSync(trimmed);
32
+ if (!stat.isFile()) {
33
+ logger.warn({ path: trimmed }, 'file tag ignored: path is not a file');
34
+ return null;
35
+ }
36
+ realFile = realpathSync(trimmed);
37
+ }
38
+ catch (err) {
39
+ logger.warn({ err, path: trimmed }, 'file tag ignored: path unreadable');
40
+ return null;
41
+ }
42
+ const outbox = outboxDir();
43
+ if (!isInsideDir(outbox, realFile)) {
44
+ logger.warn({ path: trimmed, outbox }, 'file tag ignored: path is outside storage/outbox');
45
+ return null;
46
+ }
47
+ return realFile;
48
+ }
11
49
  function extractFiles(reply) {
12
50
  const files = [];
13
51
  const text = reply.replace(FILE_TAG_RE, (_, path) => {
14
- const trimmed = path.trim();
15
- if (existsSync(trimmed)) {
16
- files.push(trimmed);
17
- }
18
- else {
19
- logger.warn({ path: trimmed }, 'file path not found, skipping');
20
- }
52
+ const resolved = resolveSendableFile(path);
53
+ if (resolved)
54
+ files.push(resolved);
21
55
  return '';
22
56
  }).trim();
23
57
  return { text, files };
@@ -50,17 +84,56 @@ function fileSize(filePath) {
50
84
  return undefined;
51
85
  }
52
86
  }
87
+ function hasSideEffects(stats) {
88
+ if (!stats)
89
+ return false;
90
+ return (stats.hasDigest ||
91
+ stats.journalSlugs.length > 0 ||
92
+ stats.journalCreateCount > 0 ||
93
+ stats.asyncCount > 0 ||
94
+ stats.asyncBrowserCount > 0 ||
95
+ stats.remindCount > 0 ||
96
+ stats.cronCount > 0 ||
97
+ stats.sendTextCount > 0 ||
98
+ stats.threadNewCount > 0 ||
99
+ stats.threadResolveCount > 0 ||
100
+ stats.threadDropCount > 0 ||
101
+ stats.threadCompressCount > 0 ||
102
+ stats.threadTouchCount > 0);
103
+ }
53
104
  // `originalMsg` is currently ignored when routing through the outbound
54
105
  // queue — Baileys quoting needs the full WAMessage embedded in
55
106
  // contextInfo, which we'd have to serialize through the DB row and
56
107
  // reconstruct. Known regression for Phase 1; see refactor-scrap.md.
57
108
  // Kept in the signature so existing callers don't change.
58
109
  export async function handleReply(job, result, _originalMsg) {
59
- const raw = result.reply?.replaceAll('—', ', ').replaceAll('–', '-');
60
- if (!raw)
110
+ const raw = result.reply?.replaceAll('—', ', ').replaceAll('–', '-').trim();
111
+ const address = addressForJob(job);
112
+ if (!raw) {
113
+ const footer = result.stats && config.reply.showStats
114
+ ? formatStatsFooter(result.stats)
115
+ : '';
116
+ const text = hasSideEffects(result.stats) || (result.jobCards?.length ?? 0) > 0
117
+ ? 'Done.'
118
+ : config.reply.errorMessage;
119
+ enqueueOutbound({
120
+ address,
121
+ kind: 'text',
122
+ text: footer ? `${text}\n\n${footer}` : text,
123
+ idempotencyKey: `reply-empty-${job.jid}-${Date.now()}`,
124
+ });
125
+ for (const card of result.jobCards ?? []) {
126
+ enqueueOutbound({
127
+ address,
128
+ kind: 'text',
129
+ text: card.text,
130
+ idempotencyKey: card.idempotencyKey,
131
+ });
132
+ }
133
+ logger.warn({ jid: job.jid, cards: result.jobCards?.length ?? 0 }, 'empty reply converted to fallback outbound');
61
134
  return;
135
+ }
62
136
  const { text, files } = extractFiles(raw);
63
- const address = addressForJob(job);
64
137
  // Surface media tags in the footer too. Files already parsed above
65
138
  // — just map each to its kind so the footer reads e.g. "+2 image".
66
139
  const mediaKinds = files.map(kindForFile);
@@ -2,7 +2,7 @@ import { getProvider } from '../ai/providers.js';
2
2
  import { config } from '../config.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { readLast } from '../store/messages.js';
5
- import { appendEntry, getLastScannedTs, getJournal, readEntries, setLastScannedTs, } from './journals.js';
5
+ import { appendEntry, getLastScannedTs, getJournal, normalizeTimestamp, readEntries, setLastScannedTs, } from './journals.js';
6
6
  // How many recent messages to include in the scan window on each sweep.
7
7
  // Observer runs every memory.sweepIntervalMs (default 3h), so this window
8
8
  // must cover at least that much chat activity to avoid gaps.
@@ -19,13 +19,21 @@ async function spawnObserver(prompt) {
19
19
  return reply;
20
20
  }
21
21
  function formatMsg(m) {
22
- const date = new Date(m.timestamp * 1000)
23
- .toISOString()
24
- .slice(0, 16)
25
- .replace('T', ' ');
22
+ const ts = normalizeMessageTs(m);
23
+ const date = ts === null ? 'unknown-time' : formatTs(ts);
26
24
  const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
27
25
  return `[${m.timestamp}] ${who} (${date}): ${m.text}`;
28
26
  }
27
+ function formatTs(ts) {
28
+ const date = new Date(ts * 1000);
29
+ if (!Number.isFinite(ts) || Number.isNaN(date.getTime())) {
30
+ return 'unknown-time';
31
+ }
32
+ return date.toISOString().slice(0, 16).replace('T', ' ');
33
+ }
34
+ function normalizeMessageTs(m) {
35
+ return normalizeTimestamp(m.timestamp);
36
+ }
29
37
  function buildPrompt(params) {
30
38
  const { journal, recentEntries, messages } = params;
31
39
  const lines = [
@@ -42,8 +50,7 @@ function buildPrompt(params) {
42
50
  recentEntries.length
43
51
  ? recentEntries
44
52
  .map((e) => {
45
- const d = new Date(e.ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
46
- return `- [${d}] ${e.note}`;
53
+ return `- [${formatTs(e.ts)}] ${e.note}`;
47
54
  })
48
55
  .join('\n')
49
56
  : '(none yet)',
@@ -107,7 +114,10 @@ export async function runJournalObserverForJid(params) {
107
114
  }
108
115
  const since = getLastScannedTs(slug, jid);
109
116
  const recent = await readLast(jid, SCAN_WINDOW);
110
- const newMessages = recent.filter((m) => m.timestamp > since);
117
+ const newMessages = recent.filter((m) => {
118
+ const ts = normalizeMessageTs(m);
119
+ return ts !== null && ts > since;
120
+ });
111
121
  if (newMessages.length === 0) {
112
122
  return { appended: 0, scanned: 0 };
113
123
  }
@@ -136,7 +146,9 @@ export async function runJournalObserverForJid(params) {
136
146
  note: e.note,
137
147
  });
138
148
  }
139
- const maxTs = newMessages[newMessages.length - 1].timestamp;
149
+ const maxTs = Math.max(...newMessages
150
+ .map(normalizeMessageTs)
151
+ .filter((ts) => ts !== null));
140
152
  setLastScannedTs(slug, jid, maxTs);
141
153
  logger.info({ slug, jid, scanned: newMessages.length, appended: entries.length }, 'journal observer pass complete');
142
154
  return { appended: entries.length, scanned: newMessages.length };
@@ -3,6 +3,12 @@ import { dirname, resolve } from 'path';
3
3
  import { logger } from '../logger.js';
4
4
  import { parseFrontmatter, serializeFrontmatter, } from './frontmatter.js';
5
5
  import { memoryRoot } from './paths.js';
6
+ const JOURNAL_SOURCES = new Set([
7
+ 'reactive',
8
+ 'observer',
9
+ 'manual',
10
+ 'async',
11
+ ]);
6
12
  // ---------- paths ----------
7
13
  function journalsRoot() {
8
14
  return resolve(memoryRoot(), 'journals');
@@ -177,14 +183,72 @@ export function updateJournalStatus(slug, status) {
177
183
  return j;
178
184
  }
179
185
  // ---------- entries ----------
186
+ export function normalizeTimestamp(value) {
187
+ if (typeof value === 'number') {
188
+ return Number.isFinite(value) ? Math.floor(value) : null;
189
+ }
190
+ if (typeof value !== 'string')
191
+ return null;
192
+ const trimmed = value.trim();
193
+ if (!trimmed)
194
+ return null;
195
+ if (/^\d+$/.test(trimmed)) {
196
+ const n = Number(trimmed);
197
+ return Number.isFinite(n) ? Math.floor(n) : null;
198
+ }
199
+ const parsed = Date.parse(trimmed);
200
+ if (!Number.isFinite(parsed))
201
+ return null;
202
+ return Math.floor(parsed / 1000);
203
+ }
204
+ function normalizeSource(value) {
205
+ return typeof value === 'string' && JOURNAL_SOURCES.has(value)
206
+ ? value
207
+ : 'manual';
208
+ }
209
+ function normalizeEntry(raw, slug, lineNumber) {
210
+ if (!raw || typeof raw !== 'object')
211
+ return null;
212
+ const obj = raw;
213
+ const ts = normalizeTimestamp(obj.ts);
214
+ if (ts === null) {
215
+ logger.warn({ slug, lineNumber, ts: obj.ts }, 'journal entry skipped: invalid timestamp');
216
+ return null;
217
+ }
218
+ const note = typeof obj.note === 'string' && obj.note.trim()
219
+ ? obj.note.trim()
220
+ : typeof obj.title === 'string' && obj.title.trim()
221
+ ? obj.title.trim()
222
+ : typeof obj.summary === 'string' && obj.summary.trim()
223
+ ? obj.summary.trim()
224
+ : null;
225
+ if (!note) {
226
+ logger.warn({ slug, lineNumber }, 'journal entry skipped: missing note/title/summary');
227
+ return null;
228
+ }
229
+ return {
230
+ ts,
231
+ source: normalizeSource(obj.source),
232
+ jid: typeof obj.jid === 'string' ? obj.jid : undefined,
233
+ senderNumber: typeof obj.senderNumber === 'string' ? obj.senderNumber : undefined,
234
+ note,
235
+ };
236
+ }
180
237
  export function appendEntry(slug, entry) {
181
238
  if (!journalExists(slug)) {
182
239
  logger.warn({ slug }, 'journal append ignored: unknown slug');
183
240
  return false;
184
241
  }
242
+ const ts = entry.ts === undefined
243
+ ? Math.floor(Date.now() / 1000)
244
+ : normalizeTimestamp(entry.ts);
245
+ if (ts === null) {
246
+ logger.warn({ slug, ts: entry.ts }, 'journal append ignored: invalid timestamp');
247
+ return false;
248
+ }
185
249
  const full = {
186
- ts: entry.ts ?? Math.floor(Date.now() / 1000),
187
- source: entry.source,
250
+ ts,
251
+ source: normalizeSource(entry.source),
188
252
  jid: entry.jid,
189
253
  senderNumber: entry.senderNumber,
190
254
  note: entry.note,
@@ -200,11 +264,15 @@ export function readEntries(slug, limit = 100) {
200
264
  if (!raw)
201
265
  return [];
202
266
  const lines = raw.trim().split(/\r?\n/).filter(Boolean);
203
- const tail = limit > 0 ? lines.slice(-limit) : lines;
267
+ const startIdx = limit > 0 ? Math.max(0, lines.length - limit) : 0;
268
+ const tail = lines.slice(startIdx);
204
269
  const out = [];
205
- for (const line of tail) {
270
+ for (let i = 0; i < tail.length; i++) {
271
+ const line = tail[i];
206
272
  try {
207
- out.push(JSON.parse(line));
273
+ const entry = normalizeEntry(JSON.parse(line), slug, startIdx + i + 1);
274
+ if (entry)
275
+ out.push(entry);
208
276
  }
209
277
  catch {
210
278
  // skip malformed line
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
+ import { resolve } from 'path';
2
3
  import { config } from '../config.js';
3
4
  import { getTimezoneForSenderNumber } from '../db/identity-sync.js';
4
5
  import { listAsyncTasks } from '../queue/async-tasks.js';
@@ -14,8 +15,22 @@ import { getRoleForContext } from '../wa/whitelist.js';
14
15
  // pointers — the model already has the long form.
15
16
  const DIGEST_REMINDER = `[DIGEST: <reason>] at end of reply for durable facts. Sparingly.`;
16
17
  const JOURNAL_REMINDER = `[JOURNAL:<slug> — <note>] at end of reply when content fits an active journal. Use listed slugs only.`;
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.`;
18
+ const ASYNC_REMINDER = `Browser use/search/current web -> [ASYNC-BROWSER: <task>]. Never WebSearch/WebFetch. File generation/edit/export and long non-browser work -> [ASYNC: <task>]. Irreversible writes: gather -> confirm -> act.`;
19
+ const THREADS_REMINDER = `THREAD-* only for active open loops shown in [Live threads]: open/update/touch/cool/resolve/drop/compress/weight. Full grammar in tag docs.`;
20
+ function buildCoreQueueContract(outboxPath) {
21
+ return [
22
+ '[Core queue contract]',
23
+ 'Final reply is the control surface. Tags queue work, memory, schedules, threads, or media.',
24
+ 'Files/browser work are async. No tag = no side effect.',
25
+ '',
26
+ '[Core tag reference]',
27
+ 'Work: [ASYNC: task], [ASYNC-BROWSER: task]',
28
+ `Media: [IMAGE|VIDEO|AUDIO|DOCUMENT: /absolute/path] from ${outboxPath}/`,
29
+ 'Memory: [DIGEST: reason], [JOURNAL:slug - note], [JOURNAL-NEW:slug - purpose]',
30
+ 'Time: [REMIND: YYYY-MM-DD HH:MM - text], [CRON: expr SAY|PROMPT|ASYNC|BROWSER - body]',
31
+ 'Threads: THREAD-* for active open loops shown in [Live threads]. Full grammar in tag docs.',
32
+ ].join('\n');
33
+ }
19
34
  // Buildable per-turn so the agent always sees the SENDER's current
20
35
  // time. Grammar reference is in cached memory-instructions.md;
21
36
  // this is just the live time + format pointer.
@@ -55,10 +70,8 @@ export function buildMemoryPreamble(params) {
55
70
  sections.push(`[Identity] ${botName}. Stay in character (voice defined in system prompt).`);
56
71
  // Time — owner-tz timestamp, no exhortations
57
72
  sections.push(`[Time] ${buildTimeLine(config.owner.timezone)}`);
58
- // Capabilitiestag list only. Rules/rationale are in system prompt.
59
- sections.push('[Caps] Send files: [IMAGE|VIDEO|AUDIO|DOCUMENT: /abs/path]. ' +
60
- 'Output dir storage/outbox/ (auto-cleaned), scratch storage/temp/. ' +
61
- 'Browser → [ASYNC-BROWSER: <task>]. Long non-browser work → [ASYNC: <task>].');
73
+ // Core tag contract this is the side-effect API for the queued app.
74
+ sections.push(buildCoreQueueContract(resolve('storage/outbox')));
62
75
  // Sender + role (+ FORBIDDEN rules for non-admin)
63
76
  sections.push(buildCriticalSection({
64
77
  senderNumber: params.senderNumber,
@@ -125,7 +138,7 @@ export function buildMemoryPreamble(params) {
125
138
  instructions.push(JOURNAL_REMINDER);
126
139
  }
127
140
  // Scheduling reminder — tells the agent the current local time in
128
- // the SENDER's timezone + lists the REMIND/CRON/SEND-TEXT grammar.
141
+ // the SENDER's timezone + lists the REMIND/CRON grammar.
129
142
  // Without this the agent never emits the tag and reminders silently
130
143
  // never fire (was the root-cause of the May 2026 reminders-not-
131
144
  // working bug).
@@ -1,3 +1,4 @@
1
+ import { resolve } from 'path';
1
2
  import { getProvider } from '../ai/providers.js';
2
3
  import { formatAddress, jidToAddress } from '../db/address.js';
3
4
  import { config } from '../config.js';
@@ -15,6 +16,12 @@ const CONCURRENCY = 3;
15
16
  // hints to the main Claude.
16
17
  const inProgress = new Map();
17
18
  const queue = fastq.promise(async (task) => {
19
+ logger.info({
20
+ id: task.id,
21
+ jid: task.jid,
22
+ address: task.address,
23
+ description: task.description.slice(0, 200),
24
+ }, 'async task claimed from queue');
18
25
  inProgress.set(task.id, task);
19
26
  try {
20
27
  await executeAsyncTask(task);
@@ -47,6 +54,15 @@ export function listAsyncTasks(jid) {
47
54
  return all.filter((t) => t.jid === jid);
48
55
  }
49
56
  // ---------- task runner ----------
57
+ function fileDeliveryLines() {
58
+ const outboxPath = resolve('storage/outbox');
59
+ return [
60
+ `FILE DELIVERY:`,
61
+ `- If this worker creates, edits, exports, or generates files, save final files under ${outboxPath}/.`,
62
+ `- Deliver each final file with [IMAGE|VIDEO|AUDIO|DOCUMENT: /absolute/path].`,
63
+ `- If no file was produced, say that plainly.`,
64
+ ];
65
+ }
50
66
  function buildPrompt(task) {
51
67
  const lines = [
52
68
  `You are a BACKGROUND WORKER doing a delayed chat reply. The chat already got an ack ("on it, will report back"). Now you do the work, and your output IS the follow-up chat reply — the full answer the owner is waiting for.`,
@@ -65,12 +81,15 @@ function buildPrompt(task) {
65
81
  `- Concrete findings, no filler. Numbers, names, dates. If you found 10 creators, list them — don't say "multiple creators".`,
66
82
  `- If the task failed or hit a wall (login wall, empty page, bot-detection, timeout), say so honestly and briefly. Don't fabricate.`,
67
83
  ``,
84
+ ...fileDeliveryLines(),
85
+ ``,
68
86
  `OPTIONAL MARKERS (at the END of your output, same pattern as main chat):`,
69
87
  `- [JOURNAL:<slug> — <one-line finding>] for any finding that belongs in an active journal. These run IN ADDITION to your chat reply — they file structured entries in journals/<slug>/entries.jsonl for future reference, dedup, and cross-session memory. Use existing slugs only (check [Journals: active] in your preamble). ONE marker per finding.`,
70
88
  `- [JOURNAL-NEW:<slug> — <one-line purpose>] if the task clearly deserves a new journal that doesn't exist yet. Conservative — only when the topic is a recurring tracking surface, not a one-off.`,
71
89
  `- [DIGEST: <one-line reason>] if you learned something durable about the owner or chat that should update the profile/brief.`,
72
90
  ``,
73
91
  `CONSTRAINTS:`,
92
+ `- You are already the async worker. Do the task here, including file work.`,
74
93
  `- Do NOT emit [ASYNC:...]. No recursive delegation.`,
75
94
  `- Markers are bonus persistence, not a substitute for the chat reply. Always write the chat reply first.`,
76
95
  `- Stay fully in character (personality).`,
@@ -297,6 +316,8 @@ function buildBrowserPrompt(task) {
297
316
  `- Concrete findings only. Numbers, names, dates. If you found 10 creators, list them.`,
298
317
  `- Failure mode: page hung, login wall, bot-detection, empty feed — say so briefly. Do NOT fabricate.`,
299
318
  ``,
319
+ ...fileDeliveryLines(),
320
+ ``,
300
321
  `BAIL CONDITIONS (stop and report, don't burn the clock):`,
301
322
  `- Same tool call with same args retried 3 times → stuck, bail.`,
302
323
  `- 3 consecutive empty/error responses from the site → site is throttling, bail.`,
@@ -309,6 +330,7 @@ function buildBrowserPrompt(task) {
309
330
  `- [DIGEST: <reason>] if a durable fact about the owner/chat came up.`,
310
331
  ``,
311
332
  `CONSTRAINTS:`,
333
+ `- You are already the browser worker. Do the browser task here.`,
312
334
  `- Do NOT emit [ASYNC:...] or [ASYNC-BROWSER:...]. No recursion.`,
313
335
  `- Markers are bonus persistence, not a substitute for the reply.`,
314
336
  `- Stay fully in character (personality).`,
@@ -11,10 +11,11 @@
11
11
  import { and, asc, eq, isNull, lte, or, sql } from 'drizzle-orm';
12
12
  import { getDb } from '../db/index.js';
13
13
  import { browserTasks } from '../db/schema.js';
14
+ import { logger } from '../logger.js';
14
15
  export function enqueueBrowserJob(input) {
15
16
  const db = getDb();
16
17
  const now = Math.floor(Date.now() / 1000);
17
- return db
18
+ const row = db
18
19
  .insert(browserTasks)
19
20
  .values({
20
21
  address: input.address,
@@ -37,11 +38,18 @@ export function enqueueBrowserJob(input) {
37
38
  })
38
39
  .returning()
39
40
  .get();
41
+ logger.info({
42
+ id: row.id,
43
+ address: row.address,
44
+ senderNumber: row.senderNumber,
45
+ chars: row.description.length,
46
+ }, 'browser job added to queue');
47
+ return row;
40
48
  }
41
49
  export function claimNextBrowserTask(workerId) {
42
50
  const db = getDb();
43
51
  const now = Math.floor(Date.now() / 1000);
44
- return db.transaction((tx) => {
52
+ const claimed = db.transaction((tx) => {
45
53
  const target = tx
46
54
  .select({ id: browserTasks.id })
47
55
  .from(browserTasks)
@@ -64,6 +72,15 @@ export function claimNextBrowserTask(workerId) {
64
72
  .get();
65
73
  return claimed ?? null;
66
74
  });
75
+ if (claimed) {
76
+ logger.info({
77
+ id: claimed.id,
78
+ address: claimed.address,
79
+ workerId,
80
+ attempts: claimed.attempts,
81
+ }, 'browser job claimed from queue');
82
+ }
83
+ return claimed;
67
84
  }
68
85
  export function markBrowserTaskDone(id, workerId) {
69
86
  const db = getDb();
@@ -8,6 +8,7 @@
8
8
  import { and, asc, eq, isNull, lte, notInArray, or, sql } from 'drizzle-orm';
9
9
  import { getDb } from '../db/index.js';
10
10
  import { inbound } from '../db/schema.js';
11
+ import { logger } from '../logger.js';
11
12
  // Idempotent on external_msg_id when set. Same channel message
12
13
  // arriving twice (Baileys replay, network retransmit) returns the
13
14
  // existing row instead of duplicating.
@@ -20,8 +21,15 @@ export function enqueueInbound(input) {
20
21
  .from(inbound)
21
22
  .where(eq(inbound.externalMsgId, input.externalMsgId))
22
23
  .get();
23
- if (found)
24
+ if (found) {
25
+ logger.info({
26
+ id: found.id,
27
+ address: found.address,
28
+ externalMsgId: input.externalMsgId,
29
+ status: found.status,
30
+ }, 'inbound job enqueue deduped');
24
31
  return { inserted: false, row: found };
32
+ }
25
33
  }
26
34
  const row = db
27
35
  .insert(inbound)
@@ -51,6 +59,15 @@ export function enqueueInbound(input) {
51
59
  })
52
60
  .returning()
53
61
  .get();
62
+ logger.info({
63
+ id: row.id,
64
+ address: row.address,
65
+ kind: row.kind,
66
+ externalMsgId: row.externalMsgId,
67
+ triggerReason: row.triggerReason,
68
+ chars: row.text.length,
69
+ mediaBytes: row.mediaBytes,
70
+ }, 'inbound job added to queue');
54
71
  return { inserted: true, row };
55
72
  }
56
73
  // Atomic claim with per-address serialization. Skips any pending row
@@ -60,7 +77,7 @@ export function enqueueInbound(input) {
60
77
  export function claimNextInbound(workerId) {
61
78
  const db = getDb();
62
79
  const now = Math.floor(Date.now() / 1000);
63
- return db.transaction((tx) => {
80
+ const claimed = db.transaction((tx) => {
64
81
  // Subquery: addresses currently claimed (= one in-flight per chat).
65
82
  const busyAddrs = tx
66
83
  .select({ address: inbound.address })
@@ -97,6 +114,16 @@ export function claimNextInbound(workerId) {
97
114
  .get();
98
115
  return claimed ?? null;
99
116
  });
117
+ if (claimed) {
118
+ logger.info({
119
+ id: claimed.id,
120
+ address: claimed.address,
121
+ workerId,
122
+ kind: claimed.kind,
123
+ attempts: claimed.attempts,
124
+ }, 'inbound job claimed from queue');
125
+ }
126
+ return claimed;
100
127
  }
101
128
  export function markInboundDone(id, workerId) {
102
129
  const db = getDb();
@@ -8,6 +8,7 @@
8
8
  import { and, asc, eq, isNull, lte, or, sql } from 'drizzle-orm';
9
9
  import { getDb } from '../db/index.js';
10
10
  import { outbound } from '../db/schema.js';
11
+ import { logger } from '../logger.js';
11
12
  // Insert a row, or no-op when the same idempotency_key already exists.
12
13
  // Returns the row either way so callers can log/observe.
13
14
  export function enqueueOutbound(input) {
@@ -22,8 +23,16 @@ export function enqueueOutbound(input) {
22
23
  .from(outbound)
23
24
  .where(eq(outbound.idempotencyKey, input.idempotencyKey))
24
25
  .get();
25
- if (found)
26
+ if (found) {
27
+ logger.info({
28
+ id: found.id,
29
+ address: found.address,
30
+ kind: found.kind,
31
+ idempotencyKey: input.idempotencyKey,
32
+ status: found.status,
33
+ }, 'outbound job enqueue deduped');
26
34
  return { inserted: false, row: found };
35
+ }
27
36
  }
28
37
  const inserted = db
29
38
  .insert(outbound)
@@ -47,6 +56,14 @@ export function enqueueOutbound(input) {
47
56
  })
48
57
  .returning()
49
58
  .get();
59
+ logger.info({
60
+ id: inserted.id,
61
+ address: inserted.address,
62
+ kind: inserted.kind,
63
+ idempotencyKey: inserted.idempotencyKey,
64
+ chars: inserted.text?.length ?? 0,
65
+ mediaBytes: inserted.mediaBytes,
66
+ }, 'outbound job added to queue');
50
67
  return { inserted: true, row: inserted };
51
68
  }
52
69
  // Atomic claim. Returns the row or null if nothing's ready.
@@ -57,7 +74,7 @@ export function claimNextOutbound(workerId) {
57
74
  const db = getDb();
58
75
  const now = Math.floor(Date.now() / 1000);
59
76
  // SQLite supports UPDATE ... RETURNING since 3.35.
60
- return db.transaction((tx) => {
77
+ const claimed = db.transaction((tx) => {
61
78
  const target = tx
62
79
  .select({ id: outbound.id })
63
80
  .from(outbound)
@@ -80,6 +97,16 @@ export function claimNextOutbound(workerId) {
80
97
  .get();
81
98
  return claimed ?? null;
82
99
  });
100
+ if (claimed) {
101
+ logger.info({
102
+ id: claimed.id,
103
+ address: claimed.address,
104
+ workerId,
105
+ kind: claimed.kind,
106
+ attempts: claimed.attempts,
107
+ }, 'outbound job claimed from queue');
108
+ }
109
+ return claimed;
83
110
  }
84
111
  // Mark done — succeeds only when the row is still owned by the caller.
85
112
  // Returns whether the update actually applied.
@@ -82,9 +82,9 @@ const DEFAULT_ROLES = {
82
82
  dailyTokenLimit: null,
83
83
  },
84
84
  user: {
85
- description: 'Chat + web search, scoped memory',
85
+ description: 'Chat + scoped memory',
86
86
  memory: 'self',
87
- tools: ['WebSearch'],
87
+ tools: [],
88
88
  // Users can flag memory observations and trigger digests on
89
89
  // themselves, but can't delegate background work or cross-chat
90
90
  // sends (those are owner-only).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "WhatsApp and Telegram AI bot powered by Claude, Codex, or Grok with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",