@unson/brainbase-mcp 0.41.11 → 0.42.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,677 @@
1
+ /*
2
+ * 「あなたの記憶」 (story-personal-memory-viewer-v1, docs/specs/personal-memory-viewer.md).
3
+ *
4
+ * The owner's own memories as a list with the selected one's detail on the
5
+ * right. The part never reads memories by itself: the host passes a port
6
+ * (`listMemories`, `readMemory`, `listJudgmentsUsing`, optional
7
+ * `countJudgmentsUsing`, `isOwner`), and the local Web host and the
8
+ * organization edition answer it the same way. Read only: there is no way
9
+ * to correct, retract or export a memory here.
10
+ *
11
+ * The address keeps the kind, state, organization and opened memory id; the
12
+ * words being searched for and the memory bodies never enter it.
13
+ */
14
+
15
+ import {
16
+ makeWorkspaceElement,
17
+ workspaceButton,
18
+ workspaceDefinition,
19
+ workspaceDetailEmpty,
20
+ workspaceErrorCard,
21
+ workspaceLedger,
22
+ workspaceNotice,
23
+ workspacePageHeader,
24
+ workspaceRailBlock,
25
+ workspaceRailHead,
26
+ } from './workspace-kit.js';
27
+
28
+ export const PERSONAL_MEMORY_UI_CONTRACT_VERSION = 'brainbase.personal-memory-ui.v1';
29
+
30
+ export const PERSONAL_MEMORY_KIND_LABELS = Object.freeze({
31
+ self: '自分',
32
+ work: '仕事',
33
+ relationship: '関係',
34
+ value: '大切にすること',
35
+ judgment: '判断',
36
+ experience: '経験',
37
+ sns_context: '発信',
38
+ });
39
+ const NO_KIND_LABEL = '種類の記録なし';
40
+
41
+ export const PERSONAL_MEMORY_STATE_LABELS = Object.freeze({
42
+ active: '有効',
43
+ unrecorded: '状態の記録なし',
44
+ superseded: '置き換え済み',
45
+ retracted: '取り消し',
46
+ });
47
+ const STATES = Object.keys(PERSONAL_MEMORY_STATE_LABELS);
48
+ const PAST_STATES = new Set(['superseded', 'retracted']);
49
+
50
+ /**
51
+ * The memories that are current: 有効, and those whose writer keeps no state
52
+ * record (the local `personal-kg.jsonl`). The latter are shown as 状態の記録なし,
53
+ * never as 有効.
54
+ */
55
+ export const DEFAULT_PERSONAL_MEMORY_STATES = Object.freeze(['active', 'unrecorded']);
56
+ const UNKNOWN_ORIGIN = '不明';
57
+ const PRIVACY_LINE = 'この画面はあなただけが見ています。組織の管理者も読めません。';
58
+ const JUDGMENT_PREVIEW = 5;
59
+
60
+ const el = makeWorkspaceElement;
61
+
62
+ // Why a read is unavailable, in the owner's words; the code stays beside it for support.
63
+ const REASON_LABELS = Object.freeze({
64
+ personal_kg_missing: '個人KGのファイルがありません',
65
+ personal_kg_invalid: '個人KGのファイルの形式が正しくありません',
66
+ personal_kg_unavailable: '個人KGを読めません',
67
+ journal_unavailable: '判断の記録を読めません',
68
+ journal_partial: '判断の記録を一部しか読めていません',
69
+ reference_source_mismatch: '判断の記録が参照している記憶は、ここにある記憶とは別の置き場のものです',
70
+ web_token_required: '起動時のトークンを確かめられません。ページを開き直してください',
71
+ });
72
+
73
+ function reasonText(reason) {
74
+ const code = String(reason ?? 'unknown');
75
+ return REASON_LABELS[code] ? `${REASON_LABELS[code]}(${code})` : code;
76
+ }
77
+
78
+ function text(value) {
79
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
80
+ }
81
+
82
+ function sameList(left, right) {
83
+ return left.length === right.length && left.every((value, index) => value === right[index]);
84
+ }
85
+
86
+ function normalizeStates(values) {
87
+ const states = STATES.filter((state) => values.includes(state));
88
+ return states.length > 0 ? states : [...DEFAULT_PERSONAL_MEMORY_STATES];
89
+ }
90
+
91
+ function normalizeRoute(route = {}) {
92
+ const kinds = Array.isArray(route.kinds) ? Object.keys(PERSONAL_MEMORY_KIND_LABELS).filter((kind) => route.kinds.includes(kind)) : [];
93
+ return {
94
+ kinds,
95
+ states: normalizeStates(Array.isArray(route.states) ? route.states : []),
96
+ organization: text(route.organization),
97
+ memoryId: text(route.memoryId),
98
+ };
99
+ }
100
+
101
+ /** Reads `kind`, `state`, `org` and `id` from the address. Unknown values fall back to the defaults. */
102
+ export function parsePersonalMemoryRoute(params) {
103
+ const list = (name) => (params.get(name) ?? '').split(',').map((value) => value.trim()).filter(Boolean);
104
+ return normalizeRoute({ kinds: list('kind'), states: list('state'), organization: params.get('org'), memoryId: params.get('id') });
105
+ }
106
+
107
+ /** The shell's hash target for a route. Anything else in the route (the search words) is dropped. */
108
+ export function personalMemoryRouteTarget(route) {
109
+ const normalized = normalizeRoute(route);
110
+ const params = new URLSearchParams();
111
+ if (normalized.kinds.length > 0) params.set('kind', normalized.kinds.join(','));
112
+ if (!sameList(normalized.states, DEFAULT_PERSONAL_MEMORY_STATES)) params.set('state', normalized.states.join(','));
113
+ if (normalized.organization) params.set('org', normalized.organization);
114
+ if (normalized.memoryId) params.set('id', normalized.memoryId);
115
+ const query = params.toString();
116
+ return query ? `memory?${query}` : 'memory';
117
+ }
118
+
119
+ function organizationOf(item) {
120
+ return text(item?.origin?.organizationLabel) ?? UNKNOWN_ORIGIN;
121
+ }
122
+
123
+ /** The filter a port may apply on its side; the local port applies it after one read. */
124
+ export function filterPersonalMemories(items, { kinds, states, organization, query } = {}) {
125
+ const words = text(query)?.toLowerCase() ?? null;
126
+ return (Array.isArray(items) ? items : []).filter((item) => {
127
+ if (Array.isArray(kinds) && kinds.length > 0 && !kinds.includes(item.kind)) return false;
128
+ if (Array.isArray(states) && states.length > 0 && !states.includes(item.state)) return false;
129
+ if (text(organization) && organizationOf(item) !== text(organization)) return false;
130
+ if (words) {
131
+ const haystack = [item.text, ...(Array.isArray(item.tags) ? item.tags : [])].join('\n').toLowerCase();
132
+ if (!haystack.includes(words)) return false;
133
+ }
134
+ return true;
135
+ });
136
+ }
137
+
138
+ function kindLabel(kind) {
139
+ return PERSONAL_MEMORY_KIND_LABELS[kind] ?? NO_KIND_LABEL;
140
+ }
141
+
142
+ function excerpt(value, length = 120) {
143
+ const body = String(value ?? '').replace(/\s+/gu, ' ').trim();
144
+ return body.length > length ? `${body.slice(0, length)}…` : body;
145
+ }
146
+
147
+ function pad(value) {
148
+ return String(value).padStart(2, '0');
149
+ }
150
+
151
+ function formatDateTime(value) {
152
+ const date = value ? new Date(value) : null;
153
+ if (!date || Number.isNaN(date.getTime())) return null;
154
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
155
+ }
156
+
157
+ function formatDay(value) {
158
+ return formatDateTime(value)?.slice(0, 10) ?? null;
159
+ }
160
+
161
+ function relativeDay(value, now) {
162
+ const date = value ? new Date(value) : null;
163
+ if (!date || Number.isNaN(date.getTime())) return null;
164
+ const days = Math.floor((now.getTime() - date.getTime()) / 86_400_000);
165
+ if (days <= 0) return '今日';
166
+ if (days < 30) return `${days}日前`;
167
+ return formatDay(value);
168
+ }
169
+
170
+ function stateBadge(doc, state) {
171
+ return el(doc, 'span', { className: `bb-pm-state is-${state}`, text: PERSONAL_MEMORY_STATE_LABELS[state] ?? state });
172
+ }
173
+
174
+ function inferredBadge(doc) {
175
+ return el(doc, 'span', { className: 'bb-pm-inferred', text: '推定', attrs: { title: 'ログイン中の組織から付いた出どころです' } });
176
+ }
177
+
178
+ function originLine(doc, origin) {
179
+ const line = el(doc, 'span', { className: 'bb-pm-origin' });
180
+ const organization = text(origin?.organizationLabel) ?? UNKNOWN_ORIGIN;
181
+ const project = text(origin?.projectLabel);
182
+ line.append(el(doc, 'span', { text: project ? `${organization} › ${project}` : organization }));
183
+ if (origin?.inferred === true) line.append(inferredBadge(doc));
184
+ return line;
185
+ }
186
+
187
+ function recallScopeText(item) {
188
+ if (item.recallScope === 'organization') return `${text(item.origin?.organizationLabel) ?? 'その組織'}の仕事でだけ使われます`;
189
+ if (item.recallScope === 'general') return 'どの組織の仕事でも使われます';
190
+ return '使われる範囲を確かめられません';
191
+ }
192
+
193
+ function usageText(usage, id) {
194
+ if (!usage || usage.phase === 'loading') return { text: '確かめています', className: 'is-loading' };
195
+ if (usage.phase !== 'ok' || !Number.isInteger(usage.counts?.[id])) return { text: '確認できない', className: 'is-unknown' };
196
+ const count = usage.counts[id];
197
+ return count > 0 ? { text: `使われた判断 ${count}件`, className: '' } : { text: 'まだ使われていない', className: 'is-none' };
198
+ }
199
+
200
+ export function createPersonalMemoryUI({
201
+ root,
202
+ rail = null,
203
+ page,
204
+ document: explicitDocument,
205
+ port,
206
+ judgmentHref = () => null,
207
+ candidatesHref = null,
208
+ candidatesCommand = null,
209
+ route: initialRoute,
210
+ onRouteChange,
211
+ now = () => new Date(),
212
+ } = {}) {
213
+ if (!root) throw new TypeError('root is required');
214
+ if (!port || typeof port.isOwner !== 'function' || typeof port.listMemories !== 'function'
215
+ || typeof port.readMemory !== 'function' || typeof port.listJudgmentsUsing !== 'function') {
216
+ throw new TypeError('port is required');
217
+ }
218
+ const doc = explicitDocument ?? (typeof document === 'undefined' ? null : document);
219
+ if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
220
+ const crumbs = Array.isArray(page?.crumbs) ? page.crumbs : ['あなたのBrainbase', 'あなたの記憶'];
221
+ const readNow = () => (typeof now === 'function' ? now() : now);
222
+
223
+ const state = {
224
+ owner: null,
225
+ route: normalizeRoute(initialRoute),
226
+ query: '',
227
+ list: { phase: 'idle', items: null, reason: null, nextCursor: null },
228
+ usage: null,
229
+ detail: null,
230
+ judgments: null,
231
+ openFilter: null,
232
+ organizations: new Set(),
233
+ destroyed: false,
234
+ };
235
+ let listGeneration = 0;
236
+ let detailGeneration = 0;
237
+
238
+ const detailHost = rail ?? el(doc, 'section', { className: 'bb-pm-detail-inline', attrs: { 'aria-label': '記憶の詳細' } });
239
+
240
+ function go(route, options = {}) {
241
+ const next = normalizeRoute({ ...state.route, ...route });
242
+ if (typeof onRouteChange === 'function') onRouteChange(next, options);
243
+ else applyRoute(next);
244
+ }
245
+
246
+ function isOwner() {
247
+ if (state.owner === null) state.owner = port.isOwner() === true;
248
+ return state.owner;
249
+ }
250
+
251
+ async function loadUsage(items, generation) {
252
+ if (typeof port.countJudgmentsUsing !== 'function') {
253
+ state.usage = { phase: 'unavailable', reason: 'not_supported' };
254
+ return;
255
+ }
256
+ state.usage = { phase: 'loading' };
257
+ let result;
258
+ try {
259
+ result = await port.countJudgmentsUsing(items.map((item) => item.id));
260
+ } catch (error) {
261
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
262
+ }
263
+ if (generation !== listGeneration || state.destroyed) return;
264
+ state.usage = result?.status === 'ok' && result.counts && typeof result.counts === 'object'
265
+ ? { phase: 'ok', counts: result.counts }
266
+ : { phase: 'unavailable', reason: result?.reason ?? 'response_invalid' };
267
+ render();
268
+ }
269
+
270
+ async function load() {
271
+ if (state.destroyed) return;
272
+ if (!isOwner()) {
273
+ render();
274
+ return;
275
+ }
276
+ const generation = ++listGeneration;
277
+ // The previous list stays on screen while the next one loads.
278
+ state.list = { ...state.list, phase: 'loading' };
279
+ render();
280
+ let result;
281
+ try {
282
+ result = await port.listMemories({ kinds: state.route.kinds, organization: state.route.organization, query: state.query || undefined });
283
+ } catch (error) {
284
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
285
+ }
286
+ if (generation !== listGeneration || state.destroyed) return;
287
+ if (result?.status === 'ok' && Array.isArray(result.items)) {
288
+ state.list = { phase: 'ready', items: result.items, reason: null, nextCursor: result.nextCursor ?? null };
289
+ for (const item of result.items) state.organizations.add(organizationOf(item));
290
+ const usage = loadUsage(result.items, generation);
291
+ render();
292
+ // The counts read the judgment journal and can be slow: the detail does not wait for them.
293
+ const detail = state.route.memoryId && state.detail?.id !== state.route.memoryId ? loadDetail(state.route.memoryId) : null;
294
+ await Promise.all([usage, detail]);
295
+ return;
296
+ }
297
+ state.list = { phase: 'error', items: null, reason: result?.reason ?? 'response_invalid', nextCursor: null };
298
+ render();
299
+ }
300
+
301
+ async function loadJudgments(id, limit) {
302
+ const generation = detailGeneration;
303
+ state.judgments = { id, phase: 'loading', result: state.judgments?.id === id ? state.judgments.result : null };
304
+ render();
305
+ let result;
306
+ try {
307
+ result = await port.listJudgmentsUsing(id, limit);
308
+ } catch (error) {
309
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
310
+ }
311
+ if (generation !== detailGeneration || state.destroyed) return;
312
+ state.judgments = result?.status === 'ok' && Array.isArray(result.items) && Number.isInteger(result.total)
313
+ ? { id, phase: 'ok', result }
314
+ : { id, phase: 'unavailable', reason: result?.reason ?? 'response_invalid' };
315
+ render();
316
+ }
317
+
318
+ async function loadDetail(id) {
319
+ if (!isOwner() || state.destroyed) return;
320
+ const generation = ++detailGeneration;
321
+ state.detail = { id, phase: 'loading', item: state.detail?.id === id ? state.detail.item : null };
322
+ state.judgments = null;
323
+ render();
324
+ let result;
325
+ try {
326
+ result = await port.readMemory(id);
327
+ } catch (error) {
328
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
329
+ }
330
+ if (generation !== detailGeneration || state.destroyed) return;
331
+ if (result?.status === 'ok' && result.item) {
332
+ state.detail = { id, phase: 'ready', item: result.item };
333
+ await loadJudgments(id, JUDGMENT_PREVIEW);
334
+ return;
335
+ }
336
+ state.detail = result?.status === 'not_found'
337
+ ? { id, phase: 'not_found' }
338
+ : { id, phase: 'error', reason: result?.reason ?? 'response_invalid' };
339
+ render();
340
+ }
341
+
342
+ function applyRoute(route) {
343
+ const next = normalizeRoute(route);
344
+ const previous = state.route;
345
+ state.route = next;
346
+ state.openFilter = null;
347
+ if (!isOwner()) {
348
+ render();
349
+ return;
350
+ }
351
+ const listChanged = !sameList(previous.kinds, next.kinds) || previous.organization !== next.organization;
352
+ if (!next.memoryId) {
353
+ detailGeneration += 1;
354
+ state.detail = null;
355
+ state.judgments = null;
356
+ }
357
+ if (listChanged || state.list.phase === 'idle') {
358
+ void load();
359
+ return;
360
+ }
361
+ if (next.memoryId && next.memoryId !== state.detail?.id) void loadDetail(next.memoryId);
362
+ render();
363
+ }
364
+
365
+ function setQuery(value) {
366
+ state.query = String(value ?? '');
367
+ void load();
368
+ }
369
+
370
+ function toggleKind(kind) {
371
+ const kinds = state.route.kinds.includes(kind) ? state.route.kinds.filter((entry) => entry !== kind) : [...state.route.kinds, kind];
372
+ go({ kinds });
373
+ }
374
+
375
+ function toggleState(value) {
376
+ const states = state.route.states.includes(value) ? state.route.states.filter((entry) => entry !== value) : [...state.route.states, value];
377
+ go({ states: states.length > 0 ? states : [...DEFAULT_PERSONAL_MEMORY_STATES] });
378
+ }
379
+
380
+ function clearAll() {
381
+ state.query = '';
382
+ const reload = state.route.kinds.length === 0 && !state.route.organization;
383
+ go({ kinds: [], states: [...DEFAULT_PERSONAL_MEMORY_STATES], organization: null });
384
+ // Only the words changed: the route is the same, so the list reloads here.
385
+ if (reload) void load();
386
+ }
387
+
388
+ function renderFilterChip(id, label, isDefault, onClear) {
389
+ const chip = el(doc, 'span', { className: `bb-pm-chip${isDefault ? '' : ' is-active'}` });
390
+ const open = workspaceButton(doc, {
391
+ text: label,
392
+ variant: 'quiet',
393
+ attrs: { 'aria-expanded': state.openFilter === id ? 'true' : 'false', 'aria-haspopup': 'true' },
394
+ onClick: () => {
395
+ state.openFilter = state.openFilter === id ? null : id;
396
+ render();
397
+ },
398
+ });
399
+ open.className = 'bb-pm-chip-button';
400
+ chip.append(open);
401
+ if (!isDefault) {
402
+ const clear = workspaceButton(doc, { text: '×', variant: 'quiet', attrs: { 'aria-label': `${label.split(':')[0]}の絞り込みを外す` }, onClick: onClear });
403
+ clear.className = 'bb-pm-chip-clear';
404
+ chip.append(clear);
405
+ }
406
+ return chip;
407
+ }
408
+
409
+ function renderOptions(label, options) {
410
+ const group = el(doc, 'div', { className: 'bb-pm-filter-options', attrs: { role: 'group', 'aria-label': label } });
411
+ for (const option of options) {
412
+ const choice = workspaceButton(doc, { text: option.label, variant: 'quiet', attrs: { 'aria-pressed': option.selected ? 'true' : 'false' }, onClick: option.onSelect });
413
+ choice.className = `bb-pm-option${option.selected ? ' is-selected' : ''}`;
414
+ group.append(choice);
415
+ }
416
+ return group;
417
+ }
418
+
419
+ function renderFilters() {
420
+ const { route } = state;
421
+ const bar = el(doc, 'div', { className: 'bb-pm-filters', attrs: { role: 'search', 'aria-label': '記憶の絞り込み' } });
422
+ const kindsDefault = route.kinds.length === 0;
423
+ const statesDefault = sameList(route.states, DEFAULT_PERSONAL_MEMORY_STATES);
424
+ bar.append(
425
+ renderFilterChip('kind', `種類:${kindsDefault ? 'すべて' : route.kinds.map(kindLabel).join('・')}`, kindsDefault, () => go({ kinds: [] })),
426
+ renderFilterChip('state', `状態:${route.states.map((value) => PERSONAL_MEMORY_STATE_LABELS[value]).join('・')}`, statesDefault, () => go({ states: [...DEFAULT_PERSONAL_MEMORY_STATES] })),
427
+ renderFilterChip('organization', `出どころ:${route.organization ?? 'すべて'}`, !route.organization, () => go({ organization: null })),
428
+ );
429
+ const search = el(doc, 'input', { className: 'bb-pm-search', attrs: { type: 'search', name: 'query', placeholder: '語句で探す', 'aria-label': '語句で探す', autocomplete: 'off' } });
430
+ search.value = state.query;
431
+ search.addEventListener('input', () => setQuery(search.value));
432
+ bar.append(search);
433
+ const clear = workspaceButton(doc, { text: 'すべて外す', variant: 'quiet', disabled: kindsDefault && statesDefault && !route.organization && !state.query, onClick: clearAll });
434
+ clear.className = 'bb-pm-clear-all';
435
+ bar.append(clear);
436
+ const wrap = el(doc, 'div', { className: 'bb-pm-filter-bar' });
437
+ wrap.append(bar);
438
+ if (state.openFilter === 'kind') {
439
+ wrap.append(renderOptions('種類', Object.keys(PERSONAL_MEMORY_KIND_LABELS).map((kind) => ({
440
+ label: kindLabel(kind), selected: route.kinds.includes(kind), onSelect: () => toggleKind(kind),
441
+ }))));
442
+ } else if (state.openFilter === 'state') {
443
+ wrap.append(renderOptions('状態', STATES.map((value) => ({
444
+ label: PERSONAL_MEMORY_STATE_LABELS[value], selected: route.states.includes(value), onSelect: () => toggleState(value),
445
+ }))));
446
+ } else if (state.openFilter === 'organization') {
447
+ const organizations = [...new Set([...state.organizations, ...(route.organization ? [route.organization] : [])])].sort();
448
+ wrap.append(renderOptions('出どころの組織', organizations.map((organization) => ({
449
+ label: organization,
450
+ selected: route.organization === organization,
451
+ onSelect: () => go({ organization: route.organization === organization ? null : organization }),
452
+ }))));
453
+ }
454
+ return wrap;
455
+ }
456
+
457
+ function renderSummary(items) {
458
+ const counts = new Map();
459
+ for (const item of items) counts.set(item.state, (counts.get(item.state) ?? 0) + 1);
460
+ const parts = STATES.filter((value) => counts.has(value)).map((value) => `${PERSONAL_MEMORY_STATE_LABELS[value]} ${counts.get(value)}件`);
461
+ const latest = items.map((item) => item.updatedAt).filter(Boolean).sort().at(-1);
462
+ const updated = latest ? formatDateTime(latest) : null;
463
+ return el(doc, 'p', { className: 'bb-pm-summary', text: `${parts.join('・')}。${updated ? `最終更新 ${updated}` : '更新日時の記録なし'}` });
464
+ }
465
+
466
+ function renderLedger(items) {
467
+ const current = readNow();
468
+ return workspaceLedger(doc, {
469
+ className: 'bb-pm-ledger',
470
+ ariaLabel: 'あなたの記憶',
471
+ columns: ['種類', '記憶', '出どころ', '使われた判断', '更新'],
472
+ empty: '絞り込みに合う記憶はありません。',
473
+ rows: items.map((item) => {
474
+ const body = el(doc, 'span', { className: 'bb-pm-excerpt' });
475
+ if (PAST_STATES.has(item.state)) body.append(stateBadge(doc, item.state));
476
+ body.append(el(doc, 'span', { text: excerpt(item.text) }));
477
+ const updated = el(doc, 'span', { className: 'bb-pm-updated', text: relativeDay(item.updatedAt, current) ?? '記録なし', attrs: { title: formatDateTime(item.updatedAt) ?? undefined } });
478
+ return {
479
+ key: item.id,
480
+ label: excerpt(item.text, 60),
481
+ selected: state.route.memoryId === item.id,
482
+ className: PAST_STATES.has(item.state) ? 'is-past' : '',
483
+ cells: [
484
+ { text: kindLabel(item.kind), className: 'bb-pm-kind' },
485
+ body,
486
+ originLine(doc, item.origin),
487
+ usageText(state.usage, item.id),
488
+ updated,
489
+ ],
490
+ onSelect: (id) => go({ memoryId: id }),
491
+ };
492
+ }),
493
+ });
494
+ }
495
+
496
+ function renderEmpty() {
497
+ const box = el(doc, 'div', { className: 'bb-pm-empty' });
498
+ box.append(el(doc, 'strong', { text: 'まだ記憶はありません。' }), el(doc, 'p', { text: '夜の整理と候補の確認で増えます。' }));
499
+ if (text(candidatesHref)) box.append(el(doc, 'a', { className: 'bb-pm-link', text: '候補の確認を開く', attrs: { href: candidatesHref } }));
500
+ else if (text(candidatesCommand)) {
501
+ const line = el(doc, 'p', { text: '候補の確認: ' });
502
+ line.append(el(doc, 'code', { text: candidatesCommand }));
503
+ box.append(line);
504
+ }
505
+ return box;
506
+ }
507
+
508
+ function renderJudgments(item) {
509
+ const wrap = el(doc, 'div', { className: 'bb-pm-judgments' });
510
+ const judgments = state.judgments?.id === item.id ? state.judgments : null;
511
+ if (!judgments || (judgments.phase === 'loading' && !judgments.result)) {
512
+ wrap.append(el(doc, 'p', { className: 'bb-pm-muted', text: '確かめています。' }));
513
+ return wrap;
514
+ }
515
+ if (judgments.phase === 'unavailable') {
516
+ wrap.append(el(doc, 'strong', { text: '確認できない' }), el(doc, 'p', { className: 'bb-pm-muted', text: `この記憶が使われたかを確かめられません。${reasonText(judgments.reason)}。0件ではありません。` }));
517
+ return wrap;
518
+ }
519
+ const { items, total } = judgments.result;
520
+ if (total === 0) {
521
+ wrap.append(el(doc, 'p', { text: 'まだ使われていない' }));
522
+ return wrap;
523
+ }
524
+ const list = el(doc, 'ol', { className: 'bb-pm-judgment-list' });
525
+ for (const judgment of items) {
526
+ const entry = el(doc, 'li');
527
+ const href = judgmentHref(judgment.decisionId);
528
+ const title = text(judgment.title) ?? '判断の内容は記録されていません';
529
+ entry.append(href ? el(doc, 'a', { text: title, attrs: { href } }) : el(doc, 'span', { text: title }));
530
+ const day = formatDay(judgment.at);
531
+ if (day) entry.append(el(doc, 'span', { className: 'bb-pm-muted', text: day }));
532
+ list.append(entry);
533
+ }
534
+ wrap.append(list);
535
+ if (total > items.length) {
536
+ wrap.append(workspaceButton(doc, { text: `すべて見る(${total}件)`, variant: 'quiet', onClick: () => void loadJudgments(item.id, total) }));
537
+ }
538
+ return wrap;
539
+ }
540
+
541
+ function renderHistory(item) {
542
+ const wrap = el(doc, 'div', { className: 'bb-pm-status' });
543
+ wrap.append(stateBadge(doc, item.state));
544
+ if (item.state === 'superseded' && text(item.supersededBy)) {
545
+ wrap.append(workspaceButton(doc, { text: '置き換え先を開く', variant: 'quiet', onClick: () => go({ memoryId: item.supersededBy }) }));
546
+ }
547
+ const history = Array.isArray(item.history) ? item.history : [];
548
+ if (history.length === 0) {
549
+ wrap.append(el(doc, 'p', { className: 'bb-pm-muted', text: '状態の移り変わりは記録されていません。' }));
550
+ return wrap;
551
+ }
552
+ const list = el(doc, 'ol', { className: 'bb-pm-history' });
553
+ for (const step of history) {
554
+ const reason = text(step.reason);
555
+ list.append(el(doc, 'li', { text: `${formatDay(step.at) ?? '日付なし'} ${PERSONAL_MEMORY_STATE_LABELS[step.state] ?? step.state}${reason ? `(${reason})` : ''}` }));
556
+ }
557
+ wrap.append(list);
558
+ return wrap;
559
+ }
560
+
561
+ function renderDetail() {
562
+ detailHost.replaceChildren();
563
+ if (!isOwner()) return;
564
+ const box = el(doc, 'div', { className: 'bb-pm-detail' });
565
+ detailHost.append(box);
566
+ const detail = state.detail;
567
+ if (!detail) {
568
+ if (rail) box.append(workspaceDetailEmpty(doc, { mark: '↗', title: '記憶を選択', text: '行を選ぶと、記憶の本文、状態、出どころ、使った判断を確かめられます。' }));
569
+ return;
570
+ }
571
+ const close = workspaceButton(doc, { text: '詳細を閉じる', variant: 'quiet', attrs: { 'aria-label': '記憶の詳細を閉じる' }, onClick: () => go({ memoryId: null }, { back: true }) });
572
+ if (detail.phase === 'loading' && !detail.item) {
573
+ box.append(el(doc, 'p', { className: 'bb-pm-muted', text: '記憶を読み込んでいます。', attrs: { role: 'status' } }));
574
+ return;
575
+ }
576
+ if (detail.phase === 'not_found') {
577
+ box.append(workspaceDetailEmpty(doc, { mark: '?', title: 'この記憶は見つかりません', text: '消えたか、IDが違います。' }), close);
578
+ return;
579
+ }
580
+ if (detail.phase === 'error') {
581
+ box.append(workspaceErrorCard(doc, { title: '記憶を読めませんでした', message: `理由: ${reasonText(detail.reason)}。`, onRetry: () => void loadDetail(detail.id) }), close);
582
+ return;
583
+ }
584
+ const { item } = detail;
585
+ const tags = Array.isArray(item.tags) ? item.tags.filter((tag) => text(tag)) : [];
586
+ box.append(
587
+ workspaceRailHead(doc, { kicker: kindLabel(item.kind), title: item.text, sub: tags.length > 0 ? tags.join('・') : undefined }),
588
+ close,
589
+ workspaceRailBlock(doc, { title: '状態', content: renderHistory(item) }),
590
+ );
591
+ const organization = el(doc, 'span');
592
+ organization.append(el(doc, 'span', { text: text(item.origin?.organizationLabel) ?? UNKNOWN_ORIGIN }));
593
+ if (item.origin?.inferred === true) organization.append(inferredBadge(doc));
594
+ box.append(
595
+ workspaceRailBlock(doc, {
596
+ title: '出どころ',
597
+ content: workspaceDefinition(doc, [
598
+ ['組織', organization],
599
+ ['プロジェクト', text(item.origin?.projectLabel) ?? UNKNOWN_ORIGIN],
600
+ ['作業の場', text(item.origin?.workplace) ?? UNKNOWN_ORIGIN],
601
+ ['発生', formatDateTime(item.occurredAt) ?? UNKNOWN_ORIGIN],
602
+ ['更新', formatDateTime(item.updatedAt) ?? UNKNOWN_ORIGIN],
603
+ ]),
604
+ }),
605
+ workspaceRailBlock(doc, { title: 'AIが使う範囲', content: el(doc, 'p', { text: recallScopeText(item) }) }),
606
+ workspaceRailBlock(doc, { title: 'この記憶を使った判断', content: renderJudgments(item) }),
607
+ );
608
+ const record = el(doc, 'details', { className: 'bb-pm-record' });
609
+ record.append(
610
+ el(doc, 'summary', { text: '記録の詳細' }),
611
+ workspaceDefinition(doc, [
612
+ ['記録ID', el(doc, 'code', { text: item.id })],
613
+ ['種類', item.kind ?? NO_KIND_LABEL],
614
+ ['状態', PERSONAL_MEMORY_STATE_LABELS[item.state] ?? item.state],
615
+ ]),
616
+ );
617
+ box.append(record);
618
+ }
619
+
620
+ function render() {
621
+ if (state.destroyed) return;
622
+ const view = el(doc, 'div', { className: 'bb-pm', attrs: { 'data-contract-version': PERSONAL_MEMORY_UI_CONTRACT_VERSION } });
623
+ view.append(workspacePageHeader(doc, { crumbs, title: 'あなたの記憶', source: page?.source ?? undefined }));
624
+ if (!isOwner()) {
625
+ view.append(workspaceNotice(doc, { label: '本人だけ', text: 'この画面は本人だけが見られます。記憶は読み込んでいません。', tone: 'warning' }));
626
+ root.replaceChildren(view);
627
+ detailHost.replaceChildren();
628
+ return;
629
+ }
630
+ view.append(el(doc, 'p', { className: 'bb-pm-privacy', text: PRIVACY_LINE }));
631
+ const { list } = state;
632
+ if (list.phase === 'loading') view.append(el(doc, 'div', { className: 'bb-pm-loading', text: '読み込んでいます', attrs: { role: 'status' } }));
633
+ if (list.items) view.append(renderSummary(list.items));
634
+ view.append(renderFilters());
635
+ if (list.phase === 'error') {
636
+ view.append(workspaceErrorCard(doc, { title: '記憶を読めませんでした', message: `理由: ${reasonText(list.reason)}。0件ではありません。`, onRetry: () => void load() }));
637
+ } else if (list.items) {
638
+ const filtered = filterPersonalMemories(list.items, { states: state.route.states });
639
+ const unfiltered = list.items.length === 0 && state.route.kinds.length === 0 && !state.route.organization && !state.query;
640
+ view.append(unfiltered ? renderEmpty() : renderLedger(filtered));
641
+ if (list.nextCursor) view.append(el(doc, 'p', { className: 'bb-pm-muted', text: '読み込んだ範囲だけを表示しています。' }));
642
+ }
643
+ if (!rail) view.append(detailHost);
644
+ // The search box keeps its focus and caret across renders.
645
+ const active = doc.activeElement;
646
+ const focusSearch = active && active.tagName === 'INPUT' && active.getAttribute?.('name') === 'query';
647
+ const caret = focusSearch ? [active.selectionStart, active.selectionEnd] : null;
648
+ root.replaceChildren(view);
649
+ renderDetail();
650
+ if (focusSearch) {
651
+ const search = view.querySelector?.('input[name="query"]');
652
+ search?.focus?.();
653
+ if (caret) search?.setSelectionRange?.(...caret);
654
+ }
655
+ }
656
+
657
+ const controller = {
658
+ load,
659
+ applyRoute,
660
+ openMemory: (id) => go({ memoryId: id }),
661
+ get state() {
662
+ return { route: { ...state.route }, query: state.query, list: state.list, detail: state.detail, judgments: state.judgments, usage: state.usage };
663
+ },
664
+ destroy() {
665
+ state.destroyed = true;
666
+ listGeneration += 1;
667
+ detailGeneration += 1;
668
+ root.replaceChildren();
669
+ detailHost.replaceChildren();
670
+ },
671
+ };
672
+ if (state.route.memoryId) state.detail = null;
673
+ render();
674
+ return controller;
675
+ }
676
+
677
+ export default createPersonalMemoryUI;