@adonis-agora/agent 0.76.3 → 0.77.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.
Files changed (275) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/providers/agent_provider.d.ts.map +1 -1
  3. package/dist/providers/agent_provider.js +9 -0
  4. package/dist/providers/agent_provider.js.map +1 -1
  5. package/dist/providers/mcp_provider.d.ts.map +1 -1
  6. package/dist/providers/mcp_provider.js +46 -10
  7. package/dist/providers/mcp_provider.js.map +1 -1
  8. package/dist/providers/poppy_connections_provider.d.ts +17 -0
  9. package/dist/providers/poppy_connections_provider.d.ts.map +1 -0
  10. package/dist/providers/poppy_connections_provider.js +186 -0
  11. package/dist/providers/poppy_connections_provider.js.map +1 -0
  12. package/dist/providers/poppy_provider.d.ts +12 -0
  13. package/dist/providers/poppy_provider.d.ts.map +1 -0
  14. package/dist/providers/poppy_provider.js +159 -0
  15. package/dist/providers/poppy_provider.js.map +1 -0
  16. package/dist/src/a2a/handler.d.ts.map +1 -1
  17. package/dist/src/a2a/handler.js +4 -2
  18. package/dist/src/a2a/handler.js.map +1 -1
  19. package/dist/src/a2a/turn.d.ts +27 -4
  20. package/dist/src/a2a/turn.d.ts.map +1 -1
  21. package/dist/src/a2a/turn.js +66 -12
  22. package/dist/src/a2a/turn.js.map +1 -1
  23. package/dist/src/action-proposal-approval.d.ts.map +1 -1
  24. package/dist/src/action-proposal-approval.js +3 -3
  25. package/dist/src/action-proposal-approval.js.map +1 -1
  26. package/dist/src/action-proposal-listeners.d.ts +12 -0
  27. package/dist/src/action-proposal-listeners.d.ts.map +1 -0
  28. package/dist/src/action-proposal-listeners.js +27 -0
  29. package/dist/src/action-proposal-listeners.js.map +1 -0
  30. package/dist/src/action-proposal-receipt.d.ts +4 -0
  31. package/dist/src/action-proposal-receipt.d.ts.map +1 -1
  32. package/dist/src/action-proposal-receipt.js +3 -1
  33. package/dist/src/action-proposal-receipt.js.map +1 -1
  34. package/dist/src/action-proposal-worker.d.ts +6 -1
  35. package/dist/src/action-proposal-worker.d.ts.map +1 -1
  36. package/dist/src/action-proposal-worker.js +10 -1
  37. package/dist/src/action-proposal-worker.js.map +1 -1
  38. package/dist/src/agent-loop.d.ts.map +1 -1
  39. package/dist/src/agent-loop.js +8 -5
  40. package/dist/src/agent-loop.js.map +1 -1
  41. package/dist/src/channels/handler.d.ts +10 -0
  42. package/dist/src/channels/handler.d.ts.map +1 -1
  43. package/dist/src/channels/handler.js +44 -1
  44. package/dist/src/channels/handler.js.map +1 -1
  45. package/dist/src/channels/texts.d.ts +9 -0
  46. package/dist/src/channels/texts.d.ts.map +1 -1
  47. package/dist/src/channels/texts.js +4 -0
  48. package/dist/src/channels/texts.js.map +1 -1
  49. package/dist/src/client/sse.d.ts.map +1 -1
  50. package/dist/src/client/sse.js +3 -0
  51. package/dist/src/client/sse.js.map +1 -1
  52. package/dist/src/define_config.d.ts +3 -0
  53. package/dist/src/define_config.d.ts.map +1 -1
  54. package/dist/src/define_config.js.map +1 -1
  55. package/dist/src/index.d.ts +2 -1
  56. package/dist/src/index.d.ts.map +1 -1
  57. package/dist/src/index.js +2 -1
  58. package/dist/src/index.js.map +1 -1
  59. package/dist/src/mcp/auth.d.ts +17 -0
  60. package/dist/src/mcp/auth.d.ts.map +1 -1
  61. package/dist/src/mcp/auth.js +56 -1
  62. package/dist/src/mcp/auth.js.map +1 -1
  63. package/dist/src/mcp/discovery.d.ts +13 -2
  64. package/dist/src/mcp/discovery.d.ts.map +1 -1
  65. package/dist/src/mcp/discovery.js +17 -2
  66. package/dist/src/mcp/discovery.js.map +1 -1
  67. package/dist/src/mcp/index.d.ts +1 -1
  68. package/dist/src/mcp/index.d.ts.map +1 -1
  69. package/dist/src/mcp/index.js.map +1 -1
  70. package/dist/src/mcp/scope.d.ts +13 -0
  71. package/dist/src/mcp/scope.d.ts.map +1 -0
  72. package/dist/src/mcp/scope.js +24 -0
  73. package/dist/src/mcp/scope.js.map +1 -0
  74. package/dist/src/mcp/server.d.ts +6 -1
  75. package/dist/src/mcp/server.d.ts.map +1 -1
  76. package/dist/src/mcp/server.js +1 -1
  77. package/dist/src/mcp/server.js.map +1 -1
  78. package/dist/src/poppy/auth.d.ts +77 -0
  79. package/dist/src/poppy/auth.d.ts.map +1 -0
  80. package/dist/src/poppy/auth.js +16 -0
  81. package/dist/src/poppy/auth.js.map +1 -0
  82. package/dist/src/poppy/conversations.d.ts +249 -0
  83. package/dist/src/poppy/conversations.d.ts.map +1 -0
  84. package/dist/src/poppy/conversations.js +1017 -0
  85. package/dist/src/poppy/conversations.js.map +1 -0
  86. package/dist/src/poppy/define_config.d.ts +98 -0
  87. package/dist/src/poppy/define_config.d.ts.map +1 -0
  88. package/dist/src/poppy/define_config.js +11 -0
  89. package/dist/src/poppy/define_config.js.map +1 -0
  90. package/dist/src/poppy/endpoints.d.ts +25 -0
  91. package/dist/src/poppy/endpoints.d.ts.map +1 -0
  92. package/dist/src/poppy/endpoints.js +31 -0
  93. package/dist/src/poppy/endpoints.js.map +1 -0
  94. package/dist/src/poppy/handler.d.ts +80 -0
  95. package/dist/src/poppy/handler.d.ts.map +1 -0
  96. package/dist/src/poppy/handler.js +362 -0
  97. package/dist/src/poppy/handler.js.map +1 -0
  98. package/dist/src/poppy/index.d.ts +17 -0
  99. package/dist/src/poppy/index.d.ts.map +1 -0
  100. package/dist/src/poppy/index.js +9 -0
  101. package/dist/src/poppy/index.js.map +1 -0
  102. package/dist/src/poppy/protocol.d.ts +132 -0
  103. package/dist/src/poppy/protocol.d.ts.map +1 -0
  104. package/dist/src/poppy/protocol.js +178 -0
  105. package/dist/src/poppy/protocol.js.map +1 -0
  106. package/dist/src/poppy/redis-store.d.ts +27 -0
  107. package/dist/src/poppy/redis-store.d.ts.map +1 -0
  108. package/dist/src/poppy/redis-store.js +148 -0
  109. package/dist/src/poppy/redis-store.js.map +1 -0
  110. package/dist/src/poppy/runtime.d.ts +6 -0
  111. package/dist/src/poppy/runtime.d.ts.map +1 -0
  112. package/dist/src/poppy/runtime.js +12 -0
  113. package/dist/src/poppy/runtime.js.map +1 -0
  114. package/dist/src/poppy/server_middleware.d.ts +11 -0
  115. package/dist/src/poppy/server_middleware.d.ts.map +1 -0
  116. package/dist/src/poppy/server_middleware.js +16 -0
  117. package/dist/src/poppy/server_middleware.js.map +1 -0
  118. package/dist/src/poppy/store.d.ts +207 -0
  119. package/dist/src/poppy/store.d.ts.map +1 -0
  120. package/dist/src/poppy/store.js +444 -0
  121. package/dist/src/poppy/store.js.map +1 -0
  122. package/dist/src/poppy-client/api.d.ts +36 -0
  123. package/dist/src/poppy-client/api.d.ts.map +1 -0
  124. package/dist/src/poppy-client/api.js +77 -0
  125. package/dist/src/poppy-client/api.js.map +1 -0
  126. package/dist/src/poppy-client/bin/poppy-agent.d.ts +3 -0
  127. package/dist/src/poppy-client/bin/poppy-agent.d.ts.map +1 -0
  128. package/dist/src/poppy-client/bin/poppy-agent.js +9 -0
  129. package/dist/src/poppy-client/bin/poppy-agent.js.map +1 -0
  130. package/dist/src/poppy-client/browser.d.ts +24 -0
  131. package/dist/src/poppy-client/browser.d.ts.map +1 -0
  132. package/dist/src/poppy-client/browser.js +83 -0
  133. package/dist/src/poppy-client/browser.js.map +1 -0
  134. package/dist/src/poppy-client/cli/main.d.ts +2 -0
  135. package/dist/src/poppy-client/cli/main.d.ts.map +1 -0
  136. package/dist/src/poppy-client/cli/main.js +776 -0
  137. package/dist/src/poppy-client/cli/main.js.map +1 -0
  138. package/dist/src/poppy-client/cli/prompt.d.ts +13 -0
  139. package/dist/src/poppy-client/cli/prompt.d.ts.map +1 -0
  140. package/dist/src/poppy-client/cli/prompt.js +50 -0
  141. package/dist/src/poppy-client/cli/prompt.js.map +1 -0
  142. package/dist/src/poppy-client/company.d.ts +87 -0
  143. package/dist/src/poppy-client/company.d.ts.map +1 -0
  144. package/dist/src/poppy-client/company.js +336 -0
  145. package/dist/src/poppy-client/company.js.map +1 -0
  146. package/dist/src/poppy-client/conversations.d.ts +89 -0
  147. package/dist/src/poppy-client/conversations.d.ts.map +1 -0
  148. package/dist/src/poppy-client/conversations.js +332 -0
  149. package/dist/src/poppy-client/conversations.js.map +1 -0
  150. package/dist/src/poppy-client/discovery.d.ts +41 -0
  151. package/dist/src/poppy-client/discovery.d.ts.map +1 -0
  152. package/dist/src/poppy-client/discovery.js +217 -0
  153. package/dist/src/poppy-client/discovery.js.map +1 -0
  154. package/dist/src/poppy-client/dpop.d.ts +19 -0
  155. package/dist/src/poppy-client/dpop.d.ts.map +1 -0
  156. package/dist/src/poppy-client/dpop.js +43 -0
  157. package/dist/src/poppy-client/dpop.js.map +1 -0
  158. package/dist/src/poppy-client/errors.d.ts +50 -0
  159. package/dist/src/poppy-client/errors.d.ts.map +1 -0
  160. package/dist/src/poppy-client/errors.js +55 -0
  161. package/dist/src/poppy-client/errors.js.map +1 -0
  162. package/dist/src/poppy-client/http.d.ts +40 -0
  163. package/dist/src/poppy-client/http.d.ts.map +1 -0
  164. package/dist/src/poppy-client/http.js +95 -0
  165. package/dist/src/poppy-client/http.js.map +1 -0
  166. package/dist/src/poppy-client/identity.d.ts +61 -0
  167. package/dist/src/poppy-client/identity.d.ts.map +1 -0
  168. package/dist/src/poppy-client/identity.js +111 -0
  169. package/dist/src/poppy-client/identity.js.map +1 -0
  170. package/dist/src/poppy-client/identity_server.d.ts +29 -0
  171. package/dist/src/poppy-client/identity_server.d.ts.map +1 -0
  172. package/dist/src/poppy-client/identity_server.js +84 -0
  173. package/dist/src/poppy-client/identity_server.js.map +1 -0
  174. package/dist/src/poppy-client/index.d.ts +19 -0
  175. package/dist/src/poppy-client/index.d.ts.map +1 -0
  176. package/dist/src/poppy-client/index.js +18 -0
  177. package/dist/src/poppy-client/index.js.map +1 -0
  178. package/dist/src/poppy-client/keys.d.ts +13 -0
  179. package/dist/src/poppy-client/keys.d.ts.map +1 -0
  180. package/dist/src/poppy-client/keys.js +31 -0
  181. package/dist/src/poppy-client/keys.js.map +1 -0
  182. package/dist/src/poppy-client/llm.d.ts +31 -0
  183. package/dist/src/poppy-client/llm.d.ts.map +1 -0
  184. package/dist/src/poppy-client/llm.js +230 -0
  185. package/dist/src/poppy-client/llm.js.map +1 -0
  186. package/dist/src/poppy-client/mcp.d.ts +16 -0
  187. package/dist/src/poppy-client/mcp.d.ts.map +1 -0
  188. package/dist/src/poppy-client/mcp.js +57 -0
  189. package/dist/src/poppy-client/mcp.js.map +1 -0
  190. package/dist/src/poppy-client/open_url.d.ts +3 -0
  191. package/dist/src/poppy-client/open_url.d.ts.map +1 -0
  192. package/dist/src/poppy-client/open_url.js +13 -0
  193. package/dist/src/poppy-client/open_url.js.map +1 -0
  194. package/dist/src/poppy-client/signin.d.ts +59 -0
  195. package/dist/src/poppy-client/signin.d.ts.map +1 -0
  196. package/dist/src/poppy-client/signin.js +210 -0
  197. package/dist/src/poppy-client/signin.js.map +1 -0
  198. package/dist/src/poppy-client/sse.d.ts +15 -0
  199. package/dist/src/poppy-client/sse.d.ts.map +1 -0
  200. package/dist/src/poppy-client/sse.js +74 -0
  201. package/dist/src/poppy-client/sse.js.map +1 -0
  202. package/dist/src/poppy-client/store.d.ts +89 -0
  203. package/dist/src/poppy-client/store.d.ts.map +1 -0
  204. package/dist/src/poppy-client/store.js +79 -0
  205. package/dist/src/poppy-client/store.js.map +1 -0
  206. package/dist/src/poppy-client/token_client.d.ts +69 -0
  207. package/dist/src/poppy-client/token_client.d.ts.map +1 -0
  208. package/dist/src/poppy-client/token_client.js +154 -0
  209. package/dist/src/poppy-client/token_client.js.map +1 -0
  210. package/dist/src/poppy-client/types.d.ts +184 -0
  211. package/dist/src/poppy-client/types.d.ts.map +1 -0
  212. package/dist/src/poppy-client/types.js +8 -0
  213. package/dist/src/poppy-client/types.js.map +1 -0
  214. package/dist/src/poppy-client/util.d.ts +42 -0
  215. package/dist/src/poppy-client/util.d.ts.map +1 -0
  216. package/dist/src/poppy-client/util.js +122 -0
  217. package/dist/src/poppy-client/util.js.map +1 -0
  218. package/dist/src/poppy-connections/config.d.ts +134 -0
  219. package/dist/src/poppy-connections/config.d.ts.map +1 -0
  220. package/dist/src/poppy-connections/config.js +44 -0
  221. package/dist/src/poppy-connections/config.js.map +1 -0
  222. package/dist/src/poppy-connections/crypto.d.ts +12 -0
  223. package/dist/src/poppy-connections/crypto.d.ts.map +1 -0
  224. package/dist/src/poppy-connections/crypto.js +36 -0
  225. package/dist/src/poppy-connections/crypto.js.map +1 -0
  226. package/dist/src/poppy-connections/http.d.ts +63 -0
  227. package/dist/src/poppy-connections/http.d.ts.map +1 -0
  228. package/dist/src/poppy-connections/http.js +217 -0
  229. package/dist/src/poppy-connections/http.js.map +1 -0
  230. package/dist/src/poppy-connections/index.d.ts +14 -0
  231. package/dist/src/poppy-connections/index.d.ts.map +1 -0
  232. package/dist/src/poppy-connections/index.js +17 -0
  233. package/dist/src/poppy-connections/index.js.map +1 -0
  234. package/dist/src/poppy-connections/resume.d.ts +46 -0
  235. package/dist/src/poppy-connections/resume.d.ts.map +1 -0
  236. package/dist/src/poppy-connections/resume.js +109 -0
  237. package/dist/src/poppy-connections/resume.js.map +1 -0
  238. package/dist/src/poppy-connections/runtime.d.ts +4 -0
  239. package/dist/src/poppy-connections/runtime.d.ts.map +1 -0
  240. package/dist/src/poppy-connections/runtime.js +9 -0
  241. package/dist/src/poppy-connections/runtime.js.map +1 -0
  242. package/dist/src/poppy-connections/server_middleware.d.ts +10 -0
  243. package/dist/src/poppy-connections/server_middleware.d.ts.map +1 -0
  244. package/dist/src/poppy-connections/server_middleware.js +14 -0
  245. package/dist/src/poppy-connections/server_middleware.js.map +1 -0
  246. package/dist/src/poppy-connections/service.d.ts +179 -0
  247. package/dist/src/poppy-connections/service.d.ts.map +1 -0
  248. package/dist/src/poppy-connections/service.js +648 -0
  249. package/dist/src/poppy-connections/service.js.map +1 -0
  250. package/dist/src/poppy-connections/store.d.ts +139 -0
  251. package/dist/src/poppy-connections/store.d.ts.map +1 -0
  252. package/dist/src/poppy-connections/store.js +260 -0
  253. package/dist/src/poppy-connections/store.js.map +1 -0
  254. package/dist/src/poppy-connections/tools.d.ts +65 -0
  255. package/dist/src/poppy-connections/tools.d.ts.map +1 -0
  256. package/dist/src/poppy-connections/tools.js +574 -0
  257. package/dist/src/poppy-connections/tools.js.map +1 -0
  258. package/dist/src/react/core/approvals/use-action-proposals.d.ts +6 -1
  259. package/dist/src/react/core/approvals/use-action-proposals.d.ts.map +1 -1
  260. package/dist/src/react/core/approvals/use-action-proposals.js +18 -3
  261. package/dist/src/react/core/approvals/use-action-proposals.js.map +1 -1
  262. package/dist/src/react/core/use-agent-chat.d.ts +6 -0
  263. package/dist/src/react/core/use-agent-chat.d.ts.map +1 -1
  264. package/dist/src/react/core/use-agent-chat.js +47 -1
  265. package/dist/src/react/core/use-agent-chat.js.map +1 -1
  266. package/dist/src/tool-presentation.d.ts +6 -0
  267. package/dist/src/tool-presentation.d.ts.map +1 -1
  268. package/dist/src/tool-presentation.js +1 -0
  269. package/dist/src/tool-presentation.js.map +1 -1
  270. package/dist/src/types.d.ts +6 -0
  271. package/dist/src/types.d.ts.map +1 -1
  272. package/package.json +41 -2
  273. package/skills/agent-governance/SKILL.md +13 -2
  274. package/skills/agent-personas-agents/SKILL.md +1 -1
  275. package/skills/agent-tools/SKILL.md +20 -3
@@ -0,0 +1,1017 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import { scopeRole } from '../a2a/gate.js';
4
+ import { PERSONAL_AGENT_ROLE } from '../a2a/permission-tool.js';
5
+ import { runA2aTurn, } from '../a2a/turn.js';
6
+ import { derivedPoppyId, messageFingerprint, newPoppyId, PoppyError, } from './protocol.js';
7
+ import { eventIdSeq, } from './store.js';
8
+ export const DEFAULT_POPPY_TEXTS = {
9
+ handoffUnavailable: 'No one is available to take this conversation right now.',
10
+ failed: 'Sorry, something went wrong on our side. Please try again.',
11
+ signIn: 'Please sign in to your account so I can continue with this request.',
12
+ actionSucceeded: 'Done: {title}',
13
+ actionFailed: 'This could not be done: {title}',
14
+ actionRejected: 'This was not done: {title}',
15
+ };
16
+ /**
17
+ * Default wording: the status line from `texts` with the confirmation title (a question like
18
+ * "Log this dose?" loses its `?`), then the confirmation detail.
19
+ */
20
+ export function formatPoppyAction(action, texts) {
21
+ const title = action.summary.title.trim().replace(/\?+$/, '');
22
+ const template = action.status === 'succeeded'
23
+ ? texts.actionSucceeded
24
+ : action.status === 'failed'
25
+ ? texts.actionFailed
26
+ : texts.actionRejected;
27
+ const line = template.replaceAll('{title}', title).replaceAll('{error}', action.error ?? '');
28
+ const detail = action.summary.detail?.trim();
29
+ return detail ? `${line}\n${detail}` : line;
30
+ }
31
+ const PAGE_SIZE = 100;
32
+ const LEASE_MS = 30_000;
33
+ const RUN_ACTIVE_RETRY_MS = 250;
34
+ const PRUNE_EVERY_MS = 60_000;
35
+ /** The actor of a principal the resolver gave none: stable per (Personal Agent, its User). */
36
+ export function poppyActorId(clientId, userId) {
37
+ return `poppy:${createHash('sha256').update(`${clientId}\n${userId}`).digest('hex').slice(0, 40)}`;
38
+ }
39
+ /** The idempotency owner of §7.3: message ids are unique per User of a Personal Agent. */
40
+ function ownerKey(principal) {
41
+ return createHash('sha256').update(`${principal.clientId}\n${principal.userId}`).digest('hex');
42
+ }
43
+ const hasContent = (message) => (message.text !== undefined && message.text.trim() !== '') || message.data !== undefined;
44
+ /** A user message as the model reads it: its text, and its data as JSON. */
45
+ function turnText(messages) {
46
+ return messages
47
+ .map((message) => [
48
+ message.text?.trim() ?? '',
49
+ message.data !== undefined
50
+ ? `\`\`\`json\n${JSON.stringify(message.data, null, 2)}\n\`\`\``
51
+ : '',
52
+ ]
53
+ .filter((part) => part !== '')
54
+ .join('\n\n'))
55
+ .filter((text) => text !== '')
56
+ .join('\n\n');
57
+ }
58
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
+ /**
60
+ * Poppy conversations (§7) over the agent runtime: the state machine, the event log and the turns.
61
+ * HTTP-free — `createPoppyHandler` puts the protocol's requests on it, and the app drives the
62
+ * Company side through it (a person joining, their messages, `user_requested`).
63
+ *
64
+ * A conversation is an agent thread owned by the principal's actor. A user message is appended to
65
+ * the event log, and the log is the turn queue: a pump per conversation (one replica at a time,
66
+ * by lease) answers the messages after `turnSeq` in one turn, streams its text as `text-delta`,
67
+ * then appends the reply as a `message` event. A replica that finds a conversation `working` with
68
+ * no pump picks it up, so a reply survives the process that started it.
69
+ *
70
+ * @experimental Tracks Personal Agent Protocol Draft 0.1 (https://personalagentprotocol.org/docs/spec),
71
+ * a spec still in development: this API WILL change as the spec evolves — possibly in breaking
72
+ * ways, outside semver majors while it is a draft.
73
+ */
74
+ export class PoppyConversations {
75
+ options;
76
+ #bus = new EventEmitter();
77
+ #pumps = new Map();
78
+ #rekick = new Set();
79
+ #locks = new Map();
80
+ #pruned = new Map();
81
+ #holder = `poppy:${randomUUID()}`;
82
+ #texts;
83
+ #closed = false;
84
+ constructor(options) {
85
+ this.options = options;
86
+ this.#texts = { ...DEFAULT_POPPY_TEXTS, ...options.texts };
87
+ this.#bus.setMaxListeners(0);
88
+ }
89
+ // ── identity and ownership ──────────────────────────────────────────────────
90
+ /**
91
+ * The actor a principal's turns run as: the resolver's (`toActor`), else the app's `actorFor`,
92
+ * else — signed in — the ACCOUNT, `{ id: accountId }`, the way a PACT delegation runs as the
93
+ * account; signed out, `poppy:<hash>` (stable per Personal Agent and User, never an account).
94
+ * It ALWAYS carries `personal_agent` (what puts it behind `personalAgentGate`) and one
95
+ * `scope:<id>` role per account scope of a signed-in token — the roles a PACT delegation gives,
96
+ * so a tool written for personal agents gates both surfaces alike.
97
+ */
98
+ async actorOf(principal) {
99
+ const defaults = principal.actor ?? {
100
+ id: principal.signedIn && principal.accountId
101
+ ? principal.accountId
102
+ : poppyActorId(principal.clientId, principal.userId),
103
+ };
104
+ const actor = principal.actor === undefined && this.options.actorFor
105
+ ? await this.options.actorFor(principal, defaults)
106
+ : defaults;
107
+ const scopes = principal.signedIn ? principal.scopes : [];
108
+ return {
109
+ ...actor,
110
+ roles: [...new Set([...(actor.roles ?? []), PERSONAL_AGENT_ROLE, ...scopes.map(scopeRole)])],
111
+ };
112
+ }
113
+ /** The account a signed-in principal is — what a conversation binds to once used (§7.2). */
114
+ async #accountOf(principal) {
115
+ if (!principal.signedIn)
116
+ return null;
117
+ return principal.accountId ?? (await this.actorOf(principal)).id;
118
+ }
119
+ #grant(principal, actor) {
120
+ return {
121
+ actor,
122
+ scopes: principal.signedIn ? [...principal.scopes] : [],
123
+ signedIn: principal.signedIn,
124
+ };
125
+ }
126
+ /**
127
+ * The conversation, when `principal` may use it (§7.2): the same Personal Agent and User — and
128
+ * once it used an account, a token signed in to that account. Anything else is indistinguishable
129
+ * from no conversation, except a signed-out token of its own User: `sign_in_required` (§7.11).
130
+ */
131
+ async #owned(principal, id) {
132
+ const conversation = await this.options.store.getConversation(id);
133
+ if (!conversation || conversation.clientId !== principal.clientId) {
134
+ throw new PoppyError('conversation_not_found');
135
+ }
136
+ if (conversation.accountRef !== null) {
137
+ if (principal.signedIn) {
138
+ if ((await this.#accountOf(principal)) !== conversation.accountRef) {
139
+ throw new PoppyError('conversation_not_found');
140
+ }
141
+ return conversation;
142
+ }
143
+ if (conversation.userId === principal.userId) {
144
+ throw new PoppyError('sign_in_required', 'This conversation used your account: sign in');
145
+ }
146
+ throw new PoppyError('conversation_not_found');
147
+ }
148
+ if (conversation.userId !== principal.userId)
149
+ throw new PoppyError('conversation_not_found');
150
+ return conversation;
151
+ }
152
+ /** Run `fn` alone among this process's mutations of conversation `id`. */
153
+ async #locked(id, fn) {
154
+ const previous = this.#locks.get(id) ?? Promise.resolve();
155
+ const next = previous.catch(() => { }).then(fn);
156
+ this.#locks.set(id, next);
157
+ try {
158
+ return await next;
159
+ }
160
+ finally {
161
+ if (this.#locks.get(id) === next)
162
+ this.#locks.delete(id);
163
+ }
164
+ }
165
+ // ── the event log ──────────────────────────────────────────────────────────
166
+ async #append(conversationId, body, key) {
167
+ const { event, created } = await this.options.store.appendEvent(conversationId, body, key !== undefined ? { key } : {});
168
+ if (created)
169
+ this.#bus.emit(`event:${conversationId}`, event);
170
+ return event;
171
+ }
172
+ /** Change status and/or responder; every change is a `state` event (§7.7). */
173
+ async #setState(conversation, next, extra = {}) {
174
+ const status = next.status ?? conversation.status;
175
+ const responder = next.responder ?? conversation.responder;
176
+ const changed = status !== conversation.status || responder !== conversation.responder;
177
+ await this.options.store.updateConversation(conversation.id, { status, responder, ...extra });
178
+ const updated = { ...conversation, ...extra, status, responder };
179
+ if (changed)
180
+ await this.#append(conversation.id, { type: 'state', status, responder });
181
+ return updated;
182
+ }
183
+ #companyMessage(text, data, sender, id = newPoppyId('msg')) {
184
+ return {
185
+ id,
186
+ role: 'company',
187
+ sender,
188
+ ...(text !== undefined && text !== '' ? { text } : {}),
189
+ ...(data !== undefined ? { data } : {}),
190
+ };
191
+ }
192
+ // ── Personal Agent requests ─────────────────────────────────────────────────
193
+ /** `POST {endpoint}` — start a conversation (or a Direct Conversation) with its first message. */
194
+ async start(principal, body) {
195
+ const actor = await this.actorOf(principal);
196
+ const owner = ownerKey(principal);
197
+ const fingerprint = messageFingerprint('new', body);
198
+ let parent = null;
199
+ if (body.parentConversationId !== undefined) {
200
+ parent = await this.#owned(principal, body.parentConversationId);
201
+ // A Direct Conversation is not a parent (§7.10): as a parent, it does not exist.
202
+ if (parent.parentId !== null)
203
+ throw new PoppyError('conversation_not_found');
204
+ }
205
+ for (;;) {
206
+ const id = newPoppyId('cnv');
207
+ const content = hasContent(body.message);
208
+ const response = {
209
+ status: 201,
210
+ body: {
211
+ conversation_id: id,
212
+ status: (content ? 'working' : 'idle'),
213
+ responder: 'agent',
214
+ },
215
+ };
216
+ const claim = await this.options.store.claimMessage(owner, body.message.id, {
217
+ fingerprint,
218
+ conversationId: id,
219
+ response,
220
+ });
221
+ if (claim.status === 'existing') {
222
+ if (claim.fingerprint !== fingerprint)
223
+ throw new PoppyError('message_id_conflict');
224
+ const existing = await this.options.store.getConversation(claim.conversationId);
225
+ if (existing) {
226
+ // A retried first message: the conversation it already started (§7.3).
227
+ return { ...claim.response, conversationId: claim.conversationId };
228
+ }
229
+ // Claimed by a delivery that died before creating it: take it over.
230
+ await this.options.store.releaseMessage(owner, body.message.id);
231
+ continue;
232
+ }
233
+ try {
234
+ if (parent !== null) {
235
+ const parentId = parent.id;
236
+ await this.#locked(parentId, async () => {
237
+ const current = await this.#owned(principal, parentId);
238
+ await this.#assertNoDirect(current);
239
+ if (current.status === 'closed')
240
+ throw new PoppyError('conversation_closed');
241
+ await this.#create(id, principal, actor, current);
242
+ await this.options.store.updateConversation(current.id, { openDirectId: id });
243
+ await this.#append(current.id, { type: 'direct_opened', conversation_id: id });
244
+ });
245
+ }
246
+ else {
247
+ await this.#create(id, principal, actor, null);
248
+ }
249
+ }
250
+ catch (error) {
251
+ await this.options.store.releaseMessage(owner, body.message.id);
252
+ throw error;
253
+ }
254
+ const created = (await this.options.store.getConversation(id));
255
+ await this.#locked(id, () => this.#acceptMessage(created, body.message, response.body.status, {}));
256
+ if (parent !== null) {
257
+ const parentId = parent.id;
258
+ await this.#hook('direct.opened', () => this.options.direct?.opened?.({
259
+ conversationId: id,
260
+ conversation: created,
261
+ actor,
262
+ parentId,
263
+ }));
264
+ }
265
+ return { ...response, conversationId: id };
266
+ }
267
+ }
268
+ async #create(id, principal, actor, parent) {
269
+ const now = Date.now();
270
+ await this.options.store.createConversation({
271
+ id,
272
+ clientId: principal.clientId,
273
+ userId: principal.userId,
274
+ accountRef: await this.#accountOf(principal),
275
+ agentName: parent?.agentName ?? this.options.agentName ?? '',
276
+ threadId: parent?.threadId ?? null,
277
+ parentId: parent?.id ?? null,
278
+ openDirectId: null,
279
+ status: 'idle',
280
+ responder: 'agent',
281
+ context: {},
282
+ activeRunId: null,
283
+ turnSeq: 0,
284
+ grant: this.#grant(principal, actor),
285
+ prunedSeq: 0,
286
+ createdAt: now,
287
+ updatedAt: now,
288
+ });
289
+ }
290
+ async #assertNoDirect(conversation) {
291
+ if (conversation.openDirectId === null)
292
+ return;
293
+ const direct = await this.options.store.getConversation(conversation.openDirectId);
294
+ if (direct && direct.status !== 'closed') {
295
+ throw new PoppyError('direct_conversation_open', undefined, {
296
+ conversation_id: direct.id,
297
+ });
298
+ }
299
+ // A Direct Conversation that closed without telling its parent: the parent is free.
300
+ await this.options.store.updateConversation(conversation.id, { openDirectId: null });
301
+ }
302
+ /** `POST {endpoint}/{id}/messages` — another message (§7.3). */
303
+ async send(principal, conversationId, body) {
304
+ const owned = await this.#owned(principal, conversationId);
305
+ const actor = await this.actorOf(principal);
306
+ const owner = ownerKey(principal);
307
+ const fingerprint = messageFingerprint(conversationId, body);
308
+ // A retry is answered as before, whatever happened since (§7.3).
309
+ return this.#locked(conversationId, async () => {
310
+ const conversation = (await this.options.store.getConversation(owned.id)) ?? owned;
311
+ const content = hasContent(body.message);
312
+ const toAgent = conversation.responder === 'agent' && conversation.status !== 'queued';
313
+ const status = content && toAgent && conversation.status !== 'closed' ? 'working' : conversation.status;
314
+ const response = {
315
+ status: 202,
316
+ body: { conversation_id: conversationId, status, responder: conversation.responder },
317
+ };
318
+ const claim = await this.options.store.claimMessage(owner, body.message.id, {
319
+ fingerprint,
320
+ conversationId,
321
+ response,
322
+ });
323
+ if (claim.status === 'existing') {
324
+ if (claim.fingerprint !== fingerprint)
325
+ throw new PoppyError('message_id_conflict');
326
+ return claim.response;
327
+ }
328
+ try {
329
+ if (conversation.status === 'closed')
330
+ throw new PoppyError('conversation_closed');
331
+ await this.#assertNoDirect(conversation);
332
+ }
333
+ catch (error) {
334
+ await this.options.store.releaseMessage(owner, body.message.id);
335
+ throw error;
336
+ }
337
+ const patch = { grant: this.#grant(principal, actor) };
338
+ // Once it is used signed in, the conversation belongs to that account (§7.2).
339
+ if (principal.signedIn && conversation.accountRef === null) {
340
+ patch.accountRef = await this.#accountOf(principal);
341
+ }
342
+ await this.#acceptMessage(conversation, body.message, status, patch);
343
+ return response;
344
+ });
345
+ }
346
+ /**
347
+ * Record a user message, and hand it to whoever answers: the agent's pump, or — while a person
348
+ * handles the conversation or is being found — the app's handoff hook.
349
+ */
350
+ async #acceptMessage(conversation, message, status, patch) {
351
+ const context = message.context !== undefined
352
+ ? { ...conversation.context, ...message.context }
353
+ : conversation.context;
354
+ if (message.context !== undefined)
355
+ patch = { ...patch, context };
356
+ const eventMessage = { role: 'user', ...message };
357
+ const stored = await this.#append(conversation.id, { type: 'message', message: eventMessage });
358
+ const toAgent = conversation.responder === 'agent' && conversation.status !== 'queued';
359
+ if (!toAgent)
360
+ patch = { ...patch, turnSeq: stored.seq };
361
+ await this.#setState(conversation, { status }, patch);
362
+ if (!toAgent) {
363
+ await this.#hook('handoff.message', () => this.options.handoff?.message?.({
364
+ conversationId: conversation.id,
365
+ conversation: { ...conversation, context },
366
+ actor: conversation.grant?.actor ?? null,
367
+ message: eventMessage,
368
+ }));
369
+ return;
370
+ }
371
+ if (hasContent(message))
372
+ this.#kick(conversation.id);
373
+ }
374
+ /** `GET {endpoint}/{id}/events` without streaming — read (and wait for) events (§7.5). */
375
+ async read(principal, conversationId, options) {
376
+ const conversation = await this.#owned(principal, conversationId);
377
+ return this.readOwned(conversation, options);
378
+ }
379
+ /** A read of a conversation already checked — also what a POST with `wait` answers (§7.3). */
380
+ async readOwned(conversation, options) {
381
+ await this.#maintain(conversation);
382
+ let afterSeq = await this.#cursorSeq(conversation, options.cursor);
383
+ const deadline = Date.now() + (options.waitMs ?? 0);
384
+ let page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE + 1);
385
+ while (page.length === 0 && Date.now() < deadline && !this.#closed) {
386
+ await this.#waitForEvent(conversation.id, deadline - Date.now());
387
+ page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE + 1);
388
+ }
389
+ const events = page.slice(0, PAGE_SIZE);
390
+ if (events.length > 0)
391
+ afterSeq = events[events.length - 1]?.seq ?? afterSeq;
392
+ const current = (await this.options.store.getConversation(conversation.id)) ?? conversation;
393
+ return {
394
+ conversation_id: conversation.id,
395
+ events: events.map((entry) => entry.event),
396
+ cursor: events.at(-1)?.event.id ?? options.cursor ?? null,
397
+ has_more: page.length > PAGE_SIZE,
398
+ status: current.status,
399
+ responder: current.responder,
400
+ };
401
+ }
402
+ /** Check a conversation is the principal's, for a caller that reads it afterwards. */
403
+ owned(principal, conversationId) {
404
+ return this.#owned(principal, conversationId);
405
+ }
406
+ /**
407
+ * `GET {endpoint}/{id}/events` as SSE (§7.6): the events after `cursor`, then new ones as they
408
+ * come, with `text-delta` pieces of a reply being written in this process. Ends when `signal`
409
+ * aborts, or once the conversation is closed and every event was sent.
410
+ */
411
+ async *stream(conversation, cursor, signal) {
412
+ await this.#maintain(conversation);
413
+ let afterSeq = await this.#cursorSeq(conversation, cursor);
414
+ const pending = [];
415
+ let wake = null;
416
+ const notify = () => {
417
+ wake?.();
418
+ wake = null;
419
+ };
420
+ const onDelta = (delta) => {
421
+ pending.push({ kind: 'delta', ...delta });
422
+ notify();
423
+ };
424
+ let dirty = true;
425
+ const onEvent = () => {
426
+ dirty = true;
427
+ notify();
428
+ };
429
+ this.#bus.on(`event:${conversation.id}`, onEvent);
430
+ this.#bus.on(`delta:${conversation.id}`, onDelta);
431
+ signal.addEventListener('abort', notify, { once: true });
432
+ try {
433
+ while (!signal.aborted && !this.#closed) {
434
+ // Deltas that arrived before the events now in the log were written before them.
435
+ while (pending.length > 0)
436
+ yield pending.shift();
437
+ if (dirty) {
438
+ dirty = false;
439
+ const page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE);
440
+ while (pending.length > 0)
441
+ yield pending.shift();
442
+ for (const entry of page) {
443
+ afterSeq = entry.seq;
444
+ yield { kind: 'event', event: entry.event };
445
+ }
446
+ if (page.length === PAGE_SIZE) {
447
+ dirty = true;
448
+ continue;
449
+ }
450
+ const current = await this.options.store.getConversation(conversation.id);
451
+ if (!current || current.status === 'closed') {
452
+ yield { kind: 'state', status: 'closed' };
453
+ return;
454
+ }
455
+ if (current.status === 'working' && !this.#pumps.has(current.id)) {
456
+ // A turn nobody here is writing (a replica that died): take it up.
457
+ this.#kick(current.id);
458
+ }
459
+ }
460
+ await new Promise((resolve) => {
461
+ wake = resolve;
462
+ const timer = setTimeout(() => {
463
+ // Another replica appends without telling this one: look again now and then.
464
+ dirty = true;
465
+ resolve();
466
+ }, this.options.pollMs ?? 1000);
467
+ timer.unref?.();
468
+ if (pending.length > 0 || dirty || signal.aborted)
469
+ resolve();
470
+ });
471
+ wake = null;
472
+ }
473
+ }
474
+ finally {
475
+ this.#bus.off(`event:${conversation.id}`, onEvent);
476
+ this.#bus.off(`delta:${conversation.id}`, onDelta);
477
+ signal.removeEventListener('abort', notify);
478
+ }
479
+ }
480
+ async #waitForEvent(conversationId, ms) {
481
+ await new Promise((resolve) => {
482
+ const done = () => {
483
+ clearTimeout(timer);
484
+ this.#bus.off(`event:${conversationId}`, done);
485
+ resolve();
486
+ };
487
+ const timer = setTimeout(done, Math.max(0, Math.min(ms, this.options.pollMs ?? 1000)));
488
+ this.#bus.on(`event:${conversationId}`, done);
489
+ });
490
+ }
491
+ /** The position after which a read starts (§7.5): unknown → `invalid_cursor`, pruned → expired. */
492
+ async #cursorSeq(conversation, cursor) {
493
+ if (cursor === undefined)
494
+ return 0;
495
+ const seq = await this.options.store.findEventSeq(conversation.id, cursor);
496
+ if (seq !== null)
497
+ return seq;
498
+ const minted = eventIdSeq(cursor);
499
+ const current = (await this.options.store.getConversation(conversation.id)) ?? conversation;
500
+ if (minted !== null && minted <= current.prunedSeq)
501
+ throw new PoppyError('cursor_expired');
502
+ throw new PoppyError('invalid_cursor');
503
+ }
504
+ /** Housekeeping on read: prune past the retention, and take up a turn nobody is pumping. */
505
+ async #maintain(conversation) {
506
+ const retention = this.options.retentionMs;
507
+ if (retention !== undefined) {
508
+ const last = this.#pruned.get(conversation.id) ?? 0;
509
+ if (Date.now() - last >= PRUNE_EVERY_MS) {
510
+ this.#pruned.set(conversation.id, Date.now());
511
+ await this.options.store.pruneEvents(conversation.id, Date.now() - retention);
512
+ }
513
+ }
514
+ if (conversation.status === 'working' && !this.#pumps.has(conversation.id)) {
515
+ this.#kick(conversation.id);
516
+ }
517
+ }
518
+ /** `POST {endpoint}/{id}/handoff` — ask for a person at the Company (§7.9). */
519
+ async requestHandoff(principal, conversationId) {
520
+ const owned = await this.#owned(principal, conversationId);
521
+ const conversation = await this.#locked(owned.id, async () => {
522
+ const current = (await this.options.store.getConversation(owned.id)) ?? owned;
523
+ if (current.status === 'closed')
524
+ throw new PoppyError('conversation_closed');
525
+ await this.#assertNoDirect(current);
526
+ return current;
527
+ });
528
+ return this.#handoff(conversation, 'requested');
529
+ }
530
+ async #handoff(conversation, by) {
531
+ // Already with a person, or being found one: nothing more to ask for.
532
+ if (conversation.status === 'queued' || conversation.responder === 'human') {
533
+ return { status: conversation.status, responder: conversation.responder };
534
+ }
535
+ const hooks = this.options.handoff;
536
+ if (!hooks) {
537
+ // No people to hand off to: the Company Agent says so, and keeps the conversation (§7.9).
538
+ await this.#append(conversation.id, {
539
+ type: 'message',
540
+ message: this.#companyMessage(this.#texts.handoffUnavailable, undefined, 'agent'),
541
+ });
542
+ return { status: conversation.status, responder: conversation.responder };
543
+ }
544
+ const queued = await this.#locked(conversation.id, async () => {
545
+ const current = (await this.options.store.getConversation(conversation.id)) ?? conversation;
546
+ return this.#setState(current, { status: 'queued' });
547
+ });
548
+ let decision;
549
+ try {
550
+ decision = await hooks.request({
551
+ conversationId: conversation.id,
552
+ conversation: queued,
553
+ actor: queued.grant?.actor ?? null,
554
+ by,
555
+ });
556
+ }
557
+ catch (error) {
558
+ this.options.onError?.(error, 'poppy: handoff.request failed');
559
+ decision = { unavailable: this.#texts.handoffUnavailable };
560
+ }
561
+ if (decision === 'joined')
562
+ return this.humanJoined(conversation.id);
563
+ if (typeof decision === 'object') {
564
+ return this.handoffUnavailable(conversation.id, decision.unavailable);
565
+ }
566
+ return { status: queued.status, responder: queued.responder };
567
+ }
568
+ /** `POST {endpoint}/{id}/close` — close the conversation (§7.12). */
569
+ async closeByAgent(principal, conversationId) {
570
+ const conversation = await this.#owned(principal, conversationId);
571
+ return this.close(conversation.id);
572
+ }
573
+ // ── the Company side (app API) ─────────────────────────────────────────────
574
+ /** Look a conversation up — for the app's own routing (a support tool, an admin page). */
575
+ get(conversationId) {
576
+ return this.options.store.getConversation(conversationId);
577
+ }
578
+ async #require(conversationId) {
579
+ const conversation = await this.options.store.getConversation(conversationId);
580
+ if (!conversation)
581
+ throw new PoppyError('conversation_not_found');
582
+ return conversation;
583
+ }
584
+ /**
585
+ * Close a conversation (§7.12): its open Direct Conversation first, a pending handoff is
586
+ * cancelled, and a `closed` state event added. Nothing it started is undone. Idempotent.
587
+ */
588
+ async close(conversationId) {
589
+ const conversation = await this.#require(conversationId);
590
+ if (conversation.status === 'closed') {
591
+ return { status: 'closed', responder: conversation.responder };
592
+ }
593
+ if (conversation.openDirectId !== null) {
594
+ const direct = await this.options.store.getConversation(conversation.openDirectId);
595
+ if (direct && direct.status !== 'closed')
596
+ await this.close(direct.id);
597
+ }
598
+ const before = (await this.options.store.getConversation(conversationId)) ?? conversation;
599
+ const closed = await this.#locked(conversationId, async () => {
600
+ const current = (await this.options.store.getConversation(conversationId)) ?? before;
601
+ if (current.status === 'closed')
602
+ return current;
603
+ return this.#setState(current, { status: 'closed' });
604
+ });
605
+ if (before.status === 'queued' || before.responder === 'human') {
606
+ await this.#hook('handoff.cancel', () => this.options.handoff?.cancel?.({
607
+ conversationId,
608
+ conversation: closed,
609
+ actor: closed.grant?.actor ?? null,
610
+ }));
611
+ }
612
+ if (closed.parentId !== null) {
613
+ const parentId = closed.parentId;
614
+ await this.#locked(parentId, async () => {
615
+ const parent = await this.options.store.getConversation(parentId);
616
+ if (parent?.openDirectId === conversationId) {
617
+ await this.options.store.updateConversation(parentId, { openDirectId: null });
618
+ await this.#append(parentId, { type: 'direct_closed', conversation_id: conversationId });
619
+ }
620
+ });
621
+ await this.#hook('direct.closed', () => this.options.direct?.closed?.({
622
+ conversationId,
623
+ conversation: closed,
624
+ actor: closed.grant?.actor ?? null,
625
+ parentId,
626
+ }));
627
+ }
628
+ return { status: 'closed', responder: closed.responder };
629
+ }
630
+ /** The Company Agent hands off to a person on its own (§7.9) — e.g. from one of your tools. */
631
+ async handoff(conversationId) {
632
+ const conversation = await this.#require(conversationId);
633
+ if (conversation.status === 'closed')
634
+ throw new PoppyError('conversation_closed');
635
+ return this.#handoff(conversation, 'company');
636
+ }
637
+ /** A person at the Company joined: `responder` becomes `human` (§7.7). */
638
+ async humanJoined(conversationId) {
639
+ return this.#locked(conversationId, async () => {
640
+ const conversation = await this.#require(conversationId);
641
+ if (conversation.status === 'closed')
642
+ throw new PoppyError('conversation_closed');
643
+ const status = conversation.status === 'queued' ? 'idle' : conversation.status;
644
+ const updated = await this.#setState(conversation, { status, responder: 'human' });
645
+ return { status: updated.status, responder: updated.responder };
646
+ });
647
+ }
648
+ /** The person left: the Company Agent answers again. */
649
+ async humanLeft(conversationId) {
650
+ return this.#locked(conversationId, async () => {
651
+ const conversation = await this.#require(conversationId);
652
+ if (conversation.status === 'closed') {
653
+ return { status: conversation.status, responder: conversation.responder };
654
+ }
655
+ const status = conversation.status === 'queued' ? 'idle' : conversation.status;
656
+ const updated = await this.#setState(conversation, { status, responder: 'agent' });
657
+ return { status: updated.status, responder: updated.responder };
658
+ });
659
+ }
660
+ /**
661
+ * No one could take a queued handoff: the Company Agent says why, and the conversation goes back
662
+ * to `idle` with the agent (§7.9).
663
+ */
664
+ async handoffUnavailable(conversationId, reason = this.#texts.handoffUnavailable) {
665
+ return this.#locked(conversationId, async () => {
666
+ const conversation = await this.#require(conversationId);
667
+ if (conversation.status !== 'queued') {
668
+ return { status: conversation.status, responder: conversation.responder };
669
+ }
670
+ await this.#append(conversationId, {
671
+ type: 'message',
672
+ message: this.#companyMessage(reason, undefined, 'agent'),
673
+ });
674
+ const updated = await this.#setState(conversation, { status: 'idle', responder: 'agent' });
675
+ return { status: updated.status, responder: updated.responder };
676
+ });
677
+ }
678
+ /**
679
+ * Add a Company message — a person's (`sender: 'human'`, while they handle the conversation) or
680
+ * an automated one. Answers the message's id.
681
+ */
682
+ async postMessage(conversationId, message) {
683
+ const conversation = await this.#require(conversationId);
684
+ if (conversation.status === 'closed')
685
+ throw new PoppyError('conversation_closed');
686
+ const event = this.#companyMessage(message.text, message.data, message.sender);
687
+ await this.#append(conversationId, { type: 'message', message: event });
688
+ return event.id;
689
+ }
690
+ /**
691
+ * Ask to talk with the User directly (§7.10): a `user_requested` event. Only the Personal Agent
692
+ * opens the Direct Conversation, once the User agrees.
693
+ */
694
+ async requestUser(conversationId, reason) {
695
+ const conversation = await this.#require(conversationId);
696
+ if (conversation.status === 'closed')
697
+ throw new PoppyError('conversation_closed');
698
+ if (conversation.parentId !== null) {
699
+ throw new PoppyError('invalid_request', 'A Direct Conversation already has the User');
700
+ }
701
+ await this.#append(conversationId, { type: 'user_requested', reason });
702
+ }
703
+ /** Stop pumping and waiting in this process (shutdown). */
704
+ shutdown() {
705
+ this.#closed = true;
706
+ this.#bus.emit('shutdown');
707
+ }
708
+ // ── turns ──────────────────────────────────────────────────────────────────
709
+ /** Make sure a pump runs for the conversation in this process (if no replica holds it). */
710
+ #kick(conversationId) {
711
+ if (this.#closed)
712
+ return;
713
+ if (this.#pumps.has(conversationId)) {
714
+ this.#rekick.add(conversationId);
715
+ return;
716
+ }
717
+ const pump = this.#pump(conversationId)
718
+ .catch((error) => this.options.onError?.(error, 'poppy: turn failed'))
719
+ .finally(() => {
720
+ this.#pumps.delete(conversationId);
721
+ if (this.#rekick.delete(conversationId))
722
+ this.#kick(conversationId);
723
+ });
724
+ this.#pumps.set(conversationId, pump);
725
+ }
726
+ /**
727
+ * Answer what waits in the conversation's log, one turn at a time, while this process holds its
728
+ * lease. Messages that arrived during a turn are answered together by the next one.
729
+ */
730
+ async #pump(conversationId) {
731
+ const store = this.options.store;
732
+ if (!(await store.leaseReader(conversationId, this.#holder, LEASE_MS)))
733
+ return;
734
+ const renew = setInterval(() => {
735
+ void store.leaseReader(conversationId, this.#holder, LEASE_MS).catch(() => { });
736
+ }, LEASE_MS / 3);
737
+ renew.unref?.();
738
+ try {
739
+ for (;;) {
740
+ this.#rekick.delete(conversationId);
741
+ const conversation = await store.getConversation(conversationId);
742
+ if (!conversation || conversation.status === 'closed')
743
+ return;
744
+ if (conversation.activeRunId !== null) {
745
+ // A turn whose writer died: write its reply from the run's own stream.
746
+ await this.#recover(conversation, conversation.activeRunId);
747
+ continue;
748
+ }
749
+ // Decided under the lock a send takes, so a message accepted meanwhile is never left
750
+ // behind an `idle` conversation.
751
+ const waiting = await this.#locked(conversationId, async () => {
752
+ const current = await store.getConversation(conversationId);
753
+ if (!current || current.status === 'closed')
754
+ return null;
755
+ const toAgent = current.responder === 'agent' && current.status !== 'queued';
756
+ const found = toAgent
757
+ ? (await this.#unanswered(current)).filter((entry) => entry.message.text !== undefined || entry.message.data !== undefined)
758
+ : [];
759
+ if (found.length === 0) {
760
+ if (current.status === 'working')
761
+ await this.#setState(current, { status: 'idle' });
762
+ return null;
763
+ }
764
+ if (current.status !== 'working')
765
+ await this.#setState(current, { status: 'working' });
766
+ return { current, found };
767
+ });
768
+ if (waiting === null) {
769
+ if (!this.#rekick.has(conversationId))
770
+ return;
771
+ continue;
772
+ }
773
+ await this.#turn(waiting.current, waiting.found.map((entry) => entry.message), waiting.found.at(-1)?.seq ?? 0);
774
+ }
775
+ }
776
+ finally {
777
+ clearInterval(renew);
778
+ await store.releaseReader(conversationId, this.#holder).catch(() => { });
779
+ }
780
+ }
781
+ /** User messages after `turnSeq`. */
782
+ async #unanswered(conversation) {
783
+ const out = [];
784
+ let after = conversation.turnSeq;
785
+ for (;;) {
786
+ const page = await this.options.store.listEvents(conversation.id, after, PAGE_SIZE);
787
+ for (const entry of page) {
788
+ after = entry.seq;
789
+ const message = entry.event.message;
790
+ if (entry.event.type === 'message' && message?.role === 'user') {
791
+ out.push({ seq: entry.seq, message });
792
+ }
793
+ }
794
+ if (page.length < PAGE_SIZE)
795
+ return out;
796
+ }
797
+ }
798
+ async #turn(conversation, messages, upToSeq) {
799
+ const grant = conversation.grant;
800
+ if (!grant) {
801
+ await this.options.store.updateConversation(conversation.id, { turnSeq: upToSeq });
802
+ return;
803
+ }
804
+ const deadline = Date.now() + (this.options.timeoutMs ?? 120_000);
805
+ const components = new Map();
806
+ let replyId = null;
807
+ let runId = null;
808
+ const last = messages.at(-1);
809
+ const service = this.options.service;
810
+ // A Direct Conversation answers in its parent's thread — which a later parent turn may create.
811
+ let threadId = conversation.threadId;
812
+ if (threadId === null && conversation.parentId !== null) {
813
+ threadId =
814
+ (await this.options.store.getConversation(conversation.parentId))?.threadId ?? null;
815
+ }
816
+ for (;;) {
817
+ try {
818
+ const result = await runA2aTurn(service, this.options.toolRoles, {
819
+ actor: grant.actor,
820
+ text: turnText(messages),
821
+ ...(conversation.agentName !== '' ? { agentName: conversation.agentName } : {}),
822
+ ...(threadId !== null ? { threadId } : {}),
823
+ delegatedScopes: grant.signedIn ? grant.scopes : null,
824
+ actions: this.options.actions ?? 'approve-delegated',
825
+ timeoutMs: Math.max(1, deadline - Date.now()),
826
+ via: 'poppy',
827
+ pageContext: {
828
+ channel: 'poppy',
829
+ poppy: {
830
+ conversationId: conversation.id,
831
+ direct: conversation.parentId !== null,
832
+ sender: last?.sender ?? 'agent',
833
+ signedIn: grant.signedIn,
834
+ context: conversation.context,
835
+ },
836
+ },
837
+ ...(this.options.drive !== false && service.drive
838
+ ? { drive: (id) => service.drive?.(id) ?? Promise.resolve() }
839
+ : {}),
840
+ onStarted: async (startedThreadId, startedRunId) => {
841
+ runId = startedRunId;
842
+ replyId = derivedPoppyId('msg', `reply:${startedRunId}`);
843
+ await this.options.store.updateConversation(conversation.id, {
844
+ threadId: startedThreadId,
845
+ activeRunId: startedRunId,
846
+ turnSeq: upToSeq,
847
+ });
848
+ // A Direct Conversation's first turn may create the thread its parent shares.
849
+ if (conversation.parentId !== null && threadId === null) {
850
+ const parent = await this.options.store.getConversation(conversation.parentId);
851
+ if (parent && parent.threadId === null) {
852
+ await this.options.store.updateConversation(parent.id, {
853
+ threadId: startedThreadId,
854
+ });
855
+ }
856
+ }
857
+ },
858
+ onText: (delta) => {
859
+ if (replyId !== null) {
860
+ this.#bus.emit(`delta:${conversation.id}`, { messageId: replyId, text: delta });
861
+ }
862
+ },
863
+ onComponent: (frame) => {
864
+ if (frame.partial === true)
865
+ return;
866
+ components.set(frame.id ?? `ui:${components.size}`, frame);
867
+ },
868
+ });
869
+ await this.#reply(conversation, grant, runId ?? randomUUID(), {
870
+ text: result.text,
871
+ components: [...components.values()],
872
+ outcomes: result.outcomes,
873
+ permission: result.permission,
874
+ failed: result.error !== null,
875
+ });
876
+ return;
877
+ }
878
+ catch (error) {
879
+ // The thread is busy — its other conversation (parent / Direct) or a run another replica
880
+ // started: wait for it, within the turn's time.
881
+ if (error.code === 'run_active' && Date.now() < deadline) {
882
+ await sleep(RUN_ACTIVE_RETRY_MS);
883
+ continue;
884
+ }
885
+ if (runId === null) {
886
+ await this.options.store.updateConversation(conversation.id, { turnSeq: upToSeq });
887
+ }
888
+ await this.#reply(conversation, grant, runId ?? randomUUID(), {
889
+ text: '',
890
+ components: [],
891
+ outcomes: [],
892
+ permission: null,
893
+ failed: true,
894
+ });
895
+ throw error;
896
+ }
897
+ }
898
+ }
899
+ /** Read a run whose writer died from its stream, and write its reply (once: keyed by run). */
900
+ async #recover(conversation, runId) {
901
+ const grant = conversation.grant;
902
+ let text = '';
903
+ const components = new Map();
904
+ let failed = false;
905
+ const iterator = this.options.service.subscribe(runId)[Symbol.asyncIterator]();
906
+ const deadline = Date.now() + (this.options.timeoutMs ?? 120_000);
907
+ try {
908
+ for (;;) {
909
+ let timer;
910
+ const timeout = new Promise((resolve) => {
911
+ timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now()));
912
+ });
913
+ const next = await Promise.race([iterator.next(), timeout]).finally(() => clearTimeout(timer));
914
+ if (next === 'timeout') {
915
+ failed = text === '';
916
+ await this.options.service.cancel(runId).catch(() => { });
917
+ break;
918
+ }
919
+ if (next.done)
920
+ break;
921
+ const frame = next.value;
922
+ if (frame.t === 'text')
923
+ text += frame.v;
924
+ else if (frame.t === 'component' && frame.partial !== true) {
925
+ components.set(frame.id ?? `ui:${components.size}`, frame);
926
+ }
927
+ else if (frame.t === 'error')
928
+ failed = true;
929
+ }
930
+ }
931
+ finally {
932
+ void iterator.return?.();
933
+ }
934
+ await this.#reply(conversation, grant ?? { actor: { id: '' }, scopes: [], signedIn: false }, runId, {
935
+ text,
936
+ components: [...components.values()],
937
+ outcomes: [],
938
+ permission: null,
939
+ failed,
940
+ });
941
+ }
942
+ /**
943
+ * Write a turn's reply: a `message` event (text, and `data` for components and executed actions),
944
+ * then an `authorization` event when the turn needed a scope the token does not carry (§7.11).
945
+ */
946
+ async #reply(conversation, grant, runId, turn) {
947
+ const store = this.options.store;
948
+ const current = await store.getConversation(conversation.id);
949
+ await store.updateConversation(conversation.id, { activeRunId: null });
950
+ // A closed conversation takes no more events from its agent (§7.12).
951
+ if (!current || current.status === 'closed')
952
+ return;
953
+ const components = turn.components.map((frame) => ({
954
+ name: frame.name,
955
+ props: frame.data,
956
+ ...(frame.fallbackText !== undefined ? { fallback_text: frame.fallbackText } : {}),
957
+ }));
958
+ let text = turn.text.trim();
959
+ if (text === '') {
960
+ // A component's fallback is all a reader that does not render it can show.
961
+ text = turn.components
962
+ .map((frame) => frame.fallbackText?.trim() ?? '')
963
+ .filter((part) => part !== '')
964
+ .join('\n\n');
965
+ }
966
+ // Every action is told in words too: a reader that ignores `data` still learns what was done.
967
+ // The model wrote its text before an action's result — so when one did not go through, the
968
+ // reply is the actions' own account (the streamed deltas are provisional and get replaced).
969
+ if (turn.outcomes.length > 0) {
970
+ const format = this.options.formatAction ?? formatPoppyAction;
971
+ const told = turn.outcomes
972
+ .map((action) => format(action, this.#texts)?.trim() ?? '')
973
+ .filter((line) => line !== '')
974
+ .join('\n\n');
975
+ const allDone = turn.outcomes.every((action) => action.status === 'succeeded');
976
+ if (!allDone || text === '')
977
+ text = told;
978
+ }
979
+ if (text === '' && turn.failed && components.length === 0 && turn.outcomes.length === 0) {
980
+ text = this.#texts.failed;
981
+ }
982
+ const data = {
983
+ ...(components.length > 0 ? { components } : {}),
984
+ ...(turn.outcomes.length > 0 ? { actions: turn.outcomes } : {}),
985
+ };
986
+ if (text !== '' || Object.keys(data).length > 0) {
987
+ await this.#append(conversation.id, {
988
+ type: 'message',
989
+ message: this.#companyMessage(text, Object.keys(data).length > 0 ? data : undefined, 'agent', derivedPoppyId('msg', `reply:${runId}`)),
990
+ }, `reply:${runId}`);
991
+ }
992
+ const missing = (turn.permission ?? []).filter((scope) => !grant.signedIn || !grant.scopes.includes(scope));
993
+ if (missing.length > 0) {
994
+ await this.#append(conversation.id, {
995
+ type: 'authorization',
996
+ error: grant.signedIn ? 'insufficient_scope' : 'sign_in_required',
997
+ scope: missing.join(' '),
998
+ }, `authorization:${runId}`);
999
+ // In a Direct Conversation the User reads the messages, not the events: ask there too.
1000
+ if (conversation.parentId !== null) {
1001
+ await this.#append(conversation.id, {
1002
+ type: 'message',
1003
+ message: this.#companyMessage(this.#texts.signIn, undefined, 'agent', derivedPoppyId('msg', `sign-in:${runId}`)),
1004
+ }, `sign-in:${runId}`);
1005
+ }
1006
+ }
1007
+ }
1008
+ async #hook(name, fn) {
1009
+ try {
1010
+ await fn();
1011
+ }
1012
+ catch (error) {
1013
+ this.options.onError?.(error, `poppy: ${name} hook failed`);
1014
+ }
1015
+ }
1016
+ }
1017
+ //# sourceMappingURL=conversations.js.map