@unson/brainbase-mcp 0.24.1 → 0.25.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.
@@ -0,0 +1,265 @@
1
+ import { type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
2
+ import type { AuthorizationServerMetadata, OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
3
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
4
+ import { type MeetingSourceAccessDetail, type MeetingSourceCallTool, type MeetingSourceFailure, type MeetingSourceFailureReason, type MeetingSourceMeeting, type MeetingSourceProvider, type MeetingSourceSegment, type TactiqTranscriptBudget, type TactiqTranscriptRead } from './meeting-source-reader.js';
5
+ import type { OrganizationConnectionBinding, OrganizationConnectionProviderAdapter } from './organization-connection.js';
6
+ export type MeetingSourceCapability = 'meetings:list' | 'transcript:read';
7
+ export type MeetingSourceConnectionStatus = 'pending' | 'connected' | 'reauth_required' | 'disabled' | 'revoked';
8
+ export interface MeetingSourceConnectionRecord {
9
+ /** Issued by Brainbase. An external connection platform's ID lives only in `backend.ref`. */
10
+ connectionId: string;
11
+ tenantId: string;
12
+ provider: MeetingSourceProvider;
13
+ scope: 'personal' | 'org';
14
+ ownerPersonId: string | null;
15
+ orgId: string | null;
16
+ backend: {
17
+ kind: string;
18
+ ref: Record<string, string> | null;
19
+ };
20
+ capabilities: MeetingSourceCapability[];
21
+ status: MeetingSourceConnectionStatus;
22
+ revision: number;
23
+ lastVerifiedAt: string | null;
24
+ }
25
+ export declare class MeetingSourceConnectionError extends Error {
26
+ readonly code: string;
27
+ constructor(code: string, message: string);
28
+ }
29
+ export declare function createMeetingSourceConnectionId(): string;
30
+ export declare function normalizeMeetingSourceConnectionRecord(value: unknown): MeetingSourceConnectionRecord;
31
+ export interface StoredMeetingSourceCredentials {
32
+ provider: MeetingSourceProvider;
33
+ binding: OrganizationConnectionBinding;
34
+ serverUrl: string;
35
+ clientInformation: OAuthClientInformationMixed;
36
+ tokens: OAuthTokens | null;
37
+ connectedAt: string;
38
+ }
39
+ export interface PendingMcpAuthorization {
40
+ provider: MeetingSourceProvider;
41
+ binding: OrganizationConnectionBinding;
42
+ serverUrl: string;
43
+ authorizationServerUrl: string;
44
+ authorizationServerMetadata: AuthorizationServerMetadata | null;
45
+ clientInformation: OAuthClientInformationMixed;
46
+ codeVerifier: string;
47
+ redirectUrl: string;
48
+ resource: string | null;
49
+ createdAt: string;
50
+ }
51
+ export interface MeetingSourceCredentialStore {
52
+ read(connectionId: string): Promise<StoredMeetingSourceCredentials | null>;
53
+ write(connectionId: string, value: StoredMeetingSourceCredentials): Promise<void>;
54
+ remove(connectionId: string): Promise<void>;
55
+ savePendingAuthorization(stateHash: string, value: PendingMcpAuthorization): Promise<void>;
56
+ /** Returns the pending authorization once and forgets it. */
57
+ takePendingAuthorization(stateHash: string): Promise<PendingMcpAuthorization | null>;
58
+ }
59
+ export declare class MemoryMeetingSourceCredentialStore implements MeetingSourceCredentialStore {
60
+ private data;
61
+ read(connectionId: string): Promise<StoredMeetingSourceCredentials | null>;
62
+ write(connectionId: string, value: StoredMeetingSourceCredentials): Promise<void>;
63
+ remove(connectionId: string): Promise<void>;
64
+ savePendingAuthorization(stateHash: string, value: PendingMcpAuthorization): Promise<void>;
65
+ takePendingAuthorization(stateHash: string): Promise<PendingMcpAuthorization | null>;
66
+ }
67
+ /**
68
+ * One JSON file (mode 0600) holding credentials per connection ID. Writes go
69
+ * through a temporary file and a rename. Calls are serialized in-process.
70
+ */
71
+ export declare class FileMeetingSourceCredentialStore implements MeetingSourceCredentialStore {
72
+ private readonly filePath;
73
+ private queue;
74
+ constructor(filePath: string);
75
+ read(connectionId: string): Promise<StoredMeetingSourceCredentials>;
76
+ write(connectionId: string, value: StoredMeetingSourceCredentials): Promise<void>;
77
+ remove(connectionId: string): Promise<void>;
78
+ savePendingAuthorization(stateHash: string, value: PendingMcpAuthorization): Promise<void>;
79
+ takePendingAuthorization(stateHash: string): Promise<PendingMcpAuthorization>;
80
+ private mutate;
81
+ private run;
82
+ private load;
83
+ private save;
84
+ }
85
+ export interface MeetingSourceConnectionNote {
86
+ capability: MeetingSourceCapability;
87
+ reason: MeetingSourceFailureReason | 'no_meetings';
88
+ message: string;
89
+ access?: MeetingSourceAccessDetail;
90
+ }
91
+ export interface MeetingSourceConnectionHealth {
92
+ status: 'connected' | 'reauth_required' | 'error';
93
+ capabilities: MeetingSourceCapability[];
94
+ checkedAt: string;
95
+ failure: MeetingSourceFailure | null;
96
+ notes: MeetingSourceConnectionNote[];
97
+ }
98
+ export interface ConnectionRuntime {
99
+ verify(connection: MeetingSourceConnectionRecord, options?: {
100
+ budget?: TactiqTranscriptBudget;
101
+ }): Promise<MeetingSourceConnectionHealth>;
102
+ callTool(connection: MeetingSourceConnectionRecord, tool: string, args: Record<string, unknown>): Promise<unknown>;
103
+ disconnect(connection: MeetingSourceConnectionRecord): Promise<void>;
104
+ }
105
+ /**
106
+ * Checks that a connection can actually list meetings and read the first page
107
+ * of a full transcript. Shared by every runtime so the result does not depend
108
+ * on how the connection is executed.
109
+ */
110
+ export declare function probeMeetingSourceConnection(input: {
111
+ provider: MeetingSourceProvider;
112
+ callTool: MeetingSourceCallTool;
113
+ budget?: TactiqTranscriptBudget;
114
+ now?: () => Date;
115
+ lookbackMs?: number;
116
+ }): Promise<MeetingSourceConnectionHealth>;
117
+ export interface NativeMcpRuntimeOptions {
118
+ credentials: MeetingSourceCredentialStore;
119
+ /** MCP server URL per provider, used when the stored credentials have none. */
120
+ serverUrlFor?: (provider: MeetingSourceProvider) => string;
121
+ /** Replaces the Streamable HTTP transport, e.g. for tests. */
122
+ createTransport?: (input: {
123
+ url: URL;
124
+ authProvider: OAuthClientProvider;
125
+ connection: MeetingSourceConnectionRecord;
126
+ }) => Transport;
127
+ clientName?: string;
128
+ now?: () => Date;
129
+ }
130
+ /** Runs MCP tools with the SDK client, reading credentials through the credential port. */
131
+ export declare class NativeMcpRuntime implements ConnectionRuntime {
132
+ private readonly options;
133
+ constructor(options: NativeMcpRuntimeOptions);
134
+ verify(connection: MeetingSourceConnectionRecord, options?: {
135
+ budget?: TactiqTranscriptBudget;
136
+ }): Promise<MeetingSourceConnectionHealth>;
137
+ callTool(connection: MeetingSourceConnectionRecord, tool: string, args: Record<string, unknown>): Promise<unknown>;
138
+ disconnect(connection: MeetingSourceConnectionRecord): Promise<void>;
139
+ }
140
+ export interface MeetingSourcePendingMeeting {
141
+ meeting: MeetingSourceMeeting;
142
+ reason: MeetingSourceFailureReason | 'delivery_failed';
143
+ message: string;
144
+ retryAt: string | null;
145
+ attempts: number;
146
+ firstSeenAt: string;
147
+ }
148
+ export interface MeetingSourceDeliveredMeeting {
149
+ digest: string;
150
+ version: number;
151
+ deliveredAt: string;
152
+ }
153
+ export interface MeetingSourceSyncState {
154
+ schemaVersion: 1;
155
+ connectionId: string;
156
+ provider: MeetingSourceProvider;
157
+ syncEnabled: boolean;
158
+ listedThrough: string | null;
159
+ pending: MeetingSourcePendingMeeting[];
160
+ unobtainable: Array<{
161
+ meetingId: string;
162
+ reason: string;
163
+ message: string;
164
+ decidedAt: string;
165
+ }>;
166
+ delivered: Record<string, MeetingSourceDeliveredMeeting>;
167
+ tactiqReads: TactiqTranscriptRead[];
168
+ lastAttemptAt: string | null;
169
+ lastSuccessAt: string | null;
170
+ lastError: {
171
+ reason: string;
172
+ message: string;
173
+ at: string;
174
+ } | null;
175
+ }
176
+ export interface MeetingSourceSyncStateStore {
177
+ load(connectionId: string): Promise<MeetingSourceSyncState | null>;
178
+ save(state: MeetingSourceSyncState): Promise<void>;
179
+ }
180
+ export interface MeetingSourceDelivery {
181
+ connectionId: string;
182
+ tenantId: string;
183
+ provider: MeetingSourceProvider;
184
+ scope: 'personal' | 'org';
185
+ ownerPersonId: string | null;
186
+ orgId: string | null;
187
+ meeting: MeetingSourceMeeting;
188
+ transcript: {
189
+ text: string;
190
+ segments: MeetingSourceSegment[];
191
+ digest: string;
192
+ };
193
+ version: number;
194
+ retrievedAt: string;
195
+ }
196
+ export type MeetingSourceDeliver = (delivery: MeetingSourceDelivery) => Promise<void>;
197
+ export interface MeetingSourceSyncResult {
198
+ status: 'ok' | 'failed' | 'skipped';
199
+ skippedReason?: 'not_connected' | 'sync_disabled';
200
+ delivered: Array<{
201
+ meetingId: string;
202
+ version: number;
203
+ }>;
204
+ pending: number;
205
+ failure: {
206
+ reason: string;
207
+ message: string;
208
+ } | null;
209
+ state: MeetingSourceSyncState;
210
+ }
211
+ export declare function emptyMeetingSourceSyncState(connection: MeetingSourceConnectionRecord): MeetingSourceSyncState;
212
+ export declare class MemoryMeetingSourceSyncStateStore implements MeetingSourceSyncStateStore {
213
+ private readonly states;
214
+ load(connectionId: string): Promise<MeetingSourceSyncState | null>;
215
+ save(state: MeetingSourceSyncState): Promise<void>;
216
+ }
217
+ export declare function syncMeetingSourceConnection(input: {
218
+ connection: MeetingSourceConnectionRecord;
219
+ runtime: ConnectionRuntime;
220
+ stateStore: MeetingSourceSyncStateStore;
221
+ deliver: MeetingSourceDeliver;
222
+ /** Re-fetch a past period. Does not move `listedThrough`. */
223
+ window?: {
224
+ since: string;
225
+ until: string;
226
+ };
227
+ recheckDelivered?: boolean;
228
+ now?: () => Date;
229
+ initialLookbackMs?: number;
230
+ overlapMs?: number;
231
+ maxAttempts?: number;
232
+ }): Promise<MeetingSourceSyncResult>;
233
+ export interface MeetingSourceListedMeeting {
234
+ connection: MeetingSourceConnectionRecord;
235
+ meeting: MeetingSourceMeeting;
236
+ }
237
+ export interface SameMeetingCandidate {
238
+ first: MeetingSourceListedMeeting;
239
+ second: MeetingSourceListedMeeting;
240
+ sharedParticipants: string[];
241
+ }
242
+ /**
243
+ * Suggests pairs that may be the same meeting. Only meetings of the same tenant
244
+ * and the same owner are compared; nothing is merged automatically.
245
+ */
246
+ export declare function findSameMeetingCandidates(entries: readonly MeetingSourceListedMeeting[], options?: {
247
+ toleranceMs?: number;
248
+ }): SameMeetingCandidate[];
249
+ export interface McpOAuthConnectionAdapterOptions {
250
+ credentials: MeetingSourceCredentialStore;
251
+ serverUrlFor: (provider: MeetingSourceProvider) => string;
252
+ redirectUrl: string;
253
+ clientName?: string;
254
+ /** Overrides the scopes requested. Defaults to the protected resource's `scopes_supported`. */
255
+ scopeFor?: (provider: MeetingSourceProvider, supported: string[] | undefined) => string | undefined;
256
+ fetchFn?: typeof fetch;
257
+ now?: () => Date;
258
+ issueConnectionId?: () => string;
259
+ }
260
+ /**
261
+ * Implements the provider side of `@unson/brainbase-mcp/organization-connection`
262
+ * for MCP servers: discovery (RFC 9728 / RFC 8414), dynamic client
263
+ * registration, PKCE, and the token exchange. Readbacks never carry tokens.
264
+ */
265
+ export declare function createMcpOAuthConnectionAdapter(options: McpOAuthConnectionAdapterOptions): OrganizationConnectionProviderAdapter;
@@ -0,0 +1,702 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { discoverOAuthServerInfo, exchangeAuthorization, registerClient, startAuthorization, } from '@modelcontextprotocol/sdk/client/auth.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
+ import { createTactiqTranscriptBudget, listMeetings, probeTranscript, readTranscript, } from './meeting-source-reader.js';
8
+ /**
9
+ * Connections to meeting sources (Plaud, Tactiq) and per-connection sync.
10
+ * Persistence, tenant and owner authorization, scheduling, and the delivery
11
+ * target are ports supplied by the caller (the organization product).
12
+ */
13
+ const HOUR_MS = 60 * 60 * 1000;
14
+ const DAY_MS = 24 * HOUR_MS;
15
+ const PROVIDERS = ['plaud', 'tactiq'];
16
+ const CAPABILITIES = ['meetings:list', 'transcript:read'];
17
+ const STATUSES = ['pending', 'connected', 'reauth_required', 'disabled', 'revoked'];
18
+ const SECRET_KEY_PATTERN = /(?:access|refresh|id)?[_-]?(?:token|secret)|credential|password|private[_-]?key|authorization[_-]?code|client[_-]?secret|code[_-]?verifier/i;
19
+ const SECRET_VALUE_PATTERN = /^\s*bearer\s|^eyJ[\w-]+\.[\w-]+\.[\w-]+$/i;
20
+ const AUTHORIZATION_REQUIRED = 'OAuth authorization is required';
21
+ export class MeetingSourceConnectionError extends Error {
22
+ code;
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.name = 'MeetingSourceConnectionError';
26
+ this.code = code;
27
+ }
28
+ }
29
+ export function createMeetingSourceConnectionId() {
30
+ return `msc_${randomBytes(16).toString('hex')}`;
31
+ }
32
+ export function normalizeMeetingSourceConnectionRecord(value) {
33
+ const record = asRecord(value);
34
+ if (!record)
35
+ throw invalid('connection record must be an object');
36
+ const connectionId = requiredString(record.connectionId, 'connectionId');
37
+ if (!/^msc_[0-9a-f]{32}$/.test(connectionId))
38
+ throw invalid('connectionId must be issued by Brainbase (msc_…)');
39
+ const provider = record.provider;
40
+ if (!PROVIDERS.includes(provider))
41
+ throw invalid('provider must be plaud or tactiq');
42
+ const scope = record.scope;
43
+ if (scope !== 'personal' && scope !== 'org')
44
+ throw invalid('scope must be personal or org');
45
+ const ownerPersonId = optionalString(record.ownerPersonId, 'ownerPersonId');
46
+ const orgId = optionalString(record.orgId, 'orgId');
47
+ if (scope === 'personal' && !ownerPersonId)
48
+ throw invalid('a personal connection needs ownerPersonId');
49
+ if (scope === 'org' && !orgId)
50
+ throw invalid('an org connection needs orgId');
51
+ const backend = asRecord(record.backend);
52
+ if (!backend)
53
+ throw invalid('backend must be an object');
54
+ const kind = requiredString(backend.kind, 'backend.kind');
55
+ let ref = null;
56
+ if (backend.ref !== null && backend.ref !== undefined) {
57
+ const raw = asRecord(backend.ref);
58
+ if (!raw)
59
+ throw invalid('backend.ref must be an object');
60
+ ref = {};
61
+ for (const [key, entry] of Object.entries(raw)) {
62
+ if (SECRET_KEY_PATTERN.test(key))
63
+ throw invalid(`backend.ref.${key} looks like a secret`);
64
+ if (typeof entry !== 'string')
65
+ throw invalid(`backend.ref.${key} must be a string`);
66
+ if (SECRET_VALUE_PATTERN.test(entry))
67
+ throw invalid(`backend.ref.${key} looks like a secret`);
68
+ ref[key] = entry;
69
+ }
70
+ }
71
+ const capabilities = Array.isArray(record.capabilities) ? record.capabilities : [];
72
+ for (const capability of capabilities) {
73
+ if (!CAPABILITIES.includes(capability))
74
+ throw invalid(`unknown capability ${String(capability)}`);
75
+ }
76
+ const status = record.status;
77
+ if (!STATUSES.includes(status))
78
+ throw invalid('unknown connection status');
79
+ const revision = record.revision;
80
+ if (!Number.isInteger(revision) || revision < 0)
81
+ throw invalid('revision must be a non-negative integer');
82
+ return {
83
+ connectionId,
84
+ tenantId: requiredString(record.tenantId, 'tenantId'),
85
+ provider: provider,
86
+ scope,
87
+ ownerPersonId,
88
+ orgId,
89
+ backend: { kind, ref },
90
+ capabilities: [...new Set(capabilities)],
91
+ status: status,
92
+ revision: revision,
93
+ lastVerifiedAt: optionalString(record.lastVerifiedAt, 'lastVerifiedAt'),
94
+ };
95
+ }
96
+ function emptyCredentialFile() {
97
+ return { version: 2, connections: {}, pending: {} };
98
+ }
99
+ export class MemoryMeetingSourceCredentialStore {
100
+ data = emptyCredentialFile();
101
+ async read(connectionId) {
102
+ const value = this.data.connections[connectionId];
103
+ return value ? structuredClone(value) : null;
104
+ }
105
+ async write(connectionId, value) {
106
+ this.data.connections[connectionId] = structuredClone(value);
107
+ }
108
+ async remove(connectionId) {
109
+ delete this.data.connections[connectionId];
110
+ }
111
+ async savePendingAuthorization(stateHash, value) {
112
+ this.data.pending[stateHash] = structuredClone(value);
113
+ }
114
+ async takePendingAuthorization(stateHash) {
115
+ const value = this.data.pending[stateHash];
116
+ delete this.data.pending[stateHash];
117
+ return value ? structuredClone(value) : null;
118
+ }
119
+ }
120
+ /**
121
+ * One JSON file (mode 0600) holding credentials per connection ID. Writes go
122
+ * through a temporary file and a rename. Calls are serialized in-process.
123
+ */
124
+ export class FileMeetingSourceCredentialStore {
125
+ filePath;
126
+ queue = Promise.resolve();
127
+ constructor(filePath) {
128
+ this.filePath = filePath;
129
+ }
130
+ read(connectionId) {
131
+ return this.run(async (data) => data.connections[connectionId] ?? null);
132
+ }
133
+ write(connectionId, value) {
134
+ return this.mutate((data) => {
135
+ data.connections[connectionId] = value;
136
+ });
137
+ }
138
+ remove(connectionId) {
139
+ return this.mutate((data) => {
140
+ delete data.connections[connectionId];
141
+ });
142
+ }
143
+ savePendingAuthorization(stateHash, value) {
144
+ return this.mutate((data) => {
145
+ data.pending[stateHash] = value;
146
+ });
147
+ }
148
+ takePendingAuthorization(stateHash) {
149
+ return this.run(async (data) => {
150
+ const value = data.pending[stateHash] ?? null;
151
+ if (value) {
152
+ delete data.pending[stateHash];
153
+ await this.save(data);
154
+ }
155
+ return value;
156
+ });
157
+ }
158
+ mutate(change) {
159
+ return this.run(async (data) => {
160
+ change(data);
161
+ await this.save(data);
162
+ });
163
+ }
164
+ run(task) {
165
+ const next = this.queue.then(async () => task(await this.load()));
166
+ this.queue = next.catch(() => undefined);
167
+ return next;
168
+ }
169
+ async load() {
170
+ let text;
171
+ try {
172
+ text = await readFile(this.filePath, 'utf8');
173
+ }
174
+ catch (error) {
175
+ if (error.code === 'ENOENT')
176
+ return emptyCredentialFile();
177
+ throw error;
178
+ }
179
+ const parsed = asRecord(JSON.parse(text));
180
+ if (!parsed || parsed.version !== 2) {
181
+ throw new MeetingSourceConnectionError('credential_store_invalid', 'Meeting source credential file has an unknown format');
182
+ }
183
+ return {
184
+ version: 2,
185
+ connections: (asRecord(parsed.connections) ?? {}),
186
+ pending: (asRecord(parsed.pending) ?? {}),
187
+ };
188
+ }
189
+ async save(data) {
190
+ await mkdir(dirname(this.filePath), { recursive: true });
191
+ const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
192
+ await writeFile(temporary, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
193
+ await rename(temporary, this.filePath);
194
+ }
195
+ }
196
+ /**
197
+ * Checks that a connection can actually list meetings and read the first page
198
+ * of a full transcript. Shared by every runtime so the result does not depend
199
+ * on how the connection is executed.
200
+ */
201
+ export async function probeMeetingSourceConnection(input) {
202
+ const now = input.now ?? (() => new Date());
203
+ const checked = now();
204
+ const listed = await listMeetings({
205
+ provider: input.provider,
206
+ callTool: input.callTool,
207
+ since: new Date(checked.getTime() - (input.lookbackMs ?? 30 * DAY_MS)).toISOString(),
208
+ until: checked.toISOString(),
209
+ now,
210
+ });
211
+ const checkedAt = checked.toISOString();
212
+ if (listed.status === 'failed') {
213
+ return {
214
+ status: listed.failure.reason === 'reauth_required' ? 'reauth_required' : 'error',
215
+ capabilities: [],
216
+ checkedAt,
217
+ failure: listed.failure,
218
+ notes: [],
219
+ };
220
+ }
221
+ const capabilities = ['meetings:list'];
222
+ const notes = [];
223
+ const newest = newestFirst(listed.meetings)[0];
224
+ if (!newest) {
225
+ notes.push({ capability: 'transcript:read', reason: 'no_meetings', message: 'No meeting in the checked period to read a transcript from' });
226
+ }
227
+ else {
228
+ const probe = await probeTranscript({
229
+ provider: input.provider,
230
+ callTool: input.callTool,
231
+ meetingId: newest.externalId,
232
+ budget: input.budget,
233
+ now,
234
+ });
235
+ if (probe.status === 'readable') {
236
+ capabilities.push('transcript:read');
237
+ }
238
+ else {
239
+ notes.push({
240
+ capability: 'transcript:read',
241
+ reason: probe.failure.reason,
242
+ message: probe.failure.message,
243
+ ...(probe.failure.access ? { access: probe.failure.access } : {}),
244
+ });
245
+ }
246
+ }
247
+ return { status: 'connected', capabilities, checkedAt, failure: null, notes };
248
+ }
249
+ /** Runs MCP tools with the SDK client, reading credentials through the credential port. */
250
+ export class NativeMcpRuntime {
251
+ options;
252
+ constructor(options) {
253
+ this.options = options;
254
+ }
255
+ async verify(connection, options = {}) {
256
+ return probeMeetingSourceConnection({
257
+ provider: connection.provider,
258
+ callTool: (name, args) => this.callTool(connection, name, args),
259
+ budget: options.budget,
260
+ now: this.options.now,
261
+ });
262
+ }
263
+ async callTool(connection, tool, args) {
264
+ const stored = await this.options.credentials.read(connection.connectionId);
265
+ if (!stored || !stored.tokens) {
266
+ throw new MeetingSourceConnectionError('reauth_required', `${connection.provider} ${AUTHORIZATION_REQUIRED}`);
267
+ }
268
+ if (stored.provider !== connection.provider) {
269
+ throw new MeetingSourceConnectionError('connection_mismatch', 'Stored credentials belong to another provider');
270
+ }
271
+ const serverUrl = stored.serverUrl || this.options.serverUrlFor?.(connection.provider);
272
+ if (!serverUrl)
273
+ throw new MeetingSourceConnectionError('server_url_missing', `${connection.provider} MCP server URL is not configured`);
274
+ const secrets = secretValues(stored);
275
+ const authProvider = new ConnectionOAuthProvider(this.options.credentials, connection);
276
+ const url = new URL(serverUrl);
277
+ const transport = this.options.createTransport
278
+ ? this.options.createTransport({ url, authProvider, connection })
279
+ : new StreamableHTTPClientTransport(url, { authProvider });
280
+ const client = new Client({ name: this.options.clientName ?? 'brainbase-meeting-source', version: '1.0.0' });
281
+ try {
282
+ await client.connect(transport);
283
+ return await client.callTool({ name: tool, arguments: args });
284
+ }
285
+ catch (error) {
286
+ throw redactError(error, [...secrets, ...secretValues(await this.options.credentials.read(connection.connectionId))]);
287
+ }
288
+ finally {
289
+ await Promise.resolve(client.close()).catch(() => undefined);
290
+ }
291
+ }
292
+ async disconnect(connection) {
293
+ await this.options.credentials.remove(connection.connectionId);
294
+ }
295
+ }
296
+ /** The SDK's OAuth provider for one connection. It never starts an interactive authorization. */
297
+ class ConnectionOAuthProvider {
298
+ store;
299
+ connection;
300
+ constructor(store, connection) {
301
+ this.store = store;
302
+ this.connection = connection;
303
+ }
304
+ get redirectUrl() {
305
+ return undefined;
306
+ }
307
+ get clientMetadata() {
308
+ return { redirect_uris: [], client_name: 'Brainbase Meeting Source' };
309
+ }
310
+ async clientInformation() {
311
+ return (await this.store.read(this.connection.connectionId))?.clientInformation;
312
+ }
313
+ async tokens() {
314
+ return (await this.store.read(this.connection.connectionId))?.tokens ?? undefined;
315
+ }
316
+ async saveTokens(tokens) {
317
+ const stored = await this.store.read(this.connection.connectionId);
318
+ if (!stored)
319
+ throw new MeetingSourceConnectionError('reauth_required', `${this.connection.provider} ${AUTHORIZATION_REQUIRED}`);
320
+ await this.store.write(this.connection.connectionId, { ...stored, tokens });
321
+ }
322
+ redirectToAuthorization() {
323
+ throw new MeetingSourceConnectionError('reauth_required', `${this.connection.provider} ${AUTHORIZATION_REQUIRED}`);
324
+ }
325
+ saveCodeVerifier() {
326
+ throw new MeetingSourceConnectionError('reauth_required', `${this.connection.provider} ${AUTHORIZATION_REQUIRED}`);
327
+ }
328
+ codeVerifier() {
329
+ throw new MeetingSourceConnectionError('reauth_required', `${this.connection.provider} ${AUTHORIZATION_REQUIRED}`);
330
+ }
331
+ async invalidateCredentials(scope) {
332
+ if (scope !== 'all' && scope !== 'tokens')
333
+ return;
334
+ const stored = await this.store.read(this.connection.connectionId);
335
+ if (stored)
336
+ await this.store.write(this.connection.connectionId, { ...stored, tokens: null });
337
+ }
338
+ }
339
+ function secretValues(stored) {
340
+ if (!stored)
341
+ return [];
342
+ const client = stored.clientInformation;
343
+ return [stored.tokens?.access_token, stored.tokens?.refresh_token, client.client_secret]
344
+ .filter((value) => typeof value === 'string' && value.length >= 8);
345
+ }
346
+ function redactError(error, secrets) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ let safe = message.replace(/bearer\s+[\w.~+/=-]+/gi, 'Bearer [redacted]');
349
+ for (const secret of secrets)
350
+ safe = safe.split(secret).join('[redacted]');
351
+ const code = error instanceof MeetingSourceConnectionError ? error.code : 'provider_call_failed';
352
+ return new MeetingSourceConnectionError(code, safe);
353
+ }
354
+ export function emptyMeetingSourceSyncState(connection) {
355
+ return {
356
+ schemaVersion: 1,
357
+ connectionId: connection.connectionId,
358
+ provider: connection.provider,
359
+ syncEnabled: true,
360
+ listedThrough: null,
361
+ pending: [],
362
+ unobtainable: [],
363
+ delivered: {},
364
+ tactiqReads: [],
365
+ lastAttemptAt: null,
366
+ lastSuccessAt: null,
367
+ lastError: null,
368
+ };
369
+ }
370
+ export class MemoryMeetingSourceSyncStateStore {
371
+ states = new Map();
372
+ async load(connectionId) {
373
+ const state = this.states.get(connectionId);
374
+ return state ? structuredClone(state) : null;
375
+ }
376
+ async save(state) {
377
+ this.states.set(state.connectionId, structuredClone(state));
378
+ }
379
+ }
380
+ const NON_FAILURE_REASONS = new Set(['not_ready', 'access_required', 'rate_limited']);
381
+ export async function syncMeetingSourceConnection(input) {
382
+ const { connection } = input;
383
+ const now = input.now ?? (() => new Date());
384
+ const stored = await input.stateStore.load(connection.connectionId);
385
+ if (stored && (stored.connectionId !== connection.connectionId || stored.provider !== connection.provider)) {
386
+ throw new MeetingSourceConnectionError('sync_state_mismatch', 'Sync state belongs to another connection');
387
+ }
388
+ const state = stored ?? emptyMeetingSourceSyncState(connection);
389
+ if (connection.status !== 'connected') {
390
+ return { status: 'skipped', skippedReason: 'not_connected', delivered: [], pending: state.pending.length, failure: null, state };
391
+ }
392
+ if (!state.syncEnabled) {
393
+ return { status: 'skipped', skippedReason: 'sync_disabled', delivered: [], pending: state.pending.length, failure: null, state };
394
+ }
395
+ const started = now();
396
+ state.lastAttemptAt = started.toISOString();
397
+ const callTool = (name, args) => input.runtime.callTool(connection, name, args);
398
+ const until = input.window ? input.window.until : started.toISOString();
399
+ const since = input.window
400
+ ? input.window.since
401
+ : state.listedThrough
402
+ ? new Date(Date.parse(state.listedThrough) - (input.overlapMs ?? DAY_MS)).toISOString()
403
+ : new Date(started.getTime() - (input.initialLookbackMs ?? 7 * DAY_MS)).toISOString();
404
+ const finish = async (failure, delivered) => {
405
+ if (failure) {
406
+ state.lastError = { ...failure, at: now().toISOString() };
407
+ }
408
+ else {
409
+ state.lastError = null;
410
+ state.lastSuccessAt = now().toISOString();
411
+ }
412
+ await input.stateStore.save(state);
413
+ return { status: failure ? 'failed' : 'ok', delivered, pending: state.pending.length, failure, state };
414
+ };
415
+ const listed = await listMeetings({ provider: connection.provider, callTool, since, until, now });
416
+ if (listed.status === 'failed')
417
+ return finish({ reason: listed.failure.reason, message: listed.failure.message }, []);
418
+ const pendingById = new Map(state.pending.map((entry) => [entry.meeting.externalId, entry]));
419
+ const unobtainable = new Set(state.unobtainable.map((entry) => entry.meetingId));
420
+ const currentTime = started.getTime();
421
+ const notYetDue = (meetingId) => {
422
+ const retryAt = pendingById.get(meetingId)?.retryAt;
423
+ return Boolean(retryAt && Date.parse(retryAt) > currentTime);
424
+ };
425
+ // Meetings already pending and not yet due stay in the pending list untouched.
426
+ const candidates = new Map();
427
+ for (const meeting of listed.meetings) {
428
+ if (unobtainable.has(meeting.externalId) || notYetDue(meeting.externalId))
429
+ continue;
430
+ if (state.delivered[meeting.externalId] && !input.recheckDelivered)
431
+ continue;
432
+ candidates.set(meeting.externalId, meeting);
433
+ }
434
+ for (const entry of state.pending) {
435
+ if (notYetDue(entry.meeting.externalId))
436
+ continue;
437
+ if (!candidates.has(entry.meeting.externalId))
438
+ candidates.set(entry.meeting.externalId, entry.meeting);
439
+ }
440
+ const budget = connection.provider === 'tactiq' ? createTactiqTranscriptBudget({ reads: state.tactiqReads }) : undefined;
441
+ const delivered = [];
442
+ let failure = null;
443
+ const queue = newestFirst([...candidates.values()]);
444
+ const keepPending = (meeting, reason, message, retryAt, countAttempt) => {
445
+ const previous = pendingById.get(meeting.externalId);
446
+ const attempts = (previous?.attempts ?? 0) + (countAttempt ? 1 : 0);
447
+ if (countAttempt && attempts >= (input.maxAttempts ?? 5)) {
448
+ pendingById.delete(meeting.externalId);
449
+ state.unobtainable.push({ meetingId: meeting.externalId, reason, message, decidedAt: now().toISOString() });
450
+ return;
451
+ }
452
+ pendingById.set(meeting.externalId, {
453
+ meeting,
454
+ reason,
455
+ message,
456
+ retryAt,
457
+ attempts,
458
+ firstSeenAt: previous?.firstSeenAt ?? now().toISOString(),
459
+ });
460
+ };
461
+ for (let index = 0; index < queue.length; index += 1) {
462
+ const meeting = queue[index];
463
+ if (budget) {
464
+ const slot = budget.tryAcquire(meeting.externalId, now());
465
+ if (!slot.ok) {
466
+ for (const rest of queue.slice(index)) {
467
+ keepPending(rest, 'rate_limited', 'Tactiq transcript read limit reached for this hour', slot.retryAt, false);
468
+ }
469
+ break;
470
+ }
471
+ }
472
+ const result = await readTranscript({ provider: connection.provider, callTool, meetingId: meeting.externalId, budget, now });
473
+ if (result.status === 'unavailable') {
474
+ const { reason, scope, message, retryAt } = result.failure;
475
+ const isFailure = !NON_FAILURE_REASONS.has(reason);
476
+ if (scope === 'connection') {
477
+ for (const rest of queue.slice(index))
478
+ keepPending(rest, reason, message, retryAt, false);
479
+ failure = { reason, message };
480
+ break;
481
+ }
482
+ keepPending(meeting, reason, message, retryAt, isFailure);
483
+ if (isFailure)
484
+ failure ??= { reason, message };
485
+ continue;
486
+ }
487
+ const previous = state.delivered[meeting.externalId];
488
+ if (previous?.digest === result.digest) {
489
+ pendingById.delete(meeting.externalId);
490
+ continue;
491
+ }
492
+ const version = (previous?.version ?? 0) + 1;
493
+ try {
494
+ await input.deliver({
495
+ connectionId: connection.connectionId,
496
+ tenantId: connection.tenantId,
497
+ provider: connection.provider,
498
+ scope: connection.scope,
499
+ ownerPersonId: connection.ownerPersonId,
500
+ orgId: connection.orgId,
501
+ meeting,
502
+ transcript: { text: result.text, segments: result.segments, digest: result.digest },
503
+ version,
504
+ retrievedAt: now().toISOString(),
505
+ });
506
+ }
507
+ catch (error) {
508
+ const message = error instanceof Error ? error.message : String(error);
509
+ keepPending(meeting, 'delivery_failed', message, null, true);
510
+ failure ??= { reason: 'delivery_failed', message };
511
+ continue;
512
+ }
513
+ state.delivered[meeting.externalId] = { digest: result.digest, version, deliveredAt: now().toISOString() };
514
+ pendingById.delete(meeting.externalId);
515
+ delivered.push({ meetingId: meeting.externalId, version });
516
+ }
517
+ state.pending = [...pendingById.values()];
518
+ if (budget)
519
+ state.tactiqReads = budget.reads(now());
520
+ if (!listed.complete) {
521
+ failure ??= { reason: 'incomplete', message: 'The meeting list could not be read completely for the period' };
522
+ }
523
+ else if (!input.window && (!state.listedThrough || Date.parse(until) > Date.parse(state.listedThrough))) {
524
+ state.listedThrough = until;
525
+ }
526
+ return finish(failure, delivered);
527
+ }
528
+ /**
529
+ * Suggests pairs that may be the same meeting. Only meetings of the same tenant
530
+ * and the same owner are compared; nothing is merged automatically.
531
+ */
532
+ export function findSameMeetingCandidates(entries, options = {}) {
533
+ const tolerance = options.toleranceMs ?? 5 * 60 * 1000;
534
+ const candidates = [];
535
+ for (let i = 0; i < entries.length; i += 1) {
536
+ for (let j = i + 1; j < entries.length; j += 1) {
537
+ const first = entries[i];
538
+ const second = entries[j];
539
+ if (first.connection.provider === second.connection.provider)
540
+ continue;
541
+ if (first.connection.tenantId !== second.connection.tenantId)
542
+ continue;
543
+ if (ownerKey(first.connection) !== ownerKey(second.connection))
544
+ continue;
545
+ const a = interval(first.meeting);
546
+ const b = interval(second.meeting);
547
+ if (!a || !b)
548
+ continue;
549
+ if (a.start > b.end + tolerance || b.start > a.end + tolerance)
550
+ continue;
551
+ const names = new Set(first.meeting.participants.map(normalizeName));
552
+ const sharedParticipants = second.meeting.participants.filter((name) => names.has(normalizeName(name)));
553
+ candidates.push({ first, second, sharedParticipants });
554
+ }
555
+ }
556
+ return candidates;
557
+ }
558
+ function ownerKey(connection) {
559
+ return connection.scope === 'personal' ? `person:${connection.ownerPersonId}` : `org:${connection.orgId}`;
560
+ }
561
+ function interval(meeting) {
562
+ const start = meeting.startedAt ? Date.parse(meeting.startedAt) : NaN;
563
+ if (!Number.isFinite(start))
564
+ return null;
565
+ return { start, end: start + (meeting.durationSeconds ?? 0) * 1000 };
566
+ }
567
+ function normalizeName(name) {
568
+ return name.normalize('NFKC').replace(/\s+/g, '').toLowerCase();
569
+ }
570
+ /**
571
+ * Implements the provider side of `@unson/brainbase-mcp/organization-connection`
572
+ * for MCP servers: discovery (RFC 9728 / RFC 8414), dynamic client
573
+ * registration, PKCE, and the token exchange. Readbacks never carry tokens.
574
+ */
575
+ export function createMcpOAuthConnectionAdapter(options) {
576
+ const now = options.now ?? (() => new Date());
577
+ const issueConnectionId = options.issueConnectionId ?? createMeetingSourceConnectionId;
578
+ const readback = (connectionId, stored) => ({
579
+ provider: stored.provider,
580
+ connectionId,
581
+ // A connection becomes `connected` only after a verification reads meetings.
582
+ status: 'pending',
583
+ connectedAt: stored.connectedAt,
584
+ updatedAt: stored.connectedAt,
585
+ });
586
+ return {
587
+ async createAuthorization({ provider, binding, state }) {
588
+ const meetingProvider = toProvider(provider);
589
+ const serverUrl = options.serverUrlFor(meetingProvider);
590
+ const info = await discoverOAuthServerInfo(serverUrl, { fetchFn: options.fetchFn });
591
+ const metadata = info.authorizationServerMetadata;
592
+ const clientMetadata = {
593
+ client_name: options.clientName ?? 'Brainbase Meeting Source',
594
+ redirect_uris: [options.redirectUrl],
595
+ grant_types: ['authorization_code', 'refresh_token'],
596
+ response_types: ['code'],
597
+ token_endpoint_auth_method: 'none',
598
+ };
599
+ const clientInformation = await registerClient(info.authorizationServerUrl, {
600
+ metadata,
601
+ clientMetadata,
602
+ fetchFn: options.fetchFn,
603
+ });
604
+ const supported = info.resourceMetadata?.scopes_supported;
605
+ const scope = options.scopeFor ? options.scopeFor(meetingProvider, supported) : supported?.join(' ') || undefined;
606
+ const resource = info.resourceMetadata?.resource ? new URL(info.resourceMetadata.resource) : undefined;
607
+ const { authorizationUrl, codeVerifier } = await startAuthorization(info.authorizationServerUrl, {
608
+ metadata,
609
+ clientInformation,
610
+ redirectUrl: options.redirectUrl,
611
+ scope,
612
+ state,
613
+ resource,
614
+ });
615
+ await options.credentials.savePendingAuthorization(hashState(state), {
616
+ provider: meetingProvider,
617
+ binding,
618
+ serverUrl,
619
+ authorizationServerUrl: info.authorizationServerUrl,
620
+ authorizationServerMetadata: metadata ?? null,
621
+ clientInformation,
622
+ codeVerifier,
623
+ redirectUrl: options.redirectUrl,
624
+ resource: resource?.toString() ?? null,
625
+ createdAt: now().toISOString(),
626
+ });
627
+ return { authorizationUrl: authorizationUrl.toString() };
628
+ },
629
+ async exchangeAuthorizationCode({ provider, binding, state, code }) {
630
+ const pending = await options.credentials.takePendingAuthorization(state.stateHash);
631
+ if (!pending)
632
+ throw new MeetingSourceConnectionError('authorization_missing', 'No pending authorization for this state');
633
+ if (pending.provider !== provider || !sameBinding(pending.binding, binding)) {
634
+ throw new MeetingSourceConnectionError('authorization_mismatch', 'The pending authorization belongs to another connection request');
635
+ }
636
+ const tokens = await exchangeAuthorization(pending.authorizationServerUrl, {
637
+ metadata: pending.authorizationServerMetadata ?? undefined,
638
+ clientInformation: pending.clientInformation,
639
+ authorizationCode: code,
640
+ codeVerifier: pending.codeVerifier,
641
+ redirectUri: pending.redirectUrl,
642
+ resource: pending.resource ? new URL(pending.resource) : undefined,
643
+ fetchFn: options.fetchFn,
644
+ });
645
+ const connectionId = issueConnectionId();
646
+ const stored = {
647
+ provider: pending.provider,
648
+ binding: pending.binding,
649
+ serverUrl: pending.serverUrl,
650
+ clientInformation: pending.clientInformation,
651
+ tokens,
652
+ connectedAt: now().toISOString(),
653
+ };
654
+ await options.credentials.write(connectionId, stored);
655
+ return readback(connectionId, stored);
656
+ },
657
+ async readConnection({ provider, binding, connectionId }) {
658
+ if (!connectionId)
659
+ return null;
660
+ const stored = await options.credentials.read(connectionId);
661
+ if (!stored || stored.provider !== provider || !sameBinding(stored.binding, binding))
662
+ return null;
663
+ return readback(connectionId, stored);
664
+ },
665
+ };
666
+ }
667
+ // ---------------------------------------------------------------------------
668
+ // Helpers
669
+ function toProvider(value) {
670
+ if (!PROVIDERS.includes(value)) {
671
+ throw new MeetingSourceConnectionError('provider_unsupported', `Unsupported meeting source provider: ${value}`);
672
+ }
673
+ return value;
674
+ }
675
+ function hashState(state) {
676
+ return createHash('sha256').update(state, 'utf8').digest('hex');
677
+ }
678
+ function sameBinding(left, right) {
679
+ return left.tenantId === right.tenantId && left.personId === right.personId;
680
+ }
681
+ function newestFirst(meetings) {
682
+ const time = (meeting) => (meeting.startedAt ? Date.parse(meeting.startedAt) : Number.NEGATIVE_INFINITY);
683
+ return [...meetings].sort((a, b) => time(b) - time(a));
684
+ }
685
+ function asRecord(value) {
686
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
687
+ }
688
+ function requiredString(value, field) {
689
+ if (typeof value !== 'string' || !value.trim())
690
+ throw invalid(`${field} is required`);
691
+ return value.trim();
692
+ }
693
+ function optionalString(value, field) {
694
+ if (value === null || value === undefined || value === '')
695
+ return null;
696
+ if (typeof value !== 'string')
697
+ throw invalid(`${field} must be a string`);
698
+ return value.trim() || null;
699
+ }
700
+ function invalid(message) {
701
+ return new MeetingSourceConnectionError('connection_record_invalid', message);
702
+ }
@@ -123,3 +123,21 @@ export declare function readProviderSummary(input: {
123
123
  meetingId: string;
124
124
  now?: () => Date;
125
125
  }): Promise<MeetingSourceSummaryResult>;
126
+ export type MeetingSourceTranscriptProbe = {
127
+ status: 'readable';
128
+ segmentCount: number;
129
+ } | {
130
+ status: 'unavailable';
131
+ failure: MeetingSourceFailure;
132
+ };
133
+ /**
134
+ * Reads only the first transcript page, to check that the connection may read
135
+ * full transcripts. A Tactiq probe spends one read of the hourly budget.
136
+ */
137
+ export declare function probeTranscript(input: {
138
+ provider: MeetingSourceProvider;
139
+ callTool: MeetingSourceCallTool;
140
+ meetingId: string;
141
+ budget?: TactiqTranscriptBudget;
142
+ now?: () => Date;
143
+ }): Promise<MeetingSourceTranscriptProbe>;
@@ -111,6 +111,37 @@ export async function readProviderSummary(input) {
111
111
  return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
112
112
  }
113
113
  }
114
+ /**
115
+ * Reads only the first transcript page, to check that the connection may read
116
+ * full transcripts. A Tactiq probe spends one read of the hourly budget.
117
+ */
118
+ export async function probeTranscript(input) {
119
+ const now = input.now ?? (() => new Date());
120
+ if (input.provider === 'tactiq') {
121
+ const slot = (input.budget ?? createTactiqTranscriptBudget()).tryAcquire(input.meetingId, now());
122
+ if (!slot.ok) {
123
+ return {
124
+ status: 'unavailable',
125
+ failure: { reason: 'rate_limited', scope: 'connection', message: 'Tactiq transcript read limit reached for this hour', retryAt: slot.retryAt },
126
+ };
127
+ }
128
+ }
129
+ try {
130
+ if (input.provider === 'plaud') {
131
+ const payload = asRecord(await invoke(input.callTool, 'get_transcript', { file_id: input.meetingId, block: 'transaction', limit: 10 }));
132
+ if (!payload || !Array.isArray(payload.segments))
133
+ throw new UnexpectedShapeError('Plaud get_transcript returned no segments');
134
+ return { status: 'readable', segmentCount: payload.segments.length };
135
+ }
136
+ const payload = asRecord(await invoke(input.callTool, 'get_transcript', { meetingId: input.meetingId, page: 1 }));
137
+ if (!payload || !Array.isArray(payload.entries))
138
+ throw new UnexpectedShapeError('Tactiq get_transcript returned no entries');
139
+ return { status: 'readable', segmentCount: payload.entries.length };
140
+ }
141
+ catch (error) {
142
+ return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
143
+ }
144
+ }
114
145
  // ---------------------------------------------------------------------------
115
146
  // Plaud
116
147
  async function listPlaud(callTool, since, until, utcOffsetMinutes, pageSize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -135,6 +135,10 @@
135
135
  "types": "./dist/meeting-minutes-lineage-adapters.d.ts",
136
136
  "import": "./dist/meeting-minutes-lineage-adapters.js"
137
137
  },
138
+ "./meeting-source-connection": {
139
+ "types": "./dist/meeting-source-connection.d.ts",
140
+ "import": "./dist/meeting-source-connection.js"
141
+ },
138
142
  "./meeting-source-reader": {
139
143
  "types": "./dist/meeting-source-reader.d.ts",
140
144
  "import": "./dist/meeting-source-reader.js"
@@ -427,5 +431,5 @@
427
431
  "engines": {
428
432
  "node": ">=20"
429
433
  },
430
- "gitHead": "3a1e004ecb50844069ccca0ad19e05b57cb63f9d"
434
+ "gitHead": "8b9b0e51e70847db4ed540d10c79655d0fd6e050"
431
435
  }