@unson/brainbase-mcp 0.23.8 → 0.24.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/dist/meeting-source-reader.d.ts +125 -0
- package/dist/meeting-source-reader.js +479 -0
- package/package.json +6 -2
- package/ui/outcome-knowledge.css +38 -8
- package/ui/outcome-knowledge.js +205 -30
- package/ui/world/world-canvas-notes.js +1 -1
- package/ui/world/world-district.js +1 -1
- package/ui/world/world-view.js +11 -11
- package/ui/world/world-work-rail.js +3 -3
- package/ui/world-model-view.css +57 -0
- package/ui/world-model-view.js +45 -7
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads meeting lists and full transcripts from the official Plaud and Tactiq
|
|
3
|
+
* MCP servers. The caller supplies `callTool`; this module owns no connection,
|
|
4
|
+
* OAuth, credential, storage, or sync state. A transcript is `complete` only
|
|
5
|
+
* when every page was read. Anything else is returned as a classified failure,
|
|
6
|
+
* never as an empty or partial transcript.
|
|
7
|
+
*/
|
|
8
|
+
export type MeetingSourceProvider = 'plaud' | 'tactiq';
|
|
9
|
+
/** Calls one MCP tool and returns its `CallToolResult`, or throws. */
|
|
10
|
+
export type MeetingSourceCallTool = (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
11
|
+
export type MeetingSourceFailureReason = 'reauth_required' | 'access_required' | 'rate_limited' | 'timeout' | 'not_ready' | 'incomplete' | 'provider_error';
|
|
12
|
+
export type MeetingSourceAccessCause = 'plan' | 'connection' | 'ai_credits' | 'preview_only' | 'unknown';
|
|
13
|
+
export interface MeetingSourceAccessDetail {
|
|
14
|
+
action: string | null;
|
|
15
|
+
title: string | null;
|
|
16
|
+
message: string | null;
|
|
17
|
+
url: string | null;
|
|
18
|
+
cause: MeetingSourceAccessCause;
|
|
19
|
+
}
|
|
20
|
+
export interface MeetingSourceFailure {
|
|
21
|
+
reason: MeetingSourceFailureReason;
|
|
22
|
+
/** `connection` affects every meeting of the connection; `meeting` only this one. */
|
|
23
|
+
scope: 'connection' | 'meeting';
|
|
24
|
+
message: string;
|
|
25
|
+
retryAt: string | null;
|
|
26
|
+
access?: MeetingSourceAccessDetail;
|
|
27
|
+
}
|
|
28
|
+
export interface MeetingSourceMeeting {
|
|
29
|
+
provider: MeetingSourceProvider;
|
|
30
|
+
externalId: string;
|
|
31
|
+
title: string | null;
|
|
32
|
+
startedAt: string | null;
|
|
33
|
+
durationSeconds: number | null;
|
|
34
|
+
participants: string[];
|
|
35
|
+
url: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface MeetingSourceWindow {
|
|
38
|
+
since: string;
|
|
39
|
+
until: string;
|
|
40
|
+
}
|
|
41
|
+
export type MeetingSourceListResult = {
|
|
42
|
+
status: 'ok';
|
|
43
|
+
meetings: MeetingSourceMeeting[];
|
|
44
|
+
complete: boolean;
|
|
45
|
+
incompleteWindows: MeetingSourceWindow[];
|
|
46
|
+
} | {
|
|
47
|
+
status: 'failed';
|
|
48
|
+
failure: MeetingSourceFailure;
|
|
49
|
+
};
|
|
50
|
+
export interface MeetingSourceSegment {
|
|
51
|
+
speaker: string | null;
|
|
52
|
+
text: string;
|
|
53
|
+
startMs: number | null;
|
|
54
|
+
endMs: number | null;
|
|
55
|
+
}
|
|
56
|
+
export type MeetingSourceTranscriptResult = {
|
|
57
|
+
status: 'complete';
|
|
58
|
+
segments: MeetingSourceSegment[];
|
|
59
|
+
text: string;
|
|
60
|
+
digest: string;
|
|
61
|
+
pageCount: number;
|
|
62
|
+
segmentCount: number;
|
|
63
|
+
} | {
|
|
64
|
+
status: 'unavailable';
|
|
65
|
+
failure: MeetingSourceFailure;
|
|
66
|
+
};
|
|
67
|
+
export interface MeetingSourceProviderSummary {
|
|
68
|
+
kind: 'provider_summary';
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
export type MeetingSourceSummaryResult = {
|
|
72
|
+
status: 'ok';
|
|
73
|
+
summary: MeetingSourceProviderSummary | null;
|
|
74
|
+
} | {
|
|
75
|
+
status: 'unavailable';
|
|
76
|
+
failure: MeetingSourceFailure;
|
|
77
|
+
};
|
|
78
|
+
export interface TactiqTranscriptRead {
|
|
79
|
+
meetingId: string;
|
|
80
|
+
readAt: string;
|
|
81
|
+
}
|
|
82
|
+
export interface TactiqTranscriptBudget {
|
|
83
|
+
/** Reserves a read for `meetingId`. Re-reading a meeting inside the window is free. */
|
|
84
|
+
tryAcquire(meetingId: string, now: Date): {
|
|
85
|
+
ok: true;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
retryAt: string;
|
|
89
|
+
};
|
|
90
|
+
/** Reads still inside the window, for the caller to persist across runs. */
|
|
91
|
+
reads(now: Date): TactiqTranscriptRead[];
|
|
92
|
+
}
|
|
93
|
+
export declare const TACTIQ_TRANSCRIPT_READS_PER_HOUR = 10;
|
|
94
|
+
export declare const TACTIQ_SEARCH_LIMIT = 50;
|
|
95
|
+
export declare function createTactiqTranscriptBudget(options?: {
|
|
96
|
+
limit?: number;
|
|
97
|
+
windowMs?: number;
|
|
98
|
+
reads?: TactiqTranscriptRead[];
|
|
99
|
+
}): TactiqTranscriptBudget;
|
|
100
|
+
/** Speaker-attributed text, normalized the same way as `brainbase-unson` `transcript_hash` input. */
|
|
101
|
+
export declare function meetingTranscriptText(segments: readonly MeetingSourceSegment[]): string;
|
|
102
|
+
export declare function meetingTranscriptDigest(text: string): string;
|
|
103
|
+
export declare function listMeetings(input: {
|
|
104
|
+
provider: MeetingSourceProvider;
|
|
105
|
+
callTool: MeetingSourceCallTool;
|
|
106
|
+
since: string;
|
|
107
|
+
until: string;
|
|
108
|
+
plaudUtcOffsetMinutes?: number;
|
|
109
|
+
plaudPageSize?: number;
|
|
110
|
+
now?: () => Date;
|
|
111
|
+
}): Promise<MeetingSourceListResult>;
|
|
112
|
+
export declare function readTranscript(input: {
|
|
113
|
+
provider: MeetingSourceProvider;
|
|
114
|
+
callTool: MeetingSourceCallTool;
|
|
115
|
+
meetingId: string;
|
|
116
|
+
budget?: TactiqTranscriptBudget;
|
|
117
|
+
plaudPageLimit?: number;
|
|
118
|
+
now?: () => Date;
|
|
119
|
+
}): Promise<MeetingSourceTranscriptResult>;
|
|
120
|
+
export declare function readProviderSummary(input: {
|
|
121
|
+
provider: MeetingSourceProvider;
|
|
122
|
+
callTool: MeetingSourceCallTool;
|
|
123
|
+
meetingId: string;
|
|
124
|
+
now?: () => Date;
|
|
125
|
+
}): Promise<MeetingSourceSummaryResult>;
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const TACTIQ_TRANSCRIPT_READS_PER_HOUR = 10;
|
|
3
|
+
export const TACTIQ_SEARCH_LIMIT = 50;
|
|
4
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
5
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
6
|
+
const MIN_TACTIQ_WINDOW_MS = 60 * 1000;
|
|
7
|
+
const MAX_PAGES = 500;
|
|
8
|
+
export function createTactiqTranscriptBudget(options = {}) {
|
|
9
|
+
const limit = options.limit ?? TACTIQ_TRANSCRIPT_READS_PER_HOUR;
|
|
10
|
+
const windowMs = options.windowMs ?? HOUR_MS;
|
|
11
|
+
let reads = [...(options.reads ?? [])];
|
|
12
|
+
const prune = (now) => {
|
|
13
|
+
const floor = now.getTime() - windowMs;
|
|
14
|
+
reads = reads.filter((read) => Date.parse(read.readAt) > floor);
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
tryAcquire(meetingId, now) {
|
|
18
|
+
prune(now);
|
|
19
|
+
if (reads.some((read) => read.meetingId === meetingId))
|
|
20
|
+
return { ok: true };
|
|
21
|
+
if (reads.length < limit) {
|
|
22
|
+
reads.push({ meetingId, readAt: now.toISOString() });
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
const oldest = Math.min(...reads.map((read) => Date.parse(read.readAt)));
|
|
26
|
+
return { ok: false, retryAt: new Date(oldest + windowMs).toISOString() };
|
|
27
|
+
},
|
|
28
|
+
reads(now) {
|
|
29
|
+
prune(now);
|
|
30
|
+
return reads.map((read) => ({ ...read }));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Speaker-attributed text, normalized the same way as `brainbase-unson` `transcript_hash` input. */
|
|
35
|
+
export function meetingTranscriptText(segments) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
for (const segment of segments) {
|
|
38
|
+
const line = normalizeWhitespace(segment.text);
|
|
39
|
+
if (!line)
|
|
40
|
+
continue;
|
|
41
|
+
lines.push(segment.speaker ? `${segment.speaker}: ${line}` : line);
|
|
42
|
+
}
|
|
43
|
+
return normalizeMultilineText(lines.join('\n'));
|
|
44
|
+
}
|
|
45
|
+
export function meetingTranscriptDigest(text) {
|
|
46
|
+
return createHash('sha256').update(text).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
export async function listMeetings(input) {
|
|
49
|
+
const now = input.now ?? (() => new Date());
|
|
50
|
+
const since = parseInstant(input.since, 'since');
|
|
51
|
+
const until = parseInstant(input.until, 'until');
|
|
52
|
+
if (since > until)
|
|
53
|
+
throw new RangeError('since must not be after until');
|
|
54
|
+
try {
|
|
55
|
+
if (input.provider === 'plaud') {
|
|
56
|
+
return await listPlaud(input.callTool, since, until, input.plaudUtcOffsetMinutes ?? 540, input.plaudPageSize ?? 50);
|
|
57
|
+
}
|
|
58
|
+
return await listTactiq(input.callTool, since, until);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return { status: 'failed', failure: await classifyFailure(error, input.provider, input.callTool, 'connection', now()) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function readTranscript(input) {
|
|
65
|
+
const now = input.now ?? (() => new Date());
|
|
66
|
+
if (input.provider === 'tactiq') {
|
|
67
|
+
const budget = input.budget ?? createTactiqTranscriptBudget();
|
|
68
|
+
const slot = budget.tryAcquire(input.meetingId, now());
|
|
69
|
+
if (!slot.ok) {
|
|
70
|
+
return unavailable('rate_limited', 'connection', 'Tactiq transcript read limit reached for this hour', slot.retryAt);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const read = input.provider === 'plaud'
|
|
75
|
+
? await readPlaudTranscript(input.callTool, input.meetingId, input.plaudPageLimit ?? 200)
|
|
76
|
+
: await readTactiqTranscript(input.callTool, input.meetingId);
|
|
77
|
+
if (read.segments.every((segment) => !normalizeWhitespace(segment.text))) {
|
|
78
|
+
return unavailable('not_ready', 'meeting', 'The transcript has no utterances yet', null);
|
|
79
|
+
}
|
|
80
|
+
const text = meetingTranscriptText(read.segments);
|
|
81
|
+
return {
|
|
82
|
+
status: 'complete',
|
|
83
|
+
segments: read.segments,
|
|
84
|
+
text,
|
|
85
|
+
digest: meetingTranscriptDigest(text),
|
|
86
|
+
pageCount: read.pageCount,
|
|
87
|
+
segmentCount: read.segments.length,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof IncompleteTranscriptError) {
|
|
92
|
+
return unavailable('incomplete', 'meeting', error.message, null);
|
|
93
|
+
}
|
|
94
|
+
return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function readProviderSummary(input) {
|
|
98
|
+
const now = input.now ?? (() => new Date());
|
|
99
|
+
try {
|
|
100
|
+
if (input.provider === 'plaud') {
|
|
101
|
+
const payload = await invoke(input.callTool, 'get_note', { file_id: input.meetingId });
|
|
102
|
+
return { status: 'ok', summary: toSummary(extractText(payload)) };
|
|
103
|
+
}
|
|
104
|
+
const payload = asRecord(await invoke(input.callTool, 'get_meeting', { meetingId: input.meetingId }));
|
|
105
|
+
const detailed = asRecord(payload?.detailedSummary);
|
|
106
|
+
if (!detailed || detailed.status !== 'ready')
|
|
107
|
+
return { status: 'ok', summary: null };
|
|
108
|
+
return { status: 'ok', summary: toSummary(extractText(detailed.content)) };
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return { status: 'unavailable', failure: await classifyFailure(error, input.provider, input.callTool, 'meeting', now()) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Plaud
|
|
116
|
+
async function listPlaud(callTool, since, until, utcOffsetMinutes, pageSize) {
|
|
117
|
+
// Plaud filters by the server-local display date. Widen by a day on each side
|
|
118
|
+
// so time-zone differences cannot drop meetings at the edges, then filter by
|
|
119
|
+
// the exact start time.
|
|
120
|
+
const dateFrom = localDate(since - DAY_MS, utcOffsetMinutes);
|
|
121
|
+
const dateTo = localDate(until + DAY_MS, utcOffsetMinutes);
|
|
122
|
+
const meetings = [];
|
|
123
|
+
let complete = false;
|
|
124
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
125
|
+
const items = extractList(await invoke(callTool, 'list_files', { date_from: dateFrom, date_to: dateTo, page, page_size: pageSize }));
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
const meeting = plaudMeeting(item, utcOffsetMinutes);
|
|
128
|
+
if (!meeting)
|
|
129
|
+
continue;
|
|
130
|
+
const started = meeting.startedAt ? Date.parse(meeting.startedAt) : NaN;
|
|
131
|
+
if (Number.isFinite(started) && (started < since || started > until))
|
|
132
|
+
continue;
|
|
133
|
+
meetings.push(meeting);
|
|
134
|
+
}
|
|
135
|
+
if (items.length < pageSize) {
|
|
136
|
+
complete = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const window = { since: new Date(since).toISOString(), until: new Date(until).toISOString() };
|
|
141
|
+
return { status: 'ok', meetings: dedupe(meetings), complete, incompleteWindows: complete ? [] : [window] };
|
|
142
|
+
}
|
|
143
|
+
function plaudMeeting(raw, utcOffsetMinutes) {
|
|
144
|
+
const item = asRecord(raw);
|
|
145
|
+
const id = stringValue(item?.id) ?? stringValue(item?.file_id) ?? stringValue(item?.fileId);
|
|
146
|
+
if (!item || !id)
|
|
147
|
+
return null;
|
|
148
|
+
const start = stringValue(item.start_at) ?? stringValue(item.created_at);
|
|
149
|
+
const startMs = start ? parseLocalInstant(start, utcOffsetMinutes) : NaN;
|
|
150
|
+
return {
|
|
151
|
+
provider: 'plaud',
|
|
152
|
+
externalId: id,
|
|
153
|
+
title: stringValue(item.name) ?? stringValue(item.title),
|
|
154
|
+
startedAt: Number.isFinite(startMs) ? new Date(startMs).toISOString() : null,
|
|
155
|
+
durationSeconds: null,
|
|
156
|
+
participants: [],
|
|
157
|
+
url: null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async function readPlaudTranscript(callTool, fileId, limit) {
|
|
161
|
+
const segments = [];
|
|
162
|
+
const seenCursors = new Set();
|
|
163
|
+
let cursor = null;
|
|
164
|
+
let pageCount = 0;
|
|
165
|
+
let total = null;
|
|
166
|
+
do {
|
|
167
|
+
const args = { file_id: fileId, block: 'transaction', limit };
|
|
168
|
+
if (cursor)
|
|
169
|
+
args.cursor = cursor;
|
|
170
|
+
const payload = asRecord(await invoke(callTool, 'get_transcript', args));
|
|
171
|
+
if (!payload || !Array.isArray(payload.segments))
|
|
172
|
+
throw new UnexpectedShapeError('Plaud get_transcript returned no segments');
|
|
173
|
+
pageCount += 1;
|
|
174
|
+
if (typeof payload.total === 'number')
|
|
175
|
+
total = payload.total;
|
|
176
|
+
for (const raw of payload.segments) {
|
|
177
|
+
const segment = asRecord(raw);
|
|
178
|
+
if (!segment)
|
|
179
|
+
continue;
|
|
180
|
+
segments.push({
|
|
181
|
+
speaker: trimmed(segment.speaker) ?? trimmed(segment.original_speaker),
|
|
182
|
+
text: typeof segment.content === 'string' ? segment.content : (typeof segment.text === 'string' ? segment.text : ''),
|
|
183
|
+
startMs: numberValue(segment.start_time),
|
|
184
|
+
endMs: numberValue(segment.end_time),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
cursor = stringValue(payload.next_cursor);
|
|
188
|
+
if (cursor && seenCursors.has(cursor))
|
|
189
|
+
throw new IncompleteTranscriptError('Plaud returned the same cursor twice');
|
|
190
|
+
if (cursor)
|
|
191
|
+
seenCursors.add(cursor);
|
|
192
|
+
if (cursor && pageCount >= MAX_PAGES)
|
|
193
|
+
throw new IncompleteTranscriptError('Plaud transcript exceeded the page limit');
|
|
194
|
+
} while (cursor);
|
|
195
|
+
if (total !== null && segments.length < total) {
|
|
196
|
+
throw new IncompleteTranscriptError(`Plaud returned ${segments.length} of ${total} utterances`);
|
|
197
|
+
}
|
|
198
|
+
return { segments, pageCount };
|
|
199
|
+
}
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Tactiq
|
|
202
|
+
async function listTactiq(callTool, since, until) {
|
|
203
|
+
const meetings = [];
|
|
204
|
+
const incompleteWindows = [];
|
|
205
|
+
const windows = [[since, until]];
|
|
206
|
+
while (windows.length) {
|
|
207
|
+
const [from, to] = windows.shift();
|
|
208
|
+
const payload = asRecord(await invoke(callTool, 'search_meetings', {
|
|
209
|
+
dateFrom: new Date(from).toISOString(),
|
|
210
|
+
dateTo: new Date(to).toISOString(),
|
|
211
|
+
limit: TACTIQ_SEARCH_LIMIT,
|
|
212
|
+
}));
|
|
213
|
+
const results = Array.isArray(payload?.results) ? payload.results : extractList(payload);
|
|
214
|
+
if (results.length >= TACTIQ_SEARCH_LIMIT) {
|
|
215
|
+
if (to - from > MIN_TACTIQ_WINDOW_MS) {
|
|
216
|
+
const middle = from + Math.floor((to - from) / 2);
|
|
217
|
+
windows.unshift([from, middle], [middle + 1, to]);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
incompleteWindows.push({ since: new Date(from).toISOString(), until: new Date(to).toISOString() });
|
|
221
|
+
}
|
|
222
|
+
for (const raw of results) {
|
|
223
|
+
const meeting = tactiqMeeting(raw);
|
|
224
|
+
if (meeting)
|
|
225
|
+
meetings.push(meeting);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { status: 'ok', meetings: dedupe(meetings), complete: incompleteWindows.length === 0, incompleteWindows };
|
|
229
|
+
}
|
|
230
|
+
function tactiqMeeting(raw) {
|
|
231
|
+
const item = asRecord(raw);
|
|
232
|
+
const id = stringValue(item?.id);
|
|
233
|
+
if (!item || !id)
|
|
234
|
+
return null;
|
|
235
|
+
const created = stringValue(item.createdAt);
|
|
236
|
+
const createdMs = created ? Date.parse(created) : NaN;
|
|
237
|
+
return {
|
|
238
|
+
provider: 'tactiq',
|
|
239
|
+
externalId: id,
|
|
240
|
+
title: stringValue(item.title),
|
|
241
|
+
startedAt: Number.isFinite(createdMs) ? new Date(createdMs).toISOString() : null,
|
|
242
|
+
durationSeconds: numberValue(item.durationSeconds),
|
|
243
|
+
participants: Array.isArray(item.attendees) ? item.attendees.filter((value) => typeof value === 'string') : [],
|
|
244
|
+
url: stringValue(item.url),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function readTactiqTranscript(callTool, meetingId) {
|
|
248
|
+
const segments = [];
|
|
249
|
+
let page = 1;
|
|
250
|
+
let pageCount = 0;
|
|
251
|
+
for (;;) {
|
|
252
|
+
const payload = asRecord(await invoke(callTool, 'get_transcript', { meetingId, page }));
|
|
253
|
+
if (!payload || !Array.isArray(payload.entries))
|
|
254
|
+
throw new UnexpectedShapeError('Tactiq get_transcript returned no entries');
|
|
255
|
+
pageCount += 1;
|
|
256
|
+
for (const raw of payload.entries) {
|
|
257
|
+
const entry = asRecord(raw);
|
|
258
|
+
if (!entry)
|
|
259
|
+
continue;
|
|
260
|
+
const start = numberValue(entry.startSeconds);
|
|
261
|
+
const end = numberValue(entry.endSeconds);
|
|
262
|
+
segments.push({
|
|
263
|
+
speaker: trimmed(entry.speaker),
|
|
264
|
+
text: typeof entry.text === 'string' ? entry.text : '',
|
|
265
|
+
startMs: start === null ? null : Math.round(start * 1000),
|
|
266
|
+
endMs: end === null ? null : Math.round(end * 1000),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (payload.hasMore !== true)
|
|
270
|
+
break;
|
|
271
|
+
const totalPages = numberValue(payload.totalPages);
|
|
272
|
+
if ((totalPages !== null && page >= totalPages) || page >= MAX_PAGES) {
|
|
273
|
+
throw new IncompleteTranscriptError(`Tactiq reported more pages after page ${page}`);
|
|
274
|
+
}
|
|
275
|
+
page += 1;
|
|
276
|
+
}
|
|
277
|
+
return { segments, pageCount };
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Tool invocation and failure classification
|
|
281
|
+
class ToolCallError extends Error {
|
|
282
|
+
body;
|
|
283
|
+
constructor(message, body) {
|
|
284
|
+
super(message);
|
|
285
|
+
this.body = body;
|
|
286
|
+
this.name = 'ToolCallError';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
class UnexpectedShapeError extends Error {
|
|
290
|
+
}
|
|
291
|
+
class IncompleteTranscriptError extends Error {
|
|
292
|
+
}
|
|
293
|
+
async function invoke(callTool, name, args) {
|
|
294
|
+
let result;
|
|
295
|
+
try {
|
|
296
|
+
result = await callTool(name, args);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
300
|
+
throw new ToolCallError(message, findJsonObject(message));
|
|
301
|
+
}
|
|
302
|
+
const record = asRecord(result);
|
|
303
|
+
if (record?.isError === true) {
|
|
304
|
+
const text = contentText(record);
|
|
305
|
+
throw new ToolCallError(text || `${name} failed`, parseJson(text) ?? findJsonObject(text));
|
|
306
|
+
}
|
|
307
|
+
return payloadOf(result);
|
|
308
|
+
}
|
|
309
|
+
async function classifyFailure(error, provider, callTool, defaultScope, now) {
|
|
310
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
311
|
+
const body = error instanceof ToolCallError ? asRecord(error.body) : null;
|
|
312
|
+
if (provider === 'tactiq' && (body?.error === 'access_required' || /access_required/.test(message))) {
|
|
313
|
+
const access = await readAccessOptions(callTool);
|
|
314
|
+
return {
|
|
315
|
+
reason: 'access_required',
|
|
316
|
+
scope: access.cause === 'preview_only' ? 'meeting' : 'connection',
|
|
317
|
+
message,
|
|
318
|
+
retryAt: null,
|
|
319
|
+
access,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (/oauth authorization is required|unauthori[sz]ed|\b401\b|invalid[_ ]token|not authenticated|login first|please log ?in|token (?:has )?expired/i.test(message)) {
|
|
323
|
+
return { reason: 'reauth_required', scope: 'connection', message, retryAt: null };
|
|
324
|
+
}
|
|
325
|
+
if (/rate.?limit|\b429\b|too many requests/i.test(message)) {
|
|
326
|
+
return { reason: 'rate_limited', scope: 'connection', message, retryAt: new Date(now.getTime() + HOUR_MS).toISOString() };
|
|
327
|
+
}
|
|
328
|
+
if (/timed? ?out|-32001|ETIMEDOUT|AbortError/i.test(message)) {
|
|
329
|
+
return { reason: 'timeout', scope: defaultScope, message, retryAt: null };
|
|
330
|
+
}
|
|
331
|
+
return { reason: 'provider_error', scope: defaultScope, message, retryAt: null };
|
|
332
|
+
}
|
|
333
|
+
async function readAccessOptions(callTool) {
|
|
334
|
+
try {
|
|
335
|
+
const access = asRecord(asRecord(await invoke(callTool, 'get_access_options', {}))?.access);
|
|
336
|
+
const detail = {
|
|
337
|
+
action: stringValue(access?.action),
|
|
338
|
+
title: stringValue(access?.title),
|
|
339
|
+
message: stringValue(access?.message),
|
|
340
|
+
url: stringValue(access?.url),
|
|
341
|
+
};
|
|
342
|
+
return { ...detail, cause: accessCause(`${detail.action ?? ''} ${detail.title ?? ''} ${detail.message ?? ''}`) };
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return { action: null, title: null, message: null, url: null, cause: 'unknown' };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function accessCause(text) {
|
|
349
|
+
const value = text.toLowerCase();
|
|
350
|
+
if (/preview/.test(value))
|
|
351
|
+
return 'preview_only';
|
|
352
|
+
if (/credit/.test(value))
|
|
353
|
+
return 'ai_credits';
|
|
354
|
+
if (/plan|upgrade|seat|team/.test(value))
|
|
355
|
+
return 'plan';
|
|
356
|
+
if (/connect|permission|authori/.test(value))
|
|
357
|
+
return 'connection';
|
|
358
|
+
return 'unknown';
|
|
359
|
+
}
|
|
360
|
+
function unavailable(reason, scope, message, retryAt) {
|
|
361
|
+
return { status: 'unavailable', failure: { reason, scope, message, retryAt } };
|
|
362
|
+
}
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Helpers
|
|
365
|
+
function payloadOf(result) {
|
|
366
|
+
const record = asRecord(result);
|
|
367
|
+
if (!record)
|
|
368
|
+
return result;
|
|
369
|
+
if (record.structuredContent !== undefined)
|
|
370
|
+
return record.structuredContent;
|
|
371
|
+
if (Array.isArray(record.content)) {
|
|
372
|
+
const text = contentText(record);
|
|
373
|
+
return parseJson(text) ?? text;
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
function contentText(record) {
|
|
378
|
+
if (!Array.isArray(record.content))
|
|
379
|
+
return '';
|
|
380
|
+
return record.content
|
|
381
|
+
.map((item) => asRecord(item))
|
|
382
|
+
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
|
383
|
+
.map((item) => item.text)
|
|
384
|
+
.join('\n');
|
|
385
|
+
}
|
|
386
|
+
function extractList(payload) {
|
|
387
|
+
if (Array.isArray(payload))
|
|
388
|
+
return payload;
|
|
389
|
+
const record = asRecord(payload);
|
|
390
|
+
if (!record)
|
|
391
|
+
return [];
|
|
392
|
+
for (const key of ['data', 'files', 'items', 'results', 'records', 'recordings']) {
|
|
393
|
+
if (Array.isArray(record[key]))
|
|
394
|
+
return record[key];
|
|
395
|
+
}
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
function extractText(value) {
|
|
399
|
+
if (typeof value === 'string')
|
|
400
|
+
return value;
|
|
401
|
+
if (Array.isArray(value))
|
|
402
|
+
return value.map(extractText).filter(Boolean).join('\n');
|
|
403
|
+
const record = asRecord(value);
|
|
404
|
+
if (!record)
|
|
405
|
+
return '';
|
|
406
|
+
for (const key of ['markdown', 'text', 'content', 'summary', 'data_content', 'data']) {
|
|
407
|
+
const text = extractText(record[key]);
|
|
408
|
+
if (text)
|
|
409
|
+
return text;
|
|
410
|
+
}
|
|
411
|
+
return '';
|
|
412
|
+
}
|
|
413
|
+
function toSummary(text) {
|
|
414
|
+
const normalized = normalizeMultilineText(text);
|
|
415
|
+
return normalized ? { kind: 'provider_summary', text: normalized } : null;
|
|
416
|
+
}
|
|
417
|
+
function dedupe(meetings) {
|
|
418
|
+
const seen = new Map();
|
|
419
|
+
for (const meeting of meetings)
|
|
420
|
+
if (!seen.has(meeting.externalId))
|
|
421
|
+
seen.set(meeting.externalId, meeting);
|
|
422
|
+
return [...seen.values()];
|
|
423
|
+
}
|
|
424
|
+
function parseInstant(value, label) {
|
|
425
|
+
const ms = Date.parse(value);
|
|
426
|
+
if (!Number.isFinite(ms))
|
|
427
|
+
throw new RangeError(`${label} must be an ISO 8601 instant`);
|
|
428
|
+
return ms;
|
|
429
|
+
}
|
|
430
|
+
/** Plaud times without an offset are server-local; interpret them with the given offset. */
|
|
431
|
+
function parseLocalInstant(value, utcOffsetMinutes) {
|
|
432
|
+
if (/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value))
|
|
433
|
+
return Date.parse(value);
|
|
434
|
+
const ms = Date.parse(`${value.replace(' ', 'T')}Z`);
|
|
435
|
+
return Number.isFinite(ms) ? ms - utcOffsetMinutes * 60 * 1000 : NaN;
|
|
436
|
+
}
|
|
437
|
+
function localDate(ms, utcOffsetMinutes) {
|
|
438
|
+
return new Date(ms + utcOffsetMinutes * 60 * 1000).toISOString().slice(0, 10);
|
|
439
|
+
}
|
|
440
|
+
function normalizeWhitespace(value) {
|
|
441
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
442
|
+
}
|
|
443
|
+
function normalizeMultilineText(value) {
|
|
444
|
+
return String(value || '')
|
|
445
|
+
.replace(/\r\n/g, '\n')
|
|
446
|
+
.replace(/\r/g, '\n')
|
|
447
|
+
.replace(/[ \t]+/g, ' ')
|
|
448
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
449
|
+
.trim();
|
|
450
|
+
}
|
|
451
|
+
function asRecord(value) {
|
|
452
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
453
|
+
}
|
|
454
|
+
function stringValue(value) {
|
|
455
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
456
|
+
return String(value);
|
|
457
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
458
|
+
}
|
|
459
|
+
function trimmed(value) {
|
|
460
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
461
|
+
}
|
|
462
|
+
function numberValue(value) {
|
|
463
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
464
|
+
}
|
|
465
|
+
function parseJson(text) {
|
|
466
|
+
if (!text)
|
|
467
|
+
return null;
|
|
468
|
+
try {
|
|
469
|
+
return JSON.parse(text);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function findJsonObject(text) {
|
|
476
|
+
const start = text.indexOf('{');
|
|
477
|
+
const end = text.lastIndexOf('}');
|
|
478
|
+
return start >= 0 && end > start ? parseJson(text.slice(start, end + 1)) : null;
|
|
479
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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-reader": {
|
|
139
|
+
"types": "./dist/meeting-source-reader.d.ts",
|
|
140
|
+
"import": "./dist/meeting-source-reader.js"
|
|
141
|
+
},
|
|
138
142
|
"./meeting-minutes-lineage-http": {
|
|
139
143
|
"types": "./dist/meeting-minutes-lineage-http.d.ts",
|
|
140
144
|
"import": "./dist/meeting-minutes-lineage-http.js"
|
|
@@ -423,5 +427,5 @@
|
|
|
423
427
|
"engines": {
|
|
424
428
|
"node": ">=20"
|
|
425
429
|
},
|
|
426
|
-
"gitHead": "
|
|
430
|
+
"gitHead": "5aa740fc6553f14deaa453f69a41746724a3fb45"
|
|
427
431
|
}
|