@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.
@@ -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
- /** One line per item, grouped by kind, for the model-facing prompt. */
122
- export declare function renderJudgmentFrameCatalog(catalog: JudgmentFrameCatalog): string;
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';
@@ -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
- function containsPublicFrameCredential(value) {
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
- /** One line per item, grouped by kind, for the model-facing prompt. */
168
- export function renderJudgmentFrameCatalog(catalog) {
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
- lines.push(`- ${item.id} [${item.status}] ${item.text}${item.applies_when ? `(適用:${item.applies_when})` : ''}`);
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 (containsPublicFrameCredential(candidate)) {
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
- catalog: renderJudgmentFrameCatalog(catalog),
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.23.9",
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": "ffddfb19cbde2520ba00f8370a251de08cd92ab5"
430
+ "gitHead": "3a1e004ecb50844069ccca0ad19e05b57cb63f9d"
427
431
  }
@@ -96,6 +96,10 @@
96
96
  margin-top: 22px;
97
97
  }
98
98
 
99
+ .bb-jh .bb-ws-notice > .bb-ws-button {
100
+ justify-self: start;
101
+ }
102
+
99
103
  .bb-jh-ledger {
100
104
  border-top-color: var(--bb-color-border-strong);
101
105
  }
@@ -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
- const record = detail ?? row;
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) || '判断内容は未記録',