@loopbacktools/sdk 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/src/client.d.ts +77 -0
- package/dist/src/client.js +189 -0
- package/dist/src/inbox.d.ts +27 -0
- package/dist/src/inbox.js +24 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +4 -0
- package/dist/src/promise.d.ts +31 -0
- package/dist/src/promise.js +51 -0
- package/package.json +36 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bison Labs Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loopbacktools/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything a frontend needs to read and answer what agents ask through Loopback: the REST calls,
|
|
4
|
+
the live event socket with reconnects, and the inbox state kept current from it.
|
|
5
|
+
|
|
6
|
+
It comes in two forms over one implementation: a promise client for any framework, and an Effect
|
|
7
|
+
service.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @loopbacktools/sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The inbox, [`@loopbacktools/inbox`](https://www.npmjs.com/package/@loopbacktools/inbox), is built only on
|
|
14
|
+
this SDK, so it is a full example to work from, or a set of components to use as they are.
|
|
15
|
+
|
|
16
|
+
## Promise client
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { ApiError, createLoopbackClient } from "@loopbacktools/sdk";
|
|
20
|
+
|
|
21
|
+
const loopback = createLoopbackClient({
|
|
22
|
+
baseUrl: "https://loopback.example.workers.dev",
|
|
23
|
+
token: "…", // or a function returning a fresh one
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const { open } = await loopback.snapshot();
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await loopback.reply(open[0].id, { outcome: "approved", note: "Ship it" });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ApiError) console.log(error.status, error.code, error.message);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Replies are one of:
|
|
36
|
+
|
|
37
|
+
| `outcome` | Fields | For |
|
|
38
|
+
| ------------------- | --------------------------------------- | ----------------------------- |
|
|
39
|
+
| `answered` | `selected: string[]`, `text?` | questions |
|
|
40
|
+
| `approved` | `note?` | reviews |
|
|
41
|
+
| `changes_requested` | `feedback` | reviews |
|
|
42
|
+
| `follow_up` | `question` — keeps the ask open | questions, reviews |
|
|
43
|
+
| `responded` | `points: { point, text?, resolved? }[]` | briefings |
|
|
44
|
+
| `dismissed` | | questions, reviews, briefings |
|
|
45
|
+
|
|
46
|
+
A briefing's points, and where each stands, are folded from its prompt and thread by
|
|
47
|
+
`foldBriefing(ask.prompt, ask.thread)`: the tree an inbox renders, the points that need the person,
|
|
48
|
+
and those the agent owes an answer on. `respondedProblem` says why a `responded` reply would be
|
|
49
|
+
refused before it is sent.
|
|
50
|
+
|
|
51
|
+
`agentSignIn()` says how an agent signs in to the deployment's MCP endpoint, `"oauth"` or `"token"`,
|
|
52
|
+
as the server tells it, for a page that explains connecting one.
|
|
53
|
+
|
|
54
|
+
`loopback.file(uploadId)` fetches an image a review shows, as a `Blob`.
|
|
55
|
+
|
|
56
|
+
### The live inbox
|
|
57
|
+
|
|
58
|
+
`loopback.inbox` is an external store. It connects when its first listener subscribes and
|
|
59
|
+
disconnects after its last one leaves.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import type { Ask } from "@loopbacktools/sdk";
|
|
63
|
+
import { useSyncExternalStore } from "react";
|
|
64
|
+
|
|
65
|
+
const titleOf = (ask: Ask) => {
|
|
66
|
+
switch (ask.kind) {
|
|
67
|
+
case "question":
|
|
68
|
+
return ask.prompt.question;
|
|
69
|
+
case "review":
|
|
70
|
+
case "briefing":
|
|
71
|
+
return ask.prompt.title;
|
|
72
|
+
case "unknown":
|
|
73
|
+
return "Reload to see this ask";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function Inbox() {
|
|
78
|
+
const inbox = useSyncExternalStore(loopback.inbox.subscribe, loopback.inbox.getSnapshot);
|
|
79
|
+
return (
|
|
80
|
+
<ul>
|
|
81
|
+
{inbox.open.map((ask) => (
|
|
82
|
+
<li key={ask.id}>{titleOf(ask)}</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`InboxState` holds `status` (`connecting`, `live` or `reconnecting`), `open` (newest first) and
|
|
90
|
+
`recent` (most recently closed first). `awaitingHuman(state)` picks the asks waiting on the person.
|
|
91
|
+
|
|
92
|
+
### Updates
|
|
93
|
+
|
|
94
|
+
`loopback.update` is an external store too, saying whether Loopback has been updated under the page,
|
|
95
|
+
as every response tells the client, the one each socket connection begins with included:
|
|
96
|
+
|
|
97
|
+
- `none`: the page's code is level with the server's.
|
|
98
|
+
- `available`: a later deploy of the same revision of the contract answers. Offer a reload.
|
|
99
|
+
- `required`: the server writes a later revision of the contract than this client reads, or refused
|
|
100
|
+
the client as newer than itself, as it does after a rollback. Reload: asks may arrive that this
|
|
101
|
+
page can't draw, and what it sends may be refused.
|
|
102
|
+
|
|
103
|
+
It never goes back. Reload when it's safe for the person, such as when the page is hidden, rather
|
|
104
|
+
than under someone typing.
|
|
105
|
+
|
|
106
|
+
## Effect
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { LoopbackClient } from "@loopbacktools/sdk";
|
|
110
|
+
import { Effect, Stream } from "effect";
|
|
111
|
+
|
|
112
|
+
const program = Effect.gen(function* () {
|
|
113
|
+
const loopback = yield* LoopbackClient;
|
|
114
|
+
yield* loopback.inbox.pipe(Stream.runForEach((state) => Effect.log(`${state.open.length} open`)));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
program.pipe(
|
|
118
|
+
Effect.provide(LoopbackClient.layer({ baseUrl: "https://…", token: "…" })),
|
|
119
|
+
Effect.runFork,
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`events` is the raw stream of socket frames, and `applyEvent` is the reducer `inbox` is built on,
|
|
124
|
+
for anyone keeping their own state. `update` is the stream of `Update`s, starting with the current
|
|
125
|
+
one.
|
|
126
|
+
|
|
127
|
+
## Types
|
|
128
|
+
|
|
129
|
+
Every type the API speaks (`Ask`, `Reply`, `Block`, `Snapshot`, …) is re-exported, generated from
|
|
130
|
+
the API's TypeSpec definition. The client decodes everything it receives against them rather than
|
|
131
|
+
trusting it, and refuses a body that isn't the contract as an `ApiError` with the code
|
|
132
|
+
`invalid_body`.
|
|
133
|
+
|
|
134
|
+
A deployment can run a newer revision of the contract than the code that reads it, so each union a
|
|
135
|
+
release can grow ends in an `unknown` member: an `Ask` of kind `unknown`, a `Block` of type
|
|
136
|
+
`unknown`, a `Reply` with the outcome `unknown`. Each keeps what can still be shown and what it was
|
|
137
|
+
(`was`). An exhaustive `switch` has to handle it, and the compiler says so. Socket frames of a type
|
|
138
|
+
the client doesn't know are skipped. What a reply sends is `ReplyBody`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type Reply as ReplyBody } from "@loopbacktools/protocol";
|
|
2
|
+
import { Ask, AskPage, Snapshot, Whoami } from "@loopbacktools/protocol/schema";
|
|
3
|
+
import { Context, Effect, Layer, Schema, Stream } from "effect";
|
|
4
|
+
import { type InboxEvent, type InboxState } from "./inbox.ts";
|
|
5
|
+
declare const ApiError_base: Schema.Class<ApiError, Schema.TaggedStruct<"ApiError", {
|
|
6
|
+
readonly status: Schema.Number;
|
|
7
|
+
readonly code: Schema.String;
|
|
8
|
+
readonly message: Schema.String;
|
|
9
|
+
}>, import("effect/Cause").YieldableError>;
|
|
10
|
+
/** A request Loopback refused, or one that never reached it (`status` 0). */
|
|
11
|
+
export declare class ApiError extends ApiError_base {
|
|
12
|
+
}
|
|
13
|
+
export interface LoopbackConfig {
|
|
14
|
+
/** Where Loopback is served, such as `https://loopback.example.workers.dev`. */
|
|
15
|
+
readonly baseUrl: string;
|
|
16
|
+
/**
|
|
17
|
+
* The credential the deployment's gate accepts, or a function that fetches a fresh one. Leave it
|
|
18
|
+
* out where something in front of Loopback already signs requests in, as a Worker that vouches
|
|
19
|
+
* for its callers does: requests then carry no credential of their own.
|
|
20
|
+
*/
|
|
21
|
+
readonly token?: string | (() => string | Promise<string>);
|
|
22
|
+
/** A `fetch` to use instead of the global one. */
|
|
23
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
24
|
+
/** A `WebSocket` constructor to use instead of the global one. */
|
|
25
|
+
readonly WebSocket?: typeof globalThis.WebSocket;
|
|
26
|
+
}
|
|
27
|
+
/** The subprotocol the event socket speaks. */
|
|
28
|
+
export declare const EVENTS_PROTOCOL = "loopback.v1";
|
|
29
|
+
/**
|
|
30
|
+
* The prefix under which the socket offers its one-time ticket, since a browser cannot set headers
|
|
31
|
+
* there. The token itself only ever travels in `Authorization`, which Cloudflare keeps out of logs.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TICKET_PROTOCOL_PREFIX = "loopback.ticket.";
|
|
34
|
+
/**
|
|
35
|
+
* Whether Loopback has been updated under this client, so that reloading the page would bring its
|
|
36
|
+
* code level with the server's. `available` once a later build of the same revision of the contract
|
|
37
|
+
* answers: a reload is worth offering. `required` once the server writes a later revision than this
|
|
38
|
+
* client reads, or refuses this client as from a newer one, as it does after a rollback: what it
|
|
39
|
+
* sends may no longer be read, nor what it hears drawn. It never goes back.
|
|
40
|
+
*/
|
|
41
|
+
export type Update = "none" | "available" | "required";
|
|
42
|
+
export interface LoopbackApi {
|
|
43
|
+
readonly whoami: Effect.Effect<Whoami, ApiError>;
|
|
44
|
+
/** Every open ask and the most recently closed. */
|
|
45
|
+
readonly snapshot: Effect.Effect<Snapshot, ApiError>;
|
|
46
|
+
readonly ask: (id: string) => Effect.Effect<Ask, ApiError>;
|
|
47
|
+
/** Closed asks, newest first, before `before` when given. */
|
|
48
|
+
readonly history: (page?: {
|
|
49
|
+
readonly before?: string;
|
|
50
|
+
readonly limit?: number;
|
|
51
|
+
}) => Effect.Effect<AskPage, ApiError>;
|
|
52
|
+
readonly reply: (id: string, reply: ReplyBody) => Effect.Effect<Ask, ApiError>;
|
|
53
|
+
/** An uploaded image, for showing in a review. */
|
|
54
|
+
readonly file: (uploadId: string) => Effect.Effect<Blob, ApiError>;
|
|
55
|
+
/**
|
|
56
|
+
* How an agent signs in to this deployment's MCP endpoint, `/mcp`: with OAuth, as behind a login,
|
|
57
|
+
* or with the deployment's token. The server says which by serving the endpoint's protected
|
|
58
|
+
* resource metadata only when it is OAuth.
|
|
59
|
+
*/
|
|
60
|
+
readonly agentSignIn: Effect.Effect<"oauth" | "token", ApiError>;
|
|
61
|
+
/** The event socket's frames, reconnecting for as long as the stream runs. */
|
|
62
|
+
readonly events: Stream.Stream<InboxEvent>;
|
|
63
|
+
/** The inbox, kept current from `events`: one state per change. */
|
|
64
|
+
readonly inbox: Stream.Stream<InboxState>;
|
|
65
|
+
/**
|
|
66
|
+
* Whether Loopback has been updated under this client, as every response says, the one each
|
|
67
|
+
* connection of `events` begins with included: the current value, then each change.
|
|
68
|
+
*/
|
|
69
|
+
readonly update: Stream.Stream<Update>;
|
|
70
|
+
}
|
|
71
|
+
export declare const make: (config: LoopbackConfig) => LoopbackApi;
|
|
72
|
+
declare const LoopbackClient_base: Context.ServiceClass<LoopbackClient, "@loopbacktools/sdk/LoopbackClient", LoopbackApi>;
|
|
73
|
+
/** The Loopback client as an Effect service. */
|
|
74
|
+
export declare class LoopbackClient extends LoopbackClient_base {
|
|
75
|
+
static readonly layer: (config: LoopbackConfig) => Layer.Layer<LoopbackClient, never, never>;
|
|
76
|
+
}
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { BUILD_HEADER, RELOAD_CODE, REVISION_HEADER, } from "@loopbacktools/protocol";
|
|
2
|
+
import { Ask, AskPage, EventTicket, REVISION, ServerEvent, Snapshot, Whoami, } from "@loopbacktools/protocol/schema";
|
|
3
|
+
import { Context, Deferred, Effect, Exit, Layer, Option, Queue, Schedule, Schema, Stream, SubscriptionRef, } from "effect";
|
|
4
|
+
import { applyEvent, initialInbox } from "./inbox.js";
|
|
5
|
+
/** A request Loopback refused, or one that never reached it (`status` 0). */
|
|
6
|
+
export class ApiError extends Schema.TaggedError()("ApiError", {
|
|
7
|
+
status: Schema.Number,
|
|
8
|
+
code: Schema.String,
|
|
9
|
+
message: Schema.String,
|
|
10
|
+
}) {
|
|
11
|
+
}
|
|
12
|
+
/** The subprotocol the event socket speaks. */
|
|
13
|
+
export const EVENTS_PROTOCOL = "loopback.v1";
|
|
14
|
+
/**
|
|
15
|
+
* The prefix under which the socket offers its one-time ticket, since a browser cannot set headers
|
|
16
|
+
* there. The token itself only ever travels in `Authorization`, which Cloudflare keeps out of logs.
|
|
17
|
+
*/
|
|
18
|
+
export const TICKET_PROTOCOL_PREFIX = "loopback.ticket.";
|
|
19
|
+
/** How often the socket sends a keep-alive ping. */
|
|
20
|
+
const PING_MS = 25_000;
|
|
21
|
+
/** Reconnects back off from half a second to ten, with jitter. */
|
|
22
|
+
const RECONNECT = Schedule.min([
|
|
23
|
+
Schedule.exponential("500 millis"),
|
|
24
|
+
Schedule.spaced("10 seconds"),
|
|
25
|
+
]).pipe(Schedule.jittered);
|
|
26
|
+
const urgency = { none: 0, available: 1, required: 2 };
|
|
27
|
+
class SocketClosed extends Schema.TaggedError()("SocketClosed", {}) {
|
|
28
|
+
}
|
|
29
|
+
/** A frame as the contract says it is, or none for one that isn't a frame at all. */
|
|
30
|
+
const decodeFrame = Schema.decodeUnknownOption(ServerEvent);
|
|
31
|
+
export const make = (config) => {
|
|
32
|
+
const base = config.baseUrl.replace(/\/+$/, "");
|
|
33
|
+
// The globals are looked up per call, so a polyfill or stub installed after this runs is used.
|
|
34
|
+
const fetcher = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
35
|
+
const socketClass = () => config.WebSocket ?? globalThis.WebSocket;
|
|
36
|
+
const { token: source } = config;
|
|
37
|
+
const token = Effect.tryPromise({
|
|
38
|
+
try: async () => (source === undefined || typeof source === "string" ? source : source()),
|
|
39
|
+
catch: (error) => new ApiError({
|
|
40
|
+
status: 0,
|
|
41
|
+
code: "token",
|
|
42
|
+
message: error instanceof Error ? error.message : String(error),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
const updates = Effect.runSync(SubscriptionRef.make("none"));
|
|
46
|
+
const raise = (to) => SubscriptionRef.update(updates, (now) => (urgency[to] > urgency[now] ? to : now));
|
|
47
|
+
/** The build that answered first, which the page is taken to be from. */
|
|
48
|
+
let firstBuild;
|
|
49
|
+
/** What a response says of the code that sent it, measured against this client's. */
|
|
50
|
+
const heard = (response) => {
|
|
51
|
+
if (Number(response.headers.get(REVISION_HEADER)) > REVISION)
|
|
52
|
+
return "required";
|
|
53
|
+
const build = response.headers.get(BUILD_HEADER);
|
|
54
|
+
if (build === null)
|
|
55
|
+
return "none";
|
|
56
|
+
firstBuild ??= build;
|
|
57
|
+
return Date.parse(build) > Date.parse(firstBuild) ? "available" : "none";
|
|
58
|
+
};
|
|
59
|
+
/** A response body that is not what the contract says it is. */
|
|
60
|
+
const unreadable = (response) => () => new ApiError({
|
|
61
|
+
status: response.status,
|
|
62
|
+
code: "invalid_body",
|
|
63
|
+
message: `Loopback answered ${response.status} with a body that could not be read.`,
|
|
64
|
+
});
|
|
65
|
+
const request = (path, init = {}) => Effect.gen(function* () {
|
|
66
|
+
const headers = new Headers(init.headers);
|
|
67
|
+
headers.set(REVISION_HEADER, String(REVISION));
|
|
68
|
+
const credential = yield* token;
|
|
69
|
+
if (credential !== undefined)
|
|
70
|
+
headers.set("Authorization", `Bearer ${credential}`);
|
|
71
|
+
const response = yield* Effect.tryPromise({
|
|
72
|
+
try: (signal) => fetcher(`${base}/api/v1${path}`, { ...init, headers, signal }),
|
|
73
|
+
catch: (error) => new ApiError({
|
|
74
|
+
status: 0,
|
|
75
|
+
code: "network",
|
|
76
|
+
message: error instanceof Error ? error.message : String(error),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
yield* raise(heard(response));
|
|
80
|
+
if (response.ok)
|
|
81
|
+
return response;
|
|
82
|
+
const problem = yield* Effect.promise(() => response.json().catch(() => ({})));
|
|
83
|
+
if (response.status === 409 && problem.code === RELOAD_CODE)
|
|
84
|
+
yield* raise("required");
|
|
85
|
+
return yield* new ApiError({
|
|
86
|
+
status: response.status,
|
|
87
|
+
code: problem.code ?? "unknown",
|
|
88
|
+
message: problem.message ?? response.statusText,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* A response body, decoded as the contract says it is rather than cast to it. A message from a
|
|
93
|
+
* newer revision decodes to the `unknown` member of its union; a body that isn't the contract at
|
|
94
|
+
* all is refused as `invalid_body`.
|
|
95
|
+
*/
|
|
96
|
+
const read = (schema) => (path, init) => Effect.flatMap(request(path, init), (response) => Effect.tryPromise({
|
|
97
|
+
try: () => response.json(),
|
|
98
|
+
catch: unreadable(response),
|
|
99
|
+
}).pipe(Effect.flatMap((body) => Effect.mapError(Schema.decodeUnknownEffect(schema)(body), unreadable(response)))));
|
|
100
|
+
/**
|
|
101
|
+
* One connection: its frames, then a failure when it closes. Only a connection that was live
|
|
102
|
+
* reports the drop: the retry's backoff starts over whenever the stream emits, so one that never
|
|
103
|
+
* connected must emit nothing for the next attempt to wait longer.
|
|
104
|
+
*/
|
|
105
|
+
const connection = Stream.callback((queue) => Effect.gen(function* () {
|
|
106
|
+
const Socket = socketClass();
|
|
107
|
+
const { ticket } = yield* Effect.mapError(read(EventTicket)("/events/tickets", { method: "POST" }), () => new SocketClosed());
|
|
108
|
+
const url = `${base.replace(/^http/, "ws")}/api/v1/events`;
|
|
109
|
+
const closed = yield* Deferred.make();
|
|
110
|
+
const socket = yield* Effect.acquireRelease(Effect.try({
|
|
111
|
+
try: () => new Socket(url, [EVENTS_PROTOCOL, `${TICKET_PROTOCOL_PREFIX}${ticket}`]),
|
|
112
|
+
catch: () => new SocketClosed(),
|
|
113
|
+
}), (open) => Effect.sync(() => open.close()));
|
|
114
|
+
let live = false;
|
|
115
|
+
socket.addEventListener("message", (message) => {
|
|
116
|
+
if (typeof message.data !== "string" || message.data === "pong")
|
|
117
|
+
return;
|
|
118
|
+
let frame;
|
|
119
|
+
try {
|
|
120
|
+
frame = JSON.parse(message.data);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// A frame of a type this client doesn't know, from a newer revision, is skipped.
|
|
126
|
+
const event = decodeFrame(frame);
|
|
127
|
+
if (Option.isNone(event) || event.value.type === "unknown")
|
|
128
|
+
return;
|
|
129
|
+
live = true;
|
|
130
|
+
Queue.offerUnsafe(queue, event.value);
|
|
131
|
+
});
|
|
132
|
+
const close = () => Deferred.doneUnsafe(closed, Exit.void);
|
|
133
|
+
socket.addEventListener("close", close);
|
|
134
|
+
socket.addEventListener("error", close);
|
|
135
|
+
const ping = setInterval(() => {
|
|
136
|
+
if (socket.readyState === Socket.OPEN)
|
|
137
|
+
socket.send("ping");
|
|
138
|
+
}, PING_MS);
|
|
139
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => clearInterval(ping)));
|
|
140
|
+
yield* Deferred.await(closed);
|
|
141
|
+
if (live)
|
|
142
|
+
Queue.offerUnsafe(queue, { type: "disconnected" });
|
|
143
|
+
return yield* new SocketClosed();
|
|
144
|
+
}));
|
|
145
|
+
const events = connection.pipe(Stream.retry(RECONNECT), Stream.catch(() => Stream.empty));
|
|
146
|
+
return {
|
|
147
|
+
whoami: read(Whoami)("/whoami"),
|
|
148
|
+
snapshot: read(Snapshot)("/snapshot"),
|
|
149
|
+
ask: (id) => read(Ask)(`/asks/${encodeURIComponent(id)}`),
|
|
150
|
+
history: (page = {}) => {
|
|
151
|
+
const query = new URLSearchParams();
|
|
152
|
+
if (page.before !== undefined)
|
|
153
|
+
query.set("before", page.before);
|
|
154
|
+
if (page.limit !== undefined)
|
|
155
|
+
query.set("limit", String(page.limit));
|
|
156
|
+
const search = query.size === 0 ? "" : `?${query}`;
|
|
157
|
+
return read(AskPage)(`/asks${search}`);
|
|
158
|
+
},
|
|
159
|
+
reply: (id, reply) => read(Ask)(`/asks/${encodeURIComponent(id)}/reply`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
body: JSON.stringify(reply),
|
|
162
|
+
headers: { "Content-Type": "application/json" },
|
|
163
|
+
}),
|
|
164
|
+
file: (uploadId) => Effect.flatMap(request(`/files/${encodeURIComponent(uploadId)}`), (response) => Effect.tryPromise({ try: () => response.blob(), catch: unreadable(response) })),
|
|
165
|
+
agentSignIn: Effect.tryPromise({
|
|
166
|
+
try: (signal) => fetcher(`${base}/.well-known/oauth-protected-resource/mcp`, { signal }),
|
|
167
|
+
catch: (error) => new ApiError({
|
|
168
|
+
status: 0,
|
|
169
|
+
code: "network",
|
|
170
|
+
message: error instanceof Error ? error.message : String(error),
|
|
171
|
+
}),
|
|
172
|
+
}).pipe(Effect.flatMap((response) => response.ok
|
|
173
|
+
? Effect.succeed("oauth")
|
|
174
|
+
: response.status === 404
|
|
175
|
+
? Effect.succeed("token")
|
|
176
|
+
: Effect.fail(new ApiError({
|
|
177
|
+
status: response.status,
|
|
178
|
+
code: "unknown",
|
|
179
|
+
message: `Loopback answered ${response.status} asked how agents sign in.`,
|
|
180
|
+
})))),
|
|
181
|
+
events,
|
|
182
|
+
inbox: events.pipe(Stream.scan(() => initialInbox, applyEvent)),
|
|
183
|
+
update: SubscriptionRef.changes(updates).pipe(Stream.changes),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
/** The Loopback client as an Effect service. */
|
|
187
|
+
export class LoopbackClient extends Context.Service()("@loopbacktools/sdk/LoopbackClient") {
|
|
188
|
+
static layer = (config) => Layer.succeed(LoopbackClient, make(config));
|
|
189
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Ask, ServerEvent, UnknownEvent } from "@loopbacktools/protocol/schema";
|
|
2
|
+
/** How many closed asks the inbox keeps, matching what a snapshot carries. */
|
|
3
|
+
export declare const RECENT_LIMIT = 50;
|
|
4
|
+
/** The inbox as a frontend renders it, kept current from the event socket. */
|
|
5
|
+
export interface InboxState {
|
|
6
|
+
/**
|
|
7
|
+
* `connecting` until the first snapshot arrives, `live` while the socket is up, and
|
|
8
|
+
* `reconnecting` after it drops, until the next snapshot replaces everything.
|
|
9
|
+
*/
|
|
10
|
+
readonly status: "connecting" | "live" | "reconnecting";
|
|
11
|
+
/** Open asks, newest first. */
|
|
12
|
+
readonly open: readonly Ask[];
|
|
13
|
+
/** Closed asks, most recently closed first. */
|
|
14
|
+
readonly recent: readonly Ask[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* What the event stream yields: the server's frames it can read, and the socket dropping. A frame of
|
|
18
|
+
* a type this client doesn't know is skipped before it gets here.
|
|
19
|
+
*/
|
|
20
|
+
export type InboxEvent = Exclude<ServerEvent, UnknownEvent> | {
|
|
21
|
+
readonly type: "disconnected";
|
|
22
|
+
};
|
|
23
|
+
export declare const initialInbox: InboxState;
|
|
24
|
+
/** The inbox after one event. Pure, so any frontend can keep its own copy with it. */
|
|
25
|
+
export declare const applyEvent: (state: InboxState, event: InboxEvent) => InboxState;
|
|
26
|
+
/** Open asks waiting on the person rather than on an agent. */
|
|
27
|
+
export declare const awaitingHuman: (state: InboxState) => readonly Ask[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** How many closed asks the inbox keeps, matching what a snapshot carries. */
|
|
2
|
+
export const RECENT_LIMIT = 50;
|
|
3
|
+
export const initialInbox = { status: "connecting", open: [], recent: [] };
|
|
4
|
+
const newestFirst = (a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0;
|
|
5
|
+
/** The inbox after one event. Pure, so any frontend can keep its own copy with it. */
|
|
6
|
+
export const applyEvent = (state, event) => {
|
|
7
|
+
switch (event.type) {
|
|
8
|
+
case "snapshot":
|
|
9
|
+
return { status: "live", open: event.snapshot.open, recent: event.snapshot.recent };
|
|
10
|
+
case "disconnected":
|
|
11
|
+
return state.status === "connecting" ? state : { ...state, status: "reconnecting" };
|
|
12
|
+
case "ask.opened":
|
|
13
|
+
case "ask.updated": {
|
|
14
|
+
const { ask } = event;
|
|
15
|
+
const open = state.open.filter((item) => item.id !== ask.id);
|
|
16
|
+
const recent = state.recent.filter((item) => item.id !== ask.id);
|
|
17
|
+
return ask.status === "closed"
|
|
18
|
+
? { ...state, open, recent: [ask, ...recent].slice(0, RECENT_LIMIT) }
|
|
19
|
+
: { ...state, open: [...open, ask].toSorted(newestFirst), recent };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Open asks waiting on the person rather than on an agent. */
|
|
24
|
+
export const awaitingHuman = (state) => state.open.filter((ask) => ask.status === "awaiting_human");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type * from "@loopbacktools/protocol/schema";
|
|
2
|
+
export type { Reply as ReplyBody } from "@loopbacktools/protocol";
|
|
3
|
+
export type { BriefingTree, PointNode, PointState, PointTurn } from "@loopbacktools/protocol";
|
|
4
|
+
export { answerEntries, answersProblem, findPoint, foldBriefing, hasWords, MAX_POINT_DEPTH, MAX_TOP_POINTS, numberBriefing, respondedProblem, } from "@loopbacktools/protocol";
|
|
5
|
+
export { ApiError, EVENTS_PROTOCOL, LoopbackClient, type LoopbackApi, type LoopbackConfig, make, TICKET_PROTOCOL_PREFIX, type Update, } from "./client.ts";
|
|
6
|
+
export { applyEvent, awaitingHuman, type InboxEvent, type InboxState, initialInbox, RECENT_LIMIT, } from "./inbox.ts";
|
|
7
|
+
export { createLoopbackClient, type InboxStore, type LoopbackPromiseClient, type Store, } from "./promise.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { answerEntries, answersProblem, findPoint, foldBriefing, hasWords, MAX_POINT_DEPTH, MAX_TOP_POINTS, numberBriefing, respondedProblem, } from "@loopbacktools/protocol";
|
|
2
|
+
export { ApiError, EVENTS_PROTOCOL, LoopbackClient, make, TICKET_PROTOCOL_PREFIX, } from "./client.js";
|
|
3
|
+
export { applyEvent, awaitingHuman, initialInbox, RECENT_LIMIT, } from "./inbox.js";
|
|
4
|
+
export { createLoopbackClient, } from "./promise.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Reply as ReplyBody } from "@loopbacktools/protocol";
|
|
2
|
+
import type { Ask, AskPage, Snapshot, Whoami } from "@loopbacktools/protocol/schema";
|
|
3
|
+
import { type LoopbackConfig, type Update } from "./client.ts";
|
|
4
|
+
import { type InboxState } from "./inbox.ts";
|
|
5
|
+
/** A value kept live as an external store: the shape React's `useSyncExternalStore` takes, and any other framework can. */
|
|
6
|
+
export interface Store<A> {
|
|
7
|
+
/** Starts following the value with the first listener and stops after the last leaves. */
|
|
8
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
9
|
+
/** The current value. The same object until something changes. */
|
|
10
|
+
readonly getSnapshot: () => A;
|
|
11
|
+
}
|
|
12
|
+
/** The inbox as an external store. */
|
|
13
|
+
export type InboxStore = Store<InboxState>;
|
|
14
|
+
/** The Loopback client for code that does not use Effect: every call is a promise. */
|
|
15
|
+
export interface LoopbackPromiseClient {
|
|
16
|
+
readonly whoami: () => Promise<Whoami>;
|
|
17
|
+
readonly snapshot: () => Promise<Snapshot>;
|
|
18
|
+
readonly ask: (id: string) => Promise<Ask>;
|
|
19
|
+
readonly history: (page?: {
|
|
20
|
+
readonly before?: string;
|
|
21
|
+
readonly limit?: number;
|
|
22
|
+
}) => Promise<AskPage>;
|
|
23
|
+
readonly reply: (id: string, reply: ReplyBody) => Promise<Ask>;
|
|
24
|
+
readonly file: (uploadId: string) => Promise<Blob>;
|
|
25
|
+
/** How an agent signs in to this deployment's MCP endpoint: see `LoopbackApi`. */
|
|
26
|
+
readonly agentSignIn: () => Promise<"oauth" | "token">;
|
|
27
|
+
readonly inbox: InboxStore;
|
|
28
|
+
/** Whether Loopback has been updated under this client: see `Update`. */
|
|
29
|
+
readonly update: Store<Update>;
|
|
30
|
+
}
|
|
31
|
+
export declare const createLoopbackClient: (config: LoopbackConfig) => LoopbackPromiseClient;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Fiber, Stream } from "effect";
|
|
2
|
+
import { make } from "./client.js";
|
|
3
|
+
import { initialInbox } from "./inbox.js";
|
|
4
|
+
/** Runs a call, rejecting with the `ApiError` itself so callers can match on it. */
|
|
5
|
+
const settle = async (effect) => {
|
|
6
|
+
const exit = await Effect.runPromiseExit(effect);
|
|
7
|
+
if (Exit.isSuccess(exit))
|
|
8
|
+
return exit.value;
|
|
9
|
+
throw Cause.squash(exit.cause);
|
|
10
|
+
};
|
|
11
|
+
/** A stream followed while anything listens, starting over from `initial` each time it is followed anew. */
|
|
12
|
+
const storeOf = (stream, initial) => {
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
let state = initial;
|
|
15
|
+
let following;
|
|
16
|
+
return {
|
|
17
|
+
subscribe: (listener) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
following ??= Effect.runFork(stream.pipe(Stream.runForEach((next) => Effect.sync(() => {
|
|
20
|
+
state = next;
|
|
21
|
+
for (const notify of listeners)
|
|
22
|
+
notify();
|
|
23
|
+
}))));
|
|
24
|
+
return () => {
|
|
25
|
+
listeners.delete(listener);
|
|
26
|
+
if (listeners.size === 0 && following !== undefined) {
|
|
27
|
+
Effect.runFork(Fiber.interrupt(following));
|
|
28
|
+
following = undefined;
|
|
29
|
+
state = initial;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
getSnapshot: () => state,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export const createLoopbackClient = (config) => {
|
|
37
|
+
const api = make(config);
|
|
38
|
+
const inbox = storeOf(api.inbox, initialInbox);
|
|
39
|
+
const update = storeOf(api.update, "none");
|
|
40
|
+
return {
|
|
41
|
+
whoami: () => settle(api.whoami),
|
|
42
|
+
snapshot: () => settle(api.snapshot),
|
|
43
|
+
ask: (id) => settle(api.ask(id)),
|
|
44
|
+
history: (page) => settle(api.history(page)),
|
|
45
|
+
reply: (id, reply) => settle(api.reply(id, reply)),
|
|
46
|
+
file: (uploadId) => settle(api.file(uploadId)),
|
|
47
|
+
agentSignIn: () => settle(api.agentSignIn),
|
|
48
|
+
inbox,
|
|
49
|
+
update,
|
|
50
|
+
};
|
|
51
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopbacktools/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "A client for Loopback: read and answer what agents ask, and follow the inbox live, from any frontend.",
|
|
5
|
+
"homepage": "https://github.com/LoopBackTools/loopback/tree/main/packages/sdk#readme",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/LoopBackTools/loopback.git",
|
|
10
|
+
"directory": "packages/sdk"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/src/index.d.ts",
|
|
19
|
+
"default": "./dist/src/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"effect": "4.0.6",
|
|
27
|
+
"@loopbacktools/protocol": "0.4.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@effect/vitest": "4.0.6",
|
|
31
|
+
"typescript": "7.0.2",
|
|
32
|
+
"vitest": "5.0.5"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"build": "tsc -p tsconfig.build.json"
|
|
38
|
+
}
|
|
6
39
|
}
|