@unson/brainbase-mcp 0.15.4 → 0.15.5

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.
@@ -130,6 +130,7 @@ export function projectOrganizationGraph(records, owner) {
130
130
  ...(type === 'project' && summary ? { goal: summary } : {}),
131
131
  ...(type === 'project' && status ? { status } : {}),
132
132
  ...(type === 'project' && text(payload.kind) ? { kind: text(payload.kind) } : {}),
133
+ ...(type === 'decision' && text(payload.decided_at) ? { decided_at: text(payload.decided_at) } : {}),
133
134
  ...(type === 'project' ? projectPlacement(record, payload, catalogByCode) : {})
134
135
  }
135
136
  });
@@ -74,6 +74,15 @@ export interface WorldBusiness {
74
74
  /** Repository names the Graph registers for this project (`metadata.repositories`). */
75
75
  readonly repositories: readonly string[];
76
76
  readonly engagements: readonly WorldEngagement[];
77
+ /** How much happened lately: decisions of this business (or its engagements) in the last 30 days. */
78
+ readonly activity: WorldBusinessActivity;
79
+ }
80
+ export interface WorldBusinessActivity {
81
+ readonly window_days: 30;
82
+ /** Decisions decided in the window, belonging to this business by scope code or a `governs` edge. */
83
+ readonly decisions: number;
84
+ /** The newest decision date of this business at any time, or null when it has none dated. */
85
+ readonly latest_decision_at: string | null;
77
86
  }
78
87
  export interface WorldBusinessProjection {
79
88
  readonly businesses: readonly WorldBusiness[];
@@ -109,7 +118,9 @@ export type WorldBusinessesResponse = ({
109
118
  * Nothing here knows any organization's words: kinds and statuses are the
110
119
  * Graph's own values.
111
120
  */
112
- export declare function projectWorldFromGraph(graph: Pick<GraphFileV2, 'entities'>, now?: Date): WorldBusinessProjection;
121
+ export declare function projectWorldFromGraph(graph: Pick<GraphFileV2, 'entities'> & {
122
+ readonly edges?: GraphFileV2['edges'];
123
+ }, now?: Date): WorldBusinessProjection;
113
124
  /**
114
125
  * The vocabulary the world draws with: the host's kinds in their order, then
115
126
  * kinds the Graph uses that the host did not name (under their own value),
@@ -135,6 +146,7 @@ export declare function readWorldBusinesses(context: Pick<LocalWebModuleContext,
135
146
  */
136
147
  export declare function projectOrganizationWorld(records: {
137
148
  readonly projects: readonly unknown[];
149
+ readonly decisions?: readonly unknown[];
138
150
  readonly glossaryTerms?: readonly unknown[];
139
151
  }, options: {
140
152
  readonly server: string;
@@ -31,6 +31,7 @@ const DEFAULT_STATUSES = Object.freeze({
31
31
  const KIND_PALETTE = ['#1261ad', '#b07a1f', '#35684c', '#6b4fa0', '#a8433a', '#2f7d86', '#7a6a2b', '#5b6470'];
32
32
  const UNCLASSIFIED_COLOR = '#69746d';
33
33
  const HEX_COLOR = /^#[0-9a-f]{6}$/iu;
34
+ const ACTIVITY_WINDOW_DAYS = 30;
34
35
  function isRecord(value) {
35
36
  return value !== null && typeof value === 'object' && !Array.isArray(value);
36
37
  }
@@ -93,6 +94,46 @@ export function projectWorldFromGraph(graph, now = new Date()) {
93
94
  else
94
95
  unplaced.push(engagementOf(entity));
95
96
  }
97
+ // Which business a decision belongs to: its scope code (organization Graph) or the project it
98
+ // governs (local Graph), resolved to the city that project is or hangs under.
99
+ const cityOfKey = new Map();
100
+ for (const [cityId, { entity, engagements }] of cities) {
101
+ cityOfKey.set(entity.id, cityId);
102
+ const code = text(entity.metadata?.code);
103
+ if (code)
104
+ cityOfKey.set(code, cityId);
105
+ for (const engagement of engagements) {
106
+ cityOfKey.set(engagement.id, cityId);
107
+ if (engagement.code)
108
+ cityOfKey.set(engagement.code, cityId);
109
+ }
110
+ }
111
+ const governs = new Map();
112
+ for (const edge of graph.edges ?? []) {
113
+ if (edge.relation !== 'governs')
114
+ continue;
115
+ governs.set(edge.fromId, [...(governs.get(edge.fromId) ?? []), edge.toId]);
116
+ }
117
+ const windowStart = now.getTime() - ACTIVITY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
118
+ const activity = new Map();
119
+ for (const decision of graph.entities) {
120
+ if (decision.type !== 'decision')
121
+ continue;
122
+ const at = text(decision.metadata?.decided_at) ?? text(decision.validFrom);
123
+ const time = at ? Date.parse(at) : Number.NaN;
124
+ if (!Number.isFinite(time))
125
+ continue;
126
+ const keys = [text(decision.metadata?.project_code), ...(governs.get(decision.id) ?? [])].filter((key) => key !== null);
127
+ const cityIds = new Set(keys.map((key) => cityOfKey.get(key)).filter((id) => id !== undefined));
128
+ for (const cityId of cityIds) {
129
+ const entry = activity.get(cityId) ?? { decisions: 0, latest: null };
130
+ if (time >= windowStart && time <= now.getTime())
131
+ entry.decisions += 1;
132
+ if (!entry.latest || time > Date.parse(entry.latest))
133
+ entry.latest = new Date(time).toISOString();
134
+ activity.set(cityId, entry);
135
+ }
136
+ }
96
137
  const businesses = [...cities.values()].map(({ entity, engagements }) => ({
97
138
  id: entity.id,
98
139
  code: text(entity.metadata?.code) ?? entity.id,
@@ -104,6 +145,11 @@ export function projectWorldFromGraph(graph, now = new Date()) {
104
145
  ? entity.metadata.repositories.map(text).filter((name) => name !== null)
105
146
  : [],
106
147
  engagements: engagements.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
148
+ activity: {
149
+ window_days: ACTIVITY_WINDOW_DAYS,
150
+ decisions: activity.get(entity.id)?.decisions ?? 0,
151
+ latest_decision_at: activity.get(entity.id)?.latest ?? null,
152
+ },
107
153
  }));
108
154
  return {
109
155
  businesses: businesses.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
@@ -231,7 +277,7 @@ export async function readWorldBusinesses(context, options = {}) {
231
277
  export function projectOrganizationWorld(records, options) {
232
278
  const now = options.now ?? new Date();
233
279
  const { graph } = projectOrganizationGraph({
234
- entities: { person: [], org: [], project: records.projects, decision: [], raci_assignment: [] },
280
+ entities: { person: [], org: [], project: records.projects, decision: (records.decisions ?? []), raci_assignment: [] },
235
281
  memberOf: [],
236
282
  assignedTo: [],
237
283
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -358,5 +358,5 @@
358
358
  "engines": {
359
359
  "node": ">=20"
360
360
  },
361
- "gitHead": "1a548f70b900633c8cdcfe3c468de0a082b156fe"
361
+ "gitHead": "9c24218a7ff1058cdf997adf92048186cd0b5e23"
362
362
  }
@@ -64,3 +64,32 @@ export function districtLots(count, step = 2.5) {
64
64
  cols += 1;
65
65
  }
66
66
  }
67
+
68
+ const DAY_MS = 24 * 60 * 60 * 1000;
69
+
70
+ /**
71
+ * How a city reads (story-world-size-and-activity-v1): its size is the engagements still going, its
72
+ * height and lit windows are what happened in the last 30 days (decisions the Graph records for it,
73
+ * and judgments placed in it when the host has them). Finished engagements stay as empty lots but do
74
+ * not make the city bigger.
75
+ */
76
+ export function cityMeasures(business, { isFinished, judgments = [], now = Date.now() } = {}) {
77
+ const engagements = Array.isArray(business?.engagements) ? business.engagements : [];
78
+ const open = engagements.filter((engagement) => !isFinished?.(engagement.status)).length;
79
+ const since = now - (business?.activity?.window_days ?? 30) * DAY_MS;
80
+ const recentJudgments = judgments.filter((entry) => {
81
+ const at = Date.parse(entry?.item?.proof?.recorded_at ?? '');
82
+ return Number.isFinite(at) && at >= since && at <= now;
83
+ }).length;
84
+ const decisions = Number.isInteger(business?.activity?.decisions) ? business.activity.decisions : 0;
85
+ const recent = decisions + recentJudgments;
86
+ return {
87
+ open,
88
+ finished: engagements.length - open,
89
+ decisions,
90
+ judgments: recentJudgments,
91
+ recent,
92
+ towerHeight: 3.2 + Math.min(Math.log2(1 + recent) * 1.6, 5.5),
93
+ lit: recent > 0,
94
+ };
95
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { THREE, MapControls } from './world-vendor.js';
18
- import { districtLots, groupJudgmentPlaces, UNPLACED_REASON_TEXT } from './world-placement.js';
18
+ import { cityMeasures, districtLots, groupJudgmentPlaces, UNPLACED_REASON_TEXT } from './world-placement.js';
19
19
  import {
20
20
  makeWorkspaceElement as el,
21
21
  workspacePageHeader,
@@ -135,7 +135,8 @@ export function layoutWorld(businesses) {
135
135
  previousKind = business.kind;
136
136
  }
137
137
  angle += slot;
138
- const n = business.engagements.length;
138
+ // The city's size is the engagements still going (finished ones stay as empty lots).
139
+ const n = business.engagements.filter((engagement) => !isFinished(engagement.status)).length;
139
140
  const size = 7 + 2.6 * Math.ceil(Math.sqrt(Math.max(n, 1)));
140
141
  const ring = CITY_RING + (index % 2) * 16 + size * 0.35;
141
142
  const city = { business, kind: business.kind, size, angle, x: Math.cos(angle) * ring, z: Math.sin(angle) * ring };
@@ -414,19 +415,21 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
414
415
  }
415
416
 
416
417
  // --- cities ----------------------------------------------------------------
417
- function landmark(form, color, height, status) {
418
+ function landmark(form, color, height, status, active = true) {
418
419
  const group = new THREE.Group();
419
420
  const phase = statusPhase(status);
420
421
  const concept = phase === 'concept';
422
+ // Windows are lit only when something happened in the last 30 days.
423
+ const lit = !concept && active;
421
424
  const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
422
425
  const options = concept ? { transparent: true, opacity: 0.38 } : {};
423
426
  if (form === 'tower') {
424
- const base = texturedBlock(3, height, 3, tone, !concept, { roughness: 0.4, ...options });
425
- const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, !concept, { roughness: 0.4, ...options });
427
+ const base = texturedBlock(3, height, 3, tone, lit, { roughness: 0.4, ...options });
428
+ const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, lit, { roughness: 0.4, ...options });
426
429
  mid.position.y += height;
427
430
  group.add(base, mid, mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.6, 6), standard(0x46524a), { y: height * 1.45 + 0.8 }));
428
431
  } else if (form === 'hall') {
429
- const hall = texturedBlock(4.4, height * 0.55, 3, tone, !concept, options);
432
+ const hall = texturedBlock(4.4, height * 0.55, 3, tone, lit, options);
430
433
  const roof = mesh(gableRoof(4.4, 3, 1.4), standard(muted(color, 0.15).multiplyScalar(0.85), options), { y: height * 0.55 });
431
434
  roof.rotation.y = Math.PI / 2;
432
435
  group.add(hall, roof);
@@ -434,7 +437,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
434
437
  group.add(mesh(new THREE.CylinderGeometry(1.9, 2.1, height * 0.5, 24), standard(tone, options), { y: height * 0.25 }));
435
438
  group.add(mesh(new THREE.SphereGeometry(1.9, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2), standard(0xf3f6f8, { roughness: 0.3, ...options }), { y: height * 0.5 }));
436
439
  } else {
437
- group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, !concept, options));
440
+ group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, lit, options));
438
441
  group.add(mesh(new THREE.BoxGeometry(1.2, 0.6, 1), standard(0xb8c2bb), { y: height * 0.75 + 0.3 }));
439
442
  }
440
443
  if (concept) {
@@ -496,8 +499,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
496
499
  group.position.set(x, 0, z);
497
500
  const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
498
501
  group.add(mesh(roundedPlate(size, size, 0.45, 1.6), standard(plateColor, { roughness: 0.95 })));
499
- const towerHeight = 3.2 + Math.min(business.engagements.length, 8) * 0.55;
500
- group.add(landmark(entry.form, color, towerHeight, business.status));
502
+ const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
503
+ const towerHeight = measures.towerHeight;
504
+ group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
501
505
  scene.add(group);
502
506
  register(group, { kind: 'city', business });
503
507
  const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
@@ -825,6 +829,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
825
829
  let placement = { byBusiness: new Map(), unplaced: [] };
826
830
  /** Judgments the host kept back because they are not this organization's (organization mode only). */
827
831
  let withheld = 0;
832
+ /** Whether the host gave judgments at all (an organization web does not: they stay on each Mac). */
833
+ let judgmentsConnected = false;
828
834
 
829
835
  function judgmentText(item, fallback) {
830
836
  const textValue = item?.proof?.interruption?.question_display_text ?? item?.proof?.decision?.summary ?? fallback;
@@ -902,10 +908,17 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
902
908
  blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }));
903
909
  blocks.push(detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'));
904
910
  } else {
905
- const open = business.engagements.filter((engagement) => !isFinished(engagement.status)).length;
911
+ const measures = cityMeasures(business, { isFinished, judgments: placement.byBusiness.get(business.code) ?? [] });
906
912
  const kind = kindEntry(business.kind);
907
913
  const kindText = kind.definition ? `${kind.label}(${kind.definition})` : kind.label;
908
- blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['分類', kindText], ['状態', statusText(business.status)], ['コード', business.code], ['案件', `${business.engagements.length}件(動いている${open}件)`]]) }));
914
+ const judgmentText = judgmentsConnected ? `・判断${measures.judgments}件` : '(判断は未接続)';
915
+ blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [
916
+ ['分類', kindText],
917
+ ['状態', statusText(business.status)],
918
+ ['コード', business.code],
919
+ ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は更地)` : ''}`],
920
+ ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
921
+ ]) }));
909
922
  const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
910
923
  for (const engagement of business.engagements) {
911
924
  const li = el(doc, 'li', { text: `${engagement.name}(${statusText(engagement.status)})` });
@@ -1034,6 +1047,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1034
1047
  else note('判断', `判断の記録を読めません(${reason})。0件ではありません。`, 'warning');
1035
1048
  } else {
1036
1049
  proofs = proofIndex(home);
1050
+ judgmentsConnected = true;
1037
1051
  const waiting = [...proofs.values()].filter(isWaiting).length;
1038
1052
  const places = placesResult.ok && placesResult.data?.status === 'available' ? placesResult.data.places : null;
1039
1053
  if (places) {