@unson/brainbase-mcp 0.37.0 → 0.39.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.
@@ -14,12 +14,16 @@ export declare const GRAPH_CORRECTION_ENTITY_FIELDS: readonly ["name", "aliases"
14
14
  export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status", "icon"];
15
15
  export declare const GRAPH_CORRECTION_EDGE_FIELDS: readonly ["role", "context", "validTo"];
16
16
  export declare const GRAPH_CORRECTION_NEW_EDGE_RELATIONS: readonly ["participates_in", "accountable_for", "member_of"];
17
+ /** Kinds a correction may create (people and companies only). */
18
+ export declare const GRAPH_CORRECTION_NEW_ENTITY_TYPES: readonly ["person", "org"];
19
+ export declare const GRAPH_CORRECTION_NEW_ENTITY_FIELDS: readonly ["type", "name", "aliases", "summary", "validFrom"];
17
20
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
18
21
  export declare const GRAPH_CORRECTION_APPLIES_TO: readonly ["search", "get_context", "resolve_entity"];
19
22
  /** Decoded bytes, shared with the browser-side project-icon helper. */
20
23
  export declare const PROJECT_ICON_MAX_BYTES: number;
21
- export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge';
24
+ export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge' | 'create_entity';
22
25
  export type GraphNewEdgeRelation = typeof GRAPH_CORRECTION_NEW_EDGE_RELATIONS[number];
26
+ export type GraphNewEntityType = typeof GRAPH_CORRECTION_NEW_ENTITY_TYPES[number];
23
27
  export interface GraphEntityCorrectionChanges {
24
28
  name?: string;
25
29
  aliases?: string[];
@@ -47,6 +51,13 @@ export interface GraphEdgeCreation {
47
51
  context?: string;
48
52
  validFrom?: string;
49
53
  }
54
+ export interface GraphEntityCreation {
55
+ type: GraphNewEntityType;
56
+ name: string;
57
+ aliases?: string[];
58
+ summary?: string;
59
+ validFrom?: string;
60
+ }
50
61
  export type GraphCorrectionInput = {
51
62
  kind: 'update_entity';
52
63
  entityId: string;
@@ -63,6 +74,10 @@ export type GraphCorrectionInput = {
63
74
  kind: 'create_edge';
64
75
  reason: string;
65
76
  edge: GraphEdgeCreation;
77
+ } | {
78
+ kind: 'create_entity';
79
+ reason: string;
80
+ entity: GraphEntityCreation;
66
81
  };
67
82
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
68
83
  [key: string]: JsonValue;
@@ -21,6 +21,9 @@ export const GRAPH_CORRECTION_ENTITY_FIELDS = ['name', 'aliases', 'summary', 'va
21
21
  export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status', 'icon'];
22
22
  export const GRAPH_CORRECTION_EDGE_FIELDS = ['role', 'context', 'validTo'];
23
23
  export const GRAPH_CORRECTION_NEW_EDGE_RELATIONS = ['participates_in', 'accountable_for', 'member_of'];
24
+ /** Kinds a correction may create (people and companies only). */
25
+ export const GRAPH_CORRECTION_NEW_ENTITY_TYPES = ['person', 'org'];
26
+ export const GRAPH_CORRECTION_NEW_ENTITY_FIELDS = ['type', 'name', 'aliases', 'summary', 'validFrom'];
24
27
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
25
28
  export const GRAPH_CORRECTION_APPLIES_TO = ['search', 'get_context', 'resolve_entity'];
26
29
  const MAX_REASON_LENGTH = 500;
@@ -83,7 +86,11 @@ export function parseGraphCorrectionInput(value) {
83
86
  assertOnlyKeys(value, ['kind', 'reason', 'edge'], '');
84
87
  return { kind, reason: requireReason(value.reason), edge: parseEdgeCreation(value.edge) };
85
88
  }
86
- invalid('correction_kind_unsupported', 'kind must be update_entity, update_edge or create_edge');
89
+ if (kind === 'create_entity') {
90
+ assertOnlyKeys(value, ['kind', 'reason', 'entity'], '');
91
+ return { kind, reason: requireReason(value.reason), entity: parseEntityCreation(value.entity) };
92
+ }
93
+ invalid('correction_kind_unsupported', 'kind must be update_entity, update_edge, create_edge or create_entity');
87
94
  }
88
95
  /**
89
96
  * Applies one owner correction to the canonical Graph v2 in `dataDir`.
@@ -190,7 +197,7 @@ function isGraphCorrectionRecord(value) {
190
197
  const target = value.target;
191
198
  return typeof value.id === 'string'
192
199
  && typeof value.at === 'string'
193
- && (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge')
200
+ && (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge' || value.kind === 'create_entity')
194
201
  && isRecord(target) && (target.recordType === 'entity' || target.recordType === 'edge') && typeof target.id === 'string'
195
202
  && typeof value.reason === 'string'
196
203
  && Array.isArray(value.changedFields) && value.changedFields.every((field) => typeof field === 'string')
@@ -204,8 +211,51 @@ function planCorrection(graph, relationships, correction, at) {
204
211
  return planEntityUpdate(graph, relationships, correction, at);
205
212
  if (correction.kind === 'update_edge')
206
213
  return planEdgeUpdate(graph, relationships, correction, at);
214
+ if (correction.kind === 'create_entity')
215
+ return planEntityCreation(graph, relationships, correction, at);
207
216
  return planEdgeCreation(graph, relationships, correction, at);
208
217
  }
218
+ /**
219
+ * Creates a person or a company. A record of the same kind whose name or
220
+ * alias already reads the same (ignoring spacing, case and full/half width) is
221
+ * returned as a conflict instead of creating a duplicate. The new record
222
+ * keeps where it came from (`metadata.source`, `metadata.sourceId`).
223
+ */
224
+ function planEntityCreation(graph, relationships, correction, at) {
225
+ const input = correction.entity;
226
+ const names = new Set([input.name, ...(input.aliases ?? [])].map(comparableName));
227
+ const existing = graph.entities.find((entity) => entity.type === input.type
228
+ && [entity.name, ...(entity.aliases ?? [])].some((label) => names.has(comparableName(label))));
229
+ if (existing) {
230
+ throw new GraphCorrectionError('conflict', 'entity_already_exists', 'A record with the same name already exists; open it (and clear validTo if it has ended) instead of creating another', { recordType: 'entity', record: existing, digest: graphRecordDigest(existing) });
231
+ }
232
+ const base = `${input.type}-${stableHash(input.name)}`;
233
+ const correctionId = graphCorrectionId(at, 'create_entity', base, null, correction.reason);
234
+ const id = graph.entities.some((entity) => entity.id === base) ? `${base}-${correctionId.slice(-8)}` : base;
235
+ if (graph.entities.some((entity) => entity.id === id)) {
236
+ invalid('entity_id_ambiguous', `Entity id ${id} is already used; repair graph.json with the CLI first`);
237
+ }
238
+ const entity = compact({
239
+ id,
240
+ type: input.type,
241
+ name: input.name,
242
+ aliases: input.aliases && input.aliases.length > 0 ? input.aliases : undefined,
243
+ summary: input.summary,
244
+ validFrom: input.validFrom,
245
+ metadata: { source: 'user_approved', sourceId: correctionId }
246
+ });
247
+ const nextGraph = validatedGraph({ ...graph, entities: [...graph.entities, entity] });
248
+ const diff = {};
249
+ for (const [key, value] of Object.entries(entity)) {
250
+ if (key === 'id')
251
+ continue;
252
+ diff[key] = { before: null, after: value };
253
+ }
254
+ return finalize('create_entity', 'entity', null, entity, diff, correction.reason, at, nextGraph, { relationships, changes: [] }, correctionId);
255
+ }
256
+ function comparableName(value) {
257
+ return value.normalize('NFKC').replace(/\s+/gu, '').toLowerCase();
258
+ }
209
259
  function planEntityUpdate(graph, relationships, correction, at) {
210
260
  const matches = graph.entities.filter((entity) => entity.id === correction.entityId);
211
261
  if (matches.length === 0)
@@ -539,6 +589,35 @@ function parseEdgeCreation(value) {
539
589
  validFrom: validFrom ?? undefined
540
590
  });
541
591
  }
592
+ function parseEntityCreation(value) {
593
+ if (!isRecord(value))
594
+ invalid('correction_invalid', 'entity must be an object');
595
+ const type = value.type;
596
+ if (typeof type !== 'string' || !GRAPH_CORRECTION_NEW_ENTITY_TYPES.includes(type)) {
597
+ invalid('correction_entity_type_not_allowed', `entity.type must be one of ${GRAPH_CORRECTION_NEW_ENTITY_TYPES.join(', ')}`);
598
+ }
599
+ const allowed = GRAPH_CORRECTION_NEW_ENTITY_FIELDS.filter((key) => key !== 'type');
600
+ for (const key of Object.keys(value)) {
601
+ if (key === 'type' || allowed.includes(key))
602
+ continue;
603
+ if (key === 'id')
604
+ invalid('correction_id_change_not_allowed', 'entity.id is assigned by Brainbase');
605
+ invalid('correction_field_not_allowed', `entity.${key} cannot be set when creating a record`);
606
+ }
607
+ const name = value.name === undefined ? null : optionalText(value.name, 'entity.name', MAX_NAME_LENGTH);
608
+ if (name === null)
609
+ invalid('correction_name_required', 'entity.name must be a non-empty string');
610
+ const aliases = value.aliases === undefined ? [] : parseAliases(value.aliases).filter((alias) => alias !== name);
611
+ const summary = value.summary === undefined ? null : optionalText(value.summary, 'entity.summary', MAX_TEXT_LENGTH);
612
+ const validFrom = value.validFrom === undefined ? null : optionalDateTime(value.validFrom, 'entity.validFrom');
613
+ return compact({
614
+ type: type,
615
+ name,
616
+ aliases: aliases.length > 0 ? aliases : undefined,
617
+ summary: summary ?? undefined,
618
+ validFrom: validFrom ?? undefined
619
+ });
620
+ }
542
621
  function assertOnlyKeys(value, allowed, prefix) {
543
622
  for (const key of Object.keys(value)) {
544
623
  if (allowed.includes(key))
@@ -425,8 +425,20 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
425
425
  && !externalIds.has(toEntity.id)
426
426
  && toEntity.entity_type === 'app'
427
427
  && toEntity.lifecycle_status === 'active';
428
- if (!localFrom && !sameOrganizationToolAccountable)
428
+ // A person's membership of a company: both active, each either in this
429
+ // snapshot or a same-organization reference. Cross-organization
430
+ // references are never a membership endpoint.
431
+ const membershipFrom = localFrom || externalFrom;
432
+ const sameOrganizationPersonMembership = operation.rel_type === 'member_of'
433
+ && membershipFrom?.entity_type === 'person'
434
+ && membershipFrom.lifecycle_status === 'active'
435
+ && (Boolean(localFrom) || (externalIds.has(membershipFrom.id) && membershipFrom.reference_scope === 'same_organization'))
436
+ && toEntity.entity_type === 'org'
437
+ && toEntity.lifecycle_status === 'active'
438
+ && (!externalIds.has(toEntity.id) || toEntity.reference_scope === 'same_organization');
439
+ if (!localFrom && !sameOrganizationToolAccountable && !sameOrganizationPersonMembership) {
429
440
  throw new Error(`Unknown entity: ${operation.from_id}`);
441
+ }
430
442
  const fromEntity = (localFrom || externalFrom);
431
443
  // A project of this scope using a same-organization tool of another project.
432
444
  const sameOrganizationProjectUsesApp = operation.rel_type === 'uses_app'
@@ -455,7 +467,7 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
455
467
  throw new Error('decision_id must match the Decision owner source');
456
468
  }
457
469
  if (fromEntity.project_code !== toEntity.project_code && !sameOrganizationAppOwner && !sameOrganizationDecisionOwner
458
- && !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp) {
470
+ && !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp && !sameOrganizationPersonMembership) {
459
471
  throw new Error('edge endpoint project scope mismatch');
460
472
  }
461
473
  if (!String(operation.rel_type || '').trim())
@@ -491,7 +503,12 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
491
503
  id: operation.edge_id,
492
504
  from_id: operation.from_id, to_id: operation.to_id, rel_type: operation.rel_type,
493
505
  // The accountable person lives in another project; the edge belongs to the tool's project.
494
- project_code: sameOrganizationToolAccountable ? toEntity.project_code : fromEntity.project_code,
506
+ // A membership belongs to the company's project when the company is
507
+ // in this snapshot, else to the person's, else to the plan's.
508
+ project_code: sameOrganizationToolAccountable ? toEntity.project_code
509
+ : sameOrganizationPersonMembership
510
+ ? (!externalIds.has(toEntity.id) ? toEntity.project_code : localFrom ? fromEntity.project_code : state.project_code)
511
+ : fromEntity.project_code,
495
512
  payload: operation.payload || {}, role_min: roleMin,
496
513
  sensitivity, lifecycle_status: 'active', version: 1
497
514
  });
@@ -13,6 +13,7 @@ const CORE_RELATIONS = [
13
13
  'participates_in',
14
14
  'accountable_for',
15
15
  'owned_by',
16
+ 'related_to',
16
17
  'governs',
17
18
  'supersedes'
18
19
  ];
@@ -16,7 +16,7 @@ export type GraphWebHttpHandler = (request: IncomingMessage, response: ServerRes
16
16
  /**
17
17
  * Node-style handler for the local Graph read and correction API:
18
18
  *
19
- * - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
19
+ * - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`, `GET {base}/people-and-companies`
20
20
  * - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
21
21
  * - `POST {base}/corrections`
22
22
  *
@@ -1,5 +1,5 @@
1
1
  import { applyGraphCorrection, GraphCorrectionError } from './graph-corrections.js';
2
- import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
2
+ import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphPeopleAndCompanies, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
3
3
  export const GRAPH_WEB_HTTP_VERSION = 'graph-web-http.v1';
4
4
  const DEFAULT_BASE_PATH = '/api/graph';
5
5
  // A 256KiB decoded image becomes roughly 350KiB as base64 in JSON. Keep a
@@ -21,7 +21,7 @@ class HttpError extends Error {
21
21
  /**
22
22
  * Node-style handler for the local Graph read and correction API:
23
23
  *
24
- * - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
24
+ * - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`, `GET {base}/people-and-companies`
25
25
  * - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
26
26
  * - `POST {base}/corrections`
27
27
  *
@@ -74,6 +74,9 @@ export function createGraphWebHttpHandler(options) {
74
74
  case 'project':
75
75
  writeJson(response, 200, await readGraphProject(dataDir, route.id, readOptions));
76
76
  break;
77
+ case 'people':
78
+ writeJson(response, 200, await readGraphPeopleAndCompanies(dataDir, readOptions));
79
+ break;
77
80
  case 'search':
78
81
  writeJson(response, 200, await searchGraphEntities(dataDir, {
79
82
  q: queryValue(url, 'q'),
@@ -105,6 +108,8 @@ function matchRoute(pathname, basePath) {
105
108
  return { name: 'status' };
106
109
  if (rest === '/projects')
107
110
  return { name: 'projects' };
111
+ if (rest === '/people-and-companies')
112
+ return { name: 'people' };
108
113
  if (rest === '/search')
109
114
  return { name: 'search' };
110
115
  if (rest === '/ontology')
@@ -320,6 +320,8 @@ export interface GraphOntologySummary<S extends GraphWebAnySource = GraphWebSour
320
320
  projectFields: readonly string[];
321
321
  edgeFields: readonly string[];
322
322
  newEdgeRelations: readonly string[];
323
+ /** Kinds a correction may create (`create_entity`). */
324
+ newEntityTypes: readonly string[];
323
325
  };
324
326
  }
325
327
  export type GraphWebErrorKind = 'invalid' | 'not_found' | 'unavailable';
@@ -335,6 +337,77 @@ export interface GraphWebReadOptions {
335
337
  }
336
338
  export declare function readGraphWebStatus(dataDir: string, options?: GraphWebReadOptions): Promise<GraphWebStatus | GraphWebUnavailable>;
337
339
  export declare function listGraphProjects(dataDir: string, options?: GraphWebReadOptions): Promise<GraphProjectList | GraphWebUnavailable>;
340
+ export type GraphOwnCompanyReason = 'resolved' | 'owner_not_found' | 'owner_has_no_company' | 'owner_has_several_companies';
341
+ export interface GraphPersonSummaryView extends GraphEntityView {
342
+ type: 'person';
343
+ /** The owner of this Graph (the person whose companies decide the own company). */
344
+ isOwner: boolean;
345
+ /** Active companies of the person (active member_of to an active org), by company name. */
346
+ companyIds: string[];
347
+ /** Distinct active projects with an active participates_in or accountable_for relation. */
348
+ projectCount: number;
349
+ accountableProjectCount: number;
350
+ /**
351
+ * The company text recorded on the person (`org`, or `organization` when
352
+ * `org` is absent), as written; null when none. A note only: it never
353
+ * decides the companies, the own company or internal / external.
354
+ */
355
+ orgNote: string | null;
356
+ }
357
+ export type GraphCompanyKind = 'customer' | 'partner' | 'prospect';
358
+ export type GraphCompanyKindSource = 'orgType' | 'lifecycle_stage' | 'related_to' | 'deals';
359
+ export interface GraphCompanyDealView {
360
+ /** The deal name as recorded (`name` or `title`), or null when the record has none. */
361
+ name: string | null;
362
+ /** The deal stage as recorded (`stage` or `status`), or null. */
363
+ stage: string | null;
364
+ }
365
+ export interface GraphCompanySummaryView extends GraphEntityView {
366
+ type: 'org';
367
+ /** Active people with an active member_of to the company, by name. */
368
+ memberIds: string[];
369
+ /** Active projects with an active owned_by to the company, by name. */
370
+ projectIds: string[];
371
+ /** Active projects the company is involved in through an active related_to, by name, with the edge role. */
372
+ relatedProjects: Array<{
373
+ projectId: string;
374
+ role: string | null;
375
+ }>;
376
+ /** The recorded company attributes, as written; null when not recorded. */
377
+ orgType: string | null;
378
+ lifecycleStage: string | null;
379
+ /** Proposals in progress recorded on the company (`deals`). */
380
+ deals: GraphCompanyDealView[];
381
+ /**
382
+ * What kind of company this is for the owner, only from the records above
383
+ * (attributes, related_to roles and deals), with where each kind came from.
384
+ * Empty when nothing is recorded: the kind is never guessed.
385
+ */
386
+ kinds: Array<{
387
+ kind: GraphCompanyKind;
388
+ sources: GraphCompanyKindSource[];
389
+ }>;
390
+ }
391
+ export interface GraphPeopleAndCompanies<S extends GraphWebAnySource = GraphWebSource> {
392
+ status: 'ok';
393
+ source: S;
394
+ asOf: string;
395
+ /**
396
+ * The company the owner belongs to. Null with the reason when the owner has
397
+ * no active company, several, or no record: the screen then does not tell
398
+ * internal from external people. No internal / external mark is stored.
399
+ */
400
+ ownCompany: {
401
+ id: string | null;
402
+ reason: GraphOwnCompanyReason;
403
+ candidateIds: string[];
404
+ };
405
+ people: GraphPersonSummaryView[];
406
+ companies: GraphCompanySummaryView[];
407
+ /** True only when the readable Graph v2 holds no person and no company. */
408
+ absenceConfirmed: boolean;
409
+ }
410
+ export declare function readGraphPeopleAndCompanies(dataDir: string, options?: GraphWebReadOptions): Promise<GraphPeopleAndCompanies | GraphWebUnavailable>;
338
411
  export declare function readGraphProject(dataDir: string, projectId: string, options?: GraphWebReadOptions): Promise<GraphProjectDetail | GraphWebUnavailable>;
339
412
  export declare function searchGraphEntities(dataDir: string, input?: GraphEntitySearchInput): Promise<GraphEntitySearchResult | GraphWebUnavailable>;
340
413
  export declare function readGraphEntity(dataDir: string, entityId: string, options?: GraphWebReadOptions): Promise<GraphEntityDetail | GraphWebUnavailable>;
package/dist/graph-web.js CHANGED
@@ -2,7 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isActiveAt } from './canonical-graph.js';
4
4
  import { assertFoundationCatalog, findFoundationRecord } from './foundation-catalog.js';
5
- import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
5
+ import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_NEW_ENTITY_TYPES, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
6
6
  import { ONTOLOGY_VERSION, portableOntology } from './ontology.js';
7
7
  import { canonicalRelationRegistry, getCanonicalRelation } from './relation-registry.js';
8
8
  import { loadPersonalOs } from './ssot.js';
@@ -93,6 +93,115 @@ function projectListOf(graph, source, asOf) {
93
93
  }).sort(byActiveThenName);
94
94
  return { status: 'ok', source, asOf, projects, absenceConfirmed: projects.length === 0 };
95
95
  }
96
+ export async function readGraphPeopleAndCompanies(dataDir, options = {}) {
97
+ const loaded = await loadForRead(dataDir);
98
+ if ('status' in loaded)
99
+ return loaded;
100
+ return peopleAndCompaniesOf(loaded.graph, loaded.source, resolveAsOf(options));
101
+ }
102
+ function peopleAndCompaniesOf(graph, source, asOf) {
103
+ const entities = entityIndex(graph);
104
+ const name = (id) => entities.get(id)?.name ?? id;
105
+ const byName = (left, right) => name(left).localeCompare(name(right), 'ja') || left.localeCompare(right, 'en');
106
+ const live = graph.edges.filter((edge) => isActiveAt(edge, asOf)
107
+ && isEntityActive(entities.get(edge.fromId), asOf) && isEntityActive(entities.get(edge.toId), asOf));
108
+ const membership = live.filter((edge) => edge.relation === 'member_of' && entities.get(edge.toId)?.type === 'org');
109
+ const companiesOf = (personId) => [...new Set(membership.filter((edge) => edge.fromId === personId).map((edge) => edge.toId))].sort(byName);
110
+ const ownerId = graph.owner?.id ?? 'self';
111
+ const owner = entities.get(ownerId);
112
+ const ownCandidates = owner?.type === 'person' && isActiveAt(owner, asOf) ? companiesOf(ownerId) : [];
113
+ const ownCompany = owner?.type !== 'person' || !isActiveAt(owner, asOf)
114
+ ? { id: null, reason: 'owner_not_found', candidateIds: [] }
115
+ : ownCandidates.length === 1
116
+ ? { id: ownCandidates[0], reason: 'resolved', candidateIds: ownCandidates }
117
+ : { id: null, reason: ownCandidates.length === 0 ? 'owner_has_no_company' : 'owner_has_several_companies', candidateIds: ownCandidates };
118
+ const people = graph.entities.filter((entity) => entity.type === 'person').map((person) => {
119
+ const work = live.filter((edge) => edge.fromId === person.id && PARTICIPATION.has(edge.relation) && entities.get(edge.toId)?.type === 'project');
120
+ return {
121
+ ...entityView(person, asOf),
122
+ type: 'person',
123
+ isOwner: person.id === ownerId,
124
+ companyIds: companiesOf(person.id),
125
+ projectCount: new Set(work.map((edge) => edge.toId)).size,
126
+ accountableProjectCount: new Set(work.filter((edge) => edge.relation === 'accountable_for').map((edge) => edge.toId)).size,
127
+ orgNote: metadataString(person, 'org')?.trim() || metadataString(person, 'organization')?.trim() || null
128
+ };
129
+ }).sort((left, right) => Number(right.active) - Number(left.active) || Number(right.isOwner) - Number(left.isOwner) || byActiveThenName(left, right));
130
+ const companies = graph.entities.filter((entity) => entity.type === 'org').map((company) => {
131
+ const relatedProjects = live.filter((edge) => edge.relation === 'related_to' && edge.fromId === company.id
132
+ && entities.get(edge.toId)?.type === 'project')
133
+ .map((edge) => ({ projectId: edge.toId, role: edge.role?.trim() || null }))
134
+ .sort((left, right) => byName(left.projectId, right.projectId));
135
+ const orgType = metadataString(company, 'orgType')?.trim() || null;
136
+ const lifecycleStage = metadataString(company, 'lifecycle_stage')?.trim() || null;
137
+ const deals = companyDeals(company.metadata?.deals);
138
+ return {
139
+ ...entityView(company, asOf),
140
+ type: 'org',
141
+ memberIds: [...new Set(membership.filter((edge) => edge.toId === company.id).map((edge) => edge.fromId))]
142
+ .sort((left, right) => Number(left !== ownerId) - Number(right !== ownerId) || byName(left, right)),
143
+ projectIds: [...new Set(live.filter((edge) => edge.relation === 'owned_by' && edge.toId === company.id
144
+ && entities.get(edge.fromId)?.type === 'project').map((edge) => edge.fromId))].sort(byName),
145
+ relatedProjects,
146
+ orgType,
147
+ lifecycleStage,
148
+ deals,
149
+ kinds: companyKinds({ orgType, lifecycleStage, roles: relatedProjects.map((item) => item.role), dealCount: deals.length })
150
+ };
151
+ }).sort((left, right) => Number(right.active) - Number(left.active)
152
+ || Number(right.id === ownCompany.id) - Number(left.id === ownCompany.id)
153
+ || Number(right.kinds.length > 0) - Number(left.kinds.length > 0)
154
+ || byActiveThenName(left, right));
155
+ return { status: 'ok', source, asOf, ownCompany, people, companies, absenceConfirmed: people.length === 0 && companies.length === 0 };
156
+ }
157
+ const COMPANY_KINDS = ['customer', 'partner', 'prospect'];
158
+ const RELATION_KIND = Object.freeze({ customer: 'customer', partner: 'partner' });
159
+ const STAGE_KIND = Object.freeze({ customer: 'customer', partner: 'partner', opportunity: 'prospect' });
160
+ /**
161
+ * The kinds of a company, only from what is recorded: `orgType` and
162
+ * related_to roles of `customer` / `partner`, `lifecycle_stage` of
163
+ * `customer` / `partner` / `opportunity`, and any recorded deal (prospect).
164
+ * Other values give no kind; nothing is guessed from names.
165
+ */
166
+ function companyKinds(input) {
167
+ const sources = new Map();
168
+ const add = (kind, source) => {
169
+ if (!kind)
170
+ return;
171
+ sources.set(kind, (sources.get(kind) ?? new Set()).add(source));
172
+ };
173
+ const lookup = (table, value) => {
174
+ const key = value?.toLowerCase();
175
+ return key && Object.hasOwn(table, key) ? table[key] : undefined;
176
+ };
177
+ add(lookup(RELATION_KIND, input.orgType), 'orgType');
178
+ add(lookup(STAGE_KIND, input.lifecycleStage), 'lifecycle_stage');
179
+ for (const role of input.roles)
180
+ add(lookup(RELATION_KIND, role), 'related_to');
181
+ if (input.dealCount > 0)
182
+ add('prospect', 'deals');
183
+ return COMPANY_KINDS.filter((kind) => sources.has(kind)).map((kind) => ({ kind, sources: [...sources.get(kind)] }));
184
+ }
185
+ function companyDeals(value) {
186
+ if (!Array.isArray(value))
187
+ return [];
188
+ return value.flatMap((deal) => {
189
+ if (typeof deal === 'string')
190
+ return deal.trim() ? [{ name: deal.trim(), stage: null }] : [];
191
+ if (!deal || typeof deal !== 'object' || Array.isArray(deal))
192
+ return [];
193
+ const record = deal;
194
+ const text = (...keys) => {
195
+ for (const key of keys) {
196
+ const item = record[key];
197
+ if (typeof item === 'string' && item.trim())
198
+ return item.trim();
199
+ }
200
+ return null;
201
+ };
202
+ return [{ name: text('name', 'title'), stage: text('stage', 'status') }];
203
+ });
204
+ }
96
205
  export async function readGraphProject(dataDir, projectId, options = {}) {
97
206
  const loaded = await loadForRead(dataDir);
98
207
  if ('status' in loaded)
@@ -236,7 +345,8 @@ function ontologyOf(graph, source, asOf) {
236
345
  entityFields: GRAPH_CORRECTION_ENTITY_FIELDS,
237
346
  projectFields: GRAPH_CORRECTION_PROJECT_FIELDS,
238
347
  edgeFields: GRAPH_CORRECTION_EDGE_FIELDS,
239
- newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS
348
+ newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS,
349
+ newEntityTypes: GRAPH_CORRECTION_NEW_ENTITY_TYPES
240
350
  }
241
351
  };
242
352
  }
@@ -882,7 +882,9 @@ export function createGraphWebModule(context) {
882
882
  'project-graph-entry.js',
883
883
  'project-graph-vendor.js',
884
884
  'graph-registry-view.js',
885
- 'graph-registry-view.css'
885
+ 'graph-registry-view.css',
886
+ 'people-and-companies-view.js',
887
+ 'people-and-companies-view.css'
886
888
  ],
887
889
  handle: handler
888
890
  };
@@ -31,6 +31,14 @@ const definitions = {
31
31
  scopeTraversal: 'project_direct',
32
32
  traversalDirection: 'reverse'
33
33
  },
34
+ related_to: {
35
+ id: 'related_to',
36
+ from: 'org',
37
+ to: 'project',
38
+ meaning: 'An organization is involved in a project it does not own, such as a customer or a partner; the edge role names how.',
39
+ scopeTraversal: 'project_direct',
40
+ traversalDirection: 'forward'
41
+ },
34
42
  governs: {
35
43
  id: 'governs',
36
44
  from: 'decision',
package/dist/server.d.ts CHANGED
@@ -73,7 +73,7 @@ export declare const toolDefinitions: readonly [{
73
73
  readonly required: readonly ["relation", "direction"];
74
74
  readonly properties: {
75
75
  readonly relation: {
76
- readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "governs", "supersedes"];
76
+ readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "related_to", "governs", "supersedes"];
77
77
  };
78
78
  readonly direction: {
79
79
  readonly enum: readonly ["incoming", "outgoing"];
package/dist/server.js CHANGED
@@ -30,7 +30,7 @@ const argsSchema = z.object({
30
30
  as_of: z.string().datetime({ offset: true }).optional(),
31
31
  seedIds: z.array(z.string().min(1)).min(1).max(10).optional(),
32
32
  steps: z.array(z.object({
33
- relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']),
33
+ relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']),
34
34
  direction: z.enum(['incoming', 'outgoing']),
35
35
  targetType: z.enum(['person', 'org', 'project', 'decision']).optional()
36
36
  }).strict()).min(1).max(3).optional(),
@@ -188,7 +188,7 @@ export const toolDefinitions = [
188
188
  seedIds: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'string' }, description: 'Canonical IDs chosen by the calling model; no embeddings needed.' },
189
189
  steps: { type: 'array', minItems: 1, maxItems: 3, items: {
190
190
  type: 'object', additionalProperties: false, required: ['relation', 'direction'], properties: {
191
- relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes'] },
191
+ relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes'] },
192
192
  direction: { enum: ['incoming', 'outgoing'] },
193
193
  targetType: { enum: ['person', 'org', 'project', 'decision'] }
194
194
  }
package/dist/templates.js CHANGED
@@ -85,7 +85,7 @@ export const schemaTemplates = {
85
85
  id: { type: 'string', minLength: 1 },
86
86
  fromId: { type: 'string', minLength: 1 },
87
87
  relation: {
88
- enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']
88
+ enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']
89
89
  },
90
90
  toId: { type: 'string', minLength: 1 },
91
91
  role: { type: 'string' },
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { FoundationDefinition, FoundationRelationReference, FoundationRevision } from './ontology-foundation.js';
2
2
  export type EntityKind = 'person' | 'org' | 'project' | 'relationship' | 'decision';
3
3
  export type CanonicalEntityKind = 'person' | 'org' | 'project' | 'decision';
4
- export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'governs' | 'supersedes';
4
+ export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'related_to' | 'governs' | 'supersedes';
5
5
  export interface GraphEntity {
6
6
  id: string;
7
7
  type: Exclude<EntityKind, 'decision'>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
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",
@@ -326,6 +326,8 @@
326
326
  "./ui/objective-editor-http-port": "./ui/objective-editor-http-port.js",
327
327
  "./ui/world-model-view": "./ui/world-model-view.js",
328
328
  "./ui/world-model-view.css": "./ui/world-model-view.css",
329
+ "./ui/people-and-companies-view": "./ui/people-and-companies-view.js",
330
+ "./ui/people-and-companies-view.css": "./ui/people-and-companies-view.css",
329
331
  "./ui/local-web-shell": "./ui/local-web-shell.js",
330
332
  "./ui/local-web-shell.css": "./ui/local-web-shell.css",
331
333
  "./ui/workspace-kit": "./ui/workspace-kit.js",
@@ -479,5 +481,5 @@
479
481
  "engines": {
480
482
  "node": ">=20"
481
483
  },
482
- "gitHead": "55c2d213cafd20800d51c9669f4d7c8d727f7968"
484
+ "gitHead": "f6631b4f6a088162dbd4a3d1fb974de3e599dc85"
483
485
  }