@abloatai/ablo 0.3.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 (278) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +12 -0
  4. package/README.md +230 -0
  5. package/dist/BaseSyncedStore.d.ts +709 -0
  6. package/dist/BaseSyncedStore.js +1843 -0
  7. package/dist/Database.d.ts +344 -0
  8. package/dist/Database.js +1259 -0
  9. package/dist/LazyReferenceCollection.d.ts +181 -0
  10. package/dist/LazyReferenceCollection.js +460 -0
  11. package/dist/Model.d.ts +339 -0
  12. package/dist/Model.js +715 -0
  13. package/dist/ModelRegistry.d.ts +200 -0
  14. package/dist/ModelRegistry.js +535 -0
  15. package/dist/NetworkMonitor.d.ts +27 -0
  16. package/dist/NetworkMonitor.js +73 -0
  17. package/dist/ObjectPool.d.ts +202 -0
  18. package/dist/ObjectPool.js +1106 -0
  19. package/dist/SyncClient.d.ts +489 -0
  20. package/dist/SyncClient.js +1555 -0
  21. package/dist/SyncEngineContext.d.ts +46 -0
  22. package/dist/SyncEngineContext.js +74 -0
  23. package/dist/adapters/alwaysOnline.d.ts +16 -0
  24. package/dist/adapters/alwaysOnline.js +19 -0
  25. package/dist/adapters/inMemoryStorage.d.ts +30 -0
  26. package/dist/adapters/inMemoryStorage.js +94 -0
  27. package/dist/agent/Agent.d.ts +358 -0
  28. package/dist/agent/Agent.js +500 -0
  29. package/dist/agent/index.d.ts +115 -0
  30. package/dist/agent/index.js +128 -0
  31. package/dist/agent/session.d.ts +90 -0
  32. package/dist/agent/session.js +156 -0
  33. package/dist/agent/types.d.ts +73 -0
  34. package/dist/agent/types.js +10 -0
  35. package/dist/ai-sdk/coordination-context.d.ts +51 -0
  36. package/dist/ai-sdk/coordination-context.js +107 -0
  37. package/dist/ai-sdk/index.d.ts +68 -0
  38. package/dist/ai-sdk/index.js +68 -0
  39. package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
  40. package/dist/ai-sdk/intent-broadcast.js +72 -0
  41. package/dist/ai-sdk/wrap.d.ts +67 -0
  42. package/dist/ai-sdk/wrap.js +45 -0
  43. package/dist/api/index.d.ts +10 -0
  44. package/dist/api/index.js +9 -0
  45. package/dist/auth/index.d.ts +137 -0
  46. package/dist/auth/index.js +246 -0
  47. package/dist/client/Ablo.d.ts +835 -0
  48. package/dist/client/Ablo.js +1440 -0
  49. package/dist/client/ApiClient.d.ts +200 -0
  50. package/dist/client/ApiClient.js +659 -0
  51. package/dist/client/auth.d.ts +79 -0
  52. package/dist/client/auth.js +81 -0
  53. package/dist/client/createInternalComponents.d.ts +44 -0
  54. package/dist/client/createInternalComponents.js +88 -0
  55. package/dist/client/createModelProxy.d.ts +152 -0
  56. package/dist/client/createModelProxy.js +199 -0
  57. package/dist/client/identity.d.ts +63 -0
  58. package/dist/client/identity.js +156 -0
  59. package/dist/client/index.d.ts +36 -0
  60. package/dist/client/index.js +33 -0
  61. package/dist/client/persistence.d.ts +7 -0
  62. package/dist/client/persistence.js +11 -0
  63. package/dist/client/validateAbloOptions.d.ts +42 -0
  64. package/dist/client/validateAbloOptions.js +43 -0
  65. package/dist/config/index.d.ts +10 -0
  66. package/dist/config/index.js +12 -0
  67. package/dist/context.d.ts +27 -0
  68. package/dist/context.js +58 -0
  69. package/dist/core/DatabaseManager.d.ts +108 -0
  70. package/dist/core/DatabaseManager.js +361 -0
  71. package/dist/core/QueryProcessor.d.ts +77 -0
  72. package/dist/core/QueryProcessor.js +262 -0
  73. package/dist/core/QueryView.d.ts +64 -0
  74. package/dist/core/QueryView.js +219 -0
  75. package/dist/core/StoreManager.d.ts +131 -0
  76. package/dist/core/StoreManager.js +334 -0
  77. package/dist/core/ViewRegistry.d.ts +20 -0
  78. package/dist/core/ViewRegistry.js +55 -0
  79. package/dist/core/index.d.ts +34 -0
  80. package/dist/core/index.js +59 -0
  81. package/dist/core/openIDBWithTimeout.d.ts +27 -0
  82. package/dist/core/openIDBWithTimeout.js +63 -0
  83. package/dist/core/query-utils.d.ts +37 -0
  84. package/dist/core/query-utils.js +60 -0
  85. package/dist/errors.d.ts +235 -0
  86. package/dist/errors.js +243 -0
  87. package/dist/index.d.ts +41 -0
  88. package/dist/index.js +82 -0
  89. package/dist/interfaces/headless.d.ts +95 -0
  90. package/dist/interfaces/headless.js +41 -0
  91. package/dist/interfaces/index.d.ts +321 -0
  92. package/dist/interfaces/index.js +8 -0
  93. package/dist/mutators/RecordingTransaction.d.ts +36 -0
  94. package/dist/mutators/RecordingTransaction.js +216 -0
  95. package/dist/mutators/Transaction.d.ts +48 -0
  96. package/dist/mutators/Transaction.js +64 -0
  97. package/dist/mutators/UndoManager.d.ts +114 -0
  98. package/dist/mutators/UndoManager.js +143 -0
  99. package/dist/mutators/defineMutators.d.ts +55 -0
  100. package/dist/mutators/defineMutators.js +28 -0
  101. package/dist/policy/index.d.ts +19 -0
  102. package/dist/policy/index.js +18 -0
  103. package/dist/policy/types.d.ts +74 -0
  104. package/dist/policy/types.js +17 -0
  105. package/dist/principal.d.ts +44 -0
  106. package/dist/principal.js +49 -0
  107. package/dist/query/client.d.ts +43 -0
  108. package/dist/query/client.js +84 -0
  109. package/dist/query/index.d.ts +6 -0
  110. package/dist/query/index.js +5 -0
  111. package/dist/query/types.d.ts +143 -0
  112. package/dist/query/types.js +36 -0
  113. package/dist/react/AbloProvider.d.ts +205 -0
  114. package/dist/react/AbloProvider.js +398 -0
  115. package/dist/react/ClientSideSuspense.d.ts +36 -0
  116. package/dist/react/ClientSideSuspense.js +17 -0
  117. package/dist/react/DefaultFallback.d.ts +24 -0
  118. package/dist/react/DefaultFallback.js +43 -0
  119. package/dist/react/SyncGroupProvider.d.ts +19 -0
  120. package/dist/react/SyncGroupProvider.js +44 -0
  121. package/dist/react/context.d.ts +161 -0
  122. package/dist/react/context.js +35 -0
  123. package/dist/react/index.d.ts +64 -0
  124. package/dist/react/index.js +73 -0
  125. package/dist/react/internalContext.d.ts +35 -0
  126. package/dist/react/internalContext.js +3 -0
  127. package/dist/react/useAblo.d.ts +72 -0
  128. package/dist/react/useAblo.js +63 -0
  129. package/dist/react/useCurrentUserId.d.ts +21 -0
  130. package/dist/react/useCurrentUserId.js +33 -0
  131. package/dist/react/useErrorListener.d.ts +20 -0
  132. package/dist/react/useErrorListener.js +39 -0
  133. package/dist/react/useIntent.d.ts +29 -0
  134. package/dist/react/useIntent.js +42 -0
  135. package/dist/react/useMutate.d.ts +83 -0
  136. package/dist/react/useMutate.js +122 -0
  137. package/dist/react/useMutationFailureListener.d.ts +26 -0
  138. package/dist/react/useMutationFailureListener.js +38 -0
  139. package/dist/react/useMutators.d.ts +56 -0
  140. package/dist/react/useMutators.js +66 -0
  141. package/dist/react/usePresence.d.ts +32 -0
  142. package/dist/react/usePresence.js +41 -0
  143. package/dist/react/useQuery.d.ts +123 -0
  144. package/dist/react/useQuery.js +145 -0
  145. package/dist/react/useReactive.d.ts +35 -0
  146. package/dist/react/useReactive.js +111 -0
  147. package/dist/react/useReader.d.ts +69 -0
  148. package/dist/react/useReader.js +73 -0
  149. package/dist/react/useSyncStatus.d.ts +61 -0
  150. package/dist/react/useSyncStatus.js +76 -0
  151. package/dist/react/useUndoScope.d.ts +36 -0
  152. package/dist/react/useUndoScope.js +73 -0
  153. package/dist/realtime/index.d.ts +10 -0
  154. package/dist/realtime/index.js +9 -0
  155. package/dist/schema/field.d.ts +134 -0
  156. package/dist/schema/field.js +264 -0
  157. package/dist/schema/index.d.ts +29 -0
  158. package/dist/schema/index.js +38 -0
  159. package/dist/schema/model.d.ts +326 -0
  160. package/dist/schema/model.js +89 -0
  161. package/dist/schema/queries.d.ts +203 -0
  162. package/dist/schema/queries.js +145 -0
  163. package/dist/schema/relation.d.ts +172 -0
  164. package/dist/schema/relation.js +104 -0
  165. package/dist/schema/schema.d.ts +259 -0
  166. package/dist/schema/schema.js +188 -0
  167. package/dist/schema/sugar.d.ts +129 -0
  168. package/dist/schema/sugar.js +94 -0
  169. package/dist/source/index.d.ts +423 -0
  170. package/dist/source/index.js +320 -0
  171. package/dist/source/pushQueue.d.ts +112 -0
  172. package/dist/source/pushQueue.js +249 -0
  173. package/dist/stores/ObjectStore.d.ts +103 -0
  174. package/dist/stores/ObjectStore.js +371 -0
  175. package/dist/stores/ObjectStoreContract.d.ts +39 -0
  176. package/dist/stores/ObjectStoreContract.js +1 -0
  177. package/dist/stores/SyncActionStore.d.ts +101 -0
  178. package/dist/stores/SyncActionStore.js +481 -0
  179. package/dist/sync/BootstrapHelper.d.ts +127 -0
  180. package/dist/sync/BootstrapHelper.js +434 -0
  181. package/dist/sync/ConnectionManager.d.ts +136 -0
  182. package/dist/sync/ConnectionManager.js +465 -0
  183. package/dist/sync/HydrationCoordinator.d.ts +137 -0
  184. package/dist/sync/HydrationCoordinator.js +468 -0
  185. package/dist/sync/NetworkProbe.d.ts +43 -0
  186. package/dist/sync/NetworkProbe.js +113 -0
  187. package/dist/sync/OfflineFlush.d.ts +9 -0
  188. package/dist/sync/OfflineFlush.js +22 -0
  189. package/dist/sync/OfflineTransactionStore.d.ts +37 -0
  190. package/dist/sync/OfflineTransactionStore.js +263 -0
  191. package/dist/sync/SyncWebSocket.d.ts +663 -0
  192. package/dist/sync/SyncWebSocket.js +1336 -0
  193. package/dist/sync/createIntentStream.d.ts +33 -0
  194. package/dist/sync/createIntentStream.js +243 -0
  195. package/dist/sync/createPresenceStream.d.ts +46 -0
  196. package/dist/sync/createPresenceStream.js +192 -0
  197. package/dist/sync/createSnapshot.d.ts +33 -0
  198. package/dist/sync/createSnapshot.js +124 -0
  199. package/dist/sync/participants.d.ts +114 -0
  200. package/dist/sync/participants.js +336 -0
  201. package/dist/sync/schemas.d.ts +79 -0
  202. package/dist/sync/schemas.js +78 -0
  203. package/dist/testing/fixtures/bootstrap.d.ts +45 -0
  204. package/dist/testing/fixtures/bootstrap.js +53 -0
  205. package/dist/testing/fixtures/deltas.d.ts +86 -0
  206. package/dist/testing/fixtures/deltas.js +139 -0
  207. package/dist/testing/fixtures/models.d.ts +82 -0
  208. package/dist/testing/fixtures/models.js +270 -0
  209. package/dist/testing/helpers/react-wrapper.d.ts +66 -0
  210. package/dist/testing/helpers/react-wrapper.js +64 -0
  211. package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
  212. package/dist/testing/helpers/sync-engine-harness.js +70 -0
  213. package/dist/testing/helpers/wait.d.ts +25 -0
  214. package/dist/testing/helpers/wait.js +44 -0
  215. package/dist/testing/index.d.ts +21 -0
  216. package/dist/testing/index.js +32 -0
  217. package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
  218. package/dist/testing/mocks/MockMutationExecutor.js +139 -0
  219. package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
  220. package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
  221. package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
  222. package/dist/testing/mocks/MockSyncContext.js +100 -0
  223. package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
  224. package/dist/testing/mocks/MockSyncStore.js +171 -0
  225. package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
  226. package/dist/testing/mocks/MockWebSocket.js +117 -0
  227. package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
  228. package/dist/transactions/OptimisticEchoTracker.js +104 -0
  229. package/dist/transactions/TransactionQueue.d.ts +499 -0
  230. package/dist/transactions/TransactionQueue.js +1895 -0
  231. package/dist/transactions/index.d.ts +16 -0
  232. package/dist/transactions/index.js +7 -0
  233. package/dist/transactions/mutation-error-handler.d.ts +5 -0
  234. package/dist/transactions/mutation-error-handler.js +39 -0
  235. package/dist/types/global.d.ts +107 -0
  236. package/dist/types/global.js +38 -0
  237. package/dist/types/index.d.ts +241 -0
  238. package/dist/types/index.js +70 -0
  239. package/dist/types/streams.d.ts +495 -0
  240. package/dist/types/streams.js +11 -0
  241. package/dist/utils/asyncIterator.d.ts +41 -0
  242. package/dist/utils/asyncIterator.js +142 -0
  243. package/dist/utils/duration.d.ts +28 -0
  244. package/dist/utils/duration.js +47 -0
  245. package/dist/utils/mobx-setup.d.ts +42 -0
  246. package/dist/utils/mobx-setup.js +381 -0
  247. package/docs/api-keys.md +24 -0
  248. package/docs/api.md +230 -0
  249. package/docs/audit.md +81 -0
  250. package/docs/capabilities.md +163 -0
  251. package/docs/client-behavior.md +202 -0
  252. package/docs/data-sources.md +214 -0
  253. package/docs/examples/agent-human.md +84 -0
  254. package/docs/examples/ai-sdk-tool.md +92 -0
  255. package/docs/examples/existing-python-backend.md +249 -0
  256. package/docs/examples/nextjs.md +88 -0
  257. package/docs/examples/server-agent.md +86 -0
  258. package/docs/guarantees.md +148 -0
  259. package/docs/index.md +97 -0
  260. package/docs/integration-guide.md +493 -0
  261. package/docs/interaction-model.md +140 -0
  262. package/docs/mcp/claude-code.md +43 -0
  263. package/docs/mcp/cursor.md +53 -0
  264. package/docs/mcp/windsurf.md +46 -0
  265. package/docs/mcp.md +59 -0
  266. package/docs/quickstart.md +152 -0
  267. package/docs/react.md +115 -0
  268. package/docs/roadmap.md +45 -0
  269. package/examples/README.md +54 -0
  270. package/examples/data-source/README.md +102 -0
  271. package/examples/data-source/ablo-driver.ts +89 -0
  272. package/examples/data-source/customer-server.ts +208 -0
  273. package/examples/data-source/run.ts +101 -0
  274. package/examples/data-source/schema.ts +25 -0
  275. package/examples/quickstart.ts +54 -0
  276. package/examples/tsconfig.json +16 -0
  277. package/llms.txt +143 -0
  278. package/package.json +147 -0
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Agent — AI SDK v6 native hooks for agent awareness.
3
+ *
4
+ * Slots directly into generateText / streamText / ToolLoopAgent via three
5
+ * hooks: prepareStep (inject awareness before each step), onStepFinish
6
+ * (announce activity after each step), and wrapTool (wrap mutation tools
7
+ * with freshness checks). Stateless REST under the hood — works with any
8
+ * API model, no WebSocket.
9
+ *
10
+ * ```ts
11
+ * import { generateText, tool, stepCountIs } from 'ai';
12
+ * import { Agent } from '@ablo/sync-engine-internal/agent';
13
+ *
14
+ * const perception = new Agent({
15
+ * syncServerUrl: 'http://localhost:8080',
16
+ * agentId: 'researcher-1',
17
+ * organizationId: 'org-1',
18
+ * syncGroups: ['deal:abc'],
19
+ * });
20
+ *
21
+ * const result = await generateText({
22
+ * model: 'anthropic/claude-sonnet-4.5',
23
+ * messages,
24
+ * stopWhen: stepCountIs(10),
25
+ * tools: {
26
+ * updateSlide: perception.wrapTool(
27
+ * tool({
28
+ * inputSchema: z.object({ id: z.string(), title: z.string() }),
29
+ * execute: async ({ id, title }) => { ... },
30
+ * }),
31
+ * { entityType: 'Slide', getEntityId: (args) => args.id },
32
+ * ),
33
+ * },
34
+ * prepareStep: perception.prepareStep(), // injects awareness
35
+ * onStepFinish: perception.onStepFinish(), // announces activity
36
+ * });
37
+ * ```
38
+ *
39
+ * Low-level primitives (gather, checkFreshness, announce) are also exposed
40
+ * for custom integrations outside the AI SDK.
41
+ */
42
+ import { createAgentSession } from './session.js';
43
+ import { AbloValidationError } from '../errors.js';
44
+ // ── Agent ───────────────────────────────────────────────────────
45
+ // Console-backed default logger. Local to this module so the agent
46
+ // SDK doesn't take a transitive dependency on `getContext()` (which
47
+ // belongs to the web-app context, not standalone agent workers).
48
+ const consoleLogger = {
49
+ debug: (msg, ...args) => console.debug('[agent]', msg, ...args),
50
+ info: (msg, ...args) => console.info('[agent]', msg, ...args),
51
+ warn: (msg, ...args) => console.warn('[agent]', msg, ...args),
52
+ error: (msg, ...args) => console.error('[agent]', msg, ...args),
53
+ };
54
+ export class Agent {
55
+ opts;
56
+ constructor(options) {
57
+ this.opts = {
58
+ authToken: options.authToken,
59
+ announcer: options.announcer,
60
+ syncServerUrl: options.syncServerUrl.replace(/\/+$/, ''),
61
+ agentId: options.agentId,
62
+ organizationId: options.organizationId,
63
+ syncGroups: options.syncGroups,
64
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
65
+ timeoutMs: options.timeoutMs ?? 5_000,
66
+ logger: options.logger ?? consoleLogger,
67
+ };
68
+ }
69
+ /**
70
+ * Build a long-lived agent session — caches `Ablo({kind:'agent'})`
71
+ * instances per `(org, user, surface, target)` and refreshes capability
72
+ * tokens before TTL elapses. Use on the server when the same agent
73
+ * identity handles many requests.
74
+ *
75
+ * Returns the cache, NOT an `Agent` instance: the long-lived path
76
+ * uses `Ablo({kind:'agent'})` over WebSocket, while the `Agent` class
77
+ * itself is the short-lived REST helper for AI SDK tool loops. The
78
+ * static method lives here so consumers reach for everything
79
+ * agent-related under one namespace.
80
+ *
81
+ * ```ts
82
+ * const session = Agent.session({ syncServerUrl, schema, issueToken });
83
+ * const ablo = await session.getAgent({ userId, organizationId, surfaceClass });
84
+ * ```
85
+ */
86
+ static session = createAgentSession;
87
+ /** The fully-qualified userId used on the wire: `agent:<agentId>`. */
88
+ get userId() {
89
+ return `agent:${this.opts.agentId}`;
90
+ }
91
+ /**
92
+ * Extract the Agent instance from an AI SDK tool's
93
+ * `experimental_context`. Use inside tool `execute` functions to reach
94
+ * the perception without closure-capturing it.
95
+ *
96
+ * ```ts
97
+ * execute: async (args, { experimental_context }) => {
98
+ * const perception = Agent.fromContext(experimental_context);
99
+ * const check = await perception.checkFreshness('Slide', args.id, lastSeenAt);
100
+ * // ...
101
+ * }
102
+ * ```
103
+ *
104
+ * Throws if the context is missing or doesn't contain an Agent.
105
+ * @param ctx The `experimental_context` passed to the tool.
106
+ * @param toolName Optional tool name for error messages.
107
+ */
108
+ static fromContext(ctx, toolName) {
109
+ if (!ctx ||
110
+ typeof ctx !== 'object' ||
111
+ !('perception' in ctx) ||
112
+ !(ctx.perception instanceof Agent)) {
113
+ const where = toolName ? ` (tool: ${toolName})` : '';
114
+ throw new AbloValidationError(`Agent.fromContext: experimental_context must contain an Agent in \`perception\`.${where} ` +
115
+ `Set \`experimental_context: { perception } satisfies AgentContext\` when calling generateText/streamText.`, { code: 'agent_perception_missing_context' });
116
+ }
117
+ return ctx.perception;
118
+ }
119
+ /**
120
+ * Narrower variant of {@link fromContext} that returns `undefined` instead
121
+ * of throwing when perception isn't in context. Useful for tools where
122
+ * awareness is optional (e.g., read-only tools that work without it).
123
+ */
124
+ static tryFromContext(ctx) {
125
+ if (!ctx ||
126
+ typeof ctx !== 'object' ||
127
+ !('perception' in ctx) ||
128
+ !(ctx.perception instanceof Agent)) {
129
+ return undefined;
130
+ }
131
+ return ctx.perception;
132
+ }
133
+ // ── Outbound: announce activity ──────────────────────────────────────
134
+ /**
135
+ * Announce this agent's presence/activity. Fire-and-forget — logs errors
136
+ * but never throws (presence failures must not block the agent loop).
137
+ *
138
+ * If a `announcer` was provided (e.g. a connected SyncAgent), routes
139
+ * through it to reuse the WebSocket. Otherwise falls back to REST POST.
140
+ */
141
+ async announce(status, activity) {
142
+ // Prefer injected announcer (WebSocket) over REST
143
+ if (this.opts.announcer) {
144
+ try {
145
+ await this.opts.announcer.announce(status, activity);
146
+ }
147
+ catch (err) {
148
+ this.opts.logger.warn('[perception] announcer error', {
149
+ error: err.message,
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ try {
155
+ const res = await this.request('POST', '/api/presence', {
156
+ userId: this.userId,
157
+ organizationId: this.opts.organizationId,
158
+ status,
159
+ activity,
160
+ syncGroups: this.opts.syncGroups,
161
+ });
162
+ if (!res.ok) {
163
+ this.opts.logger.warn(`[perception] announce failed: ${res.status} ${res.statusText}`);
164
+ }
165
+ }
166
+ catch (err) {
167
+ this.opts.logger.warn('[perception] announce error', {
168
+ error: err.message,
169
+ });
170
+ }
171
+ }
172
+ // ── Inbound: gather context for next LLM call ────────────────────────
173
+ /**
174
+ * Gather a snapshot of current activity by peers and format it as
175
+ * natural-language context for injection into the next LLM prompt.
176
+ */
177
+ async gather(options) {
178
+ const opts = {
179
+ maxChars: 2000,
180
+ includePresence: true,
181
+ excludeSelf: true,
182
+ ...options,
183
+ };
184
+ const snapshot = {
185
+ timestamp: Date.now(),
186
+ presence: [],
187
+ };
188
+ if (opts.includePresence) {
189
+ snapshot.presence = await this.fetchPresence(opts.excludeSelf);
190
+ }
191
+ const prompt = this.formatPrompt(snapshot, opts);
192
+ return { prompt, snapshot };
193
+ }
194
+ // ── Freshness check: run before mutations ────────────────────────────
195
+ /**
196
+ * Check if an entity was modified since `lastSeenAt`. Use before
197
+ * executing a mutation to detect stale state.
198
+ *
199
+ * Returns `{ stale: true, summary }` when the entity changed — feed
200
+ * `summary` back to the LLM as a tool result so it can adjust its plan.
201
+ */
202
+ async checkFreshness(entityType, entityId, lastSeenAt) {
203
+ // Parallel fan-out: freshness (entity state vs lastSeenAt) + pending
204
+ // intents (other agents about to mutate). Both are advisory — if
205
+ // either request fails the check still returns a usable result.
206
+ const [queryRes, pendingIntents] = await Promise.all([
207
+ this.request('POST', '/api/sync/query', {
208
+ organizationId: this.opts.organizationId,
209
+ queries: [{ model: entityType, ids: [entityId] }],
210
+ }).catch((err) => ({ ok: false, status: 0, _err: err })),
211
+ this.fetchPendingIntentsFor(entityType, entityId),
212
+ ]);
213
+ try {
214
+ const res = queryRes;
215
+ if (!('ok' in res) || !res.ok) {
216
+ return {
217
+ stale: false,
218
+ reason: 'ok',
219
+ summary: `Freshness check inconclusive: ${('status' in res ? res.status : 'error')}`,
220
+ pendingIntents,
221
+ };
222
+ }
223
+ const body = (await res.json());
224
+ const rows = body.results?.[0];
225
+ if (!rows || rows.length === 0) {
226
+ return {
227
+ stale: true,
228
+ reason: 'not_found',
229
+ summary: `${entityType} ${entityId} no longer exists. Another actor may have deleted it.`,
230
+ pendingIntents,
231
+ };
232
+ }
233
+ const entity = rows[0];
234
+ const updatedAtRaw = entity.updated_at ?? entity.updatedAt;
235
+ const lastModifiedBy = entity.updated_by ??
236
+ entity.updatedBy ??
237
+ entity.created_by;
238
+ const lastModifiedAt = typeof updatedAtRaw === 'string'
239
+ ? Date.parse(updatedAtRaw)
240
+ : typeof updatedAtRaw === 'number'
241
+ ? updatedAtRaw
242
+ : undefined;
243
+ if (lastModifiedAt !== undefined && lastModifiedAt > lastSeenAt) {
244
+ const ago = Math.round((Date.now() - lastModifiedAt) / 1000);
245
+ return {
246
+ stale: true,
247
+ reason: 'modified',
248
+ currentState: entity,
249
+ lastModifiedBy,
250
+ lastModifiedAt,
251
+ summary: `${entityType} ${entityId} was modified by ${lastModifiedBy ?? 'another actor'} ` +
252
+ `${ago}s ago. Your planned change is based on stale state. ` +
253
+ `Re-read the entity and adjust your approach.`,
254
+ pendingIntents,
255
+ };
256
+ }
257
+ return {
258
+ stale: false,
259
+ reason: 'ok',
260
+ currentState: entity,
261
+ lastModifiedBy,
262
+ lastModifiedAt,
263
+ pendingIntents,
264
+ };
265
+ }
266
+ catch (err) {
267
+ // Freshness check is advisory — on error, assume ok and let the
268
+ // mutation proceed. Better than blocking the agent on a flaky query.
269
+ return {
270
+ stale: false,
271
+ reason: 'ok',
272
+ summary: `Freshness check error: ${err.message}`,
273
+ pendingIntents,
274
+ };
275
+ }
276
+ }
277
+ /**
278
+ * Pull the org's presence, filter to intents targeting the given
279
+ * entity (self-intents excluded). Advisory — returns empty on any
280
+ * error so `checkFreshness` stays usable when the presence endpoint
281
+ * is down. Case-insensitive match on entityType + entityId to absorb
282
+ * PascalCase / lowercase divergence.
283
+ */
284
+ async fetchPendingIntentsFor(entityType, entityId) {
285
+ const etLower = entityType.toLowerCase();
286
+ const idLower = entityId.toLowerCase();
287
+ const entries = await this.fetchPresence(true);
288
+ const result = [];
289
+ for (const entry of entries) {
290
+ if (!entry.activeIntents)
291
+ continue;
292
+ for (const intent of entry.activeIntents) {
293
+ if (intent.entityType.toLowerCase() === etLower &&
294
+ intent.entityId.toLowerCase() === idLower) {
295
+ result.push(intent);
296
+ }
297
+ }
298
+ }
299
+ return result;
300
+ }
301
+ // ── AI SDK hooks ─────────────────────────────────────────────────────
302
+ /**
303
+ * Build a `prepareStep` hook for AI SDK's generateText / streamText /
304
+ * ToolLoopAgent. Called before each step — injects a system message
305
+ * summarizing what other agents are doing right now.
306
+ *
307
+ * ```ts
308
+ * const result = await generateText({
309
+ * // ...
310
+ * prepareStep: perception.prepareStep({ maxChars: 1500 }),
311
+ * });
312
+ * ```
313
+ */
314
+ prepareStep(options) {
315
+ const maxChars = options?.maxChars ?? 1500;
316
+ const focusFromToolCalls = options?.focusFromToolCalls;
317
+ const skipFirstStep = options?.skipFirstStep ?? false;
318
+ return async ({ stepNumber, steps, messages }) => {
319
+ if (skipFirstStep && stepNumber === 0)
320
+ return undefined;
321
+ // Derive focus entities from recent tool calls if configured
322
+ let focusEntities;
323
+ if (focusFromToolCalls && steps.length > 0) {
324
+ const focus = new Set();
325
+ for (const step of steps) {
326
+ for (const call of step.toolCalls ?? []) {
327
+ const tokens = focusFromToolCalls(call);
328
+ if (tokens)
329
+ tokens.forEach((t) => focus.add(t));
330
+ }
331
+ }
332
+ if (focus.size > 0)
333
+ focusEntities = [...focus];
334
+ }
335
+ const { prompt } = await this.gather({ maxChars, focusEntities });
336
+ const awareness = { role: 'system', content: prompt };
337
+ return {
338
+ messages: [...messages, awareness],
339
+ };
340
+ };
341
+ }
342
+ /**
343
+ * Build an `onStepFinish` hook for AI SDK. Called after each step —
344
+ * announces the agent's activity based on the tool calls that just ran.
345
+ *
346
+ * ```ts
347
+ * const result = await generateText({
348
+ * // ...
349
+ * onStepFinish: perception.onStepFinish(),
350
+ * });
351
+ * ```
352
+ */
353
+ onStepFinish(options) {
354
+ const resolveActivity = options?.activity ??
355
+ ((ctx) => {
356
+ const lastCall = ctx.toolCalls?.[ctx.toolCalls.length - 1];
357
+ if (!lastCall)
358
+ return null;
359
+ return {
360
+ entityType: 'Tool',
361
+ entityId: lastCall.toolName,
362
+ action: 'executed',
363
+ detail: lastCall.toolName,
364
+ };
365
+ });
366
+ return async (ctx) => {
367
+ const activity = resolveActivity(ctx);
368
+ if (activity) {
369
+ await this.announce('online', activity);
370
+ }
371
+ };
372
+ }
373
+ /**
374
+ * Wrap an AI SDK tool to check entity freshness before executing. If the
375
+ * entity was modified by another actor since the LLM last saw it, returns
376
+ * a diff summary as the tool result instead of executing — the LLM adjusts
377
+ * its plan rather than blindly overwriting.
378
+ *
379
+ * ```ts
380
+ * tools: {
381
+ * updateSlide: perception.wrapTool(
382
+ * tool({ inputSchema: ..., execute: ... }),
383
+ * { entityType: 'Slide', getEntityId: (args) => args.id },
384
+ * ),
385
+ * }
386
+ * ```
387
+ */
388
+ wrapTool(originalTool, config) {
389
+ const originalExecute = originalTool.execute;
390
+ if (!originalExecute)
391
+ return originalTool;
392
+ const self = this;
393
+ const announceOnExecute = config.announceOnExecute ?? true;
394
+ const wrappedExecute = async (args, opts) => {
395
+ const entityId = config.getEntityId(args);
396
+ // No id → nothing to guard, just execute
397
+ if (!entityId) {
398
+ return originalExecute(args, opts);
399
+ }
400
+ // Freshness check (skipped when no baseline timestamp is provided)
401
+ const lastSeen = config.lastSeenAt?.(args, opts);
402
+ if (lastSeen !== undefined && lastSeen > 0) {
403
+ const check = await self.checkFreshness(config.entityType, entityId, lastSeen);
404
+ if (check.stale && check.summary) {
405
+ return check.summary;
406
+ }
407
+ }
408
+ // Announce activity before executing (fire-and-forget)
409
+ if (announceOnExecute) {
410
+ void self.announce('online', {
411
+ entityType: config.entityType,
412
+ entityId,
413
+ action: 'editing',
414
+ });
415
+ }
416
+ return originalExecute(args, opts);
417
+ };
418
+ return {
419
+ ...originalTool,
420
+ execute: wrappedExecute,
421
+ };
422
+ }
423
+ // ── Internal ─────────────────────────────────────────────────────────
424
+ async fetchPresence(excludeSelf) {
425
+ try {
426
+ const url = `/api/presence?orgId=${encodeURIComponent(this.opts.organizationId)}`;
427
+ const res = await this.request('GET', url);
428
+ if (!res.ok)
429
+ return [];
430
+ const body = (await res.json());
431
+ const entries = body.entries ?? [];
432
+ // Filter by overlapping sync groups (presence API returns all org
433
+ // entries — the SDK narrows to our scope)
434
+ const ours = new Set(this.opts.syncGroups);
435
+ return entries.filter((e) => {
436
+ if (excludeSelf && e.userId === this.userId)
437
+ return false;
438
+ return (e.syncGroups ?? []).some((g) => ours.has(g));
439
+ });
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ }
445
+ formatPrompt(snapshot, opts) {
446
+ const lines = [];
447
+ const now = new Date(snapshot.timestamp).toISOString();
448
+ lines.push(`[Team context as of ${now}]`);
449
+ const focus = new Set(opts.focusEntities ?? []);
450
+ const hasFocus = focus.size > 0;
451
+ // Sort: focused entities first, then agents, then humans
452
+ const relevant = hasFocus
453
+ ? snapshot.presence.filter((e) => e.activity && focus.has(`${e.activity.entityType}:${e.activity.entityId}`))
454
+ : snapshot.presence;
455
+ if (relevant.length === 0) {
456
+ lines.push('No other participants active in your scope.');
457
+ }
458
+ else {
459
+ lines.push(hasFocus
460
+ ? `Participants working on focused entities (${opts.focusEntities.join(', ')}):`
461
+ : `Active participants:`);
462
+ for (const entry of relevant) {
463
+ const role = entry.isAgent ? 'agent' : 'human';
464
+ const base = `- ${role} ${entry.userId} [${entry.status}]`;
465
+ if (entry.activity) {
466
+ const act = entry.activity;
467
+ const detail = act.detail ? ` (${act.detail})` : '';
468
+ lines.push(`${base}: ${act.action} ${act.entityType}:${act.entityId}${detail}`);
469
+ }
470
+ else {
471
+ lines.push(base);
472
+ }
473
+ }
474
+ if (hasFocus && relevant.length < snapshot.presence.length) {
475
+ const others = snapshot.presence.length - relevant.length;
476
+ lines.push(`(${others} other participant${others === 1 ? '' : 's'} active on unrelated entities)`);
477
+ }
478
+ }
479
+ let result = lines.join('\n');
480
+ if (result.length > opts.maxChars) {
481
+ result = result.slice(0, opts.maxChars - 3) + '...';
482
+ }
483
+ return result;
484
+ }
485
+ async request(method, path, body) {
486
+ const url = `${this.opts.syncServerUrl}${path}`;
487
+ const headers = {
488
+ 'Content-Type': 'application/json',
489
+ };
490
+ if (this.opts.authToken) {
491
+ headers.Authorization = `Bearer ${this.opts.authToken}`;
492
+ }
493
+ return this.opts.fetch(url, {
494
+ method,
495
+ headers,
496
+ body: body ? JSON.stringify(body) : undefined,
497
+ signal: AbortSignal.timeout(this.opts.timeoutMs),
498
+ });
499
+ }
500
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @ablo/sync-engine-internal/agent — Agent SDK helpers
3
+ *
4
+ * Two entry points depending on agent lifetime:
5
+ *
6
+ * ─────────────────────────────────────────────────────────────────────────
7
+ * LONG-LIVED AGENT (browser, daemon, persistent Node process)
8
+ * ─────────────────────────────────────────────────────────────────────────
9
+ * Use the unified `Ablo({...})` factory directly with `kind: 'agent'`.
10
+ * The factory holds the WebSocket, reactive subscriptions, mutations, and
11
+ * presence/intents — same surface as a browser user, just with a
12
+ * server-issued capability token instead of session cookies.
13
+ *
14
+ * ```ts
15
+ * import Ablo from '@ablo/sync-engine';
16
+ *
17
+ * const ablo = Ablo({
18
+ * schema,
19
+ * url: 'wss://api.example.com',
20
+ * organizationId,
21
+ * kind: 'agent',
22
+ * agentId: 'reviewer-bot',
23
+ * capabilityToken: mintedToken,
24
+ * syncGroups: [`org:${organizationId}`],
25
+ * inMemory: true, // Node has no IndexedDB
26
+ * });
27
+ *
28
+ * await ablo.ready();
29
+ * for (const task of ablo.tasks.list({ where: { status: 'pending_review' } })) {
30
+ * await ablo.tasks.update(task.id, { status: 'reviewed' });
31
+ * }
32
+ * ```
33
+ *
34
+ * For server-side caching across requests, use {@link Agent.session}
35
+ * — it caches one engine per identity and refreshes capability tokens
36
+ * before expiry.
37
+ *
38
+ * ─────────────────────────────────────────────────────────────────────────
39
+ * SHORT-LIVED AGENT (SQS consumer, serverless, API route)
40
+ * ─────────────────────────────────────────────────────────────────────────
41
+ * Use {@link Agent}. Stateless REST hooks that slot directly into
42
+ * the Vercel AI SDK's `generateText` / `streamText`. No WebSocket. Ideal
43
+ * for agent-worker jobs that process one task and exit.
44
+ *
45
+ * ```ts
46
+ * import { generateText, tool, stepCountIs } from 'ai';
47
+ * import { Agent } from '@ablo/sync-engine-internal/agent';
48
+ *
49
+ * const perception = new Agent({
50
+ * syncServerUrl: 'http://localhost:8080',
51
+ * agentId: job.id,
52
+ * organizationId: job.organizationId,
53
+ * syncGroups: [`org:${job.organizationId}`],
54
+ * });
55
+ *
56
+ * await generateText({
57
+ * model: 'anthropic/claude-sonnet-4.5',
58
+ * messages,
59
+ * stopWhen: stepCountIs(10),
60
+ * tools: {
61
+ * updateSlide: perception.wrapTool(tool({ ... }), {
62
+ * entityType: 'Slide',
63
+ * getEntityId: (args) => args.id,
64
+ * }),
65
+ * },
66
+ * prepareStep: perception.prepareStep(),
67
+ * onStepFinish: perception.onStepFinish(),
68
+ * });
69
+ * ```
70
+ *
71
+ * ─────────────────────────────────────────────────────────────────────────
72
+ * IDIOMATIC TOOL PATTERN (ported from vercel-labs/open-agents)
73
+ * ─────────────────────────────────────────────────────────────────────────
74
+ * Tools are factory functions that pull ambient state from
75
+ * `experimental_context`. The caller builds an {@link AgentContext} once
76
+ * and passes it to `generateText`; every tool reaches in.
77
+ *
78
+ * ```ts
79
+ * import { tool } from 'ai';
80
+ * import { z } from 'zod';
81
+ * import { Agent, type AgentContext } from '@ablo/sync-engine-internal/agent';
82
+ *
83
+ * export const updateSlideTool = () => tool({
84
+ * description: 'Update a slide title',
85
+ * inputSchema: z.object({ id: z.string(), title: z.string() }),
86
+ * execute: async (args, { experimental_context }) => {
87
+ * const perception = Agent.fromContext(experimental_context, 'updateSlide');
88
+ * const check = await perception.checkFreshness('Slide', args.id, Date.now() - 5000);
89
+ * if (check.stale) return check.summary;
90
+ * return { ok: true };
91
+ * },
92
+ * });
93
+ * ```
94
+ *
95
+ * ─────────────────────────────────────────────────────────────────────────
96
+ * COMPOSED (long-lived `Ablo({kind:'agent'})` + Agent together)
97
+ * ─────────────────────────────────────────────────────────────────────────
98
+ * If you have a long-lived `Ablo` instance AND want AI SDK hooks, pass it
99
+ * as the `announcer` to Agent — presence announcements route
100
+ * through the existing WebSocket instead of opening new HTTP calls.
101
+ *
102
+ * ```ts
103
+ * const ablo = Ablo({ kind: 'agent', schema, capabilityToken, ... });
104
+ * await ablo.ready();
105
+ *
106
+ * const perception = new Agent({
107
+ * ...sharedConfig,
108
+ * announcer: ablo, // reuse the WebSocket
109
+ * });
110
+ * ```
111
+ *
112
+ * Both `Ablo` and `Agent` implement the
113
+ * {@link PresenceAnnouncer} interface.
114
+ */
115
+ export { Agent } from './Agent.js';