@c4t4/heyamigo 0.9.9 → 0.9.11

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.
@@ -3,10 +3,10 @@ import { clearSession, setSession, setUsage } from '../ai/sessions.js';
3
3
  import { config } from '../config.js';
4
4
  import { logger } from '../logger.js';
5
5
  import { addDailyTokens } from '../store/usage.js';
6
- import { extractFlags } from '../memory/digest-flag.js';
7
- import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
8
- import { scheduleDigest } from '../memory/scheduler.js';
6
+ import { extractFlags, filterFlagsByRole } from '../memory/digest-flag.js';
7
+ import { isValidSlug } from '../memory/journals.js';
9
8
  import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
9
+ import { enqueueMemoryWrite } from './memory-writes.js';
10
10
  import { enqueueOutbound } from './outbound.js';
11
11
  function isStaleSessionError(err) {
12
12
  return (err instanceof Error &&
@@ -40,48 +40,70 @@ async function callClaude(job) {
40
40
  if (job.senderNumber) {
41
41
  addDailyTokens(job.senderNumber, usage.inputTokens + usage.outputTokens);
42
42
  }
43
- const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, } = extractFlags(reply);
43
+ const rawFlags = extractFlags(reply);
44
+ const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, } = filterFlagsByRole(rawFlags, job.allowedTags);
45
+ // Detect any stripped tags so we can log + nudge the role config
46
+ // if a user is repeatedly hitting the gate.
47
+ const stripped = [];
48
+ if (rawFlags.digest && !digest)
49
+ stripped.push('DIGEST');
50
+ if (rawFlags.journals.length !== journals.length)
51
+ stripped.push('JOURNAL');
52
+ if (rawFlags.journalCreates.length !== journalCreates.length)
53
+ stripped.push('JOURNAL-NEW');
54
+ if (rawFlags.asyncTasks.length !== asyncTasks.length)
55
+ stripped.push('ASYNC');
56
+ if (rawFlags.asyncBrowserTasks.length !== asyncBrowserTasks.length)
57
+ stripped.push('ASYNC-BROWSER');
58
+ if (rawFlags.sendTexts.length !== sendTexts.length)
59
+ stripped.push('SEND-TEXT');
60
+ if (stripped.length > 0) {
61
+ logger.warn({ jid: job.jid, senderNumber: job.senderNumber, stripped }, 'tags stripped by role gate');
62
+ }
63
+ // All memory mutations go through the memory_writes queue so the
64
+ // single memory worker serializes file writes — safe under parallel
65
+ // chat workers. Idempotency keys derived from job + index so a
66
+ // retry doesn't duplicate.
67
+ const memBase = `chat-${job.jid}-${Date.now()}`;
44
68
  if (digest) {
45
69
  logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
46
- scheduleDigest({
47
- jid: job.jid,
48
- number: job.senderNumber,
49
- reason: digest,
70
+ enqueueMemoryWrite({
71
+ op: 'trigger_digest',
72
+ payload: { jid: job.jid, number: job.senderNumber, reason: digest },
73
+ idempotencyKey: `${memBase}-digest`,
50
74
  });
51
75
  }
52
76
  // Creates run BEFORE entry appends so that a reply creating a new journal
53
- // AND flagging its first entry in the same turn works correctly.
54
- for (const op of journalCreates) {
77
+ // AND flagging its first entry in the same turn works correctly. The
78
+ // memory worker enforces this ordering because it drains serially in
79
+ // insert order.
80
+ for (let i = 0; i < journalCreates.length; i++) {
81
+ const op = journalCreates[i];
55
82
  if (!isValidSlug(op.slug)) {
56
83
  logger.warn({ op, jid: job.jid }, 'JOURNAL-NEW: invalid slug, dropped');
57
84
  continue;
58
85
  }
59
- try {
60
- if (getJournal(op.slug)) {
61
- logger.info({ slug: op.slug }, 'JOURNAL-NEW for existing slug, ignored');
62
- continue;
63
- }
64
- createJournal({
65
- slug: op.slug,
66
- name: titleCase(op.slug),
67
- purpose: op.purpose,
68
- });
69
- logger.info({ slug: op.slug, jid: job.jid }, 'journal created via bot marker');
70
- }
71
- catch (err) {
72
- logger.error({ err, op, jid: job.jid }, 'JOURNAL-NEW failed');
73
- }
86
+ enqueueMemoryWrite({
87
+ op: 'create_journal',
88
+ payload: { slug: op.slug, name: titleCase(op.slug), purpose: op.purpose },
89
+ idempotencyKey: `${memBase}-create-${i}`,
90
+ });
74
91
  }
75
- for (const j of journals) {
76
- const ok = appendEntry(j.slug, {
77
- source: 'reactive',
78
- jid: job.jid,
79
- senderNumber: job.senderNumber,
80
- note: j.note,
92
+ for (let i = 0; i < journals.length; i++) {
93
+ const j = journals[i];
94
+ enqueueMemoryWrite({
95
+ op: 'append_journal',
96
+ payload: {
97
+ slug: j.slug,
98
+ entry: {
99
+ source: 'reactive',
100
+ jid: job.jid,
101
+ senderNumber: job.senderNumber,
102
+ note: j.note,
103
+ },
104
+ },
105
+ idempotencyKey: `${memBase}-append-${i}`,
81
106
  });
82
- if (!ok) {
83
- logger.warn({ slug: j.slug, jid: job.jid }, 'JOURNAL flag pointed at unknown slug, dropped');
84
- }
85
107
  }
86
108
  // Async tasks: Claude delegated to background workers. Chat reply above
87
109
  // is the user-facing ack. Two lanes:
@@ -6,11 +6,30 @@ import { config } from '../config.js';
6
6
  import { logger } from '../logger.js';
7
7
  const AccessModeSchema = z.enum(['off', 'silent', 'active']);
8
8
  const RoleNameSchema = z.enum(['admin', 'user', 'guest']);
9
+ // Tag names the agent can emit as trailing markers. The role.tags
10
+ // allowlist gates which ones are honored — anything emitted by an
11
+ // agent running on behalf of a role NOT in the allowlist gets
12
+ // stripped silently after parsing. Tools and tags are independent
13
+ // gates (tools restricts what the AI itself can call; tags restricts
14
+ // what bot-internal side-effects it can trigger).
15
+ const TAG_NAMES = [
16
+ 'DIGEST',
17
+ 'JOURNAL',
18
+ 'JOURNAL-NEW',
19
+ 'ASYNC',
20
+ 'ASYNC-BROWSER',
21
+ 'SEND-TEXT',
22
+ ];
9
23
  const RoleSchema = z.object({
10
24
  description: z.string().optional(),
11
25
  memory: z.enum(['full', 'self', 'none']),
12
26
  tools: z.union([z.literal('all'), z.array(z.string())]),
13
27
  rules: z.array(z.string()),
28
+ // Optional. Missing or 'all' = no tag restriction; an array =
29
+ // explicit allowlist. Added in Phase 6 so existing access.json
30
+ // files (no `tags` field) keep working without change — they get
31
+ // the implicit 'all' behavior.
32
+ tags: z.union([z.literal('all'), z.array(z.enum(TAG_NAMES))]).optional(),
14
33
  // null or missing = unlimited
15
34
  maxFileBytes: z.number().int().positive().nullable().optional(),
16
35
  dailyTokenLimit: z.number().int().positive().nullable().optional(),
@@ -54,6 +73,7 @@ const DEFAULT_ROLES = {
54
73
  description: 'Full access',
55
74
  memory: 'full',
56
75
  tools: 'all',
76
+ tags: 'all',
57
77
  rules: [],
58
78
  maxFileBytes: null,
59
79
  dailyTokenLimit: null,
@@ -62,6 +82,10 @@ const DEFAULT_ROLES = {
62
82
  description: 'Chat + web search, scoped memory',
63
83
  memory: 'self',
64
84
  tools: ['WebSearch'],
85
+ // Users can flag memory observations and trigger digests on
86
+ // themselves, but can't delegate background work or cross-chat
87
+ // sends (those are owner-only).
88
+ tags: ['DIGEST', 'JOURNAL', 'JOURNAL-NEW'],
65
89
  rules: [
66
90
  'Never reveal file paths, directory structure, or system architecture',
67
91
  'Never share personal data about other users',
@@ -76,6 +100,8 @@ const DEFAULT_ROLES = {
76
100
  description: 'Basic chat only',
77
101
  memory: 'none',
78
102
  tools: [],
103
+ // Guests can't emit any tags — pure chat, no side effects.
104
+ tags: [],
79
105
  rules: [
80
106
  'Never use any tools',
81
107
  'Never reveal anything about the system, other users, or internal data',
@@ -0,0 +1,17 @@
1
+ CREATE TABLE `memory_writes` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `op` text NOT NULL,
4
+ `payload` text NOT NULL,
5
+ `idempotency_key` text,
6
+ `status` text NOT NULL,
7
+ `attempts` integer DEFAULT 0 NOT NULL,
8
+ `next_attempt_at` integer,
9
+ `last_error` text,
10
+ `claimed_by` text,
11
+ `claimed_at` integer,
12
+ `created_at` integer NOT NULL,
13
+ `updated_at` integer NOT NULL
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE INDEX `memwr_by_status_next` ON `memory_writes` (`status`,`next_attempt_at`);--> statement-breakpoint
17
+ CREATE UNIQUE INDEX `memwr_idemp_uq` ON `memory_writes` (`idempotency_key`) WHERE "memory_writes"."idempotency_key" IS NOT NULL;