@unson/brainbase-mcp 0.23.9 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/judgment-frame.d.ts +49 -4
- package/dist/judgment-frame.js +145 -8
- package/dist/judgment-model-outcome.d.ts +96 -0
- package/dist/judgment-model-outcome.js +216 -0
- package/dist/meeting-source-reader.d.ts +125 -0
- package/dist/meeting-source-reader.js +479 -0
- package/package.json +6 -2
- package/ui/judgment-history.css +4 -0
- package/ui/judgment-history.js +5 -1
package/dist/judgment-frame.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { type JudgmentModelOutcome, type JudgmentModelOutcomeSummary } from './judgment-model-outcome.js';
|
|
2
3
|
/**
|
|
3
4
|
* Judgment frame (brainbase-project ADR-014).
|
|
4
5
|
*
|
|
@@ -91,7 +92,9 @@ export interface JudgmentFrameIssue {
|
|
|
91
92
|
readonly message: string;
|
|
92
93
|
}
|
|
93
94
|
/** Exceptions that ADR-014 F5 returns to a human. The host decides when they block. */
|
|
94
|
-
export type JudgmentFrameEscalationCode = 'objective_unanchored' | 'chosen_violates_philosophy' | 'chosen_philosophy_tension'
|
|
95
|
+
export type JudgmentFrameEscalationCode = 'objective_unanchored' | 'chosen_violates_philosophy' | 'chosen_philosophy_tension'
|
|
96
|
+
/** The chosen option predicts with a world model refuted at its current revision (F5, F6). */
|
|
97
|
+
| 'chosen_uses_refuted_model';
|
|
95
98
|
export interface JudgmentFrameEscalation {
|
|
96
99
|
readonly code: JudgmentFrameEscalationCode;
|
|
97
100
|
readonly refs: readonly string[];
|
|
@@ -101,6 +104,8 @@ export interface JudgmentFrameValidation {
|
|
|
101
104
|
readonly issues: readonly JudgmentFrameIssue[];
|
|
102
105
|
readonly escalations: readonly JudgmentFrameEscalation[];
|
|
103
106
|
}
|
|
107
|
+
/** True when text looks like it carries a credential. Shared by public frames and outcome records. */
|
|
108
|
+
export declare function containsJudgmentFrameCredential(value: string): boolean;
|
|
104
109
|
/**
|
|
105
110
|
* Compute the digest for the exact two-field public projection.
|
|
106
111
|
* The response adds this digest; it is intentionally separate from the
|
|
@@ -118,13 +123,16 @@ export declare function judgmentFrameRecordDigest(value: JudgmentFrameRecord): `
|
|
|
118
123
|
* Excluded records are returned with a reason so a shrinking catalog is visible.
|
|
119
124
|
*/
|
|
120
125
|
export declare function buildJudgmentFrameCatalog(records: readonly JudgmentFrameSourceRecord[]): JudgmentFrameCatalogBuild;
|
|
121
|
-
/**
|
|
122
|
-
|
|
126
|
+
/**
|
|
127
|
+
* One line per item, grouped by kind, for the model-facing prompt. Outcome
|
|
128
|
+
* summaries, when given, annotate world models; they are not part of the digest.
|
|
129
|
+
*/
|
|
130
|
+
export declare function renderJudgmentFrameCatalog(catalog: JudgmentFrameCatalog, outcomes?: ReadonlyMap<string, JudgmentModelOutcomeSummary>): string;
|
|
123
131
|
/**
|
|
124
132
|
* Check that a frame record only uses catalog references and that every
|
|
125
133
|
* selection is used in at least one option with the role of its kind.
|
|
126
134
|
*/
|
|
127
|
-
export declare function validateJudgmentFrameRecord(value: unknown, catalog: JudgmentFrameCatalog): JudgmentFrameValidation;
|
|
135
|
+
export declare function validateJudgmentFrameRecord(value: unknown, catalog: JudgmentFrameCatalog, outcomes?: ReadonlyMap<string, JudgmentModelOutcomeSummary>): JudgmentFrameValidation;
|
|
128
136
|
export declare const judgmentFrameTools: Tool[];
|
|
129
137
|
export type JudgmentFrameRecordsPage = {
|
|
130
138
|
readonly status: 'ok';
|
|
@@ -134,9 +142,38 @@ export type JudgmentFrameRecordsPage = {
|
|
|
134
142
|
readonly code: string;
|
|
135
143
|
readonly message: string;
|
|
136
144
|
};
|
|
145
|
+
export type JudgmentModelOutcomesPage = {
|
|
146
|
+
readonly status: 'ok';
|
|
147
|
+
readonly records: readonly unknown[];
|
|
148
|
+
} | {
|
|
149
|
+
readonly status: 'unavailable' | 'error';
|
|
150
|
+
readonly code: string;
|
|
151
|
+
readonly message: string;
|
|
152
|
+
};
|
|
153
|
+
export type JudgmentModelOutcomeAppendResult = {
|
|
154
|
+
readonly status: 'ok';
|
|
155
|
+
readonly id: string;
|
|
156
|
+
readonly recorded_at: string;
|
|
157
|
+
} | {
|
|
158
|
+
readonly status: 'unavailable' | 'error';
|
|
159
|
+
readonly code: string;
|
|
160
|
+
readonly message: string;
|
|
161
|
+
};
|
|
137
162
|
export interface JudgmentFrameToolDependencies {
|
|
138
163
|
/** Read every record of one kind visible to the caller, at most `limit`. */
|
|
139
164
|
readonly loadRecords: (kind: JudgmentFrameKind, limit: number) => Promise<JudgmentFrameRecordsPage>;
|
|
165
|
+
/**
|
|
166
|
+
* Read every stored world-model outcome visible to the caller (ADR-014 F6).
|
|
167
|
+
* When omitted, the catalog and escalations behave as before.
|
|
168
|
+
*/
|
|
169
|
+
readonly loadModelOutcomes?: () => Promise<JudgmentModelOutcomesPage>;
|
|
170
|
+
}
|
|
171
|
+
export interface JudgmentModelOutcomeToolDependencies extends JudgmentFrameToolDependencies {
|
|
172
|
+
readonly loadModelOutcomes: () => Promise<JudgmentModelOutcomesPage>;
|
|
173
|
+
/** Append one validated outcome. The host owns id, time, author, authorization, and storage. */
|
|
174
|
+
readonly appendModelOutcome: (outcome: JudgmentModelOutcome & {
|
|
175
|
+
readonly digest: `sha256:${string}`;
|
|
176
|
+
}) => Promise<JudgmentModelOutcomeAppendResult>;
|
|
140
177
|
}
|
|
141
178
|
export type JudgmentFrameToolResult = {
|
|
142
179
|
readonly status: 'ok';
|
|
@@ -150,5 +187,13 @@ export type JudgmentFrameToolResult = {
|
|
|
150
187
|
};
|
|
151
188
|
};
|
|
152
189
|
export declare function handleJudgmentFrameToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentFrameToolDependencies): Promise<JudgmentFrameToolResult | null>;
|
|
190
|
+
/**
|
|
191
|
+
* Opt-in tool for hosts that store world-model outcomes (ADR-014 F6).
|
|
192
|
+
* Published separately so a host without storage does not expose it.
|
|
193
|
+
*/
|
|
194
|
+
export declare const judgmentModelOutcomeTools: Tool[];
|
|
195
|
+
export declare function handleJudgmentModelOutcomeToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentModelOutcomeToolDependencies): Promise<JudgmentFrameToolResult | null>;
|
|
196
|
+
export { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
|
|
197
|
+
export type { JudgmentModelOutcome, JudgmentModelOutcomeAlternative, JudgmentModelOutcomeEvidenceKind, JudgmentModelOutcomeEvidenceRef, JudgmentModelOutcomeIssue, JudgmentModelOutcomeIssueCode, JudgmentModelOutcomeStoredRecord, JudgmentModelOutcomeSummary, JudgmentModelOutcomeValidation, JudgmentModelOutcomeVerdict, } from './judgment-model-outcome.js';
|
|
153
198
|
export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
|
|
154
199
|
export type { JudgmentFrameReadDependencies, JudgmentFrameReadRecord, } from './judgment-frame-read.js';
|
package/dist/judgment-frame.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
|
|
2
3
|
/**
|
|
3
4
|
* Judgment frame (brainbase-project ADR-014).
|
|
4
5
|
*
|
|
@@ -50,7 +51,8 @@ const PUBLIC_FRAME_SECRET_PATTERNS = [
|
|
|
50
51
|
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/u,
|
|
51
52
|
];
|
|
52
53
|
const PUBLIC_FRAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
53
|
-
|
|
54
|
+
/** True when text looks like it carries a credential. Shared by public frames and outcome records. */
|
|
55
|
+
export function containsJudgmentFrameCredential(value) {
|
|
54
56
|
return PUBLIC_FRAME_SECRET_PATTERNS.some((pattern) => pattern.test(value));
|
|
55
57
|
}
|
|
56
58
|
function publicFrameRecord(value) {
|
|
@@ -164,13 +166,24 @@ const KIND_HEADINGS = {
|
|
|
164
166
|
objective: '目的(criterionとして使う)',
|
|
165
167
|
model: '世界モデル(predictionとして使う。仮説であり、検証済みとは限らない)',
|
|
166
168
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
function renderOutcomeSummary(summary) {
|
|
170
|
+
if (!summary)
|
|
171
|
+
return '';
|
|
172
|
+
const counts = `支持${summary.supports}・反証${summary.refutes}・判定不能${summary.inconclusive}`;
|
|
173
|
+
const refuted = summary.refuted_when.length > 0 ? `。反証の条件:${summary.refuted_when.join('/')}` : '';
|
|
174
|
+
return `(この版の結果:${counts}${refuted})`;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One line per item, grouped by kind, for the model-facing prompt. Outcome
|
|
178
|
+
* summaries, when given, annotate world models; they are not part of the digest.
|
|
179
|
+
*/
|
|
180
|
+
export function renderJudgmentFrameCatalog(catalog, outcomes = new Map()) {
|
|
169
181
|
const lines = [`judgment frame catalog ${catalog.digest}`];
|
|
170
182
|
for (const kind of KINDS) {
|
|
171
183
|
lines.push('', `## ${KIND_HEADINGS[kind]}`);
|
|
172
184
|
for (const item of catalog.items.filter((candidate) => candidate.kind === kind)) {
|
|
173
|
-
|
|
185
|
+
const outcome = kind === 'model' ? renderOutcomeSummary(outcomes.get(item.id)) : '';
|
|
186
|
+
lines.push(`- ${item.id} [${item.status}] ${item.text}${item.applies_when ? `(適用:${item.applies_when})` : ''}${outcome}`);
|
|
174
187
|
}
|
|
175
188
|
}
|
|
176
189
|
return lines.join('\n');
|
|
@@ -179,7 +192,7 @@ export function renderJudgmentFrameCatalog(catalog) {
|
|
|
179
192
|
* Check that a frame record only uses catalog references and that every
|
|
180
193
|
* selection is used in at least one option with the role of its kind.
|
|
181
194
|
*/
|
|
182
|
-
export function validateJudgmentFrameRecord(value, catalog) {
|
|
195
|
+
export function validateJudgmentFrameRecord(value, catalog, outcomes = new Map()) {
|
|
183
196
|
const issues = [];
|
|
184
197
|
const issue = (code, path, message) => issues.push({ code, path, message });
|
|
185
198
|
if (!isRecord(value) || value.record_version !== JUDGMENT_FRAME_RECORD_VERSION) {
|
|
@@ -205,7 +218,7 @@ export function validateJudgmentFrameRecord(value, catalog) {
|
|
|
205
218
|
if (PUBLIC_FRAME_CONTROL_CHARACTERS.test(candidate)) {
|
|
206
219
|
issue('frame_public_invalid', `public_frame.${key}`, `${key} contains control characters`);
|
|
207
220
|
}
|
|
208
|
-
if (
|
|
221
|
+
if (containsJudgmentFrameCredential(candidate)) {
|
|
209
222
|
issue('frame_public_invalid', `public_frame.${key}`, `${key} contains a credential-like value`);
|
|
210
223
|
}
|
|
211
224
|
}
|
|
@@ -311,6 +324,12 @@ export function validateJudgmentFrameRecord(value, catalog) {
|
|
|
311
324
|
escalations.push({ code: 'chosen_violates_philosophy', refs: violated });
|
|
312
325
|
if (tension.length > 0)
|
|
313
326
|
escalations.push({ code: 'chosen_philosophy_tension', refs: tension });
|
|
327
|
+
const refuted = [...new Set(chosenUses
|
|
328
|
+
.filter((use) => isRecord(use) && use.role === 'prediction'
|
|
329
|
+
&& selected.get(String(use.ref)) === 'model' && (outcomes.get(String(use.ref))?.refutes ?? 0) > 0)
|
|
330
|
+
.map((use) => String(use.ref)))];
|
|
331
|
+
if (refuted.length > 0)
|
|
332
|
+
escalations.push({ code: 'chosen_uses_refuted_model', refs: refuted });
|
|
314
333
|
}
|
|
315
334
|
return { valid: issues.length === 0, issues, escalations };
|
|
316
335
|
}
|
|
@@ -415,6 +434,24 @@ async function loadFrameCatalog(dependencies) {
|
|
|
415
434
|
}
|
|
416
435
|
return buildJudgmentFrameCatalog(records);
|
|
417
436
|
}
|
|
437
|
+
async function loadOutcomeSummaries(dependencies, catalog) {
|
|
438
|
+
if (!dependencies.loadModelOutcomes)
|
|
439
|
+
return new Map();
|
|
440
|
+
let page;
|
|
441
|
+
try {
|
|
442
|
+
page = await dependencies.loadModelOutcomes();
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
return toolError('unavailable', 'judgment_model_outcomes_unavailable', `World model outcomes could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
}
|
|
447
|
+
if (page.status !== 'ok')
|
|
448
|
+
return toolError(page.status, page.code, page.message);
|
|
449
|
+
const parsed = parseJudgmentModelOutcomeRecords(page.records);
|
|
450
|
+
if (!parsed.ok) {
|
|
451
|
+
return toolError('error', 'judgment_model_outcomes_invalid', `Stored world model outcome ${parsed.index} is invalid: ${parsed.message}`);
|
|
452
|
+
}
|
|
453
|
+
return summarizeJudgmentModelOutcomes(parsed.records, catalog);
|
|
454
|
+
}
|
|
418
455
|
export async function handleJudgmentFrameToolCall(name, args, dependencies) {
|
|
419
456
|
if (name !== 'brainbase_judgment_frame_catalog' && name !== 'brainbase_judgment_frame_record')
|
|
420
457
|
return null;
|
|
@@ -425,6 +462,9 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
|
|
|
425
462
|
if ('status' in loaded)
|
|
426
463
|
return loaded;
|
|
427
464
|
const { catalog, excluded } = loaded;
|
|
465
|
+
const outcomes = await loadOutcomeSummaries(dependencies, catalog);
|
|
466
|
+
if (!(outcomes instanceof Map))
|
|
467
|
+
return outcomes;
|
|
428
468
|
if (name === 'brainbase_judgment_frame_catalog') {
|
|
429
469
|
return {
|
|
430
470
|
status: 'ok',
|
|
@@ -433,11 +473,12 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
|
|
|
433
473
|
catalog_digest: catalog.digest,
|
|
434
474
|
counts: Object.fromEntries(KINDS.map((kind) => [kind, catalog.items.filter((item) => item.kind === kind).length])),
|
|
435
475
|
excluded_count: excluded.length,
|
|
436
|
-
|
|
476
|
+
...(dependencies.loadModelOutcomes ? { models_with_outcomes: outcomes.size } : {}),
|
|
477
|
+
catalog: renderJudgmentFrameCatalog(catalog, outcomes),
|
|
437
478
|
},
|
|
438
479
|
};
|
|
439
480
|
}
|
|
440
|
-
const validation = validateJudgmentFrameRecord(args, catalog);
|
|
481
|
+
const validation = validateJudgmentFrameRecord(args, catalog, outcomes);
|
|
441
482
|
if (!validation.valid) {
|
|
442
483
|
return toolError('error', 'judgment_frame_invalid', 'The judgment frame does not match the catalog contract', {
|
|
443
484
|
issues: validation.issues,
|
|
@@ -463,4 +504,100 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
|
|
|
463
504
|
},
|
|
464
505
|
};
|
|
465
506
|
}
|
|
507
|
+
const outcomeTextSchema = { type: 'string', minLength: 1, maxLength: 2_000 };
|
|
508
|
+
/**
|
|
509
|
+
* Opt-in tool for hosts that store world-model outcomes (ADR-014 F6).
|
|
510
|
+
* Published separately so a host without storage does not expose it.
|
|
511
|
+
*/
|
|
512
|
+
export const judgmentModelOutcomeTools = [{
|
|
513
|
+
name: 'brainbase_judgment_model_outcome_record',
|
|
514
|
+
description: 'After the result of a judgment is observed, record whether it supports or refutes a world model that the judgment used as a prediction. Read brainbase_judgment_frame_catalog first and pass its catalog_digest and the model id at its current revision. State the prediction made from the model, what was observed, the conditions (facility, period, state) under which the result holds, and at least one evidence reference. A refutation must say, for each of measurement_error, execution_difference, and external_change, why it does not explain the gap; a prediction gap alone is not a refutation. This does not change the model, its revision, or its adoption; adoption and verification stay human decisions. The tool does not judge whether the verdict is semantically right.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
additionalProperties: false,
|
|
518
|
+
properties: {
|
|
519
|
+
record_version: { type: 'string', const: JUDGMENT_MODEL_OUTCOME_RECORD_VERSION },
|
|
520
|
+
catalog_digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
|
|
521
|
+
model: {
|
|
522
|
+
type: 'object',
|
|
523
|
+
additionalProperties: false,
|
|
524
|
+
properties: {
|
|
525
|
+
id: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
526
|
+
revision: { type: 'string', minLength: 1, maxLength: 64 },
|
|
527
|
+
},
|
|
528
|
+
required: ['id', 'revision'],
|
|
529
|
+
},
|
|
530
|
+
verdict: { type: 'string', enum: ['supports', 'refutes', 'inconclusive'] },
|
|
531
|
+
prediction: outcomeTextSchema,
|
|
532
|
+
observed: outcomeTextSchema,
|
|
533
|
+
conditions: outcomeTextSchema,
|
|
534
|
+
evidence_refs: {
|
|
535
|
+
type: 'array',
|
|
536
|
+
minItems: 1,
|
|
537
|
+
maxItems: 12,
|
|
538
|
+
items: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
additionalProperties: false,
|
|
541
|
+
properties: {
|
|
542
|
+
kind: { type: 'string', enum: ['artifact', 'query', 'document', 'judgment'] },
|
|
543
|
+
ref: { type: 'string', minLength: 1, maxLength: 800 },
|
|
544
|
+
label: { type: 'string', minLength: 1, maxLength: 200 },
|
|
545
|
+
},
|
|
546
|
+
required: ['kind', 'ref', 'label'],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
ruled_out: {
|
|
550
|
+
type: 'object',
|
|
551
|
+
additionalProperties: false,
|
|
552
|
+
description: 'Required only when verdict is refutes.',
|
|
553
|
+
properties: Object.fromEntries(JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.map((key) => [key, { type: 'string', minLength: 1, maxLength: 1_000 }])),
|
|
554
|
+
required: [...JUDGMENT_MODEL_OUTCOME_ALTERNATIVES],
|
|
555
|
+
},
|
|
556
|
+
judgment_ref: {
|
|
557
|
+
type: 'object',
|
|
558
|
+
additionalProperties: false,
|
|
559
|
+
properties: { frame_digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' } },
|
|
560
|
+
required: ['frame_digest'],
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
required: ['record_version', 'catalog_digest', 'model', 'verdict', 'prediction', 'observed', 'conditions', 'evidence_refs'],
|
|
564
|
+
},
|
|
565
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
566
|
+
}];
|
|
567
|
+
export async function handleJudgmentModelOutcomeToolCall(name, args, dependencies) {
|
|
568
|
+
if (name !== 'brainbase_judgment_model_outcome_record')
|
|
569
|
+
return null;
|
|
570
|
+
const loaded = await loadFrameCatalog(dependencies);
|
|
571
|
+
if ('status' in loaded)
|
|
572
|
+
return loaded;
|
|
573
|
+
const validation = validateJudgmentModelOutcome(args, loaded.catalog, containsJudgmentFrameCredential);
|
|
574
|
+
if (!validation.valid) {
|
|
575
|
+
return toolError('error', 'judgment_model_outcome_invalid', 'The outcome does not match the current catalog contract', {
|
|
576
|
+
issues: validation.issues,
|
|
577
|
+
current_catalog_digest: loaded.catalog.digest,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const digest = judgmentModelOutcomeDigest(validation.outcome);
|
|
581
|
+
let appended;
|
|
582
|
+
try {
|
|
583
|
+
appended = await dependencies.appendModelOutcome({ ...validation.outcome, digest });
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
return toolError('unavailable', 'judgment_model_outcome_unavailable', `The outcome could not be stored: ${error instanceof Error ? error.message : String(error)}`);
|
|
587
|
+
}
|
|
588
|
+
if (appended.status !== 'ok')
|
|
589
|
+
return toolError(appended.status, appended.code, appended.message);
|
|
590
|
+
return {
|
|
591
|
+
status: 'ok',
|
|
592
|
+
data: {
|
|
593
|
+
schema_version: 'brainbase-judgment-model-outcome-v1',
|
|
594
|
+
id: appended.id,
|
|
595
|
+
recorded_at: appended.recorded_at,
|
|
596
|
+
model: validation.outcome.model,
|
|
597
|
+
verdict: validation.outcome.verdict,
|
|
598
|
+
digest,
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
export { JUDGMENT_MODEL_OUTCOME_ALTERNATIVES, JUDGMENT_MODEL_OUTCOME_RECORD_VERSION, judgmentModelOutcomeDigest, parseJudgmentModelOutcomeRecords, summarizeJudgmentModelOutcomes, validateJudgmentModelOutcome, } from './judgment-model-outcome.js';
|
|
466
603
|
export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { JudgmentFrameCatalog } from './judgment-frame.js';
|
|
2
|
+
/**
|
|
3
|
+
* Judgment outcome feedback for world models (brainbase-project ADR-014 F6).
|
|
4
|
+
*
|
|
5
|
+
* After a judgment that used a world model as a prediction, the observed
|
|
6
|
+
* result is appended as a separate record bound to the model's id and
|
|
7
|
+
* revision. The model itself (revision, validationState, adoptionState) is
|
|
8
|
+
* never changed here: adoption and promotion stay human decisions.
|
|
9
|
+
*
|
|
10
|
+
* Records are checked structurally against the current catalog. Whether a
|
|
11
|
+
* verdict is semantically right is not judged. A refutation must explain why
|
|
12
|
+
* measurement error, execution difference, and external change were ruled
|
|
13
|
+
* out, so a prediction gap alone is never recorded as a refutation.
|
|
14
|
+
*/
|
|
15
|
+
export declare const JUDGMENT_MODEL_OUTCOME_RECORD_VERSION: "judgment-model-outcome.v1";
|
|
16
|
+
export type JudgmentModelOutcomeVerdict = 'supports' | 'refutes' | 'inconclusive';
|
|
17
|
+
export type JudgmentModelOutcomeEvidenceKind = 'artifact' | 'query' | 'document' | 'judgment';
|
|
18
|
+
export declare const JUDGMENT_MODEL_OUTCOME_ALTERNATIVES: readonly ["measurement_error", "execution_difference", "external_change"];
|
|
19
|
+
export type JudgmentModelOutcomeAlternative = typeof JUDGMENT_MODEL_OUTCOME_ALTERNATIVES[number];
|
|
20
|
+
export interface JudgmentModelOutcomeEvidenceRef {
|
|
21
|
+
readonly kind: JudgmentModelOutcomeEvidenceKind;
|
|
22
|
+
readonly ref: string;
|
|
23
|
+
readonly label: string;
|
|
24
|
+
}
|
|
25
|
+
export interface JudgmentModelOutcome {
|
|
26
|
+
readonly record_version: typeof JUDGMENT_MODEL_OUTCOME_RECORD_VERSION;
|
|
27
|
+
readonly model: {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly revision: string;
|
|
30
|
+
};
|
|
31
|
+
readonly verdict: JudgmentModelOutcomeVerdict;
|
|
32
|
+
/** What the judgment predicted from this model for this case. */
|
|
33
|
+
readonly prediction: string;
|
|
34
|
+
/** What was actually observed. */
|
|
35
|
+
readonly observed: string;
|
|
36
|
+
/** Facility, period, and state under which the result holds. */
|
|
37
|
+
readonly conditions: string;
|
|
38
|
+
readonly evidence_refs: readonly JudgmentModelOutcomeEvidenceRef[];
|
|
39
|
+
/** Required only for refutes: why each alternative explanation was ruled out. */
|
|
40
|
+
readonly ruled_out?: Readonly<Record<JudgmentModelOutcomeAlternative, string>>;
|
|
41
|
+
readonly judgment_ref?: {
|
|
42
|
+
readonly frame_digest: `sha256:${string}`;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A record as the host stored it: the outcome plus host-owned id, time, and content digest. */
|
|
46
|
+
export interface JudgmentModelOutcomeStoredRecord extends JudgmentModelOutcome {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly recorded_at: string;
|
|
49
|
+
readonly digest: `sha256:${string}`;
|
|
50
|
+
}
|
|
51
|
+
export interface JudgmentModelOutcomeSummary {
|
|
52
|
+
readonly supports: number;
|
|
53
|
+
readonly refutes: number;
|
|
54
|
+
readonly inconclusive: number;
|
|
55
|
+
/** Conditions of the most recent refutations, newest first. */
|
|
56
|
+
readonly refuted_when: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
export type JudgmentModelOutcomeIssueCode = 'outcome_record_invalid' | 'outcome_catalog_mismatch' | 'outcome_model_unknown' | 'outcome_not_model' | 'outcome_revision_mismatch' | 'outcome_ruled_out_missing' | 'outcome_ruled_out_unexpected' | 'outcome_evidence_missing' | 'outcome_text_unsafe';
|
|
59
|
+
export interface JudgmentModelOutcomeIssue {
|
|
60
|
+
readonly code: JudgmentModelOutcomeIssueCode;
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly message: string;
|
|
63
|
+
}
|
|
64
|
+
export type JudgmentModelOutcomeValidation = {
|
|
65
|
+
readonly valid: true;
|
|
66
|
+
readonly issues: readonly [];
|
|
67
|
+
readonly outcome: JudgmentModelOutcome;
|
|
68
|
+
} | {
|
|
69
|
+
readonly valid: false;
|
|
70
|
+
readonly issues: readonly JudgmentModelOutcomeIssue[];
|
|
71
|
+
};
|
|
72
|
+
/** Digest of the outcome content. Host-owned id and time are not part of it. */
|
|
73
|
+
export declare function judgmentModelOutcomeDigest(outcome: JudgmentModelOutcome): `sha256:${string}`;
|
|
74
|
+
/**
|
|
75
|
+
* Validate a new outcome against the catalog shown on this turn. The model
|
|
76
|
+
* must be a world model in the catalog, at its current revision.
|
|
77
|
+
*/
|
|
78
|
+
export declare function validateJudgmentModelOutcome(value: unknown, catalog: JudgmentFrameCatalog, unsafe?: (text: string) => boolean): JudgmentModelOutcomeValidation;
|
|
79
|
+
/**
|
|
80
|
+
* Parse records returned by the host store. A malformed record or a content
|
|
81
|
+
* digest mismatch is an error, never skipped silently.
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseJudgmentModelOutcomeRecords(values: readonly unknown[]): {
|
|
84
|
+
readonly ok: true;
|
|
85
|
+
readonly records: readonly JudgmentModelOutcomeStoredRecord[];
|
|
86
|
+
} | {
|
|
87
|
+
readonly ok: false;
|
|
88
|
+
readonly index: number;
|
|
89
|
+
readonly message: string;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Count outcomes per world model at its current catalog revision. Outcomes
|
|
93
|
+
* recorded against an older revision are not counted: whether a refutation
|
|
94
|
+
* still holds after a revision is a separate judgment.
|
|
95
|
+
*/
|
|
96
|
+
export declare function summarizeJudgmentModelOutcomes(records: readonly JudgmentModelOutcomeStoredRecord[], catalog: JudgmentFrameCatalog): ReadonlyMap<string, JudgmentModelOutcomeSummary>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Judgment outcome feedback for world models (brainbase-project ADR-014 F6).
|
|
4
|
+
*
|
|
5
|
+
* After a judgment that used a world model as a prediction, the observed
|
|
6
|
+
* result is appended as a separate record bound to the model's id and
|
|
7
|
+
* revision. The model itself (revision, validationState, adoptionState) is
|
|
8
|
+
* never changed here: adoption and promotion stay human decisions.
|
|
9
|
+
*
|
|
10
|
+
* Records are checked structurally against the current catalog. Whether a
|
|
11
|
+
* verdict is semantically right is not judged. A refutation must explain why
|
|
12
|
+
* measurement error, execution difference, and external change were ruled
|
|
13
|
+
* out, so a prediction gap alone is never recorded as a refutation.
|
|
14
|
+
*/
|
|
15
|
+
export const JUDGMENT_MODEL_OUTCOME_RECORD_VERSION = 'judgment-model-outcome.v1';
|
|
16
|
+
export const JUDGMENT_MODEL_OUTCOME_ALTERNATIVES = ['measurement_error', 'execution_difference', 'external_change'];
|
|
17
|
+
const VERDICTS = ['supports', 'refutes', 'inconclusive'];
|
|
18
|
+
const EVIDENCE_KINDS = ['artifact', 'query', 'document', 'judgment'];
|
|
19
|
+
const MAX_STATEMENT = 2_000;
|
|
20
|
+
const MAX_RULED_OUT = 1_000;
|
|
21
|
+
const MAX_EVIDENCE = 12;
|
|
22
|
+
const MAX_EVIDENCE_REF = 800;
|
|
23
|
+
const MAX_EVIDENCE_LABEL = 200;
|
|
24
|
+
const MAX_REFUTED_WHEN = 3;
|
|
25
|
+
const MAX_REFUTED_WHEN_TEXT = 120;
|
|
26
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
27
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/u;
|
|
28
|
+
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
29
|
+
const nonEmpty = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
30
|
+
function canonicalJson(value) {
|
|
31
|
+
if (Array.isArray(value))
|
|
32
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
33
|
+
if (isRecord(value)) {
|
|
34
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
}
|
|
38
|
+
function clip(value, max) {
|
|
39
|
+
const chars = [...value];
|
|
40
|
+
return chars.length <= max ? value : `${chars.slice(0, max - 1).join('')}…`;
|
|
41
|
+
}
|
|
42
|
+
/** Digest of the outcome content. Host-owned id and time are not part of it. */
|
|
43
|
+
export function judgmentModelOutcomeDigest(outcome) {
|
|
44
|
+
return `sha256:${createHash('sha256').update(canonicalJson(outcomeContent(outcome)), 'utf8').digest('hex')}`;
|
|
45
|
+
}
|
|
46
|
+
function outcomeContent(value) {
|
|
47
|
+
return {
|
|
48
|
+
record_version: value.record_version,
|
|
49
|
+
model: { id: value.model.id, revision: value.model.revision },
|
|
50
|
+
verdict: value.verdict,
|
|
51
|
+
prediction: value.prediction,
|
|
52
|
+
observed: value.observed,
|
|
53
|
+
conditions: value.conditions,
|
|
54
|
+
evidence_refs: value.evidence_refs.map(({ kind, ref, label }) => ({ kind, ref, label })),
|
|
55
|
+
...(value.ruled_out === undefined ? {} : {
|
|
56
|
+
ruled_out: Object.fromEntries(JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.map((key) => [key, value.ruled_out[key]])),
|
|
57
|
+
}),
|
|
58
|
+
...(value.judgment_ref === undefined ? {} : { judgment_ref: { frame_digest: value.judgment_ref.frame_digest } }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function checkText(value, path, max, issue, unsafe) {
|
|
62
|
+
if (!nonEmpty(value, max)) {
|
|
63
|
+
issue('outcome_record_invalid', path, `${path} must be a non-empty string of at most ${max} characters`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (CONTROL_CHARACTERS.test(value)) {
|
|
67
|
+
issue('outcome_text_unsafe', path, `${path} contains control characters`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (unsafe(value)) {
|
|
71
|
+
issue('outcome_text_unsafe', path, `${path} contains a credential-like value`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** Structural checks shared by new input and stored records. Catalog checks are separate. */
|
|
77
|
+
function checkOutcomeShape(value, issue, unsafe) {
|
|
78
|
+
if (value.record_version !== JUDGMENT_MODEL_OUTCOME_RECORD_VERSION) {
|
|
79
|
+
issue('outcome_record_invalid', 'record_version', `record_version must be ${JUDGMENT_MODEL_OUTCOME_RECORD_VERSION}`);
|
|
80
|
+
}
|
|
81
|
+
const model = isRecord(value.model) ? value.model : null;
|
|
82
|
+
if (!model || !nonEmpty(model.id, 1_000) || !nonEmpty(model.revision, 64)) {
|
|
83
|
+
issue('outcome_record_invalid', 'model', 'model needs id and revision');
|
|
84
|
+
}
|
|
85
|
+
if (!VERDICTS.includes(value.verdict)) {
|
|
86
|
+
issue('outcome_record_invalid', 'verdict', 'verdict must be supports, refutes, or inconclusive');
|
|
87
|
+
}
|
|
88
|
+
checkText(value.prediction, 'prediction', MAX_STATEMENT, issue, unsafe);
|
|
89
|
+
checkText(value.observed, 'observed', MAX_STATEMENT, issue, unsafe);
|
|
90
|
+
checkText(value.conditions, 'conditions', MAX_STATEMENT, issue, unsafe);
|
|
91
|
+
const evidence = Array.isArray(value.evidence_refs) ? value.evidence_refs : null;
|
|
92
|
+
if (!evidence || evidence.length === 0) {
|
|
93
|
+
issue('outcome_evidence_missing', 'evidence_refs', 'an outcome needs at least one evidence reference');
|
|
94
|
+
}
|
|
95
|
+
else if (evidence.length > MAX_EVIDENCE) {
|
|
96
|
+
issue('outcome_record_invalid', 'evidence_refs', `evidence_refs allows at most ${MAX_EVIDENCE} entries`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
evidence.forEach((entry, index) => {
|
|
100
|
+
const path = `evidence_refs[${index}]`;
|
|
101
|
+
if (!isRecord(entry) || !EVIDENCE_KINDS.includes(entry.kind)) {
|
|
102
|
+
issue('outcome_record_invalid', `${path}.kind`, 'evidence kind must be artifact, query, document, or judgment');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
checkText(entry.ref, `${path}.ref`, MAX_EVIDENCE_REF, issue, unsafe);
|
|
106
|
+
checkText(entry.label, `${path}.label`, MAX_EVIDENCE_LABEL, issue, unsafe);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (value.verdict === 'refutes') {
|
|
110
|
+
const ruledOut = isRecord(value.ruled_out) ? value.ruled_out : null;
|
|
111
|
+
for (const key of JUDGMENT_MODEL_OUTCOME_ALTERNATIVES) {
|
|
112
|
+
if (!ruledOut || ruledOut[key] === undefined) {
|
|
113
|
+
issue('outcome_ruled_out_missing', `ruled_out.${key}`, `a refutation must say why ${key} does not explain the gap`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
checkText(ruledOut[key], `ruled_out.${key}`, MAX_RULED_OUT, issue, unsafe);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const key of Object.keys(ruledOut ?? {})) {
|
|
120
|
+
if (!JUDGMENT_MODEL_OUTCOME_ALTERNATIVES.includes(key)) {
|
|
121
|
+
issue('outcome_record_invalid', `ruled_out.${key}`, 'ruled_out contains an unknown field');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (value.ruled_out !== undefined) {
|
|
126
|
+
issue('outcome_ruled_out_unexpected', 'ruled_out', 'ruled_out is only for refutes');
|
|
127
|
+
}
|
|
128
|
+
if (value.judgment_ref !== undefined) {
|
|
129
|
+
const ref = isRecord(value.judgment_ref) ? value.judgment_ref : null;
|
|
130
|
+
if (!ref || typeof ref.frame_digest !== 'string' || !DIGEST_PATTERN.test(ref.frame_digest) || Object.keys(ref).length !== 1) {
|
|
131
|
+
issue('outcome_record_invalid', 'judgment_ref', 'judgment_ref needs only frame_digest as sha256');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Validate a new outcome against the catalog shown on this turn. The model
|
|
137
|
+
* must be a world model in the catalog, at its current revision.
|
|
138
|
+
*/
|
|
139
|
+
export function validateJudgmentModelOutcome(value, catalog, unsafe = () => false) {
|
|
140
|
+
const issues = [];
|
|
141
|
+
const issue = (code, path, message) => issues.push({ code, path, message });
|
|
142
|
+
if (!isRecord(value)) {
|
|
143
|
+
issue('outcome_record_invalid', '$', 'outcome must be an object');
|
|
144
|
+
return { valid: false, issues };
|
|
145
|
+
}
|
|
146
|
+
if (value.catalog_digest !== catalog.digest) {
|
|
147
|
+
issue('outcome_catalog_mismatch', 'catalog_digest', 'catalog_digest does not match the current catalog');
|
|
148
|
+
}
|
|
149
|
+
checkOutcomeShape(value, issue, unsafe);
|
|
150
|
+
const model = isRecord(value.model) ? value.model : {};
|
|
151
|
+
if (typeof model.id === 'string') {
|
|
152
|
+
const item = catalog.items.find((candidate) => candidate.id === model.id);
|
|
153
|
+
if (!item) {
|
|
154
|
+
issue('outcome_model_unknown', 'model.id', `${model.id} is not in the catalog`);
|
|
155
|
+
}
|
|
156
|
+
else if (item.kind !== 'model') {
|
|
157
|
+
issue('outcome_not_model', 'model.id', `${model.id} is a ${item.kind}; outcomes are recorded only for world models`);
|
|
158
|
+
}
|
|
159
|
+
else if (model.revision !== item.revision) {
|
|
160
|
+
issue('outcome_revision_mismatch', 'model.revision', `${model.id} is at revision ${item.revision}, not ${String(model.revision)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (issues.length > 0)
|
|
164
|
+
return { valid: false, issues };
|
|
165
|
+
return { valid: true, issues: [], outcome: outcomeContent(value) };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Parse records returned by the host store. A malformed record or a content
|
|
169
|
+
* digest mismatch is an error, never skipped silently.
|
|
170
|
+
*/
|
|
171
|
+
export function parseJudgmentModelOutcomeRecords(values) {
|
|
172
|
+
const records = [];
|
|
173
|
+
for (const [index, value] of values.entries()) {
|
|
174
|
+
if (!isRecord(value) || !nonEmpty(value.id, 200) || !nonEmpty(value.recorded_at, 64)
|
|
175
|
+
|| Number.isNaN(Date.parse(value.recorded_at)) || typeof value.digest !== 'string' || !DIGEST_PATTERN.test(value.digest)) {
|
|
176
|
+
return { ok: false, index, message: 'stored outcome needs id, recorded_at, and digest' };
|
|
177
|
+
}
|
|
178
|
+
const issues = [];
|
|
179
|
+
checkOutcomeShape(value, (code, path, message) => issues.push({ code, path, message }), () => false);
|
|
180
|
+
if (issues.length > 0)
|
|
181
|
+
return { ok: false, index, message: `${issues[0].path}: ${issues[0].message}` };
|
|
182
|
+
const content = outcomeContent(value);
|
|
183
|
+
if (judgmentModelOutcomeDigest(content) !== value.digest) {
|
|
184
|
+
return { ok: false, index, message: 'stored outcome digest does not match its content' };
|
|
185
|
+
}
|
|
186
|
+
records.push({ ...content, id: value.id, recorded_at: value.recorded_at, digest: value.digest });
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, records };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Count outcomes per world model at its current catalog revision. Outcomes
|
|
192
|
+
* recorded against an older revision are not counted: whether a refutation
|
|
193
|
+
* still holds after a revision is a separate judgment.
|
|
194
|
+
*/
|
|
195
|
+
export function summarizeJudgmentModelOutcomes(records, catalog) {
|
|
196
|
+
const current = new Map(catalog.items.filter((item) => item.kind === 'model').map((item) => [item.id, item.revision]));
|
|
197
|
+
const grouped = new Map();
|
|
198
|
+
for (const record of records) {
|
|
199
|
+
if (current.get(record.model.id) !== record.model.revision)
|
|
200
|
+
continue;
|
|
201
|
+
grouped.set(record.model.id, [...(grouped.get(record.model.id) ?? []), record]);
|
|
202
|
+
}
|
|
203
|
+
const summaries = new Map();
|
|
204
|
+
for (const [id, group] of grouped) {
|
|
205
|
+
const refutations = group
|
|
206
|
+
.filter((record) => record.verdict === 'refutes')
|
|
207
|
+
.sort((left, right) => Date.parse(right.recorded_at) - Date.parse(left.recorded_at) || right.id.localeCompare(left.id));
|
|
208
|
+
summaries.set(id, {
|
|
209
|
+
supports: group.filter((record) => record.verdict === 'supports').length,
|
|
210
|
+
refutes: refutations.length,
|
|
211
|
+
inconclusive: group.filter((record) => record.verdict === 'inconclusive').length,
|
|
212
|
+
refuted_when: refutations.slice(0, MAX_REFUTED_WHEN).map((record) => clip(record.conditions, MAX_REFUTED_WHEN_TEXT)),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return summaries;
|
|
216
|
+
}
|
|
@@ -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.1",
|
|
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": "3a1e004ecb50844069ccca0ad19e05b57cb63f9d"
|
|
427
431
|
}
|
package/ui/judgment-history.css
CHANGED
package/ui/judgment-history.js
CHANGED
|
@@ -1089,7 +1089,11 @@ function appendNormalFeedbackComposer(doc, record, {
|
|
|
1089
1089
|
function renderNormalRail(doc, rail, row, detail, onClose, options = {}) {
|
|
1090
1090
|
if (!rail) return;
|
|
1091
1091
|
rail.replaceChildren();
|
|
1092
|
-
|
|
1092
|
+
// The home row keeps the canonical raw record so the selected references
|
|
1093
|
+
// remain readable while the detail request is pending or unavailable.
|
|
1094
|
+
// Once the detail readback succeeds, its normalized record takes priority.
|
|
1095
|
+
const homeRecord = row?.mode === 'normal' ? normalizeNormalRecord(row.record) : null;
|
|
1096
|
+
const record = detail ?? homeRecord ?? row;
|
|
1093
1097
|
rail.append(workspaceRailHead(doc, {
|
|
1094
1098
|
kicker: `${NORMAL_ENTRYPOINT_LABELS[record.entrypoint] ?? '入口不明'}の判断`,
|
|
1095
1099
|
title: text(record.judgment?.summary) || '判断内容は未記録',
|