zooid 0.14.1 → 0.15.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildAcpRegistry
4
- } from "./chunk-R5S26T7B.js";
4
+ } from "./chunk-35IKQPC5.js";
5
5
  export {
6
6
  buildAcpRegistry
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "An open-source, self-hostable chat app for collaborating with AI agents alongside your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "zooid": {
28
- "webVersion": "0.12.0"
28
+ "webVersion": "0.12.1"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=22"
@@ -43,14 +43,14 @@
43
43
  "tar": "^7.5.16",
44
44
  "web-push": "^3.6.7",
45
45
  "yaml": "^2.5.0",
46
- "@zooid/context-mcp": "^0.14.1",
47
- "@zooid/acp-client": "^0.14.1",
48
- "@zooid/transport-matrix": "^0.14.1",
49
- "@zooid/runtime-docker": "^0.14.1",
50
- "@zooid/core": "^0.14.1",
51
- "@zooid/runtime-local": "^0.14.1",
52
- "@zooid/transport-http": "^0.14.1",
53
- "@zooid/pi-extension": "^0.13.1"
46
+ "@zooid/acp-client": "^0.15.0",
47
+ "@zooid/core": "^0.15.0",
48
+ "@zooid/context-mcp": "^0.15.0",
49
+ "@zooid/runtime-docker": "^0.15.0",
50
+ "@zooid/pi-extension": "^0.13.2",
51
+ "@zooid/runtime-local": "^0.15.0",
52
+ "@zooid/transport-matrix": "^0.15.0",
53
+ "@zooid/transport-http": "^0.15.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@agentclientprotocol/sdk": "^0.21.0",
@@ -322,6 +322,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
322
322
  resolveRoom,
323
323
  ensureBot,
324
324
  sendMessage: (m) => client.sendMessage(m),
325
+ getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
325
326
  })
326
327
  for (const [name] of webhookTriggers) {
327
328
  console.log(`[webhook] POST ${WEBHOOK_ROUTE_PREFIX}/${name}`)
@@ -383,6 +384,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
383
384
  resolveRoom,
384
385
  ensureBot,
385
386
  sendMessage: (m) => client.sendMessage(m),
387
+ getJoinedMembers: (roomId, asUserId) => client.getJoinedMembers(roomId, asUserId),
386
388
  })
387
389
  const scheduled = Object.values(config.triggers).filter((t) => t.schedule).length
388
390
  if (scheduled > 0) console.log(`[trigger] scheduled ${scheduled} trigger(s)`)
@@ -424,6 +426,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
424
426
  spaceRoomId,
425
427
  asUserId,
426
428
  getAgents: () => bindings,
429
+ stateKey: matrix.transport.workstation ?? '',
427
430
  })
428
431
  console.log(`[matrix] published dev.zooid.workforce (${bindings.length} agents)`)
429
432
  void publisher
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { fireTrigger } from './trigger-runner.js'
3
+
4
+ const deps = (mention: string, agentUserIds: Record<string, string>) => {
5
+ const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
6
+ return {
7
+ sendMessage,
8
+ call: () =>
9
+ fireTrigger({
10
+ name: 'standup',
11
+ as: '@cloud.cron:zooid.zoon.eco',
12
+ message: { room: '#product:zooid.zoon.eco', mention, text: 'Morning standup.' },
13
+ agentUserIds,
14
+ resolveRoom: async () => '!room:zooid.zoon.eco',
15
+ ensureBot: async () => {},
16
+ sendMessage,
17
+ }),
18
+ }
19
+ }
20
+
21
+ describe('fireTrigger addressing', () => {
22
+ it('uses an MXID mention verbatim, with no local lookup', async () => {
23
+ const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
24
+ await call()
25
+ expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
26
+ user_ids: ['@ori-macbook.cpo:zooid.zoon.eco'],
27
+ })
28
+ })
29
+
30
+ it('resolves a bare name through the local agent map', async () => {
31
+ const { call, sendMessage } = deps('scout', { scout: '@cloud.scout:zooid.zoon.eco' })
32
+ await call()
33
+ expect(sendMessage.mock.calls[0]![0].content['m.mentions']).toEqual({
34
+ user_ids: ['@cloud.scout:zooid.zoon.eco'],
35
+ })
36
+ })
37
+
38
+ it('stamps dev.zooid.trigger with the trigger name and firing time', async () => {
39
+ const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
40
+ const before = Date.now()
41
+ await call()
42
+ const stamp = sendMessage.mock.calls[0]![0].content['dev.zooid.trigger'] as {
43
+ name: string
44
+ fired_at: number
45
+ ttl_ms?: number
46
+ }
47
+ expect(stamp.name).toBe('standup')
48
+ expect(stamp.fired_at).toBeGreaterThanOrEqual(before)
49
+ })
50
+
51
+ it('skips a bare name with no local binding rather than posting', async () => {
52
+ const { call, sendMessage } = deps('scout', {})
53
+ await call()
54
+ expect(sendMessage).not.toHaveBeenCalled()
55
+ })
56
+ })
57
+
58
+ describe('fireTrigger mention rendering', () => {
59
+ it('leads the body with the MXID so the room can see who was addressed', async () => {
60
+ const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
61
+ await call()
62
+ expect(sendMessage.mock.calls[0]![0].content.body).toBe(
63
+ '@ori-macbook.cpo:zooid.zoon.eco Morning standup.',
64
+ )
65
+ })
66
+
67
+ it('sends no HTML — the client pills a bare MXID, and markup would need escaping', async () => {
68
+ const { call, sendMessage } = deps('@ori-macbook.cpo:zooid.zoon.eco', {})
69
+ await call()
70
+ const c = sendMessage.mock.calls[0]![0].content
71
+ expect(c.format).toBeUndefined()
72
+ expect(c.formatted_body).toBeUndefined()
73
+ })
74
+
75
+ it('warns when the addressed user is not in the room', async () => {
76
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
77
+ const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
78
+ await fireTrigger({
79
+ name: 'standup',
80
+ as: '@cloud.cron:zooid.zoon.eco',
81
+ message: {
82
+ room: '#product:zooid.zoon.eco',
83
+ mention: '@ori-macbook.cpo:zooid.zoon.eco',
84
+ text: 'Morning standup.',
85
+ },
86
+ agentUserIds: {},
87
+ resolveRoom: async () => '!p:zooid.zoon.eco',
88
+ ensureBot: async () => {},
89
+ sendMessage,
90
+ getJoinedMembers: async () => ({ joined: { '@cloud.product:zooid.zoon.eco': {} } }),
91
+ })
92
+ // posted anyway — the agent may join before it expires
93
+ expect(sendMessage).toHaveBeenCalledTimes(1)
94
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/not in .*#product/))
95
+ warn.mockRestore()
96
+ })
97
+
98
+ it('warns only once across repeated fires at the same absent target', async () => {
99
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
100
+ const getJoinedMembers = vi.fn(async () => ({ joined: {} }))
101
+ // A distinct trigger name from the previous test: `warnedTargets` is
102
+ // module-level (dedup lasts the daemon's process lifetime, per spec), so
103
+ // reusing "standup" here would find the key already warned and this
104
+ // test's own two calls would produce zero fresh warnings.
105
+ const fire = () =>
106
+ fireTrigger({
107
+ name: 'standup-repeat',
108
+ as: '@cloud.cron:zooid.zoon.eco',
109
+ message: {
110
+ room: '#product:zooid.zoon.eco',
111
+ mention: '@ori-macbook.cpo:zooid.zoon.eco',
112
+ text: 'Morning standup.',
113
+ },
114
+ agentUserIds: {},
115
+ resolveRoom: async () => '!p:zooid.zoon.eco',
116
+ ensureBot: async () => {},
117
+ sendMessage: async () => ({ event_id: '$1' }),
118
+ getJoinedMembers,
119
+ })
120
+ await fire()
121
+ await fire()
122
+ expect(warn).toHaveBeenCalledTimes(1)
123
+ warn.mockRestore()
124
+ })
125
+
126
+ it('leaves interpolated webhook text verbatim in the body', async () => {
127
+ const sendMessage = vi.fn(async () => ({ event_id: '$1' }))
128
+ await fireTrigger({
129
+ name: 'triage',
130
+ as: '@cloud.hook:zooid.zoon.eco',
131
+ message: {
132
+ room: '#product:zooid.zoon.eco',
133
+ mention: '@cloud.product:zooid.zoon.eco',
134
+ text: 'Triage "<img src=x> & more".',
135
+ },
136
+ agentUserIds: {},
137
+ resolveRoom: async () => '!r:zooid.zoon.eco',
138
+ ensureBot: async () => {},
139
+ sendMessage,
140
+ })
141
+ const c = sendMessage.mock.calls[0]![0].content
142
+ expect(c.body).toBe('@cloud.product:zooid.zoon.eco Triage "<img src=x> & more".')
143
+ expect(c.formatted_body).toBeUndefined()
144
+ })
145
+ })
@@ -12,7 +12,7 @@ const deps = (over: Record<string, unknown> = {}) => ({
12
12
  name: 'image-currency',
13
13
  as: '@cron:example.org',
14
14
  message,
15
- agentUserId: '@architect:example.org',
15
+ agentUserIds: { architect: '@architect:example.org' },
16
16
  resolveRoom: vi.fn(async () => '!room:example.org'),
17
17
  ensureBot: vi.fn(async () => {}),
18
18
  sendMessage: vi.fn(async () => ({ event_id: '$1' })),
@@ -20,14 +20,14 @@ const deps = (over: Record<string, unknown> = {}) => ({
20
20
  })
21
21
 
22
22
  describe('fireTrigger', () => {
23
- it('posts text verbatim as the trigger bot user', async () => {
23
+ it('posts text as the trigger bot user, led by a visible mention', async () => {
24
24
  const d = deps()
25
25
  await fireTrigger(d as never)
26
26
  expect(d.sendMessage).toHaveBeenCalledTimes(1)
27
27
  const call = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]
28
28
  expect(call.roomId).toBe('!room:example.org')
29
29
  expect(call.asUserId).toBe('@cron:example.org')
30
- expect(call.content.body).toBe('Check the pinned agent CLI versions.')
30
+ expect(call.content.body).toBe('@architect:example.org Check the pinned agent CLI versions.')
31
31
  expect(call.content.msgtype).toBe('m.text')
32
32
  })
33
33
 
@@ -36,7 +36,6 @@ describe('fireTrigger', () => {
36
36
  await fireTrigger(d as never)
37
37
  const content = (d.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content
38
38
  expect(content['m.mentions']).toEqual({ user_ids: ['@architect:example.org'] })
39
- expect(content.body).not.toContain('@architect')
40
39
  })
41
40
 
42
41
  it('posts at the top level, never into a thread — runTurn makes each firing a new root', async () => {
@@ -1,10 +1,12 @@
1
1
  import type { TriggerMessage } from '@zooid/core'
2
+ import { buildMentionContent } from '@zooid/transport-matrix'
2
3
 
3
4
  export interface FireTriggerDeps {
4
5
  name: string
5
6
  as: string
6
7
  message: TriggerMessage
7
- agentUserId: string
8
+ /** Local agent name → MXID. A `mention:` that is already an MXID bypasses this. */
9
+ agentUserIds: Record<string, string>
8
10
  resolveRoom: (room: string) => Promise<string | null>
9
11
  ensureBot: (asUserId: string, roomId: string) => Promise<void>
10
12
  sendMessage: (input: {
@@ -12,11 +14,29 @@ export interface FireTriggerDeps {
12
14
  asUserId: string
13
15
  content: { msgtype: string; body: string; [k: string]: unknown }
14
16
  }) => Promise<{ event_id: string }>
17
+ /**
18
+ * Optional: enables the [[ZOD081]] §Design 6 "nobody is listening" warning.
19
+ * Omitted in tests that don't care about it — the check is then skipped
20
+ * silently, never a hard requirement to fire.
21
+ */
22
+ getJoinedMembers?: (roomId: string, asUserId: string) => Promise<{ joined: Record<string, unknown> }>
15
23
  }
16
24
 
25
+ // Module-level so a daily trigger against an off laptop logs once per daemon
26
+ // run, not once a day. Keyed by trigger name + target so distinct triggers
27
+ // (or targets) each get their own first warning.
28
+ const warnedTargets = new Set<string>()
29
+
17
30
  export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
18
- const { name, as, message, agentUserId, resolveRoom, ensureBot, sendMessage } = deps
31
+ const { name, as, message, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
19
32
  try {
33
+ const target = message.mention.startsWith('@')
34
+ ? message.mention
35
+ : agentUserIds[message.mention]
36
+ if (!target) {
37
+ console.warn(`[trigger:${name}] unknown agent "${message.mention}" — skipping`)
38
+ return
39
+ }
20
40
  const roomId = await resolveRoom(message.room)
21
41
  if (!roomId) {
22
42
  console.warn(`[trigger:${name}] cannot resolve room ${message.room} — skipping`)
@@ -27,13 +47,31 @@ export async function fireTrigger(deps: FireTriggerDeps): Promise<void> {
27
47
  roomId,
28
48
  asUserId: as,
29
49
  content: {
30
- msgtype: 'm.text',
31
- body: message.text,
32
- // Structural mention: routes deterministically AND disarms the raw-body
33
- // fallback in extractMentions, which only fires when nothing matched.
34
- 'm.mentions': { user_ids: [agentUserId] },
50
+ ...buildMentionContent({ userId: target, text: message.text, msgtype: 'm.text' }),
51
+ 'dev.zooid.trigger': {
52
+ name,
53
+ fired_at: Date.now(),
54
+ ...(message.ttlMs !== undefined ? { ttl_ms: message.ttlMs } : {}),
55
+ },
35
56
  },
36
57
  })
58
+
59
+ // Post first, warn second: an absent target isn't a lost message —
60
+ // catch-up may still deliver it if that agent joins before it expires.
61
+ // Membership, not registration: router.ts routes only to agents joined
62
+ // to the room, so an agent that exists but isn't in the room misses the
63
+ // firing exactly as an unregistered one does.
64
+ if (getJoinedMembers) {
65
+ const { joined } = await getJoinedMembers(roomId, as)
66
+ const key = `${name}|${target}`
67
+ if (!(target in joined) && !warnedTargets.has(key)) {
68
+ warnedTargets.add(key)
69
+ console.warn(
70
+ `[trigger:${name}] ${target} is not in ${message.room} — nothing will handle this ` +
71
+ `unless that agent joins before the message expires`,
72
+ )
73
+ }
74
+ }
37
75
  } catch (err) {
38
76
  // Never throw: one bad firing must not take down the scheduler.
39
77
  console.warn(`[trigger:${name}] failed:`, (err as Error).message)
@@ -8,6 +8,7 @@ export interface StartTriggerSchedulerDeps {
8
8
  resolveRoom: FireTriggerDeps['resolveRoom']
9
9
  ensureBot: FireTriggerDeps['ensureBot']
10
10
  sendMessage: FireTriggerDeps['sendMessage']
11
+ getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
11
12
  }
12
13
 
13
14
  export interface TriggerSchedulerHandle {
@@ -29,19 +30,23 @@ export function validateCron(name: string, expr: string): void {
29
30
  }
30
31
 
31
32
  export function startTriggerScheduler(deps: StartTriggerSchedulerDeps): TriggerSchedulerHandle {
32
- const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage } = deps
33
+ const { triggers, agentUserIds, resolveRoom, ensureBot, sendMessage, getJoinedMembers } = deps
33
34
  const jobs: Cron[] = []
34
35
 
35
36
  for (const [name, trigger] of Object.entries(triggers)) {
36
37
  if (!trigger.schedule) continue
37
38
  const job = new Cron(trigger.schedule, () => {
38
39
  for (const message of trigger.messages) {
39
- const agentUserId = agentUserIds[message.mention]
40
- if (!agentUserId) {
41
- console.warn(`[trigger:${name}] unknown agent "${message.mention}" — skipping`)
42
- continue
43
- }
44
- void fireTrigger({ name, as: trigger.as, message, agentUserId, resolveRoom, ensureBot, sendMessage })
40
+ void fireTrigger({
41
+ name,
42
+ as: trigger.as,
43
+ message,
44
+ agentUserIds,
45
+ resolveRoom,
46
+ ensureBot,
47
+ sendMessage,
48
+ getJoinedMembers,
49
+ })
45
50
  }
46
51
  })
47
52
  jobs.push(job)
@@ -84,6 +84,7 @@ export interface WebhookDeps {
84
84
  resolveRoom: FireTriggerDeps['resolveRoom']
85
85
  ensureBot: FireTriggerDeps['ensureBot']
86
86
  sendMessage: FireTriggerDeps['sendMessage']
87
+ getJoinedMembers?: FireTriggerDeps['getJoinedMembers']
87
88
  }
88
89
 
89
90
  /**
@@ -166,20 +167,15 @@ async function handleDelivery(
166
167
  for (const message of trigger.messages) {
167
168
  if (message.match !== undefined && !evaluateMatch(message.match, ctx)) continue
168
169
 
169
- const agentUserId = deps.agentUserIds[message.mention]
170
- if (!agentUserId) {
171
- console.warn(`[webhook:${name}] unknown agent "${message.mention}" — skipping`)
172
- continue
173
- }
174
-
175
170
  await fireTrigger({
176
171
  name,
177
172
  as: trigger.as,
178
173
  message: { ...message, text: renderTemplate(message.text, ctx) },
179
- agentUserId,
174
+ agentUserIds: deps.agentUserIds,
180
175
  resolveRoom: deps.resolveRoom,
181
176
  ensureBot: deps.ensureBot,
182
177
  sendMessage: deps.sendMessage,
178
+ getJoinedMembers: deps.getJoinedMembers,
183
179
  })
184
180
  }
185
181
  } catch (err) {