@possibl/rcrt-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +154 -0
- package/package.json +54 -0
- package/src/auth.ts +56 -0
- package/src/authn.ts +159 -0
- package/src/breadcrumbs.ts +111 -0
- package/src/capabilities.ts +93 -0
- package/src/cards.ts +110 -0
- package/src/chat.ts +83 -0
- package/src/client.ts +97 -0
- package/src/errors.ts +101 -0
- package/src/files.ts +135 -0
- package/src/grants.ts +99 -0
- package/src/index.ts +103 -0
- package/src/internal/fetch.ts +133 -0
- package/src/internal/sse.ts +236 -0
- package/src/sessions.ts +110 -0
- package/src/types/breadcrumb.ts +77 -0
- package/src/types/card.ts +298 -0
- package/src/types/index.ts +2 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @possibl/rcrt-sdk — TypeScript SDK for RCRT.
|
|
3
|
+
*
|
|
4
|
+
* Public surface. Anything not re-exported here is internal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Primary entry
|
|
8
|
+
export { RcrtClient } from './client.js';
|
|
9
|
+
export type { RcrtClientConfig } from './client.js';
|
|
10
|
+
|
|
11
|
+
// Auth
|
|
12
|
+
export { staticTokenProvider } from './auth.js';
|
|
13
|
+
export type { TokenProvider } from './auth.js';
|
|
14
|
+
|
|
15
|
+
// Errors
|
|
16
|
+
export { ApiError, SdkError } from './errors.js';
|
|
17
|
+
export type { ApiErrorDetail, KnownErrorCode } from './errors.js';
|
|
18
|
+
|
|
19
|
+
// Modules (handy if consumers want the types directly)
|
|
20
|
+
export { BreadcrumbsModule } from './breadcrumbs.js';
|
|
21
|
+
export { ChatModule } from './chat.js';
|
|
22
|
+
export { CardsModule } from './cards.js';
|
|
23
|
+
export { GrantsModule } from './grants.js';
|
|
24
|
+
export { IdentityModule } from './authn.js';
|
|
25
|
+
export { FilesModule } from './files.js';
|
|
26
|
+
export { SessionsModule } from './sessions.js';
|
|
27
|
+
export { CapabilitiesModule } from './capabilities.js';
|
|
28
|
+
export type {
|
|
29
|
+
MeResponse,
|
|
30
|
+
Tenant,
|
|
31
|
+
PendingInvitation,
|
|
32
|
+
UserProfileBreadcrumb,
|
|
33
|
+
} from './authn.js';
|
|
34
|
+
export type { SendChatRequest, SendChatResponse, SseStreamOptions } from './chat.js';
|
|
35
|
+
export type {
|
|
36
|
+
ServiceGrantSummary,
|
|
37
|
+
InitiateAuthRequest,
|
|
38
|
+
InitiateAuthResponse,
|
|
39
|
+
GrantType,
|
|
40
|
+
GrantStatus,
|
|
41
|
+
} from './grants.js';
|
|
42
|
+
export type { FileScope, UploadFileOptions, ListFilesOptions } from './files.js';
|
|
43
|
+
export type {
|
|
44
|
+
SessionParticipant,
|
|
45
|
+
ConstellationData,
|
|
46
|
+
ConstellationNode,
|
|
47
|
+
ConstellationEdge,
|
|
48
|
+
} from './sessions.js';
|
|
49
|
+
export type { CapabilityType, ChattableAgent } from './capabilities.js';
|
|
50
|
+
|
|
51
|
+
// Types — breadcrumbs
|
|
52
|
+
export type {
|
|
53
|
+
Actor,
|
|
54
|
+
ActorType,
|
|
55
|
+
Breadcrumb,
|
|
56
|
+
BreadcrumbResponse,
|
|
57
|
+
CreateBreadcrumbRequest,
|
|
58
|
+
UpdateBreadcrumbRequest,
|
|
59
|
+
QueryByTagsOptions,
|
|
60
|
+
SemanticSearchOptions,
|
|
61
|
+
} from './types/breadcrumb.js';
|
|
62
|
+
|
|
63
|
+
// Types — JIT UI cards
|
|
64
|
+
export type {
|
|
65
|
+
Card,
|
|
66
|
+
CardLayout,
|
|
67
|
+
CardHeader,
|
|
68
|
+
CardBody,
|
|
69
|
+
CardFooter,
|
|
70
|
+
CardAction,
|
|
71
|
+
ActionStyle,
|
|
72
|
+
Row,
|
|
73
|
+
TextRow,
|
|
74
|
+
FlexibleTextRow,
|
|
75
|
+
ClaimRow,
|
|
76
|
+
MetricRow,
|
|
77
|
+
EventRow,
|
|
78
|
+
PersonRow,
|
|
79
|
+
PlaceRow,
|
|
80
|
+
ToggleRow,
|
|
81
|
+
DraftPreviewRow,
|
|
82
|
+
ChartRow,
|
|
83
|
+
ChartSpec,
|
|
84
|
+
TrendDirection,
|
|
85
|
+
DraftPreview,
|
|
86
|
+
InputSpec,
|
|
87
|
+
RatingSpec,
|
|
88
|
+
ProgressState,
|
|
89
|
+
ConnectDetails,
|
|
90
|
+
CompareColumn,
|
|
91
|
+
ResolveRequest,
|
|
92
|
+
CanonicalStatus,
|
|
93
|
+
} from './types/card.js';
|
|
94
|
+
|
|
95
|
+
// SSE primitives (useful for custom consumers)
|
|
96
|
+
export type {
|
|
97
|
+
SseConnection,
|
|
98
|
+
SseHandlers,
|
|
99
|
+
SseDelta,
|
|
100
|
+
SseThought,
|
|
101
|
+
SseAction,
|
|
102
|
+
SseError,
|
|
103
|
+
} from './internal/sse.js';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal HTTP helper. Handles:
|
|
3
|
+
* - auth + tenant headers
|
|
4
|
+
* - JSON encoding + parsing
|
|
5
|
+
* - error envelope normalisation
|
|
6
|
+
* - 409 retry for optimistic-locking PATCHes
|
|
7
|
+
* - 401 one-shot retry after TokenProvider.onUnauthorized
|
|
8
|
+
* - 429 honouring Retry-After
|
|
9
|
+
*
|
|
10
|
+
* Not exported from the public surface — use the RcrtClient methods.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ApiError, parseErrorBody, SdkError } from '../errors.js';
|
|
14
|
+
import type { TokenProvider } from '../auth.js';
|
|
15
|
+
|
|
16
|
+
export interface FetchContext {
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
tenantId: string | null;
|
|
19
|
+
tokenProvider: TokenProvider;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RequestOptions {
|
|
24
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
25
|
+
/** JSON body; will be serialised. */
|
|
26
|
+
body?: unknown;
|
|
27
|
+
/** Extra query params. */
|
|
28
|
+
query?: Record<string, string | number | boolean | undefined | null>;
|
|
29
|
+
/** Extra headers. */
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
/** Skip tenant header (identity routes). */
|
|
32
|
+
skipTenant?: boolean;
|
|
33
|
+
/** Abort signal. */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
/**
|
|
36
|
+
* Retries for 409 Conflict. Each retry re-fetches first if the
|
|
37
|
+
* caller provides a `refetchBeforeRetry` hook.
|
|
38
|
+
*/
|
|
39
|
+
maxConflictRetries?: number;
|
|
40
|
+
refetchBeforeRetry?: () => Promise<Partial<{ body: unknown }>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function request<T = unknown>(
|
|
44
|
+
ctx: FetchContext,
|
|
45
|
+
path: string,
|
|
46
|
+
options: RequestOptions = {},
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
const method = options.method ?? 'GET';
|
|
49
|
+
const fetchImpl = ctx.fetchImpl ?? globalThis.fetch;
|
|
50
|
+
if (typeof fetchImpl !== 'function') {
|
|
51
|
+
throw new SdkError('NO_FETCH', 'fetch() is not available — pass `fetchImpl` in client config');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const url = buildUrl(ctx.apiUrl, path, options.query);
|
|
55
|
+
const maxRetries = options.maxConflictRetries ?? 2;
|
|
56
|
+
let attempt = 0;
|
|
57
|
+
let body = options.body;
|
|
58
|
+
let didRefreshAuth = false;
|
|
59
|
+
|
|
60
|
+
while (true) {
|
|
61
|
+
const idToken = await ctx.tokenProvider.getIdToken();
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
'Authorization': `Bearer ${idToken}`,
|
|
64
|
+
...(options.headers ?? {}),
|
|
65
|
+
};
|
|
66
|
+
if (!options.skipTenant && ctx.tenantId) {
|
|
67
|
+
headers['X-Tenant-ID'] = ctx.tenantId;
|
|
68
|
+
}
|
|
69
|
+
if (body !== undefined) {
|
|
70
|
+
headers['Content-Type'] = 'application/json';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const init: RequestInit = { method, headers };
|
|
74
|
+
if (body !== undefined) init.body = JSON.stringify(body);
|
|
75
|
+
if (options.signal) init.signal = options.signal;
|
|
76
|
+
const res = await fetchImpl(url, init);
|
|
77
|
+
|
|
78
|
+
if (res.status >= 200 && res.status < 300) {
|
|
79
|
+
if (res.status === 204) return undefined as T;
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
if (!text) return undefined as T;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text) as T;
|
|
84
|
+
} catch {
|
|
85
|
+
// Some endpoints return non-JSON (e.g. raw bytes); surface as a string.
|
|
86
|
+
return text as unknown as T;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Retry 401 once after the token provider has a chance to refresh.
|
|
91
|
+
if (res.status === 401 && !didRefreshAuth && ctx.tokenProvider.onUnauthorized) {
|
|
92
|
+
didRefreshAuth = true;
|
|
93
|
+
await ctx.tokenProvider.onUnauthorized();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Retry 409 with a fresh body if the caller supports optimistic refetch.
|
|
98
|
+
if (res.status === 409 && attempt < maxRetries && options.refetchBeforeRetry) {
|
|
99
|
+
attempt += 1;
|
|
100
|
+
const fresh = await options.refetchBeforeRetry();
|
|
101
|
+
if (fresh.body !== undefined) body = fresh.body;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Retry 429 honouring Retry-After, one time.
|
|
106
|
+
if (res.status === 429 && attempt < 1) {
|
|
107
|
+
attempt += 1;
|
|
108
|
+
const ra = parseInt(res.headers.get('Retry-After') ?? '1', 10);
|
|
109
|
+
await sleep((isNaN(ra) ? 1 : ra) * 1000);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = await res.text().catch(() => '');
|
|
114
|
+
throw new ApiError(res.status, parseErrorBody(res.status, raw), raw);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildUrl(apiUrl: string, path: string, query?: RequestOptions['query']): string {
|
|
119
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
120
|
+
const fullPath = path.startsWith('/') ? path : `/${path}`;
|
|
121
|
+
const u = new URL(base + fullPath);
|
|
122
|
+
if (query) {
|
|
123
|
+
for (const [k, v] of Object.entries(query)) {
|
|
124
|
+
if (v === undefined || v === null) continue;
|
|
125
|
+
u.searchParams.set(k, String(v));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return u.toString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sleep(ms: number): Promise<void> {
|
|
132
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
133
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE client with automatic reconnection + typed event dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Works in three environments:
|
|
5
|
+
* - Browsers: uses the built-in `EventSource`. Auth goes via query
|
|
6
|
+
* params because `EventSource` can't set custom headers.
|
|
7
|
+
* - Node 18+: uses the built-in `EventSource` (Node 22+) or a
|
|
8
|
+
* pluggable polyfill (see `config.eventSource`).
|
|
9
|
+
* - React Native: install `react-native-sse` and pass its
|
|
10
|
+
* constructor as `config.eventSource`. Custom headers work there.
|
|
11
|
+
*
|
|
12
|
+
* Subscribers get typed events. Streams auto-reconnect with
|
|
13
|
+
* exponential backoff unless `close()` was called explicitly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Breadcrumb } from '../types/breadcrumb.js';
|
|
17
|
+
|
|
18
|
+
export interface SseConnectConfig {
|
|
19
|
+
apiUrl: string;
|
|
20
|
+
path: string;
|
|
21
|
+
tenantId: string | null;
|
|
22
|
+
getToken: () => Promise<string>;
|
|
23
|
+
/** Override the EventSource implementation. Required in React Native. */
|
|
24
|
+
eventSource?: EventSourceConstructor;
|
|
25
|
+
/** Max reconnect delay in ms. Default 30000. */
|
|
26
|
+
maxBackoffMs?: number;
|
|
27
|
+
/** Extra query params (e.g. `?last_event_id=...`). */
|
|
28
|
+
query?: Record<string, string>;
|
|
29
|
+
/** Disable the query-param token fallback; requires a custom EventSource impl. */
|
|
30
|
+
useHeaderAuth?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SseDelta {
|
|
34
|
+
delta: string;
|
|
35
|
+
agent_id: string;
|
|
36
|
+
is_final: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SseThought {
|
|
40
|
+
steps: string[];
|
|
41
|
+
agent_id?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SseAction {
|
|
45
|
+
tool_name: string;
|
|
46
|
+
status: 'running' | 'completed' | 'failed';
|
|
47
|
+
request_id?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SseError {
|
|
51
|
+
code?: string;
|
|
52
|
+
message?: string;
|
|
53
|
+
fatal?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SseHandlers {
|
|
57
|
+
onConnected?: (payload: { session_id?: string; user_id?: string }) => void;
|
|
58
|
+
onDelta?: (data: SseDelta) => void;
|
|
59
|
+
onMessage?: (data: Breadcrumb) => void;
|
|
60
|
+
onThought?: (data: SseThought) => void;
|
|
61
|
+
onAction?: (data: SseAction) => void;
|
|
62
|
+
onError?: (data: SseError, isFatal: boolean) => void;
|
|
63
|
+
/** Fires any time the underlying SSE connection opens. */
|
|
64
|
+
onOpen?: () => void;
|
|
65
|
+
/** Fires any time the underlying SSE connection closes (before a reconnect attempt). */
|
|
66
|
+
onClose?: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type EventSourceConstructor = new (url: string, init?: EventSourceInit & { headers?: Record<string, string> }) => EventSourceLike;
|
|
70
|
+
|
|
71
|
+
interface EventSourceLike {
|
|
72
|
+
readonly readyState: 0 | 1 | 2;
|
|
73
|
+
close(): void;
|
|
74
|
+
addEventListener(type: string, listener: (ev: MessageEvent | Event) => void): void;
|
|
75
|
+
onerror: ((ev: Event) => void) | null;
|
|
76
|
+
onopen: ((ev: Event) => void) | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SseConnection {
|
|
80
|
+
/** Close the stream. No more reconnects. */
|
|
81
|
+
close(): void;
|
|
82
|
+
/** Current state — for UI reconnection indicators. */
|
|
83
|
+
readonly state: 'connecting' | 'open' | 'closed';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function connect(config: SseConnectConfig, handlers: SseHandlers): SseConnection {
|
|
87
|
+
const maxBackoff = config.maxBackoffMs ?? 30_000;
|
|
88
|
+
let backoff = 1_000;
|
|
89
|
+
let closed = false;
|
|
90
|
+
let es: EventSourceLike | null = null;
|
|
91
|
+
let state: 'connecting' | 'open' | 'closed' = 'connecting';
|
|
92
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
93
|
+
|
|
94
|
+
async function open(): Promise<void> {
|
|
95
|
+
if (closed) return;
|
|
96
|
+
|
|
97
|
+
const token = await config.getToken();
|
|
98
|
+
const u = new URL(config.apiUrl.replace(/\/$/, '') + config.path);
|
|
99
|
+
if (config.query) {
|
|
100
|
+
for (const [k, v] of Object.entries(config.query)) {
|
|
101
|
+
u.searchParams.set(k, v);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const EventSourceCtor: EventSourceConstructor = (config.eventSource
|
|
106
|
+
?? (globalThis as unknown as { EventSource?: EventSourceConstructor }).EventSource) as EventSourceConstructor;
|
|
107
|
+
if (!EventSourceCtor) {
|
|
108
|
+
handlers.onError?.({ message: 'No EventSource implementation available. Pass config.eventSource for React Native.', fatal: true }, true);
|
|
109
|
+
closed = true;
|
|
110
|
+
state = 'closed';
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Prefer header auth when the EventSource impl supports it (React
|
|
115
|
+
// Native with `react-native-sse`, Node 22+, any custom
|
|
116
|
+
// `config.eventSource` that accepts a `headers` init). The browser's
|
|
117
|
+
// built-in EventSource constructor has NO support for custom
|
|
118
|
+
// headers — there's no proposal in the WHATWG spec to change that —
|
|
119
|
+
// so when we're stuck with it we fall back to passing the bearer
|
|
120
|
+
// token and tenant ID as query params.
|
|
121
|
+
//
|
|
122
|
+
// Security note (Sonar hotspot review): tokens in URLs are a
|
|
123
|
+
// documented smell — they can leak via proxy logs, HTTP Referer
|
|
124
|
+
// headers, and browser history. We mitigate:
|
|
125
|
+
// * TLS is assumed (RCRT's api-gateway rejects plaintext HTTP).
|
|
126
|
+
// * Firebase ID tokens are short-lived (60 min) and auto-rotated.
|
|
127
|
+
// A leaked token in a server log expires fast; it can't be used
|
|
128
|
+
// to escalate outside the scope Firebase already granted.
|
|
129
|
+
// * `tk_*` workspace API keys are long-lived and should NEVER be
|
|
130
|
+
// passed through this path. Callers holding a tk_ key are
|
|
131
|
+
// server-side and must set `useHeaderAuth: true`. The SDK docs
|
|
132
|
+
// and the TokenProvider interface make this explicit.
|
|
133
|
+
// * This fallback only triggers for SSE subscriptions. The regular
|
|
134
|
+
// fetch-based surface (`src/internal/fetch.ts`) always uses the
|
|
135
|
+
// Authorization header.
|
|
136
|
+
// Without this fallback the SDK cannot open SSE connections from a
|
|
137
|
+
// browser at all, which would break every chat UI built on RCRT.
|
|
138
|
+
const init: EventSourceInit & { headers?: Record<string, string> } = {};
|
|
139
|
+
if (config.useHeaderAuth) {
|
|
140
|
+
init.headers = {
|
|
141
|
+
Authorization: `Bearer ${token}`,
|
|
142
|
+
...(config.tenantId ? { 'X-Tenant-ID': config.tenantId } : {}),
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
u.searchParams.set('token', token); // NOSONAR - required for browser EventSource; see block comment above
|
|
146
|
+
if (config.tenantId) u.searchParams.set('tenant_id', config.tenantId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
es = new EventSourceCtor(u.toString(), init);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
handlers.onError?.({ message: (err as Error).message, fatal: true }, true);
|
|
153
|
+
scheduleReconnect();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
es.onopen = () => {
|
|
158
|
+
backoff = 1_000;
|
|
159
|
+
state = 'open';
|
|
160
|
+
handlers.onOpen?.();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
es.addEventListener('connected', (e) => {
|
|
164
|
+
const data = tryJson((e as MessageEvent).data);
|
|
165
|
+
handlers.onConnected?.(data as { session_id?: string; user_id?: string });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
es.addEventListener('delta', (e) => {
|
|
169
|
+
const data = tryJson<SseDelta>((e as MessageEvent).data);
|
|
170
|
+
if (data) handlers.onDelta?.(data);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
es.addEventListener('message', (e) => {
|
|
174
|
+
const data = tryJson<Breadcrumb>((e as MessageEvent).data);
|
|
175
|
+
if (data) handlers.onMessage?.(data);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
es.addEventListener('thought', (e) => {
|
|
179
|
+
const data = tryJson<SseThought>((e as MessageEvent).data);
|
|
180
|
+
if (data) handlers.onThought?.(data);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
es.addEventListener('action', (e) => {
|
|
184
|
+
const data = tryJson<SseAction>((e as MessageEvent).data);
|
|
185
|
+
if (data) handlers.onAction?.(data);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
es.addEventListener('error', (e) => {
|
|
189
|
+
const data = tryJson<SseError>((e as MessageEvent | undefined)?.data ?? '');
|
|
190
|
+
handlers.onError?.(data ?? { message: 'stream error' }, false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
es.onerror = () => {
|
|
194
|
+
state = 'closed';
|
|
195
|
+
handlers.onClose?.();
|
|
196
|
+
es?.close();
|
|
197
|
+
es = null;
|
|
198
|
+
scheduleReconnect();
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function scheduleReconnect() {
|
|
203
|
+
if (closed) return;
|
|
204
|
+
state = 'connecting';
|
|
205
|
+
const delay = backoff + Math.floor(Math.random() * 500);
|
|
206
|
+
backoff = Math.min(maxBackoff, backoff * 2);
|
|
207
|
+
reconnectTimer = setTimeout(() => {
|
|
208
|
+
reconnectTimer = null;
|
|
209
|
+
void open();
|
|
210
|
+
}, delay);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
void open();
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
close() {
|
|
217
|
+
closed = true;
|
|
218
|
+
state = 'closed';
|
|
219
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
220
|
+
es?.close();
|
|
221
|
+
es = null;
|
|
222
|
+
},
|
|
223
|
+
get state() {
|
|
224
|
+
return state;
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function tryJson<T = unknown>(raw: unknown): T | undefined {
|
|
230
|
+
if (typeof raw !== 'string' || !raw) return undefined;
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(raw) as T;
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions module — multi-agent / multi-participant session metadata.
|
|
3
|
+
*
|
|
4
|
+
* The `chat.send()` + `chat.sessionStream()` flow gives you the
|
|
5
|
+
* per-turn read/write loop. This module is for everything around the
|
|
6
|
+
* session: who's in it (participants — agents AND users), and the
|
|
7
|
+
* cross-session graph view (constellation).
|
|
8
|
+
*
|
|
9
|
+
* See `packages/docs/guides/03-chat-and-sse.md` for how sessions
|
|
10
|
+
* relate to breadcrumbs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FetchContext } from './internal/fetch.js';
|
|
14
|
+
import { request } from './internal/fetch.js';
|
|
15
|
+
import { ApiError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
export interface SessionParticipant {
|
|
18
|
+
/** Either `agent` (id is an agent name like `life-coordinator`) or `user` (id is a UUID). */
|
|
19
|
+
type: 'agent' | 'user';
|
|
20
|
+
id: string;
|
|
21
|
+
/** Human-friendly label. May be undefined for never-renamed participants. */
|
|
22
|
+
name?: string;
|
|
23
|
+
invited_by?: string;
|
|
24
|
+
joined_at?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConstellationNode {
|
|
28
|
+
session_id: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
topics?: string[];
|
|
31
|
+
message_count?: number;
|
|
32
|
+
last_active?: string;
|
|
33
|
+
primary_agent?: string;
|
|
34
|
+
participant_ids?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConstellationEdge {
|
|
38
|
+
/** Two session ids the constellation maps as related. */
|
|
39
|
+
a: string;
|
|
40
|
+
b: string;
|
|
41
|
+
/** Reason for the relationship — e.g. `shared_topic:health`, `shared_participant:life-coordinator`. */
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ConstellationData {
|
|
46
|
+
nodes: ConstellationNode[];
|
|
47
|
+
edges: ConstellationEdge[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SessionsModule {
|
|
51
|
+
constructor(private readonly ctx: FetchContext) {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `GET /v1/sessions/constellation` — graph view across all the
|
|
55
|
+
* caller's sessions, grouped by topic / shared participant.
|
|
56
|
+
*
|
|
57
|
+
* Returns `null` when the constellation endpoint is unavailable
|
|
58
|
+
* or the user has zero sessions to graph.
|
|
59
|
+
*/
|
|
60
|
+
async getConstellation(limit: number = 50): Promise<ConstellationData | null> {
|
|
61
|
+
try {
|
|
62
|
+
return await request<ConstellationData>(this.ctx, '/v1/sessions/constellation', {
|
|
63
|
+
query: { limit },
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ApiError && (err.status === 404 || err.status === 204)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `GET /v1/sessions/{id}/participants` — agents + users currently in the session. */
|
|
74
|
+
async listParticipants(sessionId: string): Promise<SessionParticipant[]> {
|
|
75
|
+
const res = await request<{ participants: SessionParticipant[] } | SessionParticipant[]>(
|
|
76
|
+
this.ctx,
|
|
77
|
+
`/v1/sessions/${sessionId}/participants`,
|
|
78
|
+
);
|
|
79
|
+
return Array.isArray(res) ? res : (res.participants ?? []);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `POST /v1/sessions/{id}/participants` — invite an agent into the
|
|
84
|
+
* session. The agent will see subsequent turns and may respond.
|
|
85
|
+
*/
|
|
86
|
+
async addParticipant(
|
|
87
|
+
sessionId: string,
|
|
88
|
+
agentId: string,
|
|
89
|
+
options: { invitedBy?: string } = {},
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
await request<void>(this.ctx, `/v1/sessions/${sessionId}/participants`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
body: { agent_id: agentId, invited_by: options.invitedBy },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `DELETE /v1/sessions/{id}/participants/{agent_id}` — drop an agent from the session. */
|
|
98
|
+
async removeParticipant(sessionId: string, agentId: string): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await request<void>(
|
|
101
|
+
this.ctx,
|
|
102
|
+
`/v1/sessions/${sessionId}/participants/${agentId}`,
|
|
103
|
+
{ method: 'DELETE' },
|
|
104
|
+
);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof ApiError && err.status === 404) return; // idempotent
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb — the fundamental record of RCRT.
|
|
3
|
+
*
|
|
4
|
+
* Every meaningful state change in the system is a breadcrumb. See
|
|
5
|
+
* `packages/docs/concepts/02-primitives.md` for the mental model.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ActorType = 'user' | 'agent' | 'tool' | 'system';
|
|
9
|
+
|
|
10
|
+
export interface Actor {
|
|
11
|
+
type: ActorType;
|
|
12
|
+
/** For users: the user UUID. For agents: the agent id. For tools: the tool name. */
|
|
13
|
+
id: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Breadcrumb {
|
|
17
|
+
id: string;
|
|
18
|
+
tenant_id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
title: string;
|
|
21
|
+
content: Record<string, unknown>;
|
|
22
|
+
/** AND-semantics when queried. Prefix conventions: `interpret:*`, `service:*`, `session:*`, `user:*`, etc. */
|
|
23
|
+
tags: string[];
|
|
24
|
+
/** Breadcrumbs this derived from (SAPL provenance chain). */
|
|
25
|
+
parent_ids: string[];
|
|
26
|
+
/** Optimistic locking. PATCH must include the current value. */
|
|
27
|
+
version: number;
|
|
28
|
+
created_by: Actor;
|
|
29
|
+
created_at: string;
|
|
30
|
+
updated_at: string;
|
|
31
|
+
deleted_at: string | null;
|
|
32
|
+
/** Optional embedding for semantic search. */
|
|
33
|
+
embedding?: number[];
|
|
34
|
+
/** Relative TTL — e.g. "24h", "30d". */
|
|
35
|
+
ttl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreateBreadcrumbRequest {
|
|
39
|
+
name?: string;
|
|
40
|
+
title: string;
|
|
41
|
+
content: Record<string, unknown>;
|
|
42
|
+
tags?: string[];
|
|
43
|
+
parent_ids?: string[];
|
|
44
|
+
created_by?: Actor;
|
|
45
|
+
ttl?: string;
|
|
46
|
+
/** If a breadcrumb with the same name + tags exists, update it rather than inserting a new row. */
|
|
47
|
+
upsert?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UpdateBreadcrumbRequest {
|
|
51
|
+
/** Required. Optimistic locking fails with 409 Conflict otherwise. */
|
|
52
|
+
version: number;
|
|
53
|
+
title?: string;
|
|
54
|
+
content?: Record<string, unknown>;
|
|
55
|
+
tags?: string[];
|
|
56
|
+
parent_ids?: string[];
|
|
57
|
+
ttl?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BreadcrumbResponse {
|
|
61
|
+
breadcrumb: Breadcrumb;
|
|
62
|
+
action?: 'created' | 'updated';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface QueryByTagsOptions {
|
|
66
|
+
/** Max rows to return. Server max is 200. */
|
|
67
|
+
limit?: number;
|
|
68
|
+
offset?: number;
|
|
69
|
+
/** Exact name match. */
|
|
70
|
+
name?: string;
|
|
71
|
+
order?: 'newest' | 'oldest';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SemanticSearchOptions {
|
|
75
|
+
limit?: number;
|
|
76
|
+
tags?: string[];
|
|
77
|
+
}
|