@unson/brainbase-mcp 0.44.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { type NormalizedRunReceipt } from './run-receipt.js';
2
+ import { type RunReceiptPersistenceInput, type RunReceiptPort } from './run-receipt-port.js';
2
3
  import { type Awaitable, type RuntimeHostContext, type RuntimeHostPorts } from './runtime-host.js';
3
4
  export type AutomationRunOperation = 'automation.start' | 'automation.retry';
4
5
  export type AutomationRunPhase = 'claimed' | 'runner_started' | 'receipt_persisted' | 'completed' | 'retryable' | 'uncertain' | 'unknown';
@@ -46,18 +47,8 @@ export interface AutomationRunnerPort {
46
47
  start(input: AutomationRunnerStartInput): Awaitable<AutomationRunnerResult>;
47
48
  retry?(input: AutomationRunnerRetryInput): Awaitable<AutomationRunnerResult>;
48
49
  }
49
- export interface AutomationReceiptPersistenceInput {
50
- payload: unknown;
51
- normalized: NormalizedRunReceipt;
52
- context: RuntimeHostContext;
53
- }
54
- export interface AutomationReceiptPort {
55
- persist(input: AutomationReceiptPersistenceInput): Awaitable<unknown>;
56
- query(input: {
57
- request: AutomationRunRequest;
58
- context: RuntimeHostContext;
59
- }): Awaitable<unknown>;
60
- }
50
+ export type AutomationReceiptPersistenceInput = RunReceiptPersistenceInput<RuntimeHostContext>;
51
+ export type AutomationReceiptPort = RunReceiptPort<AutomationRunRequest, RuntimeHostContext>;
61
52
  export type AutomationRunClaim = {
62
53
  status: 'claimed';
63
54
  claimToken: string;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { normalizeRunReceipt } from './run-receipt.js';
3
+ import { assertRunReceiptPort } from './run-receipt-port.js';
3
4
  import { resolveRuntimeHostContext } from './runtime-host.js';
4
5
  function assertRequest(request) {
5
6
  if (!request || typeof request !== 'object') {
@@ -16,11 +17,7 @@ function assertPorts(ports) {
16
17
  if (!ports.runner || typeof ports.runner !== 'object' || typeof ports.runner.start !== 'function') {
17
18
  throw new Error('automation runner port with start is required');
18
19
  }
19
- if (!ports.receipt || typeof ports.receipt !== 'object'
20
- || typeof ports.receipt.persist !== 'function'
21
- || typeof ports.receipt.query !== 'function') {
22
- throw new Error('automation receipt port with persist and query is required');
23
- }
20
+ assertRunReceiptPort(ports.receipt, 'automation receipt');
24
21
  if (!ports.runStore || typeof ports.runStore !== 'object'
25
22
  || typeof ports.runStore.claim !== 'function'
26
23
  || typeof ports.runStore.markRunnerStarted !== 'function'
@@ -0,0 +1,76 @@
1
+ import { type MeetingSourceProvider } from './meeting-source-sync.js';
2
+ /**
3
+ * Provider adapters only translate provider-specific MCP responses into the
4
+ * provider-neutral artifact shape consumed by the sync core. Transport,
5
+ * OAuth, credentials, filesystem paths, and persistence stay with the host
6
+ * and are supplied through this client port.
7
+ */
8
+ export declare const MEETING_SOURCE_MCP_ADAPTER_VERSION: "brainbase.meeting-source-mcp-adapters.v1";
9
+ type UnknownRecord = Record<string, unknown>;
10
+ type MaybePromise<T> = T | Promise<T>;
11
+ export type MeetingSourceMcpCallTool = (name: string, args: Record<string, unknown>) => MaybePromise<unknown>;
12
+ export interface MeetingSourceMcpClient {
13
+ readonly callTool: MeetingSourceMcpCallTool;
14
+ readonly listTools?: () => MaybePromise<unknown>;
15
+ readonly listResources?: () => MaybePromise<unknown>;
16
+ }
17
+ export interface MeetingSourceMcpAdapterConfig {
18
+ readonly tool?: string;
19
+ readonly poll_tool?: string;
20
+ readonly list_tool?: string;
21
+ readonly detail_tool?: string;
22
+ readonly transcript_tool?: string;
23
+ readonly note_tool?: string;
24
+ readonly provider_poll?: boolean;
25
+ readonly limit?: number;
26
+ readonly page_size?: number;
27
+ readonly arguments?: Readonly<Record<string, unknown>>;
28
+ readonly resource_prefix?: string;
29
+ readonly resourcePrefix?: string;
30
+ }
31
+ export interface MeetingSourceMcpAdapterOptions {
32
+ readonly provider: MeetingSourceProvider;
33
+ readonly client: MeetingSourceMcpClient;
34
+ readonly config?: MeetingSourceMcpAdapterConfig;
35
+ readonly log?: {
36
+ readonly warn?: (...args: unknown[]) => void;
37
+ };
38
+ }
39
+ export interface MeetingSourceMcpPollInput {
40
+ /** The provider state is accepted for Host compatibility and is not persisted here. */
41
+ readonly provider?: UnknownRecord;
42
+ readonly cursor?: UnknownRecord;
43
+ readonly since?: string | null;
44
+ readonly until?: string | null;
45
+ }
46
+ export interface MeetingSourceMcpTestResult {
47
+ readonly ok: true;
48
+ readonly auth_status: 'connected';
49
+ readonly capabilities: readonly string[];
50
+ readonly configured_tool: string | null;
51
+ }
52
+ export type MeetingSourceMcpArtifact = UnknownRecord;
53
+ /** Extracts an array from the common MCP tool response envelopes. */
54
+ export declare function extractMeetingSourceArtifactList(payload: unknown): MeetingSourceMcpArtifact[];
55
+ /** Returns the structured payload or the textual payload from a CallToolResult. */
56
+ export declare function extractMeetingSourceToolPayload(payload: unknown): unknown;
57
+ /** Reads transcript/note text from provider-specific nested envelopes. */
58
+ export declare function extractMeetingSourceToolText(value: unknown): string;
59
+ export declare function meetingSourceArtifactInWindow(value: unknown, window?: {
60
+ readonly since?: string | null;
61
+ readonly until?: string | null;
62
+ }): boolean;
63
+ export declare class MeetingSourceMcpAdapter {
64
+ readonly provider: MeetingSourceProvider;
65
+ readonly client: MeetingSourceMcpClient;
66
+ readonly config: MeetingSourceMcpAdapterConfig;
67
+ readonly log: MeetingSourceMcpAdapterOptions['log'];
68
+ constructor(options: MeetingSourceMcpAdapterOptions);
69
+ private toolArgs;
70
+ private pollTactiq;
71
+ private pollPlaud;
72
+ test(_input?: unknown): Promise<MeetingSourceMcpTestResult>;
73
+ poll(input?: MeetingSourceMcpPollInput): Promise<MeetingSourceMcpArtifact[]>;
74
+ }
75
+ export declare function createMeetingSourceMcpAdapter(options: MeetingSourceMcpAdapterOptions): MeetingSourceMcpAdapter;
76
+ export {};
@@ -0,0 +1,294 @@
1
+ import { SUPPORTED_MEETING_SOURCE_PROVIDERS } from './meeting-source-sync.js';
2
+ /**
3
+ * Provider adapters only translate provider-specific MCP responses into the
4
+ * provider-neutral artifact shape consumed by the sync core. Transport,
5
+ * OAuth, credentials, filesystem paths, and persistence stay with the host
6
+ * and are supplied through this client port.
7
+ */
8
+ export const MEETING_SOURCE_MCP_ADAPTER_VERSION = 'brainbase.meeting-source-mcp-adapters.v1';
9
+ function asRecord(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
11
+ }
12
+ function parseJsonMaybe(value) {
13
+ if (typeof value !== 'string')
14
+ return value;
15
+ const trimmed = value.trim();
16
+ if (!trimmed)
17
+ return null;
18
+ try {
19
+ return JSON.parse(trimmed);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Extracts an array from the common MCP tool response envelopes. */
26
+ export function extractMeetingSourceArtifactList(payload) {
27
+ if (!payload)
28
+ return [];
29
+ if (Array.isArray(payload))
30
+ return payload.filter((item) => Boolean(asRecord(item)));
31
+ const record = asRecord(payload);
32
+ if (!record) {
33
+ const parsed = parseJsonMaybe(payload);
34
+ return parsed === payload ? [] : extractMeetingSourceArtifactList(parsed);
35
+ }
36
+ for (const key of ['artifacts', 'items', 'records', 'results', 'files', 'data', 'transcripts', 'recordings']) {
37
+ const value = record[key];
38
+ if (Array.isArray(value))
39
+ return value.filter((item) => Boolean(asRecord(item)));
40
+ }
41
+ if (record.structuredContent !== undefined)
42
+ return extractMeetingSourceArtifactList(record.structuredContent);
43
+ if (Array.isArray(record.content)) {
44
+ const parsedItems = record.content.flatMap((item) => {
45
+ const entry = asRecord(item);
46
+ if (!entry || entry.type !== 'text' || typeof entry.text !== 'string')
47
+ return [];
48
+ const parsed = parseJsonMaybe(entry.text);
49
+ return parsed === null ? [] : extractMeetingSourceArtifactList(parsed);
50
+ });
51
+ if (parsedItems.length)
52
+ return parsedItems;
53
+ }
54
+ return [];
55
+ }
56
+ /** Returns the structured payload or the textual payload from a CallToolResult. */
57
+ export function extractMeetingSourceToolPayload(payload) {
58
+ if (!payload)
59
+ return null;
60
+ const record = asRecord(payload);
61
+ if (!record)
62
+ return parseJsonMaybe(payload);
63
+ if (record.structuredContent !== undefined)
64
+ return record.structuredContent;
65
+ if (Array.isArray(record.content)) {
66
+ const textItems = record.content
67
+ .map((item) => asRecord(item))
68
+ .filter((item) => Boolean(item && item.type === 'text' && typeof item.text === 'string'));
69
+ for (const item of textItems) {
70
+ const parsed = parseJsonMaybe(item.text);
71
+ if (parsed !== null)
72
+ return parsed;
73
+ }
74
+ return textItems.map((item) => item.text).join('\n');
75
+ }
76
+ return payload;
77
+ }
78
+ /** Reads transcript/note text from provider-specific nested envelopes. */
79
+ export function extractMeetingSourceToolText(value) {
80
+ if (!value)
81
+ return '';
82
+ if (typeof value === 'string')
83
+ return value;
84
+ if (Array.isArray(value))
85
+ return value.map(extractMeetingSourceToolText).filter(Boolean).join('\n');
86
+ const record = asRecord(value);
87
+ if (!record)
88
+ return '';
89
+ for (const key of ['text', 'transcript', 'transcript_text', 'data_content', 'content', 'markdown', 'summary']) {
90
+ if (typeof record[key] === 'string')
91
+ return record[key];
92
+ }
93
+ return extractMeetingSourceToolText(record.detailedSummary ?? record.data);
94
+ }
95
+ export function meetingSourceArtifactInWindow(value, window = {}) {
96
+ if (!value)
97
+ return true;
98
+ const timestamp = Date.parse(String(value));
99
+ if (Number.isNaN(timestamp))
100
+ return true;
101
+ if (window.since && timestamp < Date.parse(window.since))
102
+ return false;
103
+ if (window.until && timestamp > Date.parse(window.until))
104
+ return false;
105
+ return true;
106
+ }
107
+ function extractResourceList(payload) {
108
+ const record = asRecord(payload);
109
+ if (Array.isArray(payload))
110
+ return payload.filter((item) => Boolean(asRecord(item)));
111
+ if (!record)
112
+ return [];
113
+ if (Array.isArray(record.resources))
114
+ return record.resources.filter((item) => Boolean(asRecord(item)));
115
+ if (record.structuredContent !== undefined)
116
+ return extractResourceList(record.structuredContent);
117
+ return [];
118
+ }
119
+ function configuredTool(config) {
120
+ return config.tool ?? config.poll_tool ?? config.list_tool ?? null;
121
+ }
122
+ function boundedNumber(value, fallback, maximum) {
123
+ const parsed = Number(value);
124
+ if (!Number.isFinite(parsed))
125
+ return fallback;
126
+ return Math.min(Math.max(Math.trunc(parsed), 1), maximum);
127
+ }
128
+ function stringValue(value) {
129
+ return typeof value === 'string' && value.trim() ? value : null;
130
+ }
131
+ function logFailure(logger, provider, kind, externalId) {
132
+ // Do not include provider response/error text: it can contain an access token.
133
+ logger?.warn?.(`[meeting-source] ${provider} ${kind} fetch failed for ${externalId}`);
134
+ }
135
+ function toolNames(payload) {
136
+ const record = asRecord(payload);
137
+ const tools = record && Array.isArray(record.tools) ? record.tools : [];
138
+ return tools
139
+ .map((tool) => asRecord(tool)?.name)
140
+ .filter((name) => typeof name === 'string' && name.trim().length > 0);
141
+ }
142
+ export class MeetingSourceMcpAdapter {
143
+ provider;
144
+ client;
145
+ config;
146
+ log;
147
+ constructor(options) {
148
+ if (!options?.client || typeof options.client.callTool !== 'function') {
149
+ throw new TypeError('client.callTool is required');
150
+ }
151
+ if (!SUPPORTED_MEETING_SOURCE_PROVIDERS.includes(options.provider)) {
152
+ throw new Error(`unsupported meeting source provider adapter: ${String(options.provider)}`);
153
+ }
154
+ this.provider = options.provider;
155
+ this.client = options.client;
156
+ this.config = Object.freeze({ ...(options.config ?? {}) });
157
+ this.log = options.log;
158
+ }
159
+ toolArgs(input) {
160
+ return {
161
+ ...(this.config.arguments ?? {}),
162
+ since: input.since ?? null,
163
+ until: input.until ?? null,
164
+ updated_since: input.since ?? null,
165
+ cursor: input.cursor ?? {},
166
+ };
167
+ }
168
+ async pollTactiq(input) {
169
+ const result = await this.client.callTool(this.config.tool ?? this.config.poll_tool ?? 'list_recent_meetings', {
170
+ ...(this.config.arguments ?? {}),
171
+ limit: boundedNumber(this.config.limit ?? this.config.page_size, 50, 50),
172
+ });
173
+ const meetings = extractMeetingSourceArtifactList(result).filter((meeting) => meetingSourceArtifactInWindow(meeting.createdAt ?? meeting.created_at ?? meeting.started_at, input));
174
+ const artifacts = [];
175
+ for (const meeting of meetings) {
176
+ const meetingId = stringValue(meeting.id ?? meeting.external_id);
177
+ let detail = null;
178
+ if (meetingId) {
179
+ try {
180
+ detail = asRecord(extractMeetingSourceToolPayload(await this.client.callTool(this.config.detail_tool ?? 'get_meeting', { id: meetingId })));
181
+ }
182
+ catch {
183
+ logFailure(this.log, this.provider, 'detail', meetingId);
184
+ }
185
+ }
186
+ const summary = asRecord(detail?.detailedSummary ?? meeting.detailedSummary);
187
+ const noteText = summary?.status === 'ready'
188
+ ? extractMeetingSourceToolText(summary.content)
189
+ : extractMeetingSourceToolText(summary);
190
+ artifacts.push({
191
+ ...meeting,
192
+ ...(detail ?? {}),
193
+ ...(meetingId ? { external_id: meetingId } : {}),
194
+ title: detail?.title ?? meeting.title,
195
+ started_at: meeting.createdAt ?? detail?.createdAt ?? meeting.created_at ?? detail?.created_at ?? null,
196
+ updated_at: meeting.createdAt ?? detail?.createdAt ?? meeting.updated_at ?? detail?.updated_at ?? null,
197
+ participants: meeting.attendees ?? detail?.attendees ?? meeting.participants ?? [],
198
+ note_text: noteText,
199
+ meeting_mode: 'online',
200
+ resource_uri: meeting.url ?? detail?.url ?? meeting.resource_uri ?? null,
201
+ });
202
+ }
203
+ return artifacts;
204
+ }
205
+ async pollPlaud(input) {
206
+ const result = await this.client.callTool(this.config.tool ?? this.config.poll_tool ?? 'list_files', {
207
+ page: 1,
208
+ page_size: boundedNumber(this.config.page_size, 20, 200),
209
+ ...(input.since ? { date_from: input.since.slice(0, 10) } : {}),
210
+ ...(input.until ? { date_to: input.until.slice(0, 10) } : {}),
211
+ ...(this.config.arguments ?? {}),
212
+ });
213
+ const files = extractMeetingSourceArtifactList(result).filter((file) => meetingSourceArtifactInWindow(file.start_at ?? file.created_at ?? file.started_at, input));
214
+ const artifacts = [];
215
+ for (const file of files) {
216
+ const fileId = stringValue(file.id ?? file.file_id ?? file.fileId);
217
+ let transcriptText = '';
218
+ let noteText = '';
219
+ if (fileId) {
220
+ try {
221
+ transcriptText = extractMeetingSourceToolText(extractMeetingSourceToolPayload(await this.client.callTool(this.config.transcript_tool ?? 'get_transcript', { file_id: fileId })));
222
+ }
223
+ catch {
224
+ logFailure(this.log, this.provider, 'transcript', fileId);
225
+ }
226
+ try {
227
+ noteText = extractMeetingSourceToolText(extractMeetingSourceToolPayload(await this.client.callTool(this.config.note_tool ?? 'get_note', { file_id: fileId })));
228
+ }
229
+ catch {
230
+ logFailure(this.log, this.provider, 'note', fileId);
231
+ }
232
+ }
233
+ artifacts.push({
234
+ ...file,
235
+ ...(fileId ? { external_id: fileId } : {}),
236
+ title: file.name ?? file.title,
237
+ started_at: file.start_at ?? file.created_at ?? null,
238
+ updated_at: file.created_at ?? file.start_at ?? null,
239
+ transcript_text: transcriptText,
240
+ note_text: noteText,
241
+ meeting_mode: 'offline',
242
+ resource_uri: fileId ? `plaud:${fileId}` : null,
243
+ });
244
+ }
245
+ return artifacts;
246
+ }
247
+ async test(_input) {
248
+ let capabilities = [];
249
+ if (typeof this.client.listTools === 'function') {
250
+ try {
251
+ capabilities = toolNames(await this.client.listTools());
252
+ }
253
+ catch {
254
+ capabilities = [];
255
+ }
256
+ }
257
+ return {
258
+ ok: true,
259
+ auth_status: 'connected',
260
+ capabilities,
261
+ configured_tool: configuredTool(this.config),
262
+ };
263
+ }
264
+ async poll(input = {}) {
265
+ // Preserve the legacy Host predicate: provider polling is selected when it
266
+ // is explicitly enabled or when no primary tool is configured. The legacy
267
+ // provider path intentionally treats poll_tool/list_tool as provider
268
+ // configuration and only uses them inside the provider-specific adapter.
269
+ const providerPoll = this.config.provider_poll === true || !this.config.tool;
270
+ if (this.provider === 'tactiq' && providerPoll)
271
+ return this.pollTactiq(input);
272
+ if (this.provider === 'plaud' && providerPoll)
273
+ return this.pollPlaud(input);
274
+ const tool = configuredTool(this.config);
275
+ if (tool) {
276
+ return extractMeetingSourceArtifactList(await this.client.callTool(tool, this.toolArgs(input)));
277
+ }
278
+ if (typeof this.client.listResources !== 'function')
279
+ return [];
280
+ const prefix = this.config.resource_prefix ?? this.config.resourcePrefix;
281
+ return extractResourceList(await this.client.listResources())
282
+ .filter((resource) => !prefix || String(resource.uri ?? '').startsWith(prefix))
283
+ .map((resource) => ({
284
+ id: resource.uri,
285
+ title: resource.name ?? resource.title ?? resource.uri,
286
+ resource_uri: resource.uri,
287
+ updated_at: resource.updated_at ?? resource.modified_at ?? null,
288
+ provider: this.provider,
289
+ }));
290
+ }
291
+ }
292
+ export function createMeetingSourceMcpAdapter(options) {
293
+ return new MeetingSourceMcpAdapter(options);
294
+ }
@@ -0,0 +1,19 @@
1
+ import { dedupeMeetingSourceArtifacts, normalizeMeetingSourceArtifact, runMeetingSourceSync, type MeetingSourceArtifact, type MeetingSourceProvider, type MeetingSourceSyncPorts, type MeetingSourceSyncResult } from './meeting-source-sync.js';
2
+ /**
3
+ * Host composition seam for the already implemented provider-neutral sync
4
+ * core. It deliberately delegates every operation; normalization, dedupe,
5
+ * durable claim/save/readback, and retry behavior remain owned by the core.
6
+ */
7
+ export declare const MEETING_SOURCE_SYNC_COMMON_ADAPTER_VERSION: "brainbase.meeting-source-sync-common-adapter.v1";
8
+ export interface MeetingSourceSyncCommonCore {
9
+ readonly normalizeMeetingSourceArtifact: typeof normalizeMeetingSourceArtifact;
10
+ readonly dedupeMeetingSourceArtifacts: typeof dedupeMeetingSourceArtifacts;
11
+ readonly runMeetingSourceSync: typeof runMeetingSourceSync;
12
+ }
13
+ export interface MeetingSourceSyncCommonAdapter {
14
+ normalizeSourceArtifact(raw: Record<string, unknown>, provider: MeetingSourceProvider): MeetingSourceArtifact;
15
+ dedupeSourceArtifacts(artifacts: readonly MeetingSourceArtifact[]): ReturnType<typeof dedupeMeetingSourceArtifacts>;
16
+ sync(input: Parameters<typeof runMeetingSourceSync>[0], ports: MeetingSourceSyncPorts): Promise<MeetingSourceSyncResult>;
17
+ }
18
+ export declare function createMeetingSourceSyncCommonAdapter(commonCore: MeetingSourceSyncCommonCore): MeetingSourceSyncCommonAdapter;
19
+ export declare const createDefaultMeetingSourceSyncCommonAdapter: () => MeetingSourceSyncCommonAdapter;
@@ -0,0 +1,34 @@
1
+ import { dedupeMeetingSourceArtifacts, normalizeMeetingSourceArtifact, runMeetingSourceSync, } from './meeting-source-sync.js';
2
+ /**
3
+ * Host composition seam for the already implemented provider-neutral sync
4
+ * core. It deliberately delegates every operation; normalization, dedupe,
5
+ * durable claim/save/readback, and retry behavior remain owned by the core.
6
+ */
7
+ export const MEETING_SOURCE_SYNC_COMMON_ADAPTER_VERSION = 'brainbase.meeting-source-sync-common-adapter.v1';
8
+ export function createMeetingSourceSyncCommonAdapter(commonCore) {
9
+ if (!commonCore || typeof commonCore.runMeetingSourceSync !== 'function') {
10
+ throw new TypeError('meeting source sync common core is required');
11
+ }
12
+ if (typeof commonCore.normalizeMeetingSourceArtifact !== 'function') {
13
+ throw new TypeError('meeting source sync normalization export is required');
14
+ }
15
+ if (typeof commonCore.dedupeMeetingSourceArtifacts !== 'function') {
16
+ throw new TypeError('meeting source sync dedupe export is required');
17
+ }
18
+ return Object.freeze({
19
+ normalizeSourceArtifact(raw, provider) {
20
+ return commonCore.normalizeMeetingSourceArtifact(raw, provider);
21
+ },
22
+ dedupeSourceArtifacts(artifacts) {
23
+ return commonCore.dedupeMeetingSourceArtifacts(artifacts);
24
+ },
25
+ sync(input, ports) {
26
+ return commonCore.runMeetingSourceSync(input, ports);
27
+ },
28
+ });
29
+ }
30
+ export const createDefaultMeetingSourceSyncCommonAdapter = () => createMeetingSourceSyncCommonAdapter({
31
+ normalizeMeetingSourceArtifact,
32
+ dedupeMeetingSourceArtifacts,
33
+ runMeetingSourceSync,
34
+ });
package/dist/mesh.d.ts CHANGED
@@ -28,6 +28,16 @@ export declare function createMeshEnvelope(input: MeshEnvelopeInput, options?: {
28
28
  nonceFactory?: () => string;
29
29
  }): MeshEnvelope;
30
30
  export declare function normalizeMeshEnvelope(value: unknown): MeshEnvelope;
31
+ /**
32
+ * Parse a serialized or already-decoded envelope through the same strict
33
+ * boundary used by the host adapter.
34
+ *
35
+ * The host runtime accepts both JSON strings and objects. Keeping that input
36
+ * shape here lets a host compare the local contract result before handing an
37
+ * envelope to its network or crypto layer without moving either dependency
38
+ * into the portable package.
39
+ */
40
+ export declare function parseMeshEnvelope(value: unknown): MeshEnvelope;
31
41
  export interface MeshProjectPort {
32
42
  projectId: string;
33
43
  localPath?: string;
package/dist/mesh.js CHANGED
@@ -21,16 +21,24 @@ function text(value, field) {
21
21
  throw new MeshContractError(field);
22
22
  return value.trim();
23
23
  }
24
- function isJson(value) {
25
- if (value === null || ['string', 'number', 'boolean'].includes(typeof value))
24
+ function isJson(value, ancestors = new Set()) {
25
+ if (value === null || ['string', 'boolean'].includes(typeof value))
26
26
  return true;
27
- if (Array.isArray(value))
28
- return value.every(isJson);
29
- if (typeof value === 'object')
30
- return Object.values(value).every(isJson);
31
- return false;
27
+ if (typeof value === 'number')
28
+ return Number.isFinite(value);
29
+ if (typeof value !== 'object' || ancestors.has(value))
30
+ return false;
31
+ ancestors.add(value);
32
+ const valid = Array.isArray(value)
33
+ ? value.every((entry) => isJson(entry, ancestors))
34
+ : (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
35
+ && Object.values(value).every((entry) => isJson(entry, ancestors));
36
+ ancestors.delete(value);
37
+ return valid;
32
38
  }
33
39
  export function createMeshEnvelope(input, options = {}) {
40
+ if (!input || typeof input !== 'object' || Array.isArray(input))
41
+ throw new MeshContractError('envelope');
34
42
  const from = text(input.from, 'from');
35
43
  const to = text(input.to, 'to');
36
44
  if (!MESH_ENVELOPE_TYPES.includes(input.type))
@@ -38,16 +46,16 @@ export function createMeshEnvelope(input, options = {}) {
38
46
  if (input.payload !== undefined && !isJson(input.payload))
39
47
  throw new MeshContractError('payload');
40
48
  const ts = options.now ?? Date.now();
41
- if (!Number.isFinite(ts))
49
+ if (!Number.isFinite(ts) || ts <= 0)
42
50
  throw new MeshContractError('ts');
43
51
  return {
44
- id: options.idFactory?.() ?? randomUUID(),
52
+ id: text(options.idFactory?.() ?? randomUUID(), 'id'),
45
53
  from,
46
54
  to,
47
55
  type: input.type,
48
56
  payload: input.payload ?? null,
49
57
  ts,
50
- nonce: options.nonceFactory?.() ?? randomUUID()
58
+ nonce: text(options.nonceFactory?.() ?? randomUUID(), 'nonce')
51
59
  };
52
60
  }
53
61
  export function normalizeMeshEnvelope(value) {
@@ -64,18 +72,40 @@ export function normalizeMeshEnvelope(value) {
64
72
  if (!Number.isFinite(candidate.ts) || typeof candidate.ts !== 'number' || candidate.ts <= 0) {
65
73
  throw new MeshContractError('ts');
66
74
  }
67
- if (!isJson(candidate.payload))
75
+ const payload = candidate.payload ?? null;
76
+ if (!isJson(payload))
68
77
  throw new MeshContractError('payload');
69
78
  return {
70
79
  id,
71
80
  from,
72
81
  to,
73
82
  type: candidate.type,
74
- payload: candidate.payload,
83
+ payload,
75
84
  ts: candidate.ts,
76
85
  nonce
77
86
  };
78
87
  }
88
+ /**
89
+ * Parse a serialized or already-decoded envelope through the same strict
90
+ * boundary used by the host adapter.
91
+ *
92
+ * The host runtime accepts both JSON strings and objects. Keeping that input
93
+ * shape here lets a host compare the local contract result before handing an
94
+ * envelope to its network or crypto layer without moving either dependency
95
+ * into the portable package.
96
+ */
97
+ export function parseMeshEnvelope(value) {
98
+ let decoded = value;
99
+ if (typeof value === 'string') {
100
+ try {
101
+ decoded = JSON.parse(value);
102
+ }
103
+ catch {
104
+ throw new MeshContractError('envelope', 'mesh envelope JSON is invalid');
105
+ }
106
+ }
107
+ return normalizeMeshEnvelope(decoded);
108
+ }
79
109
  export function normalizeMeshNodeProfile(value) {
80
110
  if (!value || typeof value !== 'object' || Array.isArray(value))
81
111
  throw new MeshContractError('profile');
@@ -94,7 +124,8 @@ export function normalizeMeshNodeProfile(value) {
94
124
  const ports = item.ports === undefined
95
125
  ? undefined
96
126
  : item.ports && typeof item.ports === 'object' && !Array.isArray(item.ports)
97
- && Object.entries(item.ports).every(([key, port]) => Boolean(key) && (typeof port === 'string' || typeof port === 'number'))
127
+ && Object.entries(item.ports).every(([key, port]) => Boolean(key)
128
+ && (typeof port === 'string' || (typeof port === 'number' && Number.isFinite(port))))
98
129
  ? { ...item.ports }
99
130
  : (() => { throw new MeshContractError(`projects.${index}.ports`); })();
100
131
  return { projectId, localPath, globInclude, ports };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Portable batch boundary for meeting-derived Personal KG extraction.
3
+ *
4
+ * A consumer supplies the meeting rule/extraction callback because source
5
+ * schemas, identity, redaction policy, and candidate shape are tenant-owned.
6
+ * This module owns the common collection/count contract and keeps the batch
7
+ * boundary independent from any database or model provider.
8
+ */
9
+ export interface RoutinePersonalKgMeetingResult<TCandidate = unknown> {
10
+ adopted?: readonly TCandidate[];
11
+ rejected?: readonly unknown[];
12
+ needs_review?: readonly unknown[];
13
+ }
14
+ export interface RoutinePersonalKgExtractionOptions<TMeeting, TCandidate = unknown> {
15
+ date: string;
16
+ meetings?: readonly TMeeting[];
17
+ sourceSystem: string;
18
+ extract: (input: {
19
+ meeting: TMeeting;
20
+ date: string;
21
+ index: number;
22
+ }) => RoutinePersonalKgMeetingResult<TCandidate>;
23
+ dedupe?: (candidates: readonly TCandidate[]) => readonly TCandidate[];
24
+ }
25
+ export interface RoutinePersonalKgExtractionResult<TCandidate = unknown> {
26
+ date: string;
27
+ source_system: string;
28
+ counts: {
29
+ adopted: number;
30
+ rejected: number;
31
+ needs_review: number;
32
+ };
33
+ adopted: TCandidate[];
34
+ rejected: unknown[];
35
+ needs_review: unknown[];
36
+ }
37
+ /**
38
+ * Collect meeting extraction results using the portable result shape.
39
+ * Extraction errors intentionally propagate to the consumer: a host decides
40
+ * whether a failed meeting should be retried, surfaced for review, or stop a
41
+ * run, and the common package must not silently convert it to success.
42
+ */
43
+ export declare function extractRoutinePersonalKgCandidates<TMeeting, TCandidate = unknown>(options: RoutinePersonalKgExtractionOptions<TMeeting, TCandidate>): RoutinePersonalKgExtractionResult<TCandidate>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Portable batch boundary for meeting-derived Personal KG extraction.
3
+ *
4
+ * A consumer supplies the meeting rule/extraction callback because source
5
+ * schemas, identity, redaction policy, and candidate shape are tenant-owned.
6
+ * This module owns the common collection/count contract and keeps the batch
7
+ * boundary independent from any database or model provider.
8
+ */
9
+ /**
10
+ * Collect meeting extraction results using the portable result shape.
11
+ * Extraction errors intentionally propagate to the consumer: a host decides
12
+ * whether a failed meeting should be retried, surfaced for review, or stop a
13
+ * run, and the common package must not silently convert it to success.
14
+ */
15
+ export function extractRoutinePersonalKgCandidates(options) {
16
+ if (!options || typeof options !== 'object') {
17
+ throw new Error('routine Personal KG extraction options are required');
18
+ }
19
+ if (typeof options.date !== 'string' || options.date.trim() === '') {
20
+ throw new Error('routine Personal KG extraction date is required');
21
+ }
22
+ if (typeof options.sourceSystem !== 'string' || options.sourceSystem.trim() === '') {
23
+ throw new Error('routine Personal KG extraction sourceSystem is required');
24
+ }
25
+ if (typeof options.extract !== 'function') {
26
+ throw new Error('routine Personal KG extraction callback is required');
27
+ }
28
+ const adopted = [];
29
+ const rejected = [];
30
+ const needsReview = [];
31
+ const meetings = Array.isArray(options.meetings) ? options.meetings : [];
32
+ meetings.forEach((meeting, index) => {
33
+ const result = options.extract({ meeting, date: options.date, index });
34
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
35
+ throw new Error('routine Personal KG extraction result must be a non-null object');
36
+ }
37
+ const buckets = [
38
+ ['adopted', result.adopted],
39
+ ['rejected', result.rejected],
40
+ ['needs_review', result.needs_review]
41
+ ];
42
+ for (const [name, bucket] of buckets) {
43
+ if (bucket !== undefined && !Array.isArray(bucket)) {
44
+ throw new Error(`routine Personal KG extraction result ${name} must be an array`);
45
+ }
46
+ }
47
+ adopted.push(...(result.adopted ?? []));
48
+ rejected.push(...(result.rejected ?? []));
49
+ needsReview.push(...(result.needs_review ?? []));
50
+ });
51
+ const deduped = options.dedupe ? options.dedupe(adopted) : adopted;
52
+ const finalAdopted = Array.isArray(deduped) ? [...deduped] : adopted;
53
+ return {
54
+ date: options.date,
55
+ source_system: options.sourceSystem,
56
+ counts: {
57
+ adopted: finalAdopted.length,
58
+ rejected: rejected.length,
59
+ needs_review: needsReview.length
60
+ },
61
+ adopted: finalAdopted,
62
+ rejected,
63
+ needs_review: needsReview
64
+ };
65
+ }
@@ -0,0 +1,30 @@
1
+ import type { Awaitable } from './runtime-host.js';
2
+ /**
3
+ * Consumer-owned operations used by the routine endpoints.
4
+ *
5
+ * The implementation belongs to the application that owns Graph, tenant,
6
+ * credentials, and persistence. This public contract keeps the routine
7
+ * cycle independent from those details while giving each consumer one
8
+ * explicit seam to validate at composition time.
9
+ */
10
+ export interface RoutineProductionPorts {
11
+ reconcile(input?: unknown, context?: unknown): Awaitable<unknown>;
12
+ compress(input?: unknown, context?: unknown): Awaitable<unknown>;
13
+ buildNightOutput(input?: unknown, context?: unknown): Awaitable<unknown>;
14
+ verify(input?: unknown, context?: unknown): Awaitable<unknown>;
15
+ recallGraph(input?: unknown, context?: unknown): Awaitable<unknown>;
16
+ recallPersonalKg(input?: unknown, context?: unknown): Awaitable<unknown>;
17
+ generate(input?: unknown, context?: unknown): Awaitable<unknown>;
18
+ recordUsage(input?: unknown, context?: unknown): Awaitable<unknown>;
19
+ evaluateMetrics(input?: unknown, context?: unknown): Awaitable<unknown>;
20
+ createImprovementCandidates(input?: unknown, context?: unknown): Awaitable<unknown>;
21
+ listKnowledgeReviews(input?: unknown, context?: unknown): Awaitable<unknown>;
22
+ }
23
+ /**
24
+ * Validate a consumer's routine endpoint port set without resolving any
25
+ * consumer-specific dependency. The same instance is returned so class
26
+ * methods retain their receiver and injected state.
27
+ */
28
+ export declare function assertRoutineProductionPorts(value: unknown): RoutineProductionPorts;
29
+ /** Alias used by composition roots that prefer a constructor-shaped API. */
30
+ export declare function createRoutineProductionPorts(value: unknown): RoutineProductionPorts;
@@ -0,0 +1,34 @@
1
+ const REQUIRED_ROUTINE_PRODUCTION_PORTS = [
2
+ 'reconcile',
3
+ 'compress',
4
+ 'buildNightOutput',
5
+ 'verify',
6
+ 'recallGraph',
7
+ 'recallPersonalKg',
8
+ 'generate',
9
+ 'recordUsage',
10
+ 'evaluateMetrics',
11
+ 'createImprovementCandidates',
12
+ 'listKnowledgeReviews'
13
+ ];
14
+ /**
15
+ * Validate a consumer's routine endpoint port set without resolving any
16
+ * consumer-specific dependency. The same instance is returned so class
17
+ * methods retain their receiver and injected state.
18
+ */
19
+ export function assertRoutineProductionPorts(value) {
20
+ if (!value || typeof value !== 'object') {
21
+ throw new Error('routine production ports are required');
22
+ }
23
+ const ports = value;
24
+ for (const name of REQUIRED_ROUTINE_PRODUCTION_PORTS) {
25
+ if (typeof ports[name] !== 'function') {
26
+ throw new Error(`routine production port ${name} must be a function`);
27
+ }
28
+ }
29
+ return value;
30
+ }
31
+ /** Alias used by composition roots that prefer a constructor-shaped API. */
32
+ export function createRoutineProductionPorts(value) {
33
+ return assertRoutineProductionPorts(value);
34
+ }
@@ -1,4 +1,5 @@
1
1
  import { type NormalizedRunReceipt } from './run-receipt.js';
2
+ import { type RunReceiptPersistenceInput, type RunReceiptPort } from './run-receipt-port.js';
2
3
  import { type Awaitable, type RuntimeHostContext, type RuntimeHostPorts } from './runtime-host.js';
3
4
  export type RoutineScheduleKind = 'daily' | 'weekly';
4
5
  export interface RoutineExpectationSchedule {
@@ -66,18 +67,8 @@ export interface RoutineRunnerPort {
66
67
  start(input: RoutineRunnerStartInput): Awaitable<RoutineRunnerResult>;
67
68
  retry?(input: RoutineRunnerRetryInput): Awaitable<RoutineRunnerResult>;
68
69
  }
69
- export interface RoutineReceiptPersistenceInput {
70
- payload: unknown;
71
- normalized: NormalizedRunReceipt;
72
- context: RuntimeHostContext;
73
- }
74
- export interface RoutineReceiptPort {
75
- persist(input: RoutineReceiptPersistenceInput): Awaitable<unknown>;
76
- query(input: {
77
- request: RoutineRunRequest;
78
- context: RuntimeHostContext;
79
- }): Awaitable<unknown>;
80
- }
70
+ export type RoutineReceiptPersistenceInput = RunReceiptPersistenceInput<RuntimeHostContext>;
71
+ export type RoutineReceiptPort = RunReceiptPort<RoutineRunRequest, RuntimeHostContext>;
81
72
  export interface RoutineRuntimePorts extends RuntimeHostPorts {
82
73
  runner: RoutineRunnerPort;
83
74
  receipt: RoutineReceiptPort;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { normalizeRunReceipt } from './run-receipt.js';
4
+ import { assertRunReceiptPort } from './run-receipt-port.js';
4
5
  import { resolveRuntimeHostContext } from './runtime-host.js';
5
6
  function fail(index, field, message) {
6
7
  throw new Error(`expectations[${index}].${field} ${message}`);
@@ -138,11 +139,7 @@ function assertRoutinePorts(ports) {
138
139
  if (!ports.runner || typeof ports.runner !== 'object' || typeof ports.runner.start !== 'function') {
139
140
  throw new Error('routine runner port with start is required');
140
141
  }
141
- if (!ports.receipt || typeof ports.receipt !== 'object'
142
- || typeof ports.receipt.persist !== 'function'
143
- || typeof ports.receipt.query !== 'function') {
144
- throw new Error('routine receipt port with persist and query is required');
145
- }
142
+ assertRunReceiptPort(ports.receipt, 'routine receipt');
146
143
  }
147
144
  export class RoutineRuntimeService {
148
145
  ports;
@@ -1,6 +1,8 @@
1
1
  export * from './routine-runtime.js';
2
2
  export * from './routine-cycle.js';
3
3
  export * from './routine-liveness.js';
4
+ export * from './routine-production-ports.js';
5
+ export * from './routine-personal-kg.js';
4
6
  export type RoutineKind = 'ohayo' | 'oyasumi' | 'retro';
5
7
  export type RoutineTarget = 'codex' | 'claude';
6
8
  export interface ScheduleOptions {
package/dist/routines.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './routine-runtime.js';
2
2
  export * from './routine-cycle.js';
3
3
  export * from './routine-liveness.js';
4
+ export * from './routine-production-ports.js';
5
+ export * from './routine-personal-kg.js';
4
6
  const ALL_KINDS = ['ohayo', 'oyasumi', 'retro'];
5
7
  const DOW_TO_RRULE = {
6
8
  MON: 'MO', TUE: 'TU', WED: 'WE', THU: 'TH', FRI: 'FR', SAT: 'SA', SUN: 'SU'
@@ -0,0 +1,27 @@
1
+ import type { NormalizedRunReceipt } from './run-receipt.js';
2
+ /**
3
+ * Shared host boundary for durable run-receipt ingest and query.
4
+ *
5
+ * The OSS package only defines the contract and validates the port shape.
6
+ * Storage, authentication, organization scoping, HTTP, and readback remain
7
+ * owned by the consumer that supplies this port.
8
+ */
9
+ export interface RunReceiptPersistenceInput<Context = unknown> {
10
+ payload: unknown;
11
+ normalized: NormalizedRunReceipt;
12
+ context: Context;
13
+ }
14
+ export interface RunReceiptQueryInput<Request = unknown, Context = unknown> {
15
+ request: Request;
16
+ context: Context;
17
+ }
18
+ export interface RunReceiptPort<Request = unknown, Context = unknown, Persisted = unknown, Queried = unknown> {
19
+ persist(input: RunReceiptPersistenceInput<Context>): Persisted | PromiseLike<Persisted>;
20
+ query(input: RunReceiptQueryInput<Request, Context>): Queried | PromiseLike<Queried>;
21
+ }
22
+ /**
23
+ * Validate the common receipt boundary without assuming a storage provider.
24
+ * A label keeps the existing routine/automation diagnostics specific to their
25
+ * host-facing service while sharing the actual shape check.
26
+ */
27
+ export declare function assertRunReceiptPort(port: unknown, label?: string): asserts port is RunReceiptPort;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validate the common receipt boundary without assuming a storage provider.
3
+ * A label keeps the existing routine/automation diagnostics specific to their
4
+ * host-facing service while sharing the actual shape check.
5
+ */
6
+ export function assertRunReceiptPort(port, label = 'run receipt') {
7
+ if (!port || typeof port !== 'object'
8
+ || typeof port.persist !== 'function'
9
+ || typeof port.query !== 'function') {
10
+ throw new Error(`${label} port with persist and query is required`);
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.44.1",
3
+ "version": "0.45.0",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -163,6 +163,10 @@
163
163
  "types": "./dist/run-receipt.d.ts",
164
164
  "import": "./dist/run-receipt.js"
165
165
  },
166
+ "./run-receipt-port": {
167
+ "types": "./dist/run-receipt-port.d.ts",
168
+ "import": "./dist/run-receipt-port.js"
169
+ },
166
170
  "./routines": {
167
171
  "types": "./dist/routines.d.ts",
168
172
  "import": "./dist/routines.js"
@@ -191,6 +195,10 @@
191
195
  "types": "./dist/meeting-source-connection.d.ts",
192
196
  "import": "./dist/meeting-source-connection.js"
193
197
  },
198
+ "./meeting-source-mcp-adapters": {
199
+ "types": "./dist/meeting-source-mcp-adapters.d.ts",
200
+ "import": "./dist/meeting-source-mcp-adapters.js"
201
+ },
194
202
  "./meeting-source-reader": {
195
203
  "types": "./dist/meeting-source-reader.d.ts",
196
204
  "import": "./dist/meeting-source-reader.js"
@@ -399,6 +407,10 @@
399
407
  "types": "./dist/meeting-source-sync.d.ts",
400
408
  "import": "./dist/meeting-source-sync.js"
401
409
  },
410
+ "./meeting-source-sync-common-adapter": {
411
+ "types": "./dist/meeting-source-sync-common-adapter.d.ts",
412
+ "import": "./dist/meeting-source-sync-common-adapter.js"
413
+ },
402
414
  "./meeting-minutes-http": {
403
415
  "types": "./dist/meeting-minutes-http.d.ts",
404
416
  "import": "./dist/meeting-minutes-http.js"
@@ -608,5 +620,5 @@
608
620
  "url": "https://github.com/Unson-LLC/brainbase/issues"
609
621
  },
610
622
  "homepage": "https://github.com/Unson-LLC/brainbase#readme",
611
- "gitHead": "3ce9dcd2ced2f30a9665cfd9b64ec6775c6a18c3"
623
+ "gitHead": "10ff1144069d177b6a1c3857a69182513ff55372"
612
624
  }
@@ -517,7 +517,39 @@
517
517
  .bb-ws-error-stack { position: absolute; z-index: 55; top: 0; right: 0; width: min(300px,100%); max-height: none; padding-bottom: 64px; pointer-events: none; box-sizing: border-box; overflow: visible; }
518
518
  .bb-ws-error-list { display: grid; gap: 6px; }
519
519
  .bb-ws-error-count { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
520
- .bb-ws-error-card { display: flex; align-items: start; gap: 8px; box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--bb-color-notice-border); border-left: 3px solid var(--bb-color-notice-danger); border-radius: 4px; background: var(--bb-color-notice-background); backdrop-filter: blur(5px); color: var(--bb-color-notice-text); font-size: 12px; line-height: 1.5; pointer-events: auto; }
520
+ .bb-ws-notification-card {
521
+ --bb-notification-accent: var(--bb-color-notice-control-border);
522
+ --bb-notification-symbol-border: var(--bb-color-notice-control-border);
523
+ --bb-notification-symbol-text: var(--bb-color-notice-text);
524
+ box-sizing: border-box;
525
+ padding: 8px 10px;
526
+ border: 1px solid var(--bb-color-notice-border);
527
+ border-left: 3px solid var(--bb-notification-accent);
528
+ border-radius: 4px;
529
+ background: var(--bb-color-notice-background);
530
+ backdrop-filter: blur(5px);
531
+ color: var(--bb-color-notice-text);
532
+ font-size: 12px;
533
+ line-height: 1.5;
534
+ }
535
+ .bb-ws-notification-card.is-warning {
536
+ --bb-notification-accent: var(--bb-color-notice-warning);
537
+ --bb-notification-symbol-border: var(--bb-color-notice-warning-symbol-border);
538
+ --bb-notification-symbol-text: var(--bb-color-notice-warning-text);
539
+ }
540
+ .bb-ws-notification-card.is-danger {
541
+ --bb-notification-accent: var(--bb-color-notice-danger);
542
+ --bb-notification-symbol-border: var(--bb-color-notice-danger-symbol-border);
543
+ --bb-notification-symbol-text: var(--bb-color-notice-danger-text);
544
+ }
545
+ .bb-ws-notification-symbol {
546
+ display: grid; place-items: center; flex: none;
547
+ width: 18px; height: 18px; border-radius: 3px;
548
+ border: 1px solid var(--bb-notification-symbol-border);
549
+ color: var(--bb-notification-symbol-text);
550
+ font-size: 11px; font-weight: 700;
551
+ }
552
+ .bb-ws-error-card { display: flex; align-items: start; gap: 8px; width: 100%; pointer-events: auto; }
521
553
  .bb-ws-error-card.is-warning { border-left-color: var(--bb-color-notice-warning); }
522
554
  .bb-ws-error-card > details { flex: 1; min-width: 0; }
523
555
  .bb-ws-error-card summary { display: flex; align-items: center; gap: 6px; list-style: none; cursor: pointer; }
@@ -371,9 +371,20 @@ export function workspaceFilterPopover(doc, { content, open = false, onToggle, t
371
371
  } };
372
372
  }
373
373
 
374
+ /** Reusable notification surface. Hosts own content, placement and lifecycle.
375
+ * Tones use shared semantic tokens; unknown values stay informational.
376
+ */
377
+ export function workspaceNotificationCard(doc, { tone = 'info', content, tag = 'section', className = '', role } = {}) {
378
+ const severity = tone === 'attention' ? 'warning' : ['info', 'warning', 'danger'].includes(tone) ? tone : 'info';
379
+ const card = el(doc, tag, { className: `bb-ws-notification-card is-${severity} ${className}`.trim(), attrs: { role } });
380
+ appendContent(card, doc, content);
381
+ return card;
382
+ }
383
+
374
384
  /** Compact notification with a fixed-center, purely visual spinner. */
375
385
  export function workspaceLoadingCard(doc, { title = 'ロード中' } = {}) {
376
- const card = el(doc, 'section', { className: 'bb-ws-error-card bb-ws-loading-card', attrs: { role: 'status', 'aria-live': 'polite' } });
386
+ const card = workspaceNotificationCard(doc, { className: 'bb-ws-error-card bb-ws-loading-card', role: 'status' });
387
+ card.setAttribute('aria-live', 'polite');
377
388
  card.append(el(doc, 'span', { className: 'bb-ws-loading-spinner', attrs: { 'aria-hidden': 'true' } }), el(doc, 'span', { text: title }));
378
389
  return card;
379
390
  }
@@ -404,7 +415,7 @@ export function workspaceDismissNotification(card, onDismiss) {
404
415
 
405
416
  /** Persistent, initially closed notification. The caller owns its lifecycle. */
406
417
  export function workspaceErrorCard(doc, { title, message, tone = 'danger', onRetry } = {}) {
407
- const card = el(doc, 'section', { className: `bb-ws-error-card is-${tone}`, attrs: { role: 'listitem' } });
418
+ const card = workspaceNotificationCard(doc, { tone, className: 'bb-ws-error-card', role: 'listitem' });
408
419
  const details = el(doc, 'details');
409
420
  const summary = el(doc, 'summary', { attrs: { 'aria-label': `${title}の通知` } });
410
421
  summary.append(el(doc, 'span', { className: 'bb-ws-error-symbol', text: '!', attrs: { 'aria-hidden': 'true' } }), el(doc, 'span', { text: title }));
@@ -103,31 +103,7 @@
103
103
  .bb-world-canvas-ui .bb-world-canvas-city:disabled { cursor: default; opacity: .65; }
104
104
  .bb-world-canvas-ui .bb-world-canvas-sky { display: grid; place-items: center; padding: 0; }
105
105
  .bb-world-canvas-ui .bb-world-canvas-sky-icon { font-size: 22px; line-height: 1; }
106
- .bb-world-canvas-ui .bb-world-canvas-sky-menu {
107
- position: absolute;
108
- top: calc(var(--bb-world-toolbar-top) + 48px);
109
- right: var(--bb-world-right-inset);
110
- min-width: 150px;
111
- padding: 5px;
112
- border: 1px solid rgb(13 25 46 / 18%);
113
- border-radius: 9px;
114
- background: #fff;
115
- box-shadow: 0 8px 25px #0d192e22;
116
- pointer-events: auto;
117
- }
118
- .bb-world-canvas-ui .bb-world-canvas-sky-option {
119
- display: block;
120
- width: 100%;
121
- padding: 8px 10px;
122
- border: 0;
123
- border-radius: 5px;
124
- background: transparent;
125
- color: inherit;
126
- font-size: 12px;
127
- text-align: left;
128
- }
129
- .bb-world-canvas-ui .bb-world-canvas-sky-option:hover,
130
- .bb-world-canvas-ui .bb-world-canvas-sky-option[aria-checked="true"] { background: #e5f8e1; }
106
+ .bb-world-canvas-ui .bb-world-canvas-sky-icon svg { display: block; width: 23px; height: 23px; }
131
107
 
132
108
  .bb-world-canvas-ui .bb-world-canvas-quests {
133
109
  position: absolute;
@@ -147,28 +123,7 @@
147
123
  min-width: 0;
148
124
  margin: 0;
149
125
  padding: 7px 10px 7px 8px;
150
- border: 1px solid rgb(255 255 255 / 18%);
151
- border-left: 3px solid #9be891;
152
- border-radius: 4px;
153
- background: linear-gradient(110deg, rgb(13 25 46 / 88%), rgb(13 25 46 / 72%));
154
- color: #fff;
155
- font-size: 12px;
156
- line-height: 18px;
157
126
  box-shadow: 0 3px 12px #0d192e18;
158
- backdrop-filter: blur(5px);
159
- }
160
- .bb-world-canvas-ui .bb-world-canvas-quest-symbol {
161
- display: grid;
162
- place-items: center;
163
- flex: none;
164
- width: 18px;
165
- height: 18px;
166
- border: 1px solid #9be89170;
167
- border-radius: 3px;
168
- background: #9be89112;
169
- color: #b5f2ab;
170
- font-size: 11px;
171
- font-weight: 700;
172
127
  }
173
128
  .bb-world-canvas-ui .bb-world-canvas-quest-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
174
129
  /* A quest with a way down (候補の確認, 区画の入口) is pressable as a whole row even though the quest list lets
@@ -181,7 +136,7 @@
181
136
  }
182
137
  .bb-world-canvas-ui .bb-world-canvas-quest.has-way:hover,
183
138
  .bb-world-canvas-ui .bb-world-canvas-quest.has-way:focus-within {
184
- border-color: rgb(155 232 145 / 55%);
139
+ border-color: var(--bb-notification-accent);
185
140
  background: linear-gradient(110deg, rgb(20 44 62 / 94%), rgb(20 44 62 / 84%));
186
141
  }
187
142
  .bb-world-canvas-ui .bb-world-canvas-quest a,
@@ -191,7 +146,7 @@
191
146
  padding: 0;
192
147
  border: 0;
193
148
  background: none;
194
- color: #b5f2ab;
149
+ color: var(--bb-notification-symbol-text);
195
150
  font: inherit;
196
151
  text-decoration: underline;
197
152
  text-underline-offset: 2px;
@@ -205,7 +160,7 @@
205
160
  .bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible,
206
161
  .bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible { outline: none; }
207
162
  .bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible::before,
208
- .bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible::before { outline: 2px solid #9be891; outline-offset: 1px; }
163
+ .bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible::before { outline: 2px solid var(--bb-notification-symbol-text); outline-offset: 1px; }
209
164
  /* A quest with nowhere to go: flat and dashed, so it reads as a sign, not a button, with its reason. */
210
165
  .bb-world-canvas-ui .bb-world-canvas-quest.is-static {
211
166
  border-style: dashed;
@@ -5,11 +5,13 @@
5
5
  * unanchored:true omits the pointer for a real record without a drawable lot.
6
6
  */
7
7
 
8
+ import { workspaceNotificationCard } from '../../workspace-kit.js';
9
+
8
10
  const SKY_MODES = Object.freeze([
9
- { value: 'auto', label: '時刻に合わせる', icon: '◷' },
10
- { value: 'day', label: '昼', icon: '☀' },
11
- { value: 'dusk', label: '夕方', icon: '◒' },
12
- { value: 'night', label: '夜', icon: '☾' },
11
+ { value: 'auto', label: '自動(現在時刻)', icon: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5l3 1.8"/>' },
12
+ { value: 'day', label: '昼', icon: '<circle cx="12" cy="12" r="5" fill="currentColor" fill-opacity=".12"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/><path d="M10 12h.01M14 12h.01m-4 2.5q2 1.6 4 0"/>' },
13
+ { value: 'dusk', label: '夕方', icon: '<path d="M5.5 13a6.5 6.5 0 0 1 13 0" fill="currentColor" fill-opacity=".12"/><path d="M12 2v2M3.5 5.5 5 7m15.5-1.5L19 7M2 13h20M3 17q3-2 6 0t6 0 6 0M6 21q3-2 6 0t6 0"/>' },
14
+ { value: 'night', label: '夜', icon: '<path d="M19.5 15.5A8.5 8.5 0 0 1 8.5 4.5a8.5 8.5 0 1 0 11 11Z" fill="currentColor" fill-opacity=".12"/><path d="m18 3 .7 2.3L21 6l-2.3.7L18 9l-.7-2.3L15 6l2.3-.7ZM22 11h.01"/>' },
13
15
  ]);
14
16
  const CLOSE_DELAY = 220;
15
17
  let instanceId = 0;
@@ -57,7 +59,6 @@ export function createWorldCanvasUI({
57
59
  let taskOpen = false;
58
60
  let taskAnchor = null;
59
61
  let helpOpen = false;
60
- let skyOpen = false;
61
62
  let skyMode = 'auto';
62
63
  let businesses = new Set();
63
64
  let overlayOrder = [];
@@ -138,10 +139,7 @@ export function createWorldCanvasUI({
138
139
  const reset = button('bb-world-canvas-control bb-world-canvas-reset', '世界全体に戻る', '全体に戻る');
139
140
  const city = element('select', 'bb-world-canvas-control bb-world-canvas-city', null, { 'aria-label': '都市を選ぶ' });
140
141
  const sky = button('bb-world-canvas-control bb-world-canvas-sky', '空:時刻に合わせる', '');
141
- sky.setAttribute('aria-haspopup', 'menu');
142
- sky.setAttribute('aria-expanded', 'false');
143
- sky.setAttribute('aria-controls', `${id}-sky`);
144
- const skyIcon = element('span', 'bb-world-canvas-sky-icon', SKY_MODES[0].icon, { 'aria-hidden': 'true' });
142
+ const skyIcon = element('span', 'bb-world-canvas-sky-icon', '', { 'aria-hidden': 'true' });
145
143
  sky.append(skyIcon);
146
144
  toolbar.append(reset, city, sky);
147
145
  const contextRoot = element('div', 'bb-world-canvas-context', null, {
@@ -153,22 +151,7 @@ export function createWorldCanvasUI({
153
151
  const contextNextAction = element('p', 'bb-world-canvas-context-next-action', worldContext.unavailableCity, {
154
152
  'data-world-context': 'next-action',
155
153
  });
156
- contextRoot.append(contextPurpose, contextNextAction);
157
- const skyMenu = element('div', 'bb-world-canvas-sky-menu', null, { id: `${id}-sky`, role: 'menu', 'aria-label': '空の時間帯' });
158
- skyMenu.hidden = true;
159
- const skyOptions = SKY_MODES.map((mode) => {
160
- const option = button('bb-world-canvas-sky-option', mode.label);
161
- option.setAttribute('role', 'menuitemradio');
162
- option.setAttribute('aria-checked', String(mode.value === skyMode));
163
- option.setAttribute('tabindex', '-1');
164
- listen(option, 'click', () => {
165
- setSky(mode.value);
166
- closeSky(true);
167
- onSky?.(mode.value);
168
- });
169
- skyMenu.append(option);
170
- return option;
171
- });
154
+ contextRoot.append(contextNextAction);
172
155
  const quests = element('div', 'bb-world-canvas-quests', null, { 'aria-live': 'polite', 'aria-label': '確認が必要なこと' });
173
156
 
174
157
  const edge = button('bb-world-canvas-edge', '都市の詳細を開く', '');
@@ -203,7 +186,7 @@ export function createWorldCanvasUI({
203
186
  const notes = element('div', 'bb-world-canvas-notes');
204
187
  helpBody.append(helpContent, notes);
205
188
  help.append(helpTop, helpBody);
206
- root.append(toolbar, contextRoot, skyMenu, quests, edge, rail, task, helpButton, help);
189
+ root.append(toolbar, contextRoot, quests, edge, rail, task, helpButton, help);
207
190
  stage.append(root);
208
191
 
209
192
  function setCities(items = [], selectedCode = null) {
@@ -222,6 +205,7 @@ export function createWorldCanvasUI({
222
205
  }
223
206
  city.replaceChildren(...options);
224
207
  city.disabled = businesses.size === 0;
208
+ contextRoot.hidden = businesses.size > 0;
225
209
  contextNextAction.textContent = businesses.size === 0
226
210
  ? worldContext.unavailableCity
227
211
  : worldContext.knownCityAction;
@@ -236,27 +220,12 @@ export function createWorldCanvasUI({
236
220
  const match = SKY_MODES.find((entry) => entry.value === mode);
237
221
  if (!match) return;
238
222
  skyMode = mode;
239
- skyIcon.textContent = match.icon;
240
- sky.setAttribute('aria-label', `空:${match.label}`);
241
- sky.setAttribute('title', `空:${match.label}`);
242
- skyOptions.forEach((option, index) => option.setAttribute('aria-checked', String(SKY_MODES[index].value === mode)));
243
- }
244
- function openSky() {
245
- if (disposed) return;
246
- skyOpen = true;
247
- skyMenu.hidden = false;
248
- sky.setAttribute('aria-expanded', 'true');
249
- activate('sky', skyMenu);
250
- focus(skyOptions[SKY_MODES.findIndex((entry) => entry.value === skyMode)]);
251
- }
252
- function closeSky(restoreFocus = false) {
253
- if (!skyOpen) return;
254
- const shouldRestore = restoreFocus && focusedWithin(skyMenu);
255
- skyOpen = false;
256
- skyMenu.hidden = true;
257
- sky.setAttribute('aria-expanded', 'false');
258
- forget('sky');
259
- if (shouldRestore) focus(sky);
223
+ // Artwork is fixed code, never host data. Native button activation covers Enter/Space.
224
+ skyIcon.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${match.icon}</svg>`;
225
+ const next = SKY_MODES[(SKY_MODES.indexOf(match) + 1) % SKY_MODES.length];
226
+ const label = `空:${match.label}。次は${next.label}`;
227
+ sky.setAttribute('aria-label', label);
228
+ sky.setAttribute('title', label);
260
229
  }
261
230
  function cancelRailTimer() {
262
231
  if (railTimer != null) clearTimeout(railTimer);
@@ -397,10 +366,10 @@ export function createWorldCanvasUI({
397
366
  // A quest is a way down (a link already checked by the caller, or an action in this page) and then the
398
367
  // whole row is pressable; without one it is drawn flat, so it never looks pressable (story-world-clickable-honesty-v1).
399
368
  const way = link?.href ? 'link' : typeof action?.onClick === 'function' ? 'action' : null;
400
- const quest = element('p', `bb-world-canvas-quest ${way ? 'has-way' : 'is-static'}`);
401
- const icon = tone === 'attention' || tone === 'warning' ? '!' : 'i';
369
+ const quest = workspaceNotificationCard(doc, { tag: 'p', tone, className: `bb-world-canvas-quest ${way ? 'has-way' : 'is-static'}` });
370
+ const icon = ['attention', 'warning', 'danger'].includes(tone) ? '!' : 'i';
402
371
  quest.append(
403
- element('span', 'bb-world-canvas-quest-symbol', icon, { 'aria-hidden': 'true' }),
372
+ element('span', 'bb-world-canvas-quest-symbol bb-ws-notification-symbol', icon, { 'aria-hidden': 'true' }),
404
373
  element('span', 'bb-world-canvas-quest-text', summary.trim()),
405
374
  );
406
375
  // The accessible name contains the unabridged summary even when visual text
@@ -428,38 +397,23 @@ export function createWorldCanvasUI({
428
397
  // A selected task outside the camera is not a visible overlay to dismiss.
429
398
  const name = [...overlayOrder].reverse().find((entry) => entry !== 'task' || !task.hidden);
430
399
  if (!name) return false;
431
- if (name === 'sky') closeSky(true);
432
- else if (name === 'help') closeHelp();
400
+ if (name === 'help') closeHelp();
433
401
  else if (name === 'rail') closeRail();
434
402
  else if (name === 'task') dismissTask();
435
403
  return true;
436
404
  }
437
405
 
438
406
  listen(reset, 'click', () => {
439
- closeSky();
440
407
  closeHelp();
441
408
  closeRail();
442
409
  closeTask();
443
410
  onReset?.();
444
411
  });
445
412
  listen(city, 'change', () => { if (businesses.has(city.value)) onCity?.(city.value); });
446
- listen(sky, 'click', () => { if (skyOpen) closeSky(); else openSky(); });
447
- listen(sky, 'keydown', (event) => {
448
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); openSky(); }
449
- });
450
- listen(skyMenu, 'keydown', (event) => {
451
- let index = skyOptions.indexOf(doc.activeElement);
452
- if (event.key === 'ArrowDown') index = (index + 1) % skyOptions.length;
453
- else if (event.key === 'ArrowUp') index = (index - 1 + skyOptions.length) % skyOptions.length;
454
- else if (event.key === 'Home') index = 0;
455
- else if (event.key === 'End') index = skyOptions.length - 1;
456
- else if (event.key === 'Tab') { closeSky(true); return; }
457
- else return;
458
- event.preventDefault();
459
- focus(skyOptions[index]);
460
- });
461
- listen(skyMenu, 'focusout', (event) => {
462
- if (!skyMenu.contains(event.relatedTarget) && event.relatedTarget !== sky) closeSky();
413
+ listen(sky, 'click', () => {
414
+ const next = SKY_MODES[(SKY_MODES.findIndex(entry => entry.value === skyMode) + 1) % SKY_MODES.length];
415
+ setSky(next.value);
416
+ onSky?.(next.value);
463
417
  });
464
418
  for (const target of [edge, rail]) {
465
419
  listen(target, 'pointerenter', railEnter);
@@ -493,10 +447,11 @@ export function createWorldCanvasUI({
493
447
  observer?.observe(stage);
494
448
  observer?.observe(taskBody);
495
449
  setCities([]);
450
+ setSky(skyMode);
496
451
 
497
452
  return {
498
453
  setCities, setCity, setSky, showRail, openRail, showTask, closeTask, setTaskAnchor,
499
- setHelp(nodes) { if (!disposed) helpContent.replaceChildren(...nodeList(nodes)); },
454
+ setHelp(nodes) { if (!disposed) helpContent.replaceChildren(contextPurpose, element('p', '', worldContext.knownCityAction), ...nodeList(nodes)); },
500
455
  addNote, clearNotes, dismissOverlay,
501
456
  dispose() {
502
457
  if (disposed) return;