@abloatai/ablo 0.18.0 → 0.19.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 (56) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/ObjectPool.d.ts +14 -1
  3. package/dist/ObjectPool.js +8 -1
  4. package/dist/SyncClient.js +7 -1
  5. package/dist/SyncEngineContext.js +2 -0
  6. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  7. package/dist/ai-sdk/coordination-context.js +3 -3
  8. package/dist/ai-sdk/index.d.ts +3 -4
  9. package/dist/ai-sdk/index.js +2 -3
  10. package/dist/ai-sdk/wrap.d.ts +13 -18
  11. package/dist/ai-sdk/wrap.js +2 -6
  12. package/dist/cli.cjs +253 -160
  13. package/dist/client/Ablo.d.ts +83 -22
  14. package/dist/client/Ablo.js +103 -30
  15. package/dist/client/ApiClient.d.ts +2 -2
  16. package/dist/client/ApiClient.js +27 -32
  17. package/dist/client/auth.d.ts +26 -0
  18. package/dist/client/auth.js +208 -0
  19. package/dist/client/createModelProxy.d.ts +16 -11
  20. package/dist/client/createModelProxy.js +15 -15
  21. package/dist/client/sessionMint.d.ts +10 -0
  22. package/dist/client/sessionMint.js +8 -1
  23. package/dist/coordination/schema.d.ts +10 -10
  24. package/dist/coordination/schema.js +7 -8
  25. package/dist/coordination/trace.d.ts +91 -0
  26. package/dist/coordination/trace.js +147 -0
  27. package/dist/errorCodes.d.ts +2 -0
  28. package/dist/errorCodes.js +2 -0
  29. package/dist/errors.d.ts +1 -2
  30. package/dist/errors.js +6 -9
  31. package/dist/index.d.ts +5 -1
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +45 -2
  34. package/dist/policy/types.d.ts +18 -9
  35. package/dist/policy/types.js +26 -16
  36. package/dist/react/AbloProvider.d.ts +2 -2
  37. package/dist/schema/ddl.d.ts +36 -0
  38. package/dist/schema/ddl.js +66 -0
  39. package/dist/schema/index.d.ts +1 -1
  40. package/dist/schema/index.js +1 -1
  41. package/dist/sync/HydrationCoordinator.js +7 -3
  42. package/dist/sync/SyncWebSocket.d.ts +11 -2
  43. package/dist/sync/SyncWebSocket.js +67 -3
  44. package/dist/sync/createClaimStream.d.ts +9 -2
  45. package/dist/sync/createClaimStream.js +9 -7
  46. package/dist/sync/participants.d.ts +12 -11
  47. package/dist/sync/participants.js +5 -5
  48. package/dist/transactions/TransactionQueue.d.ts +1 -0
  49. package/dist/transactions/TransactionQueue.js +40 -3
  50. package/dist/types/streams.d.ts +57 -135
  51. package/docs/coordination.md +16 -0
  52. package/docs/debugging.md +194 -0
  53. package/docs/index.md +1 -0
  54. package/package.json +1 -1
  55. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  56. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -100,7 +100,7 @@ export interface ContextChange {
100
100
  * `range`, `field`, and `meta` are generic coordination hints for
101
101
  * products like code editors, document editors, and design tools.
102
102
  */
103
- export interface EntityRef {
103
+ export interface ClaimTarget {
104
104
  readonly type: string;
105
105
  readonly id: string;
106
106
  readonly path?: string;
@@ -110,11 +110,11 @@ export interface EntityRef {
110
110
  }
111
111
  /**
112
112
  * A pointer to one entity the participant is acting on. Either a
113
- * typed `EntityRef` (`{ type, id, ... }`), or a tuple
113
+ * typed `ClaimTarget` (`{ type, id, ... }`), or a tuple
114
114
  * `['Clause', 'cl_3']` for ergonomic inline use. The verb methods
115
115
  * below accept both.
116
116
  */
117
- export type PresenceTarget = EntityRef | readonly [type: string, id: string];
117
+ export type PresenceTarget = ClaimTarget | readonly [type: string, id: string];
118
118
  /**
119
119
  * Reactive livestream of what every multiplayer participant is doing.
120
120
  * Every participant gets one; it's always on, always current.
@@ -336,27 +336,12 @@ export interface ClaimOptions extends ClaimLeaseOptions {
336
336
  readonly queue?: boolean;
337
337
  }
338
338
  export interface ClaimStream {
339
- /**
340
- * Claim an exclusive claim on a target. Returns a handle — call
341
- * `.revoke()` to cancel, let it expire via TTL, or use `await using`
342
- * (TC39 explicit resource management) to auto-revoke on scope exit.
343
- *
344
- * Server rejects via `claim_rejected` when another participant
345
- * already holds a claim on the same target. Default `reason` is
346
- * `'editing'`; pass `{reason: 'writing'}` (or any string) to override.
347
- *
348
- * The frame ships on the open WS immediately. One method, one shape —
349
- * the verb shortcuts (`editing`, `writing`, `announce`) and the
350
- * scoped `claim(reason, opts)` overload were collapsed into this
351
- * single primitive.
352
- */
353
- claim(target: PresenceTarget, opts?: ClaimOptions): ClaimHandle;
354
339
  /**
355
340
  * Reactive view of every other participant's active claims.
356
341
  * Reads return the current snapshot; pair with `subscribe(...)`
357
342
  * below to get notified on change.
358
343
  */
359
- readonly others: ReadonlyArray<ActiveClaim>;
344
+ readonly others: ReadonlyArray<Claim>;
360
345
  /**
361
346
  * Reactive view of the wait queue on one target — the FIFO line of
362
347
  * `status: 'queued'` claims behind the current holder, each with its
@@ -425,7 +410,7 @@ export interface ClaimStream {
425
410
  * }
426
411
  * ```
427
412
  */
428
- [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<ActiveClaim>>;
413
+ [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<Claim>>;
429
414
  }
430
415
  /**
431
416
  * You LOST an claim you were HOLDING — distinct from `ClaimRejection` (a
@@ -452,95 +437,6 @@ export interface ClaimLost {
452
437
  readonly meta?: Record<string, unknown>;
453
438
  };
454
439
  }
455
- export interface ClaimDeclaration {
456
- readonly target: EntityRef;
457
- /** Human-readable reason — "rewriting title" / "restyling chart". */
458
- readonly reason: string;
459
- /**
460
- * Seconds remaining until the server auto-expires this claim. An OUTPUT
461
- * field carrying a concrete countdown, so it's a plain `number` — distinct
462
- * from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
463
- * from `expiresAt - now`.
464
- */
465
- readonly ttlSeconds?: number;
466
- }
467
- /**
468
- * Handle returned from `announce(...)` / `analyzing(...)` / etc.
469
- *
470
- * Implements `Symbol.asyncDispose` so callers can write:
471
- *
472
- * ```ts
473
- * {
474
- * await using work = participant.claims.analyzing(clause, { ttl: '3m' });
475
- * // ... do the work; claim auto-revokes when the block exits
476
- * }
477
- * ```
478
- */
479
- /**
480
- * THE one claim handle. Returned by every claim door — the typed
481
- * `ablo.<model>.claim({ id })` (rich: `data`/`readAt`/`target` populated) and
482
- * the low-level `participant.claims.claim()` lease (minimal: `claimId` +
483
- * `revoke`/`release`). Row-level fields are optional precisely because the
484
- * low-level lease has no row snapshot; the model door fills them in.
485
- *
486
- * Implements `Symbol.asyncDispose` so callers can `await using claim = ...`
487
- * and have it auto-release on scope exit.
488
- */
489
- export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposable {
490
- readonly object: 'claim';
491
- readonly claimId: string;
492
- /**
493
- * True when the grant came AFTER waiting in the server's FIFO line
494
- * (`claim_granted`) — the authoritative "the row may have changed under us"
495
- * signal. Absent for an immediate grant or a non-queued lease.
496
- */
497
- readonly waited?: boolean;
498
- /**
499
- * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
500
- * carrying the handle use it as the `readAt` stale guard. Present for
501
- * model-scoped claims; absent for low-level leases.
502
- */
503
- readonly readAt?: number;
504
- readonly target: {
505
- readonly model: string;
506
- readonly id: string;
507
- readonly field?: string;
508
- readonly path?: string;
509
- readonly range?: TargetRange;
510
- readonly meta?: Record<string, unknown>;
511
- };
512
- /**
513
- * The human-readable phase this claim represents — `'editing'`, `'writing'`,
514
- * `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
515
- * distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
516
- * to `'editing'`. Serialized on the wire as `action`.
517
- */
518
- readonly reason: string;
519
- readonly description?: string;
520
- /** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
521
- readonly data?: T;
522
- release(): Promise<void>;
523
- revoke(): void;
524
- }
525
- export interface ActiveClaim extends ClaimDeclaration {
526
- readonly id: string;
527
- readonly heldBy: string;
528
- /**
529
- * Whether the holding participant is a user (session), an agent, or a
530
- * system actor. First-class field so UIs can style "agent editing X"
531
- * differently from "user editing X" without string-parsing `heldBy`.
532
- * Canonical `'user' | 'agent' | 'system'` — the presence/claim stream
533
- * derives the value from the boolean `isAgent` wire flag (so it produces
534
- * only `'user'`/`'agent'`), but the type stays the full union it shares
535
- * with the HTTP claim surface and lease store.
536
- */
537
- readonly participantKind: ParticipantKind;
538
- readonly description?: string;
539
- /** Epoch-ms the claim was announced. */
540
- readonly announcedAt: number;
541
- /** Epoch-ms the server auto-expires it. */
542
- readonly expiresAt: number;
543
- }
544
440
  /**
545
441
  * Every lifecycle state of a coordination claim, in one enum.
546
442
  * `active` = the current holder (the lock). `queued` = waiting in the FIFO
@@ -554,44 +450,70 @@ export interface ClaimWaitOptions {
554
450
  readonly pollInterval?: number;
555
451
  readonly signal?: AbortSignal;
556
452
  }
557
- /**
558
- * The coordination state of one entity. Self-describing on the wire via
559
- * `object: 'claim'`. Existence with `status: 'active'` *is* the lock;
560
- * the fields *are* the awareness ("agent X is editing this until Y").
561
- *
562
- * Deliberately omits a Stripe-style `next_action`: a contender's only
563
- * response is "wait until free, then re-read", and the runtime performs
564
- * that uniformly — `claim` serializes behind the holder via the server
565
- * FIFO queue (or low-level `claims.waitFor` to wait without claiming), and the
566
- * stale-context guard forces the re-read. Encoding a constant instruction
567
- * the engine always takes would be the kind of ceremony this object exists
568
- * to remove.
569
- */
570
- export interface Claim {
453
+ export interface Claim<T = Record<string, unknown>> {
571
454
  readonly object: 'claim';
455
+ /** This claim's id. */
572
456
  readonly id: string;
573
- readonly status: ClaimStatus;
457
+ /**
458
+ * Lifecycle state. `active` = the holder (the lock); `queued` = waiting in
459
+ * the FIFO line (carries `position`). Optional on shapes derived from a
460
+ * presence frame that don't carry it — a present entry is `active` by
461
+ * construction.
462
+ */
463
+ readonly status?: ClaimStatus;
574
464
  /** What is being coordinated. */
575
- readonly target: EntityRef;
576
- /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
577
- * field on every claim surface; distinct from the CRUD operation. Serialized
578
- * on the wire as `action`. */
465
+ readonly target: ClaimTarget;
466
+ /**
467
+ * Human-readable phase `'editing'`, `'writing'`, `'reviewing'`. The same
468
+ * field on every claim surface; distinct from the CRUD operation. Defaults to
469
+ * `'editing'`.
470
+ */
579
471
  readonly reason: string;
580
472
  /** Peer-visible explanation of the work being performed. */
581
473
  readonly description?: string;
582
- /** Participant holding it. */
583
- readonly heldBy: string;
584
- readonly participantKind: ParticipantKind;
474
+ /** Participant holding it. Absent on a handle you hold for yourself. */
475
+ readonly heldBy?: string;
585
476
  /**
586
- * Epoch-ms the holder opened it. Optional until the lease wire carries
587
- * it derived shapes (e.g. mapped from a presence frame) may omit it.
477
+ * Whether the holder is a user (session), agent, or system actor — so UIs
478
+ * style "agent editing X" vs "user editing X" without string-parsing
479
+ * `heldBy`. Present on observed claims; absent on your own fresh handle.
588
480
  */
481
+ readonly participantKind?: ParticipantKind;
482
+ /** Epoch-ms the holder opened it. */
589
483
  readonly createdAt?: number;
590
484
  /** Epoch-ms the server auto-expires it if the holder doesn't finish. */
591
- readonly expiresAt: number;
485
+ readonly expiresAt?: number;
486
+ /**
487
+ * Seconds remaining until auto-expiry — an OUTPUT countdown (plain `number`),
488
+ * distinct from the input `ttl: Duration` (`'3m'`) you pass when claiming.
489
+ */
490
+ readonly ttlSeconds?: number;
592
491
  /**
593
492
  * 0-based place in the FIFO line — present only when `status: 'queued'`
594
- * (`0` = next in line behind the holder). Absent for the active holder.
493
+ * (`0` = next behind the holder). Absent for the active holder.
595
494
  */
596
495
  readonly position?: number;
496
+ /**
497
+ * True when the grant came AFTER waiting in the server's FIFO line
498
+ * (`claim_granted`) — the authoritative "the row may have changed under us"
499
+ * signal. Absent for an immediate (uncontended) grant.
500
+ */
501
+ readonly waited?: boolean;
502
+ /**
503
+ * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
504
+ * carrying the claim use it as the `readAt` stale guard. Present on a claim
505
+ * you hold.
506
+ */
507
+ readonly readAt?: number;
508
+ /** Row snapshot under the lease — present on a claim you hold. */
509
+ readonly data?: T;
510
+ /**
511
+ * Release the lease. Present only on a claim you HOLD (returned by
512
+ * `ablo.<model>.claim`); absent on observed peer claims.
513
+ */
514
+ release?(): Promise<void>;
515
+ /** Synchronously abandon the lease. Present only on a claim you hold. */
516
+ revoke?(): void;
517
+ /** Auto-release on `await using` scope exit. Present only on a held claim. */
518
+ [Symbol.asyncDispose]?(): PromiseLike<void>;
597
519
  }
@@ -25,6 +25,22 @@ one answer to "how do two agents not clobber each other" — then covers the
25
25
  (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
26
26
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
27
27
 
28
+ > **Before anything else: one identity per agent.** Coordination excludes
29
+ > *participants*, and a participant **is the key**, not the client object. The
30
+ > server derives identity from the credential's scope — so **N clients sharing
31
+ > one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
32
+ > queue behind each other, and a "second" claimer silently re-takes the lease it
33
+ > already holds (no mutual exclusion). To get real per-agent exclusion, mint a
34
+ > **distinct scoped `rk_` per agent** and bind a client to it:
35
+ >
36
+ > ```ts
37
+ > const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
38
+ > const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
39
+ > ```
40
+ >
41
+ > Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
42
+ > turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
43
+
28
44
  ---
29
45
 
30
46
  ## The model — three layers, one decision
@@ -0,0 +1,194 @@
1
+ # Debugging & Logs
2
+
3
+ By default the SDK is quiet — it logs only warnings and errors. When you're building a human + agent flow and want to *see* the coordination happen (who claimed what, who's waiting in line, who got preempted), turn on Ablo's diagnostic logging. Every line is prefixed `[Ablo]` so it's obvious which output is ours in a console full of other tools.
4
+
5
+ ## Turn it on
6
+
7
+ ```ts
8
+ import Ablo from '@abloatai/ablo';
9
+ import { schema } from './ablo/schema';
10
+
11
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true });
12
+ ```
13
+
14
+ `debug: true` is the simple switch. For finer control use `logLevel`, or set it without touching code via the `ABLO_LOG_LEVEL` environment variable.
15
+
16
+ ```ts
17
+ Ablo({ schema, apiKey }) // quiet — warnings + errors only (default)
18
+ Ablo({ schema, apiKey, debug: true }) // everything (coordination + lifecycle)
19
+ Ablo({ schema, apiKey, logLevel: 'info' }) // coordination + connection, no per-model noise
20
+ ```
21
+
22
+ ```bash
23
+ ABLO_LOG_LEVEL=debug npm run dev # same, from the environment
24
+ ```
25
+
26
+ ### Levels
27
+
28
+ | Level | What it shows |
29
+ |---|---|
30
+ | `silent` | nothing |
31
+ | `error` | failures only |
32
+ | `warn` | **default** — warnings + errors |
33
+ | `info` | the above + the **coordination trace** (claims, grants, queueing) + connection state |
34
+ | `debug` | the above + internal lifecycle (per-model registration, store hydration) — the full firehose |
35
+
36
+ Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then `ABLO_LOG_LEVEL`, then the `warn` default. `debug: false` (or omitting it) just means "don't raise the level."
37
+
38
+ > For watching coordination, **`logLevel: 'info'` is the sweet spot** — you get the claim trace without the per-model registration chatter that `debug` adds.
39
+
40
+ ## What you'll see — the coordination trace
41
+
42
+ These lines (all at `info`) let you watch the human + agent handover you built:
43
+
44
+ ```
45
+ [Ablo] claim: requesting documents:doc_42 for "editing" (will queue if contended)
46
+ [Ablo] claim: queued for documents:doc_42 — position 2 of 3, waiting
47
+ [Ablo] claim: granted 7f3c… — your turn (waited in queue)
48
+ [Ablo] claim: rejected documents:doc_42 — held by agent_writer
49
+ [Ablo] claim: lost documents:doc_42 (preempted or expired)
50
+ [Ablo] claim: released documents:doc_42
51
+ ```
52
+
53
+ Read it as the lifecycle of one claim:
54
+
55
+ - **`requesting`** — your code (or an agent) called `ablo.<model>.claim(...)`. `(will queue if contended)` appears when you passed `{ queue: true }`.
56
+ - **`queued … position N of M`** — the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
57
+ - **`granted … your turn`** — you reached the head of the line; the lease is now yours and the row may have changed while you waited.
58
+ - **`rejected … held by <who>`** — your claim was refused because someone else holds it (and the model's policy didn't let you in).
59
+ - **`lost`** — you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
60
+ - **`released`** — you (or `await using`'s scope exit) gave the lease back.
61
+
62
+ ## Where the logs run
63
+
64
+ The coordination trace and the proactive credential refresh run **in the browser** (and any client that holds a live socket) — that's where the human + agent activity is. Server-side code that mints credentials or does one-shot reads won't emit the trace; it has no live session to narrate.
65
+
66
+ ## Bring your own logger
67
+
68
+ Pass a `logger` to route Ablo's output into your own logging stack (Pino, Sentry breadcrumbs, etc.). A custom logger bypasses `debug`/`logLevel` entirely — you decide what to do with each level.
69
+
70
+ ```ts
71
+ Ablo({
72
+ schema,
73
+ apiKey,
74
+ logger: {
75
+ debug: (...a) => {},
76
+ info: (...a) => myLogger.info({ ablo: a }),
77
+ warn: (...a) => myLogger.warn({ ablo: a }),
78
+ error: (...a) => myLogger.error({ ablo: a }),
79
+ },
80
+ });
81
+ ```
82
+
83
+ ## Read the coordination in code — the activity log
84
+
85
+ The console trace above is for *you*, at a terminal. To put the same activity **inside your app** — an activity feed, a "who's editing" badge, a Sentry breadcrumb trail — read it programmatically. Same events, three layers; pick by audience:
86
+
87
+ | Layer | You get | Best for |
88
+ |---|---|---|
89
+ | `logger` (above) | `[Ablo]` text lines | watching a terminal |
90
+ | `observability` | typed `ClaimEvent` / `ConflictEvent` objects | dashboards, alerting (Sentry / Datadog / OTel) |
91
+ | `ClaimLog` | an ordered, **reactive** list of both | rendering an activity feed on a page |
92
+
93
+ ### The events
94
+
95
+ Every claim state change is a `ClaimEvent`; every notify-instead-of-abort stale write (a write that succeeded but whose premise had moved) is a `ConflictEvent`:
96
+
97
+ ```ts
98
+ interface ClaimEvent {
99
+ phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired';
100
+ model?: string; id?: string; field?: string; // the claimed row
101
+ actor?: string; participantKind?: 'user' | 'agent' | 'system';
102
+ position?: number; // FIFO position, when queued
103
+ reason?: string; // why, on rejected
104
+ claimId?: string;
105
+ }
106
+
107
+ interface ConflictEvent {
108
+ clientTxId: string;
109
+ rows: { model: string; id: string; fields: string[]; writtenBy?: 'user' | 'agent' | 'system' }[];
110
+ }
111
+ ```
112
+
113
+ `phase` is past-tense — the state the claim just entered — and maps one-to-one to what arrives on the wire.
114
+
115
+ ### Collect them — `ClaimLog`
116
+
117
+ `ClaimLog` records both into an ordered list. Hand it to `observability`, then read it back:
118
+
119
+ ```ts
120
+ import Ablo, { ClaimLog } from '@abloatai/ablo';
121
+
122
+ const log = new ClaimLog();
123
+ const ablo = Ablo({ schema, apiKey, observability: log });
124
+
125
+ // …run the agents…
126
+ console.log(`${log}`); // a printable, ⚠-marked timeline
127
+ log.entries; // ClaimLogEntry[] — every event, in order, with a `.line`
128
+ log.collisions(); // just the rejected/lost claims + stale writes
129
+ ```
130
+
131
+ It's also the simplest way to **assert** coordination in a test — no log scraping:
132
+
133
+ ```ts
134
+ expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
135
+ ```
136
+
137
+ ### Show it on a page — reactive
138
+
139
+ `ClaimLog.onChange` fires on every event and returns an unsubscribe — the exact shape `useSyncExternalStore` wants, so a live feed is a few lines:
140
+
141
+ ```tsx
142
+ import { useSyncExternalStore } from 'react';
143
+ import { ClaimLog } from '@abloatai/ablo';
144
+
145
+ function ActivityFeed({ log }: { log: ClaimLog }) {
146
+ const entries = useSyncExternalStore(log.onChange, () => log.entries);
147
+ return (
148
+ <ul>
149
+ {entries.map((e) => (
150
+ <li key={e.seq} className={e.collision ? 'text-amber-600' : undefined}>{e.line}</li>
151
+ ))}
152
+ </ul>
153
+ );
154
+ }
155
+ ```
156
+
157
+ > `ClaimLog` lives in browser memory: it starts empty on load and shows events that arrive while mounted. For a feed that survives reload, persist `entries` yourself — but for a live coordination panel, the in-memory log is exactly right.
158
+
159
+ For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring:
160
+
161
+ ```tsx
162
+ const holder = useAblo((ablo) => ablo.documents.claim.state({ id })); // Claim | null
163
+ ```
164
+
165
+ See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs.
166
+
167
+ ### Route to your own backend
168
+
169
+ `ClaimLog` is one implementation of the `observability` slot. To push events into Sentry, Datadog, or OpenTelemetry instead, spread `noopObservability` and override just the two coordination hooks:
170
+
171
+ ```ts
172
+ import Ablo, { noopObservability } from '@abloatai/ablo';
173
+
174
+ const ablo = Ablo({
175
+ schema, apiKey,
176
+ observability: {
177
+ ...noopObservability,
178
+ captureClaim: (e) => {
179
+ if (e.phase === 'rejected') Sentry.captureMessage(`claim blocked: ${e.model}/${e.id} by ${e.actor}`);
180
+ },
181
+ captureConflict: (e) => Sentry.captureMessage(`stale write tx ${e.clientTxId} on ${e.rows.length} row(s)`),
182
+ },
183
+ });
184
+ ```
185
+
186
+ ## Errors
187
+
188
+ Ablo's thrown errors are typed and self-describing — `String(err)` (or logging it) yields one clean line, never a stack dump:
189
+
190
+ ```
191
+ AbloValidationError [model_required_field_missing]: A required field was absent. (see https://docs.abloatai.com/errors#model_required_field_missing) [request_id: req_8Fk2aQ]
192
+ ```
193
+
194
+ Branch on `err.code` (stable) — never on the message (rewordable). See [Client Behavior](./client-behavior.md) for the full error model and which codes are safe to retry.
package/docs/index.md CHANGED
@@ -48,6 +48,7 @@ Three things stay true no matter how you use Ablo:
48
48
  - [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
49
49
  - [API Reference](./api.md) — Model-by-model method shape.
50
50
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
51
+ - [Debugging & Logs](./debugging.md) — Turn on the `[Ablo]` coordination trace (`debug` / `logLevel`) to watch claims, queueing, and grants while you build — or read the same activity in code via `ClaimLog` to render an activity feed.
51
52
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
52
53
  - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
53
54
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,83 +0,0 @@
1
- /**
2
- * Claim broadcast middleware — wraps a language model so the agent
3
- * declares "I'm about to edit entity X" over the sync engine's
4
- * claim primitive at stream start, and abandons the claim at
5
- * stream end.
6
- *
7
- * Cross-cutting by design — composes via the AI SDK's
8
- * `wrapLanguageModel`. Same middleware works for every chat
9
- * surface, and for non-chat agent loops that share the AI SDK's
10
- * middleware interface (workers, MCP tools, autonomous loops).
11
- *
12
- * Open-source-clean: depends only on `@ai-sdk/provider` types and
13
- * the package's own `SyncAgent`. No app-specific assumptions —
14
- * Ablo's web app uses this, but so can any consumer of `@abloatai/ablo`.
15
- *
16
- * Cost: one WS frame at stream start (`claim_begin`), one at end
17
- * (`claim_abandon`). No DB I/O, no extra LLM tokens.
18
- */
19
- import type { LanguageModelV3Middleware } from '@ai-sdk/provider';
20
- import type { Ablo } from '../client/Ablo.js';
21
- import type { SchemaRecord } from '../schema/schema.js';
22
- /**
23
- * Target entity for the claim broadcast.
24
- *
25
- * `entityType` is a free-form string — convention is the schema's
26
- * typename (e.g. `'SlideDeck'`, `'Task'`, `'Matter'`) so peers can
27
- * filter consistently. The wire format treats it opaquely.
28
- */
29
- export interface ClaimTarget {
30
- readonly entityType: string;
31
- readonly entityId: string;
32
- /** Optional path for file/document-like targets. */
33
- readonly path?: string;
34
- /** Optional line/column range for partial-entity coordination. */
35
- readonly range?: {
36
- readonly startLine: number;
37
- readonly endLine: number;
38
- readonly startColumn?: number;
39
- readonly endColumn?: number;
40
- };
41
- /**
42
- * Optional sub-field within the entity. Useful when the agent
43
- * knows it's only editing a specific field — peers can filter
44
- * on the field too.
45
- */
46
- readonly field?: string;
47
- /** App-defined structured metadata. Opaque to the core SDK. */
48
- readonly meta?: Record<string, unknown>;
49
- /**
50
- * Hint for the server-side TTL on the claim. Caps at 10 minutes
51
- * server-side; default 60s — typical chat turn.
52
- */
53
- readonly estimatedMs?: number;
54
- }
55
- export interface ClaimBroadcastMiddlewareOptions<R extends SchemaRecord = SchemaRecord> {
56
- /** Connected Ablo. Null disables the middleware (no-op). */
57
- readonly agent: Ablo<R> | null;
58
- /** Target entity. Null skips the broadcast (purely conversational). */
59
- readonly target: ClaimTarget | null;
60
- /**
61
- * Human-readable phase describing what the agent is doing. Convention:
62
- * `'edit'`, `'read'`, `'review'`, `'generate'`. Default `'edit'`. The same
63
- * `reason` field used on every claim surface.
64
- */
65
- readonly reason?: string;
66
- /**
67
- * Peer-visible explanation of the specific work this model call is about to
68
- * perform. Surfaces to other agents through `ActiveClaim.description`.
69
- */
70
- readonly description?: string;
71
- }
72
- /**
73
- * Build the middleware. When `agent` or `target` is null, returns a
74
- * pass-through — keeps call sites unconditional regardless of
75
- * whether the surface has an entity in scope.
76
- *
77
- * Generic over the schema record so callers passing
78
- * `Ablo<typeof schema>` don't have to widen — `Ablo<S>` and
79
- * `Ablo<SchemaRecord>` are structurally non-compatible because the
80
- * widened version collapses model proxies to an index signature
81
- * that clashes with the named methods (`ready`, `dispose`, etc.).
82
- */
83
- export declare function claimBroadcastMiddleware<R extends SchemaRecord = SchemaRecord>(options: ClaimBroadcastMiddlewareOptions<R>): LanguageModelV3Middleware;
@@ -1,79 +0,0 @@
1
- /**
2
- * Claim broadcast middleware — wraps a language model so the agent
3
- * declares "I'm about to edit entity X" over the sync engine's
4
- * claim primitive at stream start, and abandons the claim at
5
- * stream end.
6
- *
7
- * Cross-cutting by design — composes via the AI SDK's
8
- * `wrapLanguageModel`. Same middleware works for every chat
9
- * surface, and for non-chat agent loops that share the AI SDK's
10
- * middleware interface (workers, MCP tools, autonomous loops).
11
- *
12
- * Open-source-clean: depends only on `@ai-sdk/provider` types and
13
- * the package's own `SyncAgent`. No app-specific assumptions —
14
- * Ablo's web app uses this, but so can any consumer of `@abloatai/ablo`.
15
- *
16
- * Cost: one WS frame at stream start (`claim_begin`), one at end
17
- * (`claim_abandon`). No DB I/O, no extra LLM tokens.
18
- */
19
- /**
20
- * Build the middleware. When `agent` or `target` is null, returns a
21
- * pass-through — keeps call sites unconditional regardless of
22
- * whether the surface has an entity in scope.
23
- *
24
- * Generic over the schema record so callers passing
25
- * `Ablo<typeof schema>` don't have to widen — `Ablo<S>` and
26
- * `Ablo<SchemaRecord>` are structurally non-compatible because the
27
- * widened version collapses model proxies to an index signature
28
- * that clashes with the named methods (`ready`, `dispose`, etc.).
29
- */
30
- export function claimBroadcastMiddleware(options) {
31
- const { agent, target } = options;
32
- const reason = options.reason ?? 'edit';
33
- const description = options.description;
34
- const openClaim = () => {
35
- if (!agent || !target)
36
- return null;
37
- return agent.claims.claim({
38
- type: target.entityType,
39
- id: target.entityId,
40
- path: target.path,
41
- range: target.range,
42
- field: target.field,
43
- meta: target.meta,
44
- }, {
45
- reason,
46
- description,
47
- ttl: target.estimatedMs ?? 60_000,
48
- });
49
- };
50
- return {
51
- specificationVersion: 'v3',
52
- // The AI SDK's middleware contract passes a no-arg `doStream` /
53
- // `doGenerate` thunk — params have already been transformed by
54
- // any earlier `transformParams` middleware in the chain. We
55
- // open the claim, call the inner, abandon when the inner
56
- // resolves (or rejects).
57
- async wrapStream({ doStream }) {
58
- const handle = openClaim();
59
- try {
60
- return await doStream();
61
- }
62
- finally {
63
- // Always abandon — even on error. The server's TTL would
64
- // eventually clean up regardless, but explicit release means
65
- // peers see the claim drop the moment generation completes.
66
- handle?.revoke();
67
- }
68
- },
69
- async wrapGenerate({ doGenerate }) {
70
- const handle = openClaim();
71
- try {
72
- return await doGenerate();
73
- }
74
- finally {
75
- handle?.revoke();
76
- }
77
- },
78
- };
79
- }