@unson/brainbase-mcp 0.41.11 → 0.41.13

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.
@@ -0,0 +1,248 @@
1
+ /*
2
+ * 「情報の種類とつながり方」: the rules of the Graph (its ontology) — which
3
+ * kinds of record it can hold and how records may be connected. These are
4
+ * definitions, not records; the records are in 「情報と関係」.
5
+ *
6
+ * Seeing and proposing changes to the definitions is infrequent management
7
+ * work, so a host places this part under its settings (ledger D14, v5). The
8
+ * part reads `GET {base}/ontology` (see `graph-view-shared.js`) and draws
9
+ * read-only ledgers:
10
+ *
11
+ * - every relation has a plain Japanese name; relations with the same name
12
+ * are one row with each of their start and end kinds (`groupRelationKinds`);
13
+ * relation ids appear only in the details;
14
+ * - a count column is drawn only when the host counted something; a count the
15
+ * host could not finish is 未確認 or a lower bound, never zero (P9).
16
+ *
17
+ * The host injects the fetcher, base path and page context; this module keeps
18
+ * no global state.
19
+ */
20
+
21
+ import {
22
+ createGraphClient,
23
+ facts,
24
+ getDocument,
25
+ graphPageContext,
26
+ graphStateNotice,
27
+ groupRelationKinds,
28
+ isRecord,
29
+ makeElement,
30
+ ownLabel,
31
+ readOnlyGraphClient,
32
+ recordDetails,
33
+ relationLabel,
34
+ textOrNull,
35
+ typeLabel,
36
+ GRAPH_CORRECTION_FIELDS,
37
+ GRAPH_ENTITY_TYPE_MEANINGS,
38
+ GRAPH_RELATION_LABELS,
39
+ } from './graph-view-shared.js';
40
+ import {
41
+ workspaceLedger,
42
+ workspacePageHeader,
43
+ workspaceSectionTitle,
44
+ } from './workspace-kit.js';
45
+
46
+ export const GRAPH_ONTOLOGY_VIEW_CONTRACT_VERSION = 'brainbase.graph-ontology-view.v1';
47
+ export const GRAPH_ONTOLOGY_TITLE = '情報の種類とつながり方';
48
+
49
+ const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
50
+
51
+ // A host that could not finish counting sends null (with countStatus
52
+ // 'unknown', or 'at_least' plus atLeast) rather than a number it cannot vouch for.
53
+ function isCount(value) {
54
+ return value === null || value === undefined || Number.isInteger(value);
55
+ }
56
+
57
+ /** The `/ontology` answer is `ok` when its kinds and relations are well formed. */
58
+ export function normalizeOntology(result) {
59
+ if (result?.state !== 'ok') return result ?? INVALID;
60
+ const payload = result.payload;
61
+ const entityTypesOk = Array.isArray(payload.entityTypes) && payload.entityTypes.every((item) => isRecord(item)
62
+ && typeof item.id === 'string' && isCount(item.count));
63
+ const relationsOk = Array.isArray(payload.relations) && payload.relations.every((item) => isRecord(item)
64
+ && typeof item.id === 'string' && typeof item.from === 'string' && typeof item.to === 'string'
65
+ && isCount(item.count) && isCount(item.activeCount));
66
+ if (!entityTypesOk || !relationsOk || !isRecord(payload.ontology)) return INVALID;
67
+ return { state: 'ok', payload };
68
+ }
69
+
70
+ /** Whether the host counted anything in these rows; without a count the column is not drawn. */
71
+ function anyCounted(items) {
72
+ return items.some((item) => Number.isInteger(item.count) || (item.countStatus === 'at_least' && Number.isInteger(item.atLeast)));
73
+ }
74
+
75
+ function countText(item) {
76
+ if (Number.isInteger(item.count)) {
77
+ return Number.isInteger(item.activeCount) ? `${item.count}件(うち有効 ${item.activeCount}件)` : `${item.count}件`;
78
+ }
79
+ if (item.countStatus === 'at_least' && Number.isInteger(item.atLeast)) return `${item.atLeast}件以上`;
80
+ return { text: '未確認', className: 'is-unresolved' };
81
+ }
82
+
83
+ /** The plain names of the kinds a relation starts or ends at (`org・project`, or a list). */
84
+ function kindNames(single, list) {
85
+ const ids = Array.isArray(list) && list.length > 0 ? list : String(single ?? '').split(/[・,]/u);
86
+ return ids.map((id) => id.trim()).filter(Boolean).map(typeLabel).join('・');
87
+ }
88
+
89
+ /** One kind of a merged relation row, as a sentence: 組織 → アプリ・プロジェクト(を所有). */
90
+ function kindSentence(item) {
91
+ const phrase = ownLabel(GRAPH_RELATION_LABELS, item.id)?.outgoing;
92
+ return `${kindNames(item.from, item.fromTypes)} → ${kindNames(item.to, item.toTypes)}${phrase ? `(${phrase})` : ''}`;
93
+ }
94
+
95
+ function fieldNames(fields) {
96
+ return (Array.isArray(fields) ? fields : []).map((field) => GRAPH_CORRECTION_FIELDS[field]?.label ?? field).join('・');
97
+ }
98
+
99
+ /** A type's meaning: the plain Japanese one for a local kind, otherwise what the host's answer says. */
100
+ function typeMeaning(item) {
101
+ return ownLabel(GRAPH_ENTITY_TYPE_MEANINGS, item.id) ?? textOrNull(item.meaning) ?? '';
102
+ }
103
+
104
+ /**
105
+ * Draws the definitions of an `ok` ontology state.
106
+ *
107
+ * @param {Document} doc
108
+ * @param {{ payload: object }} ontologyState
109
+ * @param {{ canCorrect?: boolean }} [options] With `canCorrect`, lists what the host lets 情報と関係 correct.
110
+ * @returns {Element[]}
111
+ */
112
+ export function renderOntologyDefinitions(doc, { payload }, { canCorrect = false } = {}) {
113
+ const ontology = payload.ontology;
114
+ const version = textOrNull(ontology.version);
115
+ const currentVersion = textOrNull(ontology.currentVersion);
116
+ const freshness = ontology.upToDate === false && currentVersion
117
+ ? `最新ではありません。最新は ${currentVersion}`
118
+ : ontology.upToDate === true && version ? '最新' : '最新性は未確認';
119
+ const children = [makeElement(doc, 'p', { className: 'bb-go-caption', text: `定義の版 ${version ?? '未確認'}(${freshness})` })];
120
+
121
+ const types = payload.entityTypes;
122
+ const typesCounted = anyCounted(types);
123
+ children.push(workspaceSectionTitle(doc, { title: '情報の種類', lead: 'Graphに登録できる記録の種類です。' }));
124
+ children.push(workspaceLedger(doc, {
125
+ className: `bb-go-type-ledger${typesCounted ? ' is-counted' : ''}`,
126
+ ariaLabel: '情報の種類',
127
+ columns: typesCounted ? ['種類', '意味', '件数'] : ['種類', '意味'],
128
+ rows: types.map((item) => ({
129
+ key: item.id,
130
+ cells: [typeLabel(item.id), typeMeaning(item), ...(typesCounted ? [countText(item)] : [])],
131
+ })),
132
+ }));
133
+ if (!typesCounted && types.length > 0) {
134
+ children.push(makeElement(doc, 'p', { className: 'bb-go-note', attrs: { role: 'status' }, text: 'このGraphでは、種類ごとの件数を数えていません。' }));
135
+ }
136
+
137
+ const rows = groupRelationKinds(payload.relations);
138
+ const relationsCounted = anyCounted(payload.relations);
139
+ children.push(workspaceSectionTitle(doc, { title: 'つながり方', lead: '記録どうしを、どの種類からどの種類へ、どんな意味でつなげるかの決まりです。同じ意味のつながりは1行にまとめています。' }));
140
+ if (!relationsCounted && rows.length > 0) {
141
+ children.push(makeElement(doc, 'p', { className: 'bb-go-note', attrs: { role: 'status' }, text: 'このGraphでは、つながりの件数を数えていません。' }));
142
+ }
143
+ children.push(workspaceLedger(doc, {
144
+ className: `bb-go-relation-ledger${relationsCounted ? ' is-counted' : ''}`,
145
+ ariaLabel: 'つながり方',
146
+ columns: relationsCounted ? ['つながり', 'つながる種類', '意味', '件数'] : ['つながり', 'つながる種類', '意味'],
147
+ rows: rows.map((row) => {
148
+ const kinds = makeElement(doc, 'ul', { className: 'bb-go-kinds' });
149
+ // Kinds that read the same (has_project and has_engagement, both project → project) are one line.
150
+ for (const sentence of new Set(row.kinds.map(kindSentence))) kinds.append(makeElement(doc, 'li', { text: sentence }));
151
+ const counted = row.kinds.filter((item) => Number.isInteger(item.count));
152
+ const count = counted.length === row.kinds.length
153
+ ? countText({
154
+ count: counted.reduce((sum, item) => sum + item.count, 0),
155
+ activeCount: counted.every((item) => Number.isInteger(item.activeCount)) ? counted.reduce((sum, item) => sum + item.activeCount, 0) : null,
156
+ })
157
+ : row.kinds.length === 1 ? countText(row.kinds[0]) : { text: '一部未確認', className: 'is-unresolved' };
158
+ return {
159
+ key: row.label,
160
+ cells: [row.label, kinds, row.meanings.join('/'), ...(relationsCounted ? [count] : [])],
161
+ };
162
+ }),
163
+ }));
164
+ const ids = makeElement(doc, 'details', { className: 'bb-graph-audit' });
165
+ ids.append(makeElement(doc, 'summary', { text: 'つながりのID' }), facts(doc, rows.map((row) => [row.label, row.kinds.map((item) => item.id).join('・')]), 'bb-graph-facts is-mono'));
166
+ children.push(ids);
167
+
168
+ const corrections = canCorrect && isRecord(payload.corrections) ? payload.corrections : null;
169
+ if (corrections) {
170
+ children.push(workspaceSectionTitle(doc, { title: '「情報と関係」で直せること' }));
171
+ const list = makeElement(doc, 'ul', { className: 'bb-graph-plain-list' });
172
+ list.append(
173
+ makeElement(doc, 'li', { text: `記録: ${fieldNames(corrections.entityFields)}(プロジェクトは${fieldNames(corrections.projectFields)}も)` }),
174
+ makeElement(doc, 'li', { text: `関係: ${fieldNames(corrections.edgeFields)}` }),
175
+ makeElement(doc, 'li', { text: `新しく加えられる関係: ${(Array.isArray(corrections.newEdgeRelations) ? corrections.newEdgeRelations : []).map((id) => relationLabel(id)).join('・')}` }),
176
+ makeElement(doc, 'li', { text: '記録と関係は削除しません。終わったものは終了日で表します。関係の種類や相手を変えるときは、今の関係を終えてから新しく加えます。' }),
177
+ );
178
+ children.push(list);
179
+ }
180
+ children.push(recordDetails(doc, [
181
+ ['定義ID', textOrNull(ontology.id) ?? '未確認'],
182
+ ['定義の版', version ?? '未確認'],
183
+ ['release digest', textOrNull(ontology.releaseDigest) ?? '未確認'],
184
+ ]));
185
+ return children;
186
+ }
187
+
188
+ /**
189
+ * Mounts 「情報の種類とつながり方」.
190
+ *
191
+ * @param {object} options
192
+ * @param {Element} options.root Where the screen is drawn.
193
+ * @param {{ crumbs?: string[], source?: string }} [options.page] Breadcrumb and source label of the page head.
194
+ * @param {Document} [options.document]
195
+ * @param {Function} [options.fetcher] `fetch`-compatible function for the Graph routes.
196
+ * @param {string} [options.basePath='/api/graph'] Where the Graph routes are served.
197
+ * @param {boolean} [options.canCorrect=false] Lists what 情報と関係 can correct (the host's answer says what).
198
+ * @param {boolean} [options.autoLoad=true]
199
+ * @returns The controller: `state`, `render()`, `load()`.
200
+ */
201
+ export function createGraphOntologyView({
202
+ root,
203
+ page,
204
+ document: explicitDocument,
205
+ fetcher,
206
+ basePath = '/api/graph',
207
+ canCorrect = false,
208
+ autoLoad = true,
209
+ } = {}) {
210
+ if (!root) throw new TypeError('root is required');
211
+ const doc = getDocument(explicitDocument);
212
+ const client = readOnlyGraphClient(createGraphClient({ fetcher, basePath }));
213
+ const context = graphPageContext(page);
214
+ const state = { ontology: { state: 'loading' } };
215
+
216
+ const controller = {
217
+ get state() { return state; },
218
+ render() {
219
+ const surface = makeElement(doc, 'section', {
220
+ className: 'bb-graph bb-go',
221
+ attrs: { 'data-contract-version': GRAPH_ONTOLOGY_VIEW_CONTRACT_VERSION, 'aria-label': GRAPH_ONTOLOGY_TITLE },
222
+ });
223
+ surface.append(workspacePageHeader(doc, {
224
+ crumbs: context.crumbs,
225
+ title: GRAPH_ONTOLOGY_TITLE,
226
+ lead: 'Graphに登録できる記録の種類と、記録どうしのつながり方の決まり(オントロジー)です。記録そのものは「情報と関係」で見ます。',
227
+ source: context.source,
228
+ }));
229
+ const notice = graphStateNotice(doc, state.ontology, { onRetry: () => controller.load(), loadingText: '情報の種類を読み込んでいます。' });
230
+ if (notice) surface.append(notice);
231
+ else surface.append(...renderOntologyDefinitions(doc, state.ontology, { canCorrect }).filter(Boolean));
232
+ root.replaceChildren(surface);
233
+ return controller;
234
+ },
235
+ async load() {
236
+ state.ontology = { state: 'loading' };
237
+ controller.render();
238
+ state.ontology = normalizeOntology(await client.read('/ontology'));
239
+ controller.render();
240
+ return state.ontology;
241
+ },
242
+ };
243
+ controller.render();
244
+ if (autoLoad) void controller.load();
245
+ return controller;
246
+ }
247
+
248
+ export default createGraphOntologyView;
@@ -37,6 +37,7 @@ import {
37
37
  relationLabel,
38
38
  textOrNull,
39
39
  typeLabel,
40
+ validityLabel,
40
41
  validityText,
41
42
  GRAPH_RELATION_LABELS,
42
43
  } from './graph-view-shared.js';
@@ -244,6 +245,172 @@ export function ownEntityDetail(doc, detail, { onClose, onRetry, onOpen } = {})
244
245
  return wrap;
245
246
  }
246
247
 
248
+ // ---------------------------------------------------------------------------
249
+ // 引き継いだ自分の分 (on its own)
250
+
251
+ export const GRAPH_OWN_SHARE_LEDGER_COLUMNS = Object.freeze(['名前', '種類', '要約', '有効期間']);
252
+ export const GRAPH_OWN_SHARE_SEARCH_LIMIT = 50;
253
+ /**
254
+ * The default lead of the standalone section: what the snapshot is, who sees
255
+ * it, and that it becomes an organization record only once it is promoted.
256
+ */
257
+ export const GRAPH_OWN_SHARE_VIEW_LEAD = '手元のOSSから送った最新の束です(brainbase graph:upgrade)。組織の記録ではなく、あなたにだけ表示しています。読み取りのみで、ここでは直せません。組織の記録にするには、取り込む記録を選んで組織の事実へ昇格します。';
258
+
259
+ function isOwnEntity(value) {
260
+ return isRecord(value) && typeof value.id === 'string' && typeof value.name === 'string' && Array.isArray(value.aliases);
261
+ }
262
+
263
+ /**
264
+ * Mounts 「引き継いだ自分の分」 on its own: the owner's latest snapshot, searched
265
+ * by name or alias on `{basePath}/search`, with a record opened below the list
266
+ * from `{basePath}/entities/:id` (and a decision's judgment record). Read-only.
267
+ * A host places it where the owner takes records in (候補の確認, ledger D14),
268
+ * next to the entry that promotes them to organization facts, never among the
269
+ * organization's own records.
270
+ *
271
+ * @param {object} options
272
+ * @param {Element} options.root Where the section is drawn.
273
+ * @param {Document} [options.document]
274
+ * @param {Function} [options.fetcher] `fetch`-compatible function.
275
+ * @param {string} [options.basePath='/api/graph/own'] The host's owner-private routes.
276
+ * @param {string} [options.label] The section label (default 引き継いだ自分の分(組織には未反映)).
277
+ * @param {string} [options.lead] Replaces the section lead.
278
+ * @param {() => Date} [options.now] The owner's clock.
279
+ * @param {boolean} [options.autoLoad=true] Runs the empty search on mount.
280
+ * @returns The controller: `state`, `render()`, `search(query?)`, `openEntity(id)`, `destroy()`.
281
+ */
282
+ export function createOwnShareView({
283
+ root,
284
+ document: explicitDocument,
285
+ fetcher,
286
+ basePath = '/api/graph/own',
287
+ label,
288
+ lead = GRAPH_OWN_SHARE_VIEW_LEAD,
289
+ now = () => new Date(),
290
+ autoLoad = true,
291
+ } = {}) {
292
+ if (!root) throw new TypeError('root is required');
293
+ const doc = explicitDocument ?? globalThis.document;
294
+ const source = graphOwnShareSource({ basePath, label }, { fetcher });
295
+ const state = { query: '', applied: '', search: { state: 'loading' }, detail: null };
296
+ let destroyed = false;
297
+ let generation = 0;
298
+
299
+ function searchForm() {
300
+ const form = makeElement(doc, 'form', { className: 'bb-graph-form bb-graph-own-search', attrs: { role: 'search', novalidate: true } });
301
+ form.addEventListener('submit', (event) => {
302
+ event?.preventDefault?.();
303
+ return controller.search();
304
+ });
305
+ const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
306
+ const input = makeElement(doc, 'input', { attrs: { id: 'bb-graph-own-q', name: 'q', type: 'search', maxlength: 200, placeholder: '例: 田中' } });
307
+ input.value = state.query;
308
+ const update = (event) => { state.query = event?.target?.value ?? input.value; };
309
+ input.addEventListener('input', update);
310
+ input.addEventListener('change', update);
311
+ wrap.append(makeElement(doc, 'label', { text: '名前・別名', attrs: { for: 'bb-graph-own-q' } }), input);
312
+ form.append(wrap, makeElement(doc, 'button', { className: 'bb-ws-button', text: '束の中を探す', attrs: { type: 'submit' } }));
313
+ return form;
314
+ }
315
+
316
+ function body() {
317
+ const notice = ownShareStateNotice(doc, state.search, { onRetry: () => controller.search(), loadingText: '引き継いだ自分の分を探しています。' });
318
+ if (notice) return { children: [notice], handover: null };
319
+ const { payload } = state.search;
320
+ const asOf = payload.query?.asOf ?? now().toISOString();
321
+ const children = [searchForm()];
322
+ if (payload.graphEmpty === true) {
323
+ children.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: '束に記録がありません。' }));
324
+ return { children, handover: payload.handover };
325
+ }
326
+ children.push(makeElement(doc, 'p', {
327
+ className: 'bb-graph-own-caption',
328
+ text: `束の中の記録 ${Number.isInteger(payload.total) ? `${payload.total}件` : `${payload.results.length}件を表示`}${payload.truncated ? `(うち${payload.results.length}件を表示)` : ''}`,
329
+ }));
330
+ children.push(workspaceLedger(doc, {
331
+ className: 'bb-graph-entity-ledger bb-graph-own-ledger',
332
+ ariaLabel: `${source.label}の一覧`,
333
+ columns: GRAPH_OWN_SHARE_LEDGER_COLUMNS,
334
+ empty: '条件に合う記録は、この束にはありません。',
335
+ rows: payload.results.map((entity) => ({
336
+ key: entity.id,
337
+ selected: entity.id === state.detail?.id,
338
+ className: entity.active === false ? 'is-ended' : '',
339
+ onSelect: (id) => { void controller.openEntity(id); },
340
+ cells: [
341
+ { primary: entity.name, secondary: entity.id },
342
+ typeLabel(entity.type),
343
+ textOrNull(entity.summary) ?? { text: '要約なし', className: 'is-unresolved' },
344
+ validityLabel(entity, asOf),
345
+ ],
346
+ })),
347
+ }));
348
+ if (state.detail) {
349
+ children.push(ownEntityDetail(doc, state.detail, {
350
+ onClose: () => controller.openEntity(null),
351
+ onRetry: () => controller.openEntity(state.detail.id),
352
+ onOpen: (id) => controller.openEntity(id),
353
+ }));
354
+ }
355
+ return { children, handover: payload.handover };
356
+ }
357
+
358
+ const controller = {
359
+ get state() { return state; },
360
+ render() {
361
+ if (destroyed) return controller;
362
+ const { children, handover } = body();
363
+ root.replaceChildren(ownShareSection(doc, { label: source.label, lead, handover, children, className: 'bb-graph-own-view' }));
364
+ return controller;
365
+ },
366
+ /** Searches the snapshot (the whole snapshot for an empty query); an open record stays while it is listed. */
367
+ async search(query = state.query) {
368
+ if (destroyed) return state.search;
369
+ const requestGeneration = ++generation;
370
+ state.query = String(query ?? '');
371
+ state.applied = state.query.trim();
372
+ state.search = { state: 'loading' };
373
+ controller.render();
374
+ const result = await source.client.read('/search', { q: state.applied, limit: GRAPH_OWN_SHARE_SEARCH_LIMIT });
375
+ if (destroyed || requestGeneration !== generation) return state.search;
376
+ // An empty answer is an answer here: the host confirms the whole snapshot was searched.
377
+ state.search = result.state === 'ok' && !(Array.isArray(result.payload?.results) && result.payload.results.every(isOwnEntity)) ? INVALID : result;
378
+ const openId = state.detail?.id;
379
+ if (openId && !(state.search.state === 'ok' && state.search.payload.results.some((entity) => entity.id === openId))) state.detail = null;
380
+ controller.render();
381
+ return state.search;
382
+ },
383
+ /** Opens (or with null closes) a record of the snapshot below the list. */
384
+ async openEntity(id) {
385
+ if (destroyed) return null;
386
+ const target = textOrNull(id);
387
+ if (!target) {
388
+ state.detail = null;
389
+ controller.render();
390
+ return null;
391
+ }
392
+ const listed = state.search.state === 'ok' ? state.search.payload.results.find((entity) => entity.id === target) : null;
393
+ const pending = listed ?? (state.detail?.state === 'ok' ? [...state.detail.payload.outgoing, ...state.detail.payload.incoming]
394
+ .map((edge) => edge.counterpart).find((counterpart) => counterpart.id === target) : null) ?? null;
395
+ state.detail = { id: target, state: 'loading', pending: pending ? { id: target, name: pending.name, type: pending.type } : null };
396
+ controller.render();
397
+ const result = await source.client.read(`/entities/${encodeURIComponent(target)}`);
398
+ if (destroyed || state.detail?.id !== target) return state.detail;
399
+ const base = { id: target, pending: state.detail.pending };
400
+ if (result.state === 'error' && result.status === 404) state.detail = { ...base, state: 'not_found', reason: 'この記録は束の中に見つかりません。' };
401
+ else if (result.state !== 'ok') state.detail = { ...base, ...result };
402
+ else if (!isOwnEntity(result.payload?.entity) || !Array.isArray(result.payload.outgoing) || !Array.isArray(result.payload.incoming)) state.detail = { ...base, ...INVALID };
403
+ else state.detail = { ...base, state: 'ok', payload: result.payload };
404
+ controller.render();
405
+ return state.detail;
406
+ },
407
+ destroy() { destroyed = true; generation++; root.replaceChildren(); },
408
+ };
409
+ controller.render();
410
+ if (autoLoad) void controller.search('');
411
+ return controller;
412
+ }
413
+
247
414
  // ---------------------------------------------------------------------------
248
415
  // 引き継いだ自分の目的
249
416