@unson/brainbase-mcp 0.11.0 → 0.12.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.
@@ -21,6 +21,7 @@ import { createObjectiveEditorController } from './objective-editor.js';
21
21
  import { createObjectiveEditorHttpPort } from './objective-editor-http-port.js';
22
22
  import { createValueProofReviewUI } from './value-proof-review.js';
23
23
  import { createWorldModelView } from './world-model-view.js';
24
+ import { createGraphClient } from './graph-view-shared.js';
24
25
 
25
26
  export const LOCAL_WEB_SHELL_CONTRACT_VERSION = 'brainbase.local-web-shell.v1';
26
27
 
@@ -75,6 +76,60 @@ function isRecord(value) {
75
76
  return value !== null && typeof value === 'object' && !Array.isArray(value);
76
77
  }
77
78
 
79
+ /**
80
+ * Parses the local shell's hash target without treating query data as a
81
+ * screen id. Only the Graph screen accepts an entity target; all other
82
+ * screens deliberately discard query parameters.
83
+ */
84
+ export function parseLocalWebTarget(target) {
85
+ const raw = typeof target === 'string' ? target.replace(/^#/, '') : '';
86
+ const separator = raw.indexOf('?');
87
+ const screenId = separator < 0 ? raw : raw.slice(0, separator);
88
+ if (!screenId) return { screenId: null, entityId: null };
89
+ const query = separator < 0 ? '' : raw.slice(separator + 1);
90
+ const params = new URLSearchParams(query);
91
+ return {
92
+ screenId,
93
+ entityId: screenId === 'graph' ? params.get('entity_id')?.trim() || null : null,
94
+ };
95
+ }
96
+
97
+ function graphSourceReader(context) {
98
+ const client = createGraphClient({ fetcher: context.fetcher, basePath: '/api/graph', token: context.token });
99
+ return async (source) => {
100
+ if (!isRecord(source) || source.kind !== 'local_graph'
101
+ || typeof source.entity_id !== 'string' || !source.entity_id.trim()
102
+ || typeof source.entity_type !== 'string' || !source.entity_type.trim()) {
103
+ return { state: 'unavailable', reason: 'source_contract_invalid' };
104
+ }
105
+ const result = await client.read(`/entities/${encodeURIComponent(source.entity_id)}`);
106
+ if (result.state === 'error') {
107
+ if (result.status === 404 || result.code === 'entity_not_found') return { state: 'not_found' };
108
+ if (result.status === 403 || result.code === 'authorization_denied' || result.code === 'scope_violation') return { state: 'forbidden' };
109
+ if (result.code === 'entity_id_ambiguous') return { state: 'ambiguous' };
110
+ return { state: 'unavailable', reason: result.code };
111
+ }
112
+ if (result.state !== 'ok' || !isRecord(result.payload?.entity)) return { state: 'unavailable', reason: 'source_response_invalid' };
113
+ const entity = result.payload.entity;
114
+ if (entity.id !== source.entity_id || entity.type !== source.entity_type) {
115
+ return { state: 'unavailable', reason: 'source_target_mismatch' };
116
+ }
117
+ if (source.digest && entity.digest !== source.digest) {
118
+ return { state: 'unavailable', reason: 'source_digest_mismatch' };
119
+ }
120
+ // The local Graph response currently exposes a content digest, not a
121
+ // revision number. A requested version is therefore linkable only when
122
+ // the provider returns the same explicit version field.
123
+ if (source.version) {
124
+ const currentVersion = entity.version ?? entity.revision ?? null;
125
+ if (!currentVersion || currentVersion !== source.version) {
126
+ return { state: 'unavailable', reason: 'source_version_mismatch' };
127
+ }
128
+ }
129
+ return { state: 'available' };
130
+ };
131
+ }
132
+
78
133
  function makePage(container, context) {
79
134
  const page = makeElement(context.document, 'div', { className: 'bb-shell-page' });
80
135
  container.append(page);
@@ -89,6 +144,7 @@ function mountToday(container, context) {
89
144
  document: context.document,
90
145
  fetcher: context.fetcher,
91
146
  token: context.token,
147
+ sourceReader: graphSourceReader(context),
92
148
  });
93
149
  }
94
150
 
@@ -129,7 +185,13 @@ function mountObjectives(container, context) {
129
185
  constraintsEditable: false,
130
186
  storyLinks: false,
131
187
  });
132
- const worldModel = createWorldModelView({ root: worldRoot, rail: context.rail, document: doc, fetcher: context.fetcher });
188
+ const worldModel = createWorldModelView({
189
+ root: worldRoot,
190
+ rail: context.rail,
191
+ document: doc,
192
+ fetcher: context.fetcher,
193
+ token: context.token,
194
+ });
133
195
  return { editor, worldModel };
134
196
  }
135
197
 
@@ -144,6 +206,7 @@ function mountGraphScreen(createView) {
144
206
  document: context.document,
145
207
  fetcher: context.fetcher,
146
208
  token: context.token,
209
+ initialEntityId: context.initialEntityId,
147
210
  });
148
211
  };
149
212
  }
@@ -248,6 +311,7 @@ export function createLocalWebShell({
248
311
  const mounted = new Map();
249
312
  const slots = new Map();
250
313
  const links = new Map();
314
+ const pendingEntityIds = new Map();
251
315
  let active = null;
252
316
 
253
317
  const shell = makeElement(doc, 'div', { className: 'bb-shell', attrs: { 'data-contract-version': LOCAL_WEB_SHELL_CONTRACT_VERSION } });
@@ -311,8 +375,14 @@ export function createLocalWebShell({
311
375
  for (const [screenId, railSlot] of rails) railSlot.hidden = !(showRail && screenId === active);
312
376
  }
313
377
 
314
- function ensureMounted(screen) {
315
- if (mounted.has(screen.id)) return;
378
+ function ensureMounted(screen, entityId = pendingEntityIds.get(screen.id) ?? null) {
379
+ if (mounted.has(screen.id)) {
380
+ if (entityId && screen.id === 'graph') {
381
+ const view = mounted.get(screen.id);
382
+ if (typeof view?.openEntity === 'function') void view.openEntity(entityId);
383
+ }
384
+ return;
385
+ }
316
386
  const slot = slots.get(screen.id);
317
387
  if (screen.usesGraph && !(status.phase === 'ready' && status.data.graph.status === 'ready')) {
318
388
  renderGraphGate(doc, slot, status, controller.refreshStatus, screen.label);
@@ -322,7 +392,7 @@ export function createLocalWebShell({
322
392
  const rail = rails.get(screen.id) ?? null;
323
393
  rail?.replaceChildren();
324
394
  const page = Object.freeze({ crumbs: Object.freeze(['あなたのBrainbase', screen.label]), source: screen.source ?? null });
325
- mounted.set(screen.id, screen.mount(slot, Object.freeze({ ...context, rail, page })) ?? true);
395
+ mounted.set(screen.id, screen.mount(slot, Object.freeze({ ...context, rail, page, initialEntityId: entityId })) ?? true);
326
396
  updateRail();
327
397
  }
328
398
 
@@ -330,15 +400,17 @@ export function createLocalWebShell({
330
400
  get state() {
331
401
  return { active, status, mounted: [...mounted.keys()] };
332
402
  },
333
- show(id) {
334
- const screen = screens.find((candidate) => candidate.id === id) ?? screens[0];
403
+ show(target) {
404
+ const parsed = parseLocalWebTarget(target);
405
+ const screen = screens.find((candidate) => candidate.id === parsed.screenId) ?? screens[0];
406
+ pendingEntityIds.set(screen.id, screen.id === 'graph' ? parsed.entityId : null);
335
407
  active = screen.id;
336
408
  for (const [screenId, slot] of slots) slot.hidden = screenId !== screen.id;
337
409
  for (const [screenId, link] of links) {
338
410
  if (screenId === screen.id) link.setAttribute('aria-current', 'page');
339
411
  else link.removeAttribute('aria-current');
340
412
  }
341
- ensureMounted(screen);
413
+ ensureMounted(screen, pendingEntityIds.get(screen.id));
342
414
  updateRail();
343
415
  return controller;
344
416
  },
@@ -362,7 +434,7 @@ export function createLocalWebShell({
362
434
  renderSource(doc, source, status, controller.refreshStatus);
363
435
  // Re-evaluate Graph-backed screens that are waiting behind the gate.
364
436
  for (const screen of screens) {
365
- if (screen.usesGraph && !mounted.has(screen.id) && (screen.id === active)) ensureMounted(screen);
437
+ if (screen.usesGraph && !mounted.has(screen.id) && (screen.id === active)) ensureMounted(screen, pendingEntityIds.get(screen.id));
366
438
  }
367
439
  return status;
368
440
  },
@@ -667,7 +667,7 @@ function renderDenseOverview(doc, detail, context, entities, actions = {}) {
667
667
  todayHead.append(makeElement(doc, 'h4', { text: '今日判断・対応すること' }));
668
668
  if (typeof actions.openWorkList === 'function') todayHead.append(denseButton(doc, 'すべて見る', 'bb-pkw-dense-action', actions.openWorkList));
669
669
  today.append(todayHead);
670
- const todayNotice = denseContextStateNotice(doc, taskSection ?? (context?.state !== 'ok' ? context : null), 'タスクを読み取れませんでした。');
670
+ const todayNotice = denseContextStateNotice(doc, taskSection ?? (context?.state && context.state !== 'ok' ? context : { state: 'unknown' }), 'タスクを読み取れませんでした。');
671
671
  if (todayNotice) today.append(todayNotice);
672
672
  if (!todayNotice && due.length === 0) today.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '表示できる仕事はありません。' }));
673
673
  if (!todayNotice && due.length > 0) {
@@ -93,6 +93,28 @@ body:has(#brainbase-value-proof-review) {
93
93
  font-weight: 700;
94
94
  }
95
95
 
96
+ .bb-vpr-setup-guide {
97
+ display: grid;
98
+ gap: var(--bb-space-1);
99
+ padding: var(--bb-space-2) var(--bb-space-3);
100
+ border: 1px solid var(--bb-color-border, #d8dee9);
101
+ background: var(--bb-color-panel, #ffffff);
102
+ }
103
+
104
+ .bb-vpr-setup-guide p,
105
+ .bb-vpr-setup-guide ol {
106
+ margin: 0;
107
+ }
108
+
109
+ .bb-vpr-setup-guide ol {
110
+ padding-left: var(--bb-space-5);
111
+ }
112
+
113
+ .bb-vpr-setup-guide code {
114
+ overflow-wrap: anywhere;
115
+ font-size: .92em;
116
+ }
117
+
96
118
  .bb-vpr-rejected summary {
97
119
  cursor: pointer;
98
120
  }
@@ -225,6 +247,38 @@ body:has(#brainbase-value-proof-review) {
225
247
  font-size: 13px;
226
248
  }
227
249
 
250
+ /* A source-bearing target is still trace metadata until the host has read the
251
+ * exact Graph record. Only the same-origin link is styled as an action. */
252
+ .bb-vpr-source-target {
253
+ display: inline-flex;
254
+ flex-wrap: wrap;
255
+ gap: var(--bb-space-1, 4px);
256
+ align-items: baseline;
257
+ }
258
+
259
+ .bb-vpr-source-link {
260
+ color: var(--vpr-action);
261
+ font-weight: 680;
262
+ text-decoration: underline;
263
+ text-underline-offset: 2px;
264
+ }
265
+
266
+ .bb-vpr-source-status {
267
+ color: var(--vpr-muted);
268
+ font-size: 12px;
269
+ }
270
+
271
+ .bb-vpr-source-status.is-loading {
272
+ color: var(--vpr-muted);
273
+ }
274
+
275
+ .bb-vpr-source-status.is-not_found,
276
+ .bb-vpr-source-status.is-ambiguous,
277
+ .bb-vpr-source-status.is-forbidden,
278
+ .bb-vpr-source-status.is-unavailable {
279
+ color: var(--vpr-warning);
280
+ }
281
+
228
282
  /* Detail: in the rail, or after the workspace when the host has none. */
229
283
  .bb-vpr-inline-detail {
230
284
  max-width: 760px;
@@ -4,8 +4,9 @@
4
4
  * The host supplies a same-origin request function and a review token. A host
5
5
  * that cannot read a local journal may replace the unavailable notice. This
6
6
  * module owns presentation only: it never treats an unavailable journal as an
7
- * empty list, never edits the judgment journal, and keeps internal IDs inside
8
- * the audit details.
7
+ * empty list and never edits the judgment journal. Recorded basis target IDs
8
+ * are shown as trace metadata; no source URL is invented when the payload has
9
+ * no source-read contract.
9
10
  *
10
11
  * The screen follows the organization edition's pattern (workspace-kit): page
11
12
  * head, the 記録の範囲 notice, filters, metrics, and the 委任の地図 as a ledger
@@ -547,11 +548,98 @@ function renderDelegationMap(doc, state, callbacks) {
547
548
  }
548
549
 
549
550
  function basisText(entry) {
550
- const application = text(entry.application) ?? '適用内容なし';
551
- const layer = BASIS_LAYER_LABELS[entry.layer];
551
+ const application = text(entry?.application) ?? '適用内容なし';
552
+ const layer = BASIS_LAYER_LABELS[entry?.layer];
552
553
  return layer ? `[${layer}] ${application}` : application;
553
554
  }
554
555
 
556
+ function basisTargetText(entry) {
557
+ const entityId = text(entry?.entity_id);
558
+ if (!entityId) return '対象の記録なし';
559
+ const version = text(entry?.version);
560
+ return version ? `${entityId}(版 ${version})` : entityId;
561
+ }
562
+
563
+ const SOURCE_READ_STATES = Object.freeze(['loading', 'available', 'not_found', 'ambiguous', 'forbidden', 'unavailable']);
564
+ const SOURCE_READ_LABELS = Object.freeze({
565
+ loading: '出典を確認中…',
566
+ not_found: '出典が見つかりません(未確認)',
567
+ ambiguous: '同じIDの出典が複数あります(未確認)',
568
+ forbidden: '出典を読む権限がありません(未確認)',
569
+ unavailable: '出典を確認できません(未確認)',
570
+ });
571
+
572
+ function sourceContract(entry) {
573
+ const source = entry?.source;
574
+ if (!isRecord(source) || source.kind !== 'local_graph') return null;
575
+ const entityId = text(source.entity_id);
576
+ const entityType = text(source.entity_type);
577
+ if (!entityId || !entityType || entityId !== text(entry?.entity_id)) return null;
578
+ return source;
579
+ }
580
+
581
+ function sourceKey(source) {
582
+ return [source.kind, source.entity_id, source.entity_type, source.version ?? '', source.digest ?? ''].join('\u0000');
583
+ }
584
+
585
+ function sourceReadState(value) {
586
+ const state = text(value?.state);
587
+ return SOURCE_READ_STATES.includes(state) ? state : 'unavailable';
588
+ }
589
+
590
+ function renderBasisTarget(doc, entry, state) {
591
+ const target = makeElement(doc, 'span', { className: 'bb-vpr-source-target' });
592
+ target.append(makeElement(doc, 'span', { text: basisTargetText(entry) }));
593
+ const source = sourceContract(entry);
594
+ if (!source) return target;
595
+ const key = sourceKey(source);
596
+ const read = state.sourceReads instanceof Map ? state.sourceReads.get(key) : null;
597
+ if (!read) {
598
+ target.append(makeElement(doc, 'span', {
599
+ className: 'bb-vpr-source-status is-unavailable',
600
+ text: '出典の読み取り先を確認できません(未確認)',
601
+ }));
602
+ return target;
603
+ }
604
+ const readState = sourceReadState(read);
605
+ if (readState === 'available') {
606
+ const link = makeElement(doc, 'a', {
607
+ className: 'bb-vpr-source-link',
608
+ text: '出典を開く',
609
+ attrs: { href: `#graph?entity_id=${encodeURIComponent(source.entity_id)}` },
610
+ });
611
+ target.append(link);
612
+ return target;
613
+ }
614
+ target.append(makeElement(doc, 'span', {
615
+ className: `bb-vpr-source-status is-${readState}`,
616
+ text: SOURCE_READ_LABELS[readState] ?? SOURCE_READ_LABELS.unavailable,
617
+ }));
618
+ return target;
619
+ }
620
+
621
+ function journalSetupGuide(doc) {
622
+ const guide = makeElement(doc, 'div', { className: 'bb-vpr-setup-guide' });
623
+ guide.append(makeElement(doc, 'strong', { text: '接続を直す' }));
624
+ guide.append(makeElement(doc, 'p', { text: '判断journalの場所を指定して画面を起動し直したあと、「再試行」を押してください。' }));
625
+ const list = makeElement(doc, 'ol');
626
+ const journalOption = makeElement(doc, 'li');
627
+ journalOption.append(
628
+ makeElement(doc, 'span', { text: '起動時に ' }),
629
+ makeElement(doc, 'code', { text: 'brainbase review:serve --journal <判断journalの場所>' }),
630
+ makeElement(doc, 'span', { text: ' を指定する' }),
631
+ );
632
+ const journalEnv = makeElement(doc, 'li');
633
+ journalEnv.append(
634
+ makeElement(doc, 'span', { text: 'または環境変数 ' }),
635
+ makeElement(doc, 'code', { text: 'BRAINBASE_JUDGMENT_JOURNAL_DIR=<判断journalの場所>' }),
636
+ makeElement(doc, 'span', { text: ' を設定する' }),
637
+ );
638
+ list.append(journalOption, journalEnv);
639
+ guide.append(list);
640
+ return guide;
641
+ }
642
+
555
643
  function inheritanceText(inheritance) {
556
644
  const sources = Array.isArray(inheritance?.sources) ? inheritance.sources : [];
557
645
  if (sources.length === 0) return '引き継ぎの記録なし';
@@ -733,6 +821,7 @@ function renderJudgmentDetail(doc, state, item, callbacks) {
733
821
  title: '根拠と引き継ぎ',
734
822
  content: facts(doc, [
735
823
  ['根拠', basis.length > 0 ? basis.map(basisText).join(' / ') : '根拠の記録なし'],
824
+ ['根拠の対象', basis.length > 0 ? basis.map((entry) => renderBasisTarget(doc, entry, state)) : '対象の記録なし'],
736
825
  ['過去の学習の再利用', reuse === true ? 'あり' : reuse === false ? 'なし' : '未確認'],
737
826
  ['引き継ぎ', inheritanceText(proof.decision.inheritance)],
738
827
  ]),
@@ -861,13 +950,18 @@ export function renderValueProofReview(root, state, callbacks = {}, options = {}
861
950
  if (home.status === 'unavailable') {
862
951
  const hostNotice = options.unavailableNotice;
863
952
  const title = makeElement(doc, 'p', { className: 'bb-vpr-notice-title', text: text(hostNotice?.title) ?? '判断journalに接続できません' });
953
+ const retry = workspaceButton(doc, { text: '再試行', variant: 'primary', onClick: () => void callbacks.onReload?.() });
954
+ const defaultGuide = hostNotice ? null : journalSetupGuide(doc);
864
955
  surface.append(workspaceNotice(doc, {
865
956
  label: '記録の範囲',
866
957
  tone: 'danger',
867
958
  text: noticeBody(doc,
868
959
  title,
869
960
  home.root ? `場所: ${home.root}(${home.reason})` : `理由: ${home.reason}`,
870
- `${text(hostNotice?.guidance) ?? '記録の場所は、起動時の --journal か環境変数 BRAINBASE_JUDGMENT_JOURNAL_DIR で指定できます。'}0件としては扱いません。`),
961
+ text(hostNotice?.guidance) ?? (defaultGuide ? null : '接続元の案内に従って記録を読める状態にしてください。'),
962
+ defaultGuide,
963
+ retry,
964
+ '接続できる状態にしたら「再試行」を押してください。0件としては扱いません。'),
871
965
  }));
872
966
  return finish(null, unreadable);
873
967
  }
@@ -934,6 +1028,8 @@ export function createValueProofReviewUI({
934
1028
  clipboard,
935
1029
  /** Optional `{ title, guidance }` for hosts where the local journal hint does not apply. */
936
1030
  unavailableNotice,
1031
+ /** Optional host-owned reader for typed basis sources. It must not invent a URL. */
1032
+ sourceReader,
937
1033
  autoLoad = true,
938
1034
  } = {}) {
939
1035
  if (!root) throw new TypeError('root is required');
@@ -958,6 +1054,30 @@ export function createValueProofReviewUI({
958
1054
  save: { state: 'idle', message: '' },
959
1055
  focusCard: false,
960
1056
  consultMessage: '',
1057
+ sourceReads: new Map(),
1058
+ };
1059
+
1060
+ const readSelectedSources = async (item) => {
1061
+ if (typeof sourceReader !== 'function' || !item?.proof?.decision?.basis) return;
1062
+ const sources = item.proof.decision.basis.map(sourceContract).filter(Boolean);
1063
+ for (const source of sources) {
1064
+ const key = sourceKey(source);
1065
+ state.sourceReads.set(key, { state: 'loading' });
1066
+ }
1067
+ if (sources.length > 0) controller.render();
1068
+ await Promise.all(sources.map(async (source) => {
1069
+ const key = sourceKey(source);
1070
+ let result;
1071
+ try {
1072
+ result = await sourceReader(source);
1073
+ } catch {
1074
+ result = { state: 'unavailable' };
1075
+ }
1076
+ if (state.selectedKey !== itemKey(item.proof)) return;
1077
+ const normalized = isRecord(result) ? result : {};
1078
+ state.sourceReads.set(key, { ...normalized, state: sourceReadState(normalized) });
1079
+ }));
1080
+ if (state.selectedKey === itemKey(item.proof)) controller.render();
961
1081
  };
962
1082
 
963
1083
  const resetJudgmentInput = () => {
@@ -1000,6 +1120,8 @@ export function createValueProofReviewUI({
1000
1120
  state.focusCard = true;
1001
1121
  controller.render();
1002
1122
  state.focusCard = false;
1123
+ const item = findItem(state, key);
1124
+ if (item) void readSelectedSources(item);
1003
1125
  },
1004
1126
  onDraft(patch) {
1005
1127
  state.draft = { ...state.draft, ...patch };
@@ -60,6 +60,59 @@
60
60
  justify-self: start;
61
61
  }
62
62
 
63
+ /* First-user registration stays in the empty-state notice, beside the fact it affects. */
64
+ .bb-wm-registration-form {
65
+ display: grid;
66
+ grid-template-columns: repeat(2, minmax(0, 1fr));
67
+ gap: var(--bb-space-3);
68
+ margin-top: var(--bb-space-2);
69
+ max-width: 680px;
70
+ }
71
+
72
+ .bb-wm-registration-field {
73
+ display: grid;
74
+ gap: 5px;
75
+ min-width: 0;
76
+ }
77
+
78
+ .bb-wm-registration-field > label {
79
+ color: var(--bb-color-ink);
80
+ font-size: 12px;
81
+ font-weight: 680;
82
+ }
83
+
84
+ .bb-wm-registration-input {
85
+ width: 100%;
86
+ min-height: 34px;
87
+ padding: 7px 9px;
88
+ border: 1px solid var(--bb-color-border-strong);
89
+ border-radius: var(--bb-radius-sm);
90
+ background: var(--bb-color-paper);
91
+ color: var(--bb-color-ink);
92
+ font: inherit;
93
+ font-size: 13px;
94
+ }
95
+
96
+ .bb-wm-registration-input:focus {
97
+ outline: 2px solid var(--bb-color-accent);
98
+ outline-offset: 1px;
99
+ }
100
+
101
+ .bb-wm-registration-status {
102
+ grid-column: 1 / -1;
103
+ color: var(--bb-color-muted);
104
+ font-size: 12px;
105
+ }
106
+
107
+ .bb-wm-registration-form .bb-ws-button {
108
+ justify-self: start;
109
+ }
110
+
111
+ .bb-wm-registration-boundary {
112
+ color: var(--bb-color-muted);
113
+ font-size: 12px;
114
+ }
115
+
63
116
  /* The local shell resets `.bb-shell button { font: inherit }`, which outranks the kit's button type. */
64
117
  .bb-wm .bb-ws-button {
65
118
  font-size: 12px;
@@ -159,6 +212,14 @@
159
212
  }
160
213
 
161
214
  @media (max-width: 760px) {
215
+ .bb-wm-registration-form {
216
+ grid-template-columns: minmax(0, 1fr);
217
+ }
218
+
219
+ .bb-wm-registration-status {
220
+ grid-column: auto;
221
+ }
222
+
162
223
  .bb-wm-view-ledger .bb-ws-ledger-row {
163
224
  grid-template-columns: minmax(0, 1fr) 72px;
164
225
  }