@unson/brainbase-mcp 0.9.0 → 0.10.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.
@@ -0,0 +1,722 @@
1
+ /*
2
+ * Shared project knowledge workspace.
3
+ *
4
+ * The project screen is deliberately a projection of records already read by
5
+ * the host. It does not invent tasks, pending decisions, or graph edges from
6
+ * a supplementary section. Hosts provide a Sigma.js graph mount function and
7
+ * an entity reader; this module keeps the accessible list and evidence panel
8
+ * usable when the graph cannot be mounted.
9
+ */
10
+
11
+ import {
12
+ activityBadge,
13
+ badge,
14
+ formatDateTime,
15
+ getDocument,
16
+ isEdgeView,
17
+ isEntityView,
18
+ isRecord,
19
+ makeElement,
20
+ recordDetails,
21
+ relationLabel,
22
+ renderProvenance,
23
+ textOrNull,
24
+ typeLabel,
25
+ validityText,
26
+ } from './graph-view-shared.js';
27
+ import { workspaceDefinition, workspaceNotice } from './workspace-kit.js';
28
+
29
+ export const PROJECT_WORKSPACE_CONTRACT_VERSION = 'brainbase.project-workspace.v1';
30
+
31
+ const CONTEXT_STATES = new Set(['ok', 'loading', 'unavailable', 'failed']);
32
+
33
+ function cleanId(value) {
34
+ return textOrNull(value);
35
+ }
36
+
37
+ function cleanType(value) {
38
+ return textOrNull(value) ?? 'unknown';
39
+ }
40
+
41
+ function cleanName(value, fallback = null) {
42
+ return textOrNull(value) ?? fallback;
43
+ }
44
+
45
+ function firstText(...values) {
46
+ for (const value of values) {
47
+ const text = textOrNull(value);
48
+ if (text) return text;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function sourceLabel(source) {
54
+ if (typeof source === 'string') return textOrNull(source);
55
+ if (!isRecord(source)) return null;
56
+ return firstText(source.label, source.name, source.authority, source.dataDir);
57
+ }
58
+
59
+ function normalizeContextItem(item) {
60
+ if (!isRecord(item)) return null;
61
+ const id = cleanId(item.id);
62
+ const title = cleanName(item.title, item.name);
63
+ if (!id || !title) return null;
64
+ const normalized = { id, title };
65
+ for (const key of ['kind', 'status', 'summary', 'owner', 'dueAt', 'updatedAt']) {
66
+ const value = textOrNull(item[key]);
67
+ if (value) normalized[key] = value;
68
+ }
69
+ const source = sourceLabel(item.source);
70
+ if (source) normalized.source = source;
71
+ return normalized;
72
+ }
73
+
74
+ function normalizeContextSection(section) {
75
+ if (!isRecord(section)) return null;
76
+ const id = cleanId(section.id);
77
+ const title = cleanName(section.title, section.name);
78
+ if (!id || !title) return null;
79
+ const state = CONTEXT_STATES.has(section.state) ? section.state : 'unavailable';
80
+ const normalized = { id, title, state };
81
+ const message = textOrNull(section.message);
82
+ if (message) normalized.message = message;
83
+ const asOf = textOrNull(section.asOf);
84
+ if (asOf) normalized.asOf = asOf;
85
+ normalized.items = Array.isArray(section.items) ? section.items.map(normalizeContextItem).filter(Boolean) : [];
86
+ return normalized;
87
+ }
88
+
89
+ /**
90
+ * Normalizes the optional host context contract without turning a failed read
91
+ * into an empty, successful section.
92
+ */
93
+ export function normalizeProjectContext(projectId, result) {
94
+ const id = cleanId(projectId) ?? '';
95
+ if (result?.state === 'failed' || result?.state === 'error') {
96
+ return { projectId: id, state: 'failed', message: firstText(result.message, result.reason) ?? '補足情報を読み取れませんでした。', sections: [] };
97
+ }
98
+ if (result?.state === 'loading') return { projectId: id, state: 'loading', sections: [] };
99
+ if (result?.state === 'unavailable') {
100
+ return { projectId: id, state: 'unavailable', message: firstText(result.message, result.reason) ?? '補足情報は接続されていません。', sections: [] };
101
+ }
102
+ const payload = result?.state === 'ok' && isRecord(result.payload) ? result.payload : result;
103
+ if (!isRecord(payload) || !Array.isArray(payload.sections)) {
104
+ return { projectId: id, state: 'unavailable', message: '補足情報の形式を確認できません。', sections: [] };
105
+ }
106
+ const sections = payload.sections.map(normalizeContextSection).filter(Boolean);
107
+ if (sections.length === 0 && payload.sections.length > 0) {
108
+ return { projectId: id, state: 'unavailable', message: '補足情報の形式を確認できません。', sections: [] };
109
+ }
110
+ return { projectId: id, state: 'ok', sections };
111
+ }
112
+
113
+ function addEntity(map, candidate) {
114
+ if (!isRecord(candidate)) return;
115
+ const id = cleanId(candidate.id);
116
+ const name = cleanName(candidate.name, candidate.title);
117
+ if (!id || !name) return;
118
+ const existing = map.get(id);
119
+ map.set(id, {
120
+ ...(existing ?? {}),
121
+ ...candidate,
122
+ id,
123
+ type: cleanType(candidate.type ?? existing?.type),
124
+ name,
125
+ });
126
+ }
127
+
128
+ function counterpartEntity(edge) {
129
+ if (!isRecord(edge?.counterpart)) return null;
130
+ return edge.counterpart;
131
+ }
132
+
133
+ function edgeSourceTarget(edge, projectId) {
134
+ if (!isRecord(edge)) return { source: null, target: null };
135
+ let source = cleanId(edge.source ?? edge.fromId);
136
+ let target = cleanId(edge.target ?? edge.toId);
137
+ const counterpart = cleanId(edge.counterpart?.id);
138
+ if (!source || !target) {
139
+ if (edge.direction === 'incoming') {
140
+ source = source ?? counterpart;
141
+ target = target ?? projectId;
142
+ } else {
143
+ source = source ?? projectId;
144
+ target = target ?? counterpart;
145
+ }
146
+ }
147
+ return { source, target };
148
+ }
149
+
150
+ function edgeLabel(edge) {
151
+ return firstText(edge?.label, relationLabel(edge?.relation)) ?? '関係';
152
+ }
153
+
154
+ function projectDetailPayload(detail) {
155
+ if (!isRecord(detail)) return null;
156
+ if (detail.project) return detail;
157
+ if (detail.payload?.project) return detail.payload;
158
+ return null;
159
+ }
160
+
161
+ /** Returns actual project/counterpart/optional knowledge entities, retaining custom types. */
162
+ export function projectKnowledgeEntities(detail) {
163
+ const payload = projectDetailPayload(detail);
164
+ const map = new Map();
165
+ if (!payload) return [];
166
+ addEntity(map, payload.project);
167
+ for (const edge of [...(payload.participants ?? []), ...(payload.relations ?? [])]) addEntity(map, counterpartEntity(edge));
168
+ for (const entity of payload.knowledge?.entities ?? []) addEntity(map, entity);
169
+ return [...map.values()];
170
+ }
171
+
172
+ /** Returns actual relation edges only; supplementary section membership is never an edge. */
173
+ export function projectKnowledgeEdges(detail) {
174
+ const payload = projectDetailPayload(detail);
175
+ if (!payload || !cleanId(payload.project?.id)) return [];
176
+ const projectId = payload.project.id;
177
+ const byId = new Map();
178
+ const addEdge = (edge) => {
179
+ if (!isRecord(edge)) return;
180
+ const id = cleanId(edge.id);
181
+ const { source, target } = edgeSourceTarget(edge, projectId);
182
+ if (!id || !source || !target) return;
183
+ if (byId.has(id)) return;
184
+ byId.set(id, {
185
+ ...edge,
186
+ id,
187
+ source,
188
+ target,
189
+ relation: cleanId(edge.relation) ?? 'unknown',
190
+ label: edgeLabel(edge),
191
+ });
192
+ };
193
+ for (const edge of [...(payload.participants ?? []), ...(payload.relations ?? []), ...(payload.knowledge?.edges ?? [])]) addEdge(edge);
194
+ return [...byId.values()];
195
+ }
196
+
197
+ /** Unknowns are explicit missing project fields/host supplied unknowns, never inferred pending work. */
198
+ export function projectKnowledgeUnknowns(detail) {
199
+ const payload = projectDetailPayload(detail);
200
+ if (!payload) return ['プロジェクトの記録'];
201
+ const values = [];
202
+ const add = (value) => {
203
+ const text = typeof value === 'string' ? textOrNull(value) : textOrNull(value?.label ?? value?.title ?? value?.name);
204
+ if (text && !values.includes(text)) values.push(text);
205
+ };
206
+ for (const item of payload.unknowns ?? []) add(item);
207
+ if (!textOrNull(payload.project?.goal)) add('目的');
208
+ if (!textOrNull(payload.project?.status)) add('状態');
209
+ if (!payload.source) add('出典');
210
+ if (!textOrNull(payload.asOf)) add('読み取った時点');
211
+ return values;
212
+ }
213
+
214
+ function graphEntityMap(detail) {
215
+ return new Map(projectKnowledgeEntities(detail).map((entity) => [entity.id, entity]));
216
+ }
217
+
218
+ function graphEvidenceEdges(detail) {
219
+ return projectKnowledgeEdges(detail);
220
+ }
221
+
222
+ function resultEntity(result) {
223
+ if (!isRecord(result)) return null;
224
+ const payload = result.state === 'ok' && isRecord(result.payload) ? result.payload : result;
225
+ if (isEntityView(payload.entity)) return payload;
226
+ if (isEntityView(payload)) return { entity: payload };
227
+ if (isRecord(payload.entity) && typeof payload.entity.id === 'string') return payload;
228
+ return null;
229
+ }
230
+
231
+ function resultFailure(result) {
232
+ return firstText(result?.message, result?.reason, result?.error?.message) ?? 'この記録の詳細を読み取れませんでした。';
233
+ }
234
+
235
+ function appendText(parent, doc, text, className = '') {
236
+ if (!text) return;
237
+ parent.append(makeElement(doc, 'p', { className, text }));
238
+ }
239
+
240
+ function recordLink(doc, label, id, onSelect, className = 'bb-pkw-record-link') {
241
+ if (!id || typeof onSelect !== 'function') return makeElement(doc, 'strong', { text: label });
242
+ const button = makeElement(doc, 'button', {
243
+ className,
244
+ text: label,
245
+ attrs: { type: 'button', 'data-record-id': id },
246
+ });
247
+ button.addEventListener('click', () => onSelect(id));
248
+ return button;
249
+ }
250
+
251
+ function relationEvidence(doc, edges, entityId, entities, asOf, onSelect) {
252
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-evidence-list' });
253
+ const incident = edges.filter((edge) => edge.source === entityId || edge.target === entityId);
254
+ if (incident.length === 0) {
255
+ list.append(makeElement(doc, 'li', { className: 'bb-pkw-muted', text: 'この画面で確認できる関係はありません。' }));
256
+ return list;
257
+ }
258
+ for (const edge of incident) {
259
+ const otherId = edge.source === entityId ? edge.target : edge.source;
260
+ const other = entities.get(otherId);
261
+ const item = makeElement(doc, 'li', { className: 'bb-pkw-evidence-item' });
262
+ const head = makeElement(doc, 'div', { className: 'bb-pkw-evidence-head' });
263
+ head.append(badge(doc, relationLabel(edge.relation), 'muted'), recordLink(doc, other?.name ?? otherId, otherId, onSelect));
264
+ if (edge.active !== undefined) head.append(activityBadge(doc, edge, asOf));
265
+ item.append(head);
266
+ item.append(workspaceDefinition(doc, [
267
+ ['意味', textOrNull(edge.meaning) ?? edge.label],
268
+ ['役割', textOrNull(edge.role)],
269
+ ['文脈', textOrNull(edge.context)],
270
+ ['有効期間', validityText(edge.validFrom, edge.validTo)],
271
+ ['出典', isRecord(edge.provenance) ? renderProvenance(doc, edge.provenance) : null],
272
+ ]));
273
+ item.append(recordDetails(doc, [['関係ID', edge.id], ['起点', edge.source], ['終点', edge.target], ['digest', edge.digest]]));
274
+ list.append(item);
275
+ }
276
+ return list;
277
+ }
278
+
279
+ function renderEntityRelations(doc, edges, direction, entity, asOf, onSelect) {
280
+ const candidates = Array.isArray(edges) ? edges : [];
281
+ if (candidates.length === 0) return null;
282
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-detail-relations' });
283
+ for (const edge of candidates) {
284
+ if (!isRecord(edge)) continue;
285
+ const item = makeElement(doc, 'li');
286
+ const counterpart = edge.counterpart ?? {};
287
+ item.append(badge(doc, relationLabel(edge.relation), 'muted'), recordLink(doc, cleanName(counterpart.name, counterpart.id) ?? '名前不明', cleanId(counterpart.id), onSelect));
288
+ if (edge.active !== undefined) item.append(activityBadge(doc, edge, asOf));
289
+ if (edge.provenance) item.append(renderProvenance(doc, edge.provenance));
290
+ list.append(item);
291
+ }
292
+ return list.children.length > 0 ? list : null;
293
+ }
294
+
295
+ function renderContext(doc, context) {
296
+ if (!context) return null;
297
+ const section = makeElement(doc, 'section', { className: 'bb-pkw-section bb-pkw-context', attrs: { 'aria-label': '補足情報' } });
298
+ section.append(makeElement(doc, 'h3', { text: '補足情報' }));
299
+ if (context.state === 'loading') {
300
+ section.append(workspaceNotice(doc, { label: '読み込み中', text: 'タスクや知識の補足情報を読み込んでいます。', tone: 'info', role: 'status' }));
301
+ return section;
302
+ }
303
+ if (context.state !== 'ok') {
304
+ section.append(workspaceNotice(doc, { label: context.state === 'failed' ? '読み取り失敗' : '未接続', text: context.message ?? '補足情報を確認できません。', tone: context.state === 'failed' ? 'danger' : 'warning' }));
305
+ return section;
306
+ }
307
+ if (context.sections.length === 0) {
308
+ section.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '補足情報のセクションはありません。' }));
309
+ return section;
310
+ }
311
+ for (const item of context.sections) {
312
+ const group = makeElement(doc, 'section', { className: 'bb-pkw-context-group', attrs: { 'aria-label': item.title } });
313
+ const heading = makeElement(doc, 'div', { className: 'bb-pkw-context-heading' });
314
+ heading.append(makeElement(doc, 'h4', { text: item.title }), badge(doc, item.state === 'ok' ? '利用可能' : item.state === 'loading' ? '読み込み中' : item.state === 'failed' ? '読み取り失敗' : '未接続', item.state === 'ok' ? 'success' : item.state === 'failed' ? 'danger' : 'muted'));
315
+ group.append(heading);
316
+ if (item.message) group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: item.message }));
317
+ if (item.asOf) group.append(makeElement(doc, 'small', { className: 'bb-pkw-muted', text: `読み取った時点: ${formatDateTime(item.asOf)}` }));
318
+ if (item.items.length > 0) {
319
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-context-list' });
320
+ for (const entry of item.items) {
321
+ const row = makeElement(doc, 'li');
322
+ row.append(makeElement(doc, 'strong', { text: entry.title }));
323
+ if (entry.status) row.append(badge(doc, entry.status, 'muted'));
324
+ const facts = [entry.summary, entry.owner ? `担当: ${entry.owner}` : null, entry.dueAt ? `期限: ${entry.dueAt}` : null, entry.updatedAt ? `更新: ${entry.updatedAt}` : null, entry.source ? `出典: ${entry.source}` : null].filter(Boolean);
325
+ if (facts.length > 0) row.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: facts.join(' · ') }));
326
+ list.append(row);
327
+ }
328
+ group.append(list);
329
+ } else if (item.state === 'ok') {
330
+ group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: 'このセクションに表示できる記録はありません。' }));
331
+ }
332
+ section.append(group);
333
+ }
334
+ return section;
335
+ }
336
+
337
+ function renderOverview(doc, detail, context, edges, entities, onSelect) {
338
+ const payload = projectDetailPayload(detail);
339
+ const project = payload?.project;
340
+ const panel = makeElement(doc, 'div', { className: 'bb-pkw-panel bb-pkw-overview', attrs: { role: 'tabpanel', 'aria-label': '概要' } });
341
+ const columns = makeElement(doc, 'div', { className: 'bb-pkw-columns' });
342
+ const summary = makeElement(doc, 'section', { className: 'bb-pkw-section', attrs: { 'aria-label': '目的と現在地' } });
343
+ summary.append(makeElement(doc, 'h3', { text: '目的と現在地' }), workspaceDefinition(doc, [
344
+ ['目的', textOrNull(project?.goal)],
345
+ ['状態', textOrNull(project?.status)],
346
+ ['有効期間', validityText(project?.validFrom, project?.validTo)],
347
+ ['出典', sourceLabel(payload?.source)],
348
+ ['読み取った時点', payload?.asOf ? formatDateTime(payload.asOf) : null],
349
+ ]));
350
+ columns.append(summary);
351
+
352
+ const people = makeElement(doc, 'section', { className: 'bb-pkw-section', attrs: { 'aria-label': '関係者' } });
353
+ people.append(makeElement(doc, 'h3', { text: '関係者と役割' }));
354
+ const participantEdges = Array.isArray(payload?.participants) ? payload.participants : [];
355
+ if (participantEdges.length === 0) {
356
+ people.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '関係者の記録はありません。' }));
357
+ } else {
358
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-people-list' });
359
+ for (const edge of participantEdges) {
360
+ const item = makeElement(doc, 'li');
361
+ const counterpart = edge.counterpart ?? {};
362
+ item.append(recordLink(doc, cleanName(counterpart.name, counterpart.id) ?? '名前不明', cleanId(counterpart.id), onSelect), badge(doc, relationLabel(edge.relation), edge.relation === 'accountable_for' ? 'accent' : 'muted'));
363
+ const facts = [textOrNull(edge.role) ? `役割: ${edge.role}` : null, textOrNull(edge.context) ? `文脈: ${edge.context}` : null].filter(Boolean);
364
+ if (facts.length > 0) item.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: facts.join(' · ') }));
365
+ list.append(item);
366
+ }
367
+ people.append(list);
368
+ }
369
+ columns.append(people);
370
+ panel.append(columns);
371
+
372
+ const decisions = makeElement(doc, 'section', { className: 'bb-pkw-section', attrs: { 'aria-label': '記録された判断' } });
373
+ decisions.append(makeElement(doc, 'h3', { text: '記録された判断' }));
374
+ const decisionEdges = edges.filter((edge) => entities.get(edge.source)?.type === 'decision' || entities.get(edge.target)?.type === 'decision');
375
+ if (decisionEdges.length === 0) decisions.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: 'このプロジェクトに結びつく判断の記録はありません。' }));
376
+ else {
377
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-decision-list' });
378
+ for (const edge of decisionEdges) {
379
+ const decisionId = entities.get(edge.source)?.type === 'decision' ? edge.source : edge.target;
380
+ const decision = entities.get(decisionId);
381
+ const item = makeElement(doc, 'li');
382
+ item.append(recordLink(doc, decision?.name ?? decisionId, decisionId, onSelect), badge(doc, relationLabel(edge.relation), 'muted'));
383
+ if (decision?.summary) item.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: decision.summary }));
384
+ list.append(item);
385
+ }
386
+ decisions.append(list);
387
+ }
388
+ panel.append(decisions);
389
+
390
+ const objects = makeElement(doc, 'section', { className: 'bb-pkw-section', attrs: { 'aria-label': '関連する情報' } });
391
+ objects.append(makeElement(doc, 'h3', { text: '関連する情報' }));
392
+ const groups = new Map();
393
+ for (const entity of entities.values()) {
394
+ if (entity.id === project?.id || entity.type === 'person' || entity.type === 'decision') continue;
395
+ if (!groups.has(entity.type)) groups.set(entity.type, []);
396
+ groups.get(entity.type).push(entity);
397
+ }
398
+ if (groups.size === 0) objects.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '関連する情報の記録はありません。' }));
399
+ else {
400
+ const grid = makeElement(doc, 'div', { className: 'bb-pkw-info-grid' });
401
+ for (const [type, records] of groups) {
402
+ const group = makeElement(doc, 'section', { className: 'bb-pkw-object', attrs: { 'aria-label': typeLabel(type) } });
403
+ group.append(makeElement(doc, 'h4', { text: typeLabel(type) }));
404
+ const list = makeElement(doc, 'ul');
405
+ for (const record of records) {
406
+ const item = makeElement(doc, 'li');
407
+ item.append(recordLink(doc, record.name, record.id, onSelect));
408
+ list.append(item);
409
+ }
410
+ group.append(list);
411
+ grid.append(group);
412
+ }
413
+ objects.append(grid);
414
+ }
415
+ panel.append(objects);
416
+
417
+ const unknowns = projectKnowledgeUnknowns(detail);
418
+ if (unknowns.length > 0) {
419
+ const missing = makeElement(doc, 'section', { className: 'bb-pkw-section bb-pkw-unknown', attrs: { 'aria-label': '未記録・未確認' } });
420
+ missing.append(makeElement(doc, 'h3', { text: '未記録・未確認' }));
421
+ const list = makeElement(doc, 'ul');
422
+ for (const unknown of unknowns) list.append(makeElement(doc, 'li', { text: unknown }));
423
+ missing.append(list);
424
+ panel.append(missing);
425
+ }
426
+ const contextBlock = renderContext(doc, context);
427
+ if (contextBlock) panel.append(contextBlock);
428
+ return panel;
429
+ }
430
+
431
+ /**
432
+ * Mounts the shared project knowledge UI. `mountGraph` is normally backed by
433
+ * Sigma.js in the host and is intentionally injected to keep this module
434
+ * testable and usable by the organization shell.
435
+ */
436
+ export function createProjectKnowledgeWorkspace({
437
+ document: explicitDocument,
438
+ detail,
439
+ context = null,
440
+ mountGraph,
441
+ readEntity,
442
+ onTabChange,
443
+ } = {}) {
444
+ const doc = getDocument(explicitDocument);
445
+ const payload = projectDetailPayload(detail);
446
+ const project = payload?.project;
447
+ if (!project) throw new TypeError('project detail is required');
448
+ const entities = graphEntityMap(payload);
449
+ const edges = graphEvidenceEdges(payload);
450
+ let destroyed = false;
451
+ let selectedId = project.id;
452
+ let activeTab = 'overview';
453
+ let graphHandle = null;
454
+ let graphTimer = null;
455
+ let graphMountToken = 0;
456
+ let graphFailure = null;
457
+ let entityRead = null;
458
+ let entityReadRequest = 0;
459
+ let graphSearch = '';
460
+ let graphType = '';
461
+ // Keep direct references as well as the DOM lookup. The local test harness
462
+ // intentionally has no querySelector implementation, and a host can replace
463
+ // the panel while a detail read is still in flight.
464
+ let selectedPanel = null;
465
+
466
+ const root = makeElement(doc, 'section', { className: 'bb-pkw', attrs: { 'data-contract-version': PROJECT_WORKSPACE_CONTRACT_VERSION, 'aria-label': 'プロジェクトの知識' } });
467
+ const body = makeElement(doc, 'div', { className: 'bb-pkw-body' });
468
+ const tabs = makeElement(doc, 'div', { className: 'bb-pkw-tabs', attrs: { role: 'tablist', 'aria-label': 'プロジェクト情報の表示' } });
469
+ const panels = makeElement(doc, 'div', { className: 'bb-pkw-panels' });
470
+ root.append(makeElement(doc, 'div', { className: 'bb-pkw-kicker', text: 'プロジェクトの記録' }));
471
+ const header = makeElement(doc, 'header', { className: 'bb-pkw-header' });
472
+ header.append(makeElement(doc, 'h2', { text: project.name }), makeElement(doc, 'p', { className: 'bb-pkw-lead', text: '目的・関係・判断の根拠を、実際に記録された情報からたどれます。' }));
473
+ const metadata = makeElement(doc, 'div', { className: 'bb-pkw-meta' });
474
+ if (sourceLabel(payload.source)) metadata.append(badge(doc, `出典: ${sourceLabel(payload.source)}`, 'muted'));
475
+ if (payload.asOf) metadata.append(badge(doc, `読み取り: ${formatDateTime(payload.asOf)}`, 'muted'));
476
+ header.append(metadata);
477
+ root.append(header);
478
+ root.append(tabs, panels);
479
+ root.append(body);
480
+
481
+ function setTab(tab) {
482
+ activeTab = tab === 'graph' ? 'graph' : 'overview';
483
+ if (typeof onTabChange === 'function') onTabChange(activeTab);
484
+ render();
485
+ }
486
+
487
+ function mergeEntityRead(read) {
488
+ if (!isRecord(read?.entity)) return;
489
+ addEntity(entities, read.entity);
490
+ for (const edge of [...(read.incoming ?? []), ...(read.outgoing ?? [])]) {
491
+ addEntity(entities, counterpartEntity(edge));
492
+ const id = cleanId(edge?.id);
493
+ const { source, target } = edgeSourceTarget(edge, read.entity.id);
494
+ if (!id || !source || !target || edges.some((candidate) => candidate.id === id)) continue;
495
+ edges.push({ ...edge, id, source, target, relation: cleanId(edge.relation) ?? 'unknown', label: edgeLabel(edge) });
496
+ }
497
+ }
498
+
499
+ function select(id, { fromGraph = false } = {}) {
500
+ const target = cleanId(id);
501
+ if (!target || !entities.has(target) || destroyed) return;
502
+ // A list click synchronizes Sigma by calling its `select`, which notifies
503
+ // this workspace again. Keep the second notification from starting a
504
+ // duplicate entity read (or resetting a completed detail read).
505
+ if (selectedId === target) {
506
+ if (!fromGraph && graphHandle?.select) graphHandle.select(target);
507
+ return;
508
+ }
509
+ selectedId = target;
510
+ entityRead = null;
511
+ renderGraphList();
512
+ renderSelectedPanel();
513
+ if (!fromGraph && graphHandle?.select) graphHandle.select(target);
514
+ if (target !== project.id && typeof readEntity === 'function') void loadEntity(target);
515
+ }
516
+
517
+ function selectAndExplore(id) {
518
+ const target = cleanId(id);
519
+ if (!target || !entities.has(target) || destroyed) return;
520
+ activeTab = 'graph';
521
+ select(target);
522
+ render();
523
+ }
524
+
525
+ function tabButton(label, tab) {
526
+ const button = makeElement(doc, 'button', { className: `bb-pkw-tab${activeTab === tab ? ' is-active' : ''}`, text: label, attrs: { type: 'button', role: 'tab', 'aria-selected': activeTab === tab ? 'true' : 'false', 'aria-controls': `bb-pkw-panel-${tab}` } });
527
+ button.addEventListener('click', () => setTab(tab));
528
+ return button;
529
+ }
530
+
531
+ function renderGraphList() {
532
+ const list = root.querySelector?.('.bb-pkw-graph-list') ?? graphList;
533
+ if (!list) return;
534
+ list.replaceChildren();
535
+ const visible = filteredGraphEntities();
536
+ if (visible.length === 0) {
537
+ list.append(makeElement(doc, 'li', { className: 'bb-pkw-muted', text: '条件に一致する記録はありません。' }));
538
+ return;
539
+ }
540
+ for (const entity of visible) {
541
+ const row = makeElement(doc, 'li');
542
+ const button = makeElement(doc, 'button', { className: `bb-pkw-graph-item${selectedId === entity.id ? ' is-selected' : ''}`, attrs: { type: 'button', 'aria-pressed': selectedId === entity.id ? 'true' : 'false' } });
543
+ button.append(makeElement(doc, 'strong', { text: entity.name }), badge(doc, typeLabel(entity.type), 'muted'));
544
+ button.addEventListener('click', () => select(entity.id));
545
+ row.append(button);
546
+ list.append(row);
547
+ }
548
+ }
549
+
550
+ const graphList = makeElement(doc, 'ul', { className: 'bb-pkw-graph-list', attrs: { 'aria-label': 'グラフの記録一覧' } });
551
+
552
+ function filteredGraphEntities() {
553
+ const query = graphSearch.trim().toLocaleLowerCase();
554
+ return [...entities.values()].filter((entity) => {
555
+ if (graphType && entity.type !== graphType) return false;
556
+ if (!query) return true;
557
+ return [entity.name, entity.type, entity.summary].filter(Boolean).some((value) => String(value).toLocaleLowerCase().includes(query));
558
+ });
559
+ }
560
+
561
+ function renderSelectedPanel() {
562
+ const panel = root.querySelector?.('.bb-pkw-selection') ?? selectedPanel;
563
+ if (!panel) return;
564
+ panel.replaceChildren();
565
+ const entity = entities.get(selectedId);
566
+ if (!entity) return;
567
+ const read = entityRead?.id === selectedId ? entityRead : null;
568
+ const fetched = selectedId === project.id ? { entity: project, incoming: [], outgoing: [] } : read?.entity ? read : null;
569
+ const selectedEntity = fetched?.entity ?? entity;
570
+ panel.append(makeElement(doc, 'h3', { text: '選択した記録' }), makeElement(doc, 'div', { className: 'bb-pkw-selected-head' }));
571
+ const head = panel.children[1];
572
+ head.append(makeElement(doc, 'strong', { text: selectedEntity.name }), badge(doc, typeLabel(selectedEntity.type), 'accent'));
573
+ panel.append(workspaceDefinition(doc, [
574
+ ['要約', textOrNull(selectedEntity.summary)],
575
+ ['状態', textOrNull(selectedEntity.status)],
576
+ ['目的', textOrNull(selectedEntity.goal)],
577
+ ['有効期間', validityText(selectedEntity.validFrom, selectedEntity.validTo)],
578
+ ]));
579
+ panel.append(makeElement(doc, 'h4', { text: 'このプロジェクトとの関係' }), relationEvidence(doc, edges, selectedId, entities, payload.asOf, selectAndExplore));
580
+ if (selectedId !== project.id && !fetched) {
581
+ const failed = read?.state === 'failed';
582
+ panel.append(workspaceNotice(doc, {
583
+ label: failed ? '読み取り失敗' : read?.state === 'loading' ? '読み込み中' : '未接続',
584
+ text: failed ? read.message : read?.state === 'loading' ? 'この記録の詳細を読み込んでいます。' : 'このホストでは記録の詳細取得先が接続されていません。',
585
+ tone: failed ? 'danger' : 'info',
586
+ role: failed ? 'alert' : 'status',
587
+ }));
588
+ }
589
+ if (fetched?.entity) {
590
+ const incoming = renderEntityRelations(doc, fetched.incoming, 'incoming', fetched.entity, payload.asOf, selectAndExplore);
591
+ const outgoing = renderEntityRelations(doc, fetched.outgoing, 'outgoing', fetched.entity, payload.asOf, selectAndExplore);
592
+ if (incoming) panel.append(makeElement(doc, 'h4', { text: '入ってくる関係' }), incoming);
593
+ if (outgoing) panel.append(makeElement(doc, 'h4', { text: '出ていく関係' }), outgoing);
594
+ const metadataValues = isRecord(fetched.entity.metadata) ? Object.entries(fetched.entity.metadata).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).map(([key, value]) => [key, String(value)]) : [];
595
+ if (metadataValues.length > 0) panel.append(makeElement(doc, 'h4', { text: 'メタデータ' }), workspaceDefinition(doc, metadataValues));
596
+ panel.append(recordDetails(doc, [['記録ID', fetched.entity.id], ['digest', fetched.entity.digest], ['読み取った時点', fetched.asOf ?? payload.asOf]]));
597
+ }
598
+ }
599
+
600
+ async function loadEntity(id) {
601
+ const request = ++entityReadRequest;
602
+ entityRead = { id, state: 'loading' };
603
+ renderSelectedPanel();
604
+ try {
605
+ const result = await readEntity(id);
606
+ if (destroyed || request !== entityReadRequest || selectedId !== id) return;
607
+ const normalized = resultEntity(result);
608
+ entityRead = normalized ? { id, ...normalized } : { id, state: 'failed', message: resultFailure(result) };
609
+ } catch (error) {
610
+ if (destroyed || request !== entityReadRequest || selectedId !== id) return;
611
+ entityRead = { id, state: 'failed', message: firstText(error?.message) ?? 'この記録の詳細を読み取れませんでした。' };
612
+ }
613
+ if (entityRead?.entity) mergeEntityRead(entityRead);
614
+ render();
615
+ }
616
+
617
+ function renderGraphPanel() {
618
+ const panel = makeElement(doc, 'section', { className: 'bb-pkw-panel bb-pkw-graph-panel', attrs: { role: 'tabpanel', id: 'bb-pkw-panel-graph', 'aria-label': '情報を探す' } });
619
+ const intro = makeElement(doc, 'div', { className: 'bb-pkw-graph-intro' });
620
+ intro.append(makeElement(doc, 'div', { className: 'bb-pkw-section-title' }), makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '線はGraphに記録された関係です。補足セクションの所属だけから線を作ることはありません。' }));
621
+ intro.children[0].append(makeElement(doc, 'h3', { text: '関係をたどる' }), badge(doc, `${entities.size}件の記録 · ${edges.length}件の関係`, 'muted'));
622
+ panel.append(intro);
623
+ const filters = makeElement(doc, 'div', { className: 'bb-pkw-graph-filters', attrs: { 'aria-label': '記録一覧の絞り込み' } });
624
+ filters.append(makeElement(doc, 'span', { className: 'bb-pkw-graph-filter-label', text: '記録一覧を絞り込む' }));
625
+ const search = makeElement(doc, 'input', {
626
+ className: 'bb-pkw-graph-search',
627
+ attrs: { type: 'search', placeholder: '記録を検索', 'aria-label': '記録を検索' },
628
+ });
629
+ search.value = graphSearch;
630
+ search.addEventListener('input', () => {
631
+ graphSearch = search.value ?? '';
632
+ renderGraphList();
633
+ });
634
+ const type = makeElement(doc, 'select', { className: 'bb-pkw-graph-type', attrs: { 'aria-label': '種類で絞り込む' } });
635
+ type.append(makeElement(doc, 'option', { text: 'すべての種類', attrs: { value: '' } }));
636
+ for (const value of new Set([...entities.values()].map((entity) => entity.type))) {
637
+ type.append(makeElement(doc, 'option', { text: typeLabel(value), attrs: { value } }));
638
+ }
639
+ type.value = graphType;
640
+ type.addEventListener('change', () => {
641
+ graphType = type.value ?? '';
642
+ renderGraphList();
643
+ });
644
+ filters.append(search, type);
645
+ panel.append(filters);
646
+ const graph = makeElement(doc, 'div', { className: 'bb-pkw-graph' });
647
+ const canvas = makeElement(doc, 'div', { className: 'bb-pkw-graph-canvas', attrs: { 'aria-label': 'プロジェクトの関係グラフ' } });
648
+ graph.append(canvas);
649
+ if (graphFailure) graph.append(workspaceNotice(doc, { label: 'グラフを表示できません', text: '一覧から関係を確認できます。', tone: 'warning' }));
650
+ graph.append(graphList);
651
+ const selected = makeElement(doc, 'section', { className: 'bb-pkw-selection', attrs: { 'aria-label': '選択した記録' } });
652
+ selectedPanel = selected;
653
+ graph.append(selected);
654
+ panel.append(graph);
655
+ renderGraphList();
656
+ renderSelectedPanel();
657
+ if (activeTab === 'graph' && typeof mountGraph === 'function' && !graphFailure) {
658
+ const nodes = [...entities.values()].map((entity) => ({ id: entity.id, label: entity.name, type: entity.type }));
659
+ const initialSelectedId = selectedId;
660
+ const token = ++graphMountToken;
661
+ const mount = () => {
662
+ graphTimer = null;
663
+ if (destroyed || token !== graphMountToken || graphHandle) return;
664
+ Promise.resolve()
665
+ .then(() => mountGraph(canvas, { nodes, edges, selectedId: initialSelectedId, onSelect: (id) => select(id, { fromGraph: true }) }))
666
+ .then((handle) => {
667
+ if (destroyed || token !== graphMountToken || activeTab !== 'graph') {
668
+ handle?.destroy?.();
669
+ return;
670
+ }
671
+ graphHandle = handle ?? null;
672
+ if (selectedId !== initialSelectedId) graphHandle?.select?.(selectedId);
673
+ })
674
+ .catch(() => {
675
+ if (destroyed || token !== graphMountToken || activeTab !== 'graph') return;
676
+ graphFailure = true;
677
+ graph.append(workspaceNotice(doc, { label: 'グラフを表示できません', text: '一覧から関係を確認できます。', tone: 'warning' }));
678
+ });
679
+ };
680
+ graphTimer = typeof setTimeout === 'function' ? setTimeout(mount, 0) : null;
681
+ if (graphTimer === null && typeof queueMicrotask === 'function') queueMicrotask(mount);
682
+ } else if (typeof mountGraph !== 'function') {
683
+ graph.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: 'グラフ表示はこのホストで未接続です。一覧から関係を確認できます。' }));
684
+ }
685
+ return panel;
686
+ }
687
+
688
+ function teardownGraph() {
689
+ graphMountToken += 1;
690
+ if (graphTimer !== null && typeof clearTimeout === 'function') clearTimeout(graphTimer);
691
+ graphTimer = null;
692
+ if (graphHandle?.destroy) graphHandle.destroy();
693
+ graphHandle = null;
694
+ }
695
+
696
+ function render() {
697
+ if (destroyed) return;
698
+ if (typeof onTabChange === 'function') onTabChange(activeTab);
699
+ teardownGraph();
700
+ tabs.replaceChildren(tabButton('概要', 'overview'), tabButton('情報を探す', 'graph'));
701
+ panels.replaceChildren();
702
+ const overview = renderOverview(doc, payload, context, edges, entities, selectAndExplore);
703
+ overview.id = 'bb-pkw-panel-overview';
704
+ overview.hidden = activeTab !== 'overview';
705
+ panels.append(overview);
706
+ const graph = renderGraphPanel();
707
+ graph.hidden = activeTab !== 'graph';
708
+ panels.append(graph);
709
+ }
710
+
711
+ function destroy() {
712
+ destroyed = true;
713
+ entityReadRequest += 1;
714
+ teardownGraph();
715
+ if (typeof onTabChange === 'function') onTabChange('overview');
716
+ }
717
+
718
+ render();
719
+ return Object.freeze({ element: root, destroy, select, setTab });
720
+ }
721
+
722
+ export default createProjectKnowledgeWorkspace;