@unson/brainbase-mcp 0.2.3 → 0.3.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,129 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { canonicalEdgeId, isActiveAt, validateCanonicalGraph } from './canonical-graph.js';
4
+ import { getCanonicalRelation } from './relation-registry.js';
5
+ export async function diagnoseGraph(dataDir, asOf = new Date().toISOString()) {
6
+ let graph;
7
+ let rawGraph;
8
+ try {
9
+ rawGraph = await readFile(join(dataDir, 'graph.json'), 'utf8');
10
+ }
11
+ catch (error) {
12
+ return emptyDiagnosis('unavailable', asOf, [{ class: 'unavailable', detail: error instanceof Error ? error.message : String(error) }]);
13
+ }
14
+ try {
15
+ graph = JSON.parse(rawGraph);
16
+ }
17
+ catch {
18
+ return emptyDiagnosis('invalid', asOf, [{ class: 'invalid', detail: 'graph.json is not valid JSON.' }]);
19
+ }
20
+ const schemaVersion = typeof graph?.version === 'number' ? graph.version : null;
21
+ const entities = Array.isArray(graph?.entities) ? graph.entities : [];
22
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
23
+ const issues = [];
24
+ if (schemaVersion === 1)
25
+ issues.push({ class: 'migration', detail: 'Graph v1 must be migrated before ID-edge retrieval is available.' });
26
+ const entityIds = new Set();
27
+ let duplicateEntities = 0;
28
+ for (const entity of entities) {
29
+ if (typeof entity?.id !== 'string')
30
+ continue;
31
+ if (entityIds.has(entity.id))
32
+ duplicateEntities += 1;
33
+ entityIds.add(entity.id);
34
+ }
35
+ if (duplicateEntities > 0)
36
+ issues.push({ class: 'duplicate', detail: `${duplicateEntities} duplicate entity IDs` });
37
+ const edgeIds = new Set();
38
+ const tuples = new Set();
39
+ let danglingEdges = 0;
40
+ let invalidEdges = 0;
41
+ let duplicateEdges = 0;
42
+ let activeEdges = 0;
43
+ const entityById = new Map(entities.filter((entity) => typeof entity?.id === 'string').map((entity) => [entity.id, entity]));
44
+ for (const edge of edges) {
45
+ const tuple = JSON.stringify([edge?.fromId, edge?.relation, edge?.toId]);
46
+ const duplicate = edgeIds.has(edge?.id) || tuples.has(tuple);
47
+ if (duplicate) {
48
+ duplicateEdges += 1;
49
+ issues.push({ class: 'duplicate', recordId: String(edge?.id ?? ''), detail: 'Duplicate edge ID or tuple.' });
50
+ }
51
+ if (typeof edge?.id === 'string')
52
+ edgeIds.add(edge.id);
53
+ tuples.add(tuple);
54
+ const from = entityById.get(edge?.fromId);
55
+ const to = entityById.get(edge?.toId);
56
+ if (!from || !to) {
57
+ danglingEdges += 1;
58
+ issues.push({ class: 'dangling', recordId: String(edge?.id ?? ''), detail: 'Edge endpoint does not resolve to an entity ID.' });
59
+ continue;
60
+ }
61
+ let valid = true;
62
+ try {
63
+ const relation = getCanonicalRelation(edge.relation);
64
+ valid = from.type === relation.from && to.type === relation.to && edge.id === canonicalEdgeId(edge);
65
+ if (valid && !duplicate && isActiveAt(edge, asOf) && isActiveAt(from, asOf) && isActiveAt(to, asOf))
66
+ activeEdges += 1;
67
+ }
68
+ catch {
69
+ valid = false;
70
+ }
71
+ if (!valid) {
72
+ invalidEdges += 1;
73
+ issues.push({ class: 'invalid', recordId: String(edge?.id ?? ''), detail: 'Edge relation, endpoint types, stable ID, or validity interval is invalid.' });
74
+ }
75
+ }
76
+ const { projections, unresolvedRecords } = await diagnoseLegacyViews(dataDir, entities);
77
+ if (projections > 0)
78
+ issues.push({ class: 'projection', detail: `${projections} legacy records are projections of canonical entities.` });
79
+ if (unresolvedRecords > 0)
80
+ issues.push({ class: 'unresolved', detail: `${unresolvedRecords} legacy records do not resolve to a canonical entity ID.` });
81
+ let structurallyInvalid = false;
82
+ try {
83
+ validateCanonicalGraph(graph);
84
+ }
85
+ catch {
86
+ structurallyInvalid = true;
87
+ }
88
+ const status = schemaVersion === 1 ? 'migration_required' : structurallyInvalid ? 'invalid' : issues.length > 0 ? 'issues' : 'healthy';
89
+ return { status, schemaVersion, ontology: graph?.ontology ?? null, asOf, migrationRequired: schemaVersion === 1,
90
+ counts: { entities: entities.length, edges: edges.length, activeEdges, danglingEdges, invalidEdges, duplicateEdges, duplicateEntities, unresolvedRecords, projections }, issues };
91
+ }
92
+ async function diagnoseLegacyViews(dataDir, entities) {
93
+ const personKeys = canonicalKeys(entities.filter((entity) => entity.type === 'person'));
94
+ const decisionKeys = canonicalKeys(entities.filter((entity) => entity.type === 'decision'));
95
+ let projections = 0;
96
+ let unresolvedRecords = 0;
97
+ try {
98
+ const relationships = JSON.parse(await readFile(join(dataDir, 'relationships.json'), 'utf8')).relationships;
99
+ for (const record of Array.isArray(relationships) ? relationships : [])
100
+ personKeys.has(normalize(String(record.person ?? ''))) ? projections += 1 : unresolvedRecords += 1;
101
+ }
102
+ catch { /* loadPersonalOs reports sidecar errors */ }
103
+ try {
104
+ for (const row of (await readFile(join(dataDir, 'decisions.jsonl'), 'utf8')).split(/\r?\n/u).filter(Boolean)) {
105
+ const record = JSON.parse(row);
106
+ decisionKeys.has(String(record.id ?? '')) || decisionKeys.has(normalize(String(record.title ?? ''))) ? projections += 1 : unresolvedRecords += 1;
107
+ }
108
+ }
109
+ catch { /* loadPersonalOs reports sidecar errors */ }
110
+ return { projections, unresolvedRecords };
111
+ }
112
+ function canonicalKeys(entities) {
113
+ const candidates = new Map();
114
+ for (const entity of entities) {
115
+ const id = String(entity.id ?? '');
116
+ for (const key of [id, normalize(String(entity.name ?? '')), ...(Array.isArray(entity.aliases) ? entity.aliases.map((alias) => normalize(String(alias))) : [])]) {
117
+ if (!key)
118
+ continue;
119
+ const matches = candidates.get(key) ?? new Set();
120
+ matches.add(id);
121
+ candidates.set(key, matches);
122
+ }
123
+ }
124
+ return new Set([...candidates].filter(([, matches]) => matches.size === 1).map(([key]) => key));
125
+ }
126
+ function normalize(value) { return value.normalize('NFKC').replace(/\s+/g, '').toLowerCase(); }
127
+ function emptyDiagnosis(status, asOf, issues) {
128
+ return { status, schemaVersion: null, ontology: null, asOf, migrationRequired: false, counts: { entities: 0, edges: 0, activeEdges: 0, danglingEdges: 0, invalidEdges: 0, duplicateEdges: 0, duplicateEntities: 0, unresolvedRecords: 0, projections: 0 }, issues };
129
+ }
@@ -70,7 +70,7 @@ export function buildGuidedFirstRun(input) {
70
70
  '--dir', input.dataDir,
71
71
  '--scenario', firstValueScenario
72
72
  ]);
73
- const actualAgentPrompt = `Brainbaseのget_contextとsearchを使い、保存済み文脈を根拠に「${firstValueScenario}」へ回答してください。使った前提と未確認事項を分けてください。`;
73
+ const actualAgentPrompt = `Brainbaseのresolve_entity、get_context、searchを使い、保存済み文脈を根拠に「${firstValueScenario}」へ回答してください。使った正規ID、関係、前提、未確認事項を分けてください。`;
74
74
  const projectDryRunCommand = project
75
75
  ? projectCommand(input.dataDir, project.project.name, input.project, project.project.sources, false)
76
76
  : undefined;
@@ -132,7 +132,7 @@ export function buildGuidedFirstRun(input) {
132
132
  },
133
133
  {
134
134
  id: 'doctor',
135
- title: 'doctor と MCP get_context/search で確認する',
135
+ title: 'doctor と MCP resolve_entity/get_context/search で確認する',
136
136
  when: 'MCP設定を実configへmergeし、対象エージェントを再起動した後',
137
137
  command: operationalization.pending.find((item) => item.id === 'verification')?.command ?? command(['brainbase', 'doctor', '--dir', input.dataDir])
138
138
  },
@@ -202,9 +202,9 @@ export function buildGuidedFirstRun(input) {
202
202
  ],
203
203
  completionCheck: [
204
204
  'CLIサンプルは接続前プレビューであり、初回価値の達成証拠にしない。',
205
- '新しい実エージェントがBrainbase MCPのget_context/searchを使って現実の依頼へ回答する。',
205
+ '新しい実エージェントがBrainbase MCPのresolve_entity/get_context/searchを使って現実の依頼へ回答する。',
206
206
  '実回答を見た本人が役立ったかを判断する。',
207
- '完了報告には、公開skills、ohayo/oyasumi/retro、MCP実config merge、source allowlist/import/candidate review、MCP get_context/search確認の未完了タスクを必ず出す。',
207
+ '完了報告には、公開skills、ohayo/oyasumi/retro、MCP実config merge、source allowlist/import/candidate review、MCP resolve_entity/get_context/search確認の未完了タスクを必ず出す。',
208
208
  `${targetLabel(input.target)} のMCP設定に Brainbase が実登録されている。`,
209
209
  '最初のプロジェクトが get_context/search で見える。',
210
210
  '外部ソースは許可範囲、保存先、正本化前レビューの流れが説明できる。'
@@ -1,4 +1,5 @@
1
- import type { DecisionRecord, GraphEntity, PersonalKgEntry, RelationshipRecord } from './types.js';
1
+ import { type CanonicalWriteSet } from './canonical-edge-builder.js';
2
+ import type { CanonicalEdge, CanonicalEntity, DecisionRecord, PersonalKgEntry, RelationshipRecord } from './types.js';
2
3
  export type SourceProvider = 'gmail' | 'calendar' | 'drive' | 'local';
3
4
  export interface GmailSourceRecord {
4
5
  kind: 'gmail';
@@ -86,14 +87,18 @@ export interface ExtractOptions {
86
87
  export declare function extractCandidates(records: SourceRecord[], options?: ExtractOptions): ExtractedCandidate[];
87
88
  export declare function buildExtractedCandidateSet(candidates: ExtractedCandidate[], dataDir: string): ExtractedCandidateSet;
88
89
  export interface ApplyInput {
89
- graphEntities: GraphEntity[];
90
+ graphEntities: CanonicalEntity[];
91
+ graphEdges: CanonicalEdge[];
90
92
  relationships: RelationshipRecord[];
91
93
  personalKg: PersonalKgEntry[];
92
94
  decisions: DecisionRecord[];
93
95
  ownerName?: string;
96
+ provenanceSourceKind?: 'import' | 'onboarding';
94
97
  }
95
98
  export interface ApplyResult {
96
- graphEntities: GraphEntity[];
99
+ graphEntities: CanonicalEntity[];
100
+ graphEdges: CanonicalEdge[];
101
+ canonicalWrites: CanonicalWriteSet;
97
102
  relationships: RelationshipRecord[];
98
103
  personalKgAdditions: PersonalKgEntry[];
99
104
  decisionAdditions: DecisionRecord[];
@@ -1,3 +1,4 @@
1
+ import { buildCanonicalEdge } from './canonical-edge-builder.js';
1
2
  const PROVIDER_SOURCE_PATH = {
2
3
  gmail: 'sources/gmail/threads.jsonl',
3
4
  calendar: 'sources/calendar/events.jsonl',
@@ -306,13 +307,31 @@ export function loadApplyCandidates(raw) {
306
307
  }
307
308
  export function planApply(candidates, selection, base, now) {
308
309
  const graphEntities = [...base.graphEntities];
310
+ const graphEdges = [...base.graphEdges];
311
+ const canonicalWrites = { entities: [], edges: [] };
309
312
  const relationships = [...base.relationships];
310
313
  const personalKgAdditions = [];
311
314
  const decisionAdditions = [];
312
315
  const applied = [];
313
316
  const skipped = [];
314
317
  let ownerName = base.ownerName;
315
- for (const candidate of candidates) {
318
+ const provenanceSourceKind = base.provenanceSourceKind ?? 'import';
319
+ const selectedCandidates = candidates.filter((candidate) => selection.all || selection.ids.has(candidate.id));
320
+ const availableProjectIds = new Set(graphEntities.filter((entity) => entity.type === 'project').map((entity) => entity.id));
321
+ const availableDecisionIds = new Set(graphEntities.filter((entity) => entity.type === 'decision').map((entity) => entity.id));
322
+ for (const candidate of selectedCandidates) {
323
+ if (candidate.kind === 'project') {
324
+ const name = payloadString(candidate.payload, 'name');
325
+ if (name)
326
+ availableProjectIds.add(`project-${stableHash(name)}`);
327
+ }
328
+ if (candidate.kind === 'decision') {
329
+ const text = payloadString(candidate.payload, 'decision') || payloadString(candidate.payload, 'text');
330
+ if (text)
331
+ availableDecisionIds.add(`decision-${stableHash(text)}`);
332
+ }
333
+ }
334
+ for (const candidate of [...candidates].sort((left, right) => left.id.localeCompare(right.id, 'en'))) {
316
335
  if (!selection.all && !selection.ids.has(candidate.id)) {
317
336
  skipped.push({ id: candidate.id, reason: 'not selected' });
318
337
  continue;
@@ -326,8 +345,8 @@ export function planApply(candidates, selection, base, now) {
326
345
  break;
327
346
  }
328
347
  ownerName = name;
329
- upsertEntity(graphEntities, { id: 'self', type: 'person', name, summary: 'Owner of this local Brainbase Personal OS.', tags: ['self'] });
330
- personalKgAdditions.push({ id: `self-${stableHash(name)}`, type: 'self', text: `I am ${name}.`, tags: ['self'], source: candidate.id, updatedAt: now });
348
+ writeEntity(graphEntities, canonicalWrites.entities, { id: 'self', type: 'person', name, summary: 'Owner of this local Brainbase Personal OS.', tags: ['self'] });
349
+ personalKgAdditions.push(reuseById(base.personalKg, { id: `self-${stableHash(name)}`, type: 'self', text: `I am ${name}.`, tags: ['self'], source: candidate.id, updatedAt: now }));
331
350
  applied.push({ id: candidate.id, kind: candidate.kind, summary: name, canonicalIds: ['self', `self-${stableHash(name)}`] });
332
351
  break;
333
352
  }
@@ -338,19 +357,39 @@ export function planApply(candidates, selection, base, now) {
338
357
  skipped.push({ id: candidate.id, reason: 'person candidate missing name' });
339
358
  break;
340
359
  }
360
+ const projectId = payloadString(payload, 'projectId');
361
+ if (!projectId) {
362
+ skipped.push({ id: candidate.id, reason: 'unresolved_project_reference: explicit payload.projectId is required' });
363
+ break;
364
+ }
365
+ if (!availableProjectIds.has(projectId)) {
366
+ skipped.push({ id: candidate.id, reason: `unresolved_project_reference: ${projectId} is not a canonical or selected project` });
367
+ break;
368
+ }
341
369
  const context = payloadString(payload, 'context') || payloadString(payload, 'email') || 'Imported from onboarding sources.';
342
370
  const role = payloadString(payload, 'role');
343
- upsertEntity(graphEntities, { id: `person-${stableHash(person)}`, type: 'person', name: person, summary: context, tags: ['relationship'] });
371
+ const personId = `person-${stableHash(person)}`;
372
+ writeEntity(graphEntities, canonicalWrites.entities, { id: personId, type: 'person', name: person, summary: context, tags: ['relationship'] });
373
+ const edge = buildCanonicalEdge({
374
+ fromId: personId,
375
+ relation: 'participates_in',
376
+ toId: projectId,
377
+ role: role || undefined,
378
+ context,
379
+ provenance: { sourceKind: provenanceSourceKind, sourceId: candidate.id }
380
+ });
381
+ writeEdge(graphEdges, canonicalWrites.edges, edge);
382
+ const relationshipId = `relationship-${stableHash(`${person}|${context}`)}`;
344
383
  if (candidate.kind === 'relationship' && !relationships.some((relationship) => relationship.person === person && relationship.context === context)) {
345
- relationships.push({ id: `relationship-${stableHash(`${person}|${context}`)}`, person, role: role || undefined, context, tags: ['relationship'], updatedAt: now });
384
+ relationships.push({ id: relationshipId, person, role: role || undefined, context, tags: ['relationship'], updatedAt: now });
346
385
  }
347
386
  applied.push({
348
387
  id: candidate.id,
349
388
  kind: candidate.kind,
350
389
  summary: person,
351
390
  canonicalIds: candidate.kind === 'relationship'
352
- ? [`person-${stableHash(person)}`, `relationship-${stableHash(`${person}|${context}`)}`]
353
- : [`person-${stableHash(person)}`]
391
+ ? [personId, edge.id, relationshipId]
392
+ : [personId, edge.id]
354
393
  });
355
394
  break;
356
395
  }
@@ -360,7 +399,7 @@ export function planApply(candidates, selection, base, now) {
360
399
  skipped.push({ id: candidate.id, reason: 'org candidate missing name' });
361
400
  break;
362
401
  }
363
- upsertEntity(graphEntities, { id: `org-${stableHash(name)}`, type: 'org', name, summary: 'Imported from onboarding sources.', tags: ['org'] });
402
+ writeEntity(graphEntities, canonicalWrites.entities, { id: `org-${stableHash(name)}`, type: 'org', name, summary: 'Imported from onboarding sources.', tags: ['org'] });
364
403
  applied.push({ id: candidate.id, kind: candidate.kind, summary: name, canonicalIds: [`org-${stableHash(name)}`] });
365
404
  break;
366
405
  }
@@ -370,8 +409,8 @@ export function planApply(candidates, selection, base, now) {
370
409
  skipped.push({ id: candidate.id, reason: 'project candidate missing name' });
371
410
  break;
372
411
  }
373
- upsertEntity(graphEntities, { id: `project-${stableHash(name)}`, type: 'project', name, summary: 'Imported from onboarding sources.', tags: ['work'] });
374
- personalKgAdditions.push({ id: `work-${stableHash(name)}`, type: 'work', text: name, tags: ['work'], source: candidate.id, updatedAt: now });
412
+ writeEntity(graphEntities, canonicalWrites.entities, { id: `project-${stableHash(name)}`, type: 'project', name, summary: 'Imported from onboarding sources.', tags: ['work'] });
413
+ personalKgAdditions.push(reuseById(base.personalKg, { id: `work-${stableHash(name)}`, type: 'work', text: name, tags: ['work'], source: candidate.id, updatedAt: now }));
375
414
  applied.push({ id: candidate.id, kind: candidate.kind, summary: name, canonicalIds: [`project-${stableHash(name)}`, `work-${stableHash(name)}`] });
376
415
  break;
377
416
  }
@@ -381,7 +420,7 @@ export function planApply(candidates, selection, base, now) {
381
420
  skipped.push({ id: candidate.id, reason: 'value candidate missing text' });
382
421
  break;
383
422
  }
384
- personalKgAdditions.push({ id: `value-${stableHash(text)}`, type: 'value', text, tags: ['onboarding'], source: candidate.id, updatedAt: now });
423
+ personalKgAdditions.push(reuseById(base.personalKg, { id: `value-${stableHash(text)}`, type: 'value', text, tags: ['onboarding'], source: candidate.id, updatedAt: now }));
385
424
  applied.push({ id: candidate.id, kind: candidate.kind, summary: text, canonicalIds: [`value-${stableHash(text)}`] });
386
425
  break;
387
426
  }
@@ -391,7 +430,7 @@ export function planApply(candidates, selection, base, now) {
391
430
  skipped.push({ id: candidate.id, reason: 'next_action candidate missing text' });
392
431
  break;
393
432
  }
394
- personalKgAdditions.push({ id: `next-action-${stableHash(text)}`, type: 'work', text, tags: ['next-action'], source: candidate.id, updatedAt: now });
433
+ personalKgAdditions.push(reuseById(base.personalKg, { id: `next-action-${stableHash(text)}`, type: 'work', text, tags: ['next-action'], source: candidate.id, updatedAt: now }));
395
434
  applied.push({ id: candidate.id, kind: candidate.kind, summary: text, canonicalIds: [`next-action-${stableHash(text)}`] });
396
435
  break;
397
436
  }
@@ -401,14 +440,30 @@ export function planApply(candidates, selection, base, now) {
401
440
  skipped.push({ id: candidate.id, reason: 'decision candidate missing decision text' });
402
441
  break;
403
442
  }
443
+ const projectId = payloadString(payload, 'projectId');
444
+ if (!projectId) {
445
+ skipped.push({ id: candidate.id, reason: 'unresolved_project_reference: explicit payload.projectId is required' });
446
+ break;
447
+ }
448
+ if (!availableProjectIds.has(projectId)) {
449
+ skipped.push({ id: candidate.id, reason: `unresolved_project_reference: ${projectId} is not a canonical or selected project` });
450
+ break;
451
+ }
404
452
  const topic = payloadString(payload, 'topic');
405
453
  const effectiveAt = payloadString(payload, 'effectiveAt');
406
454
  const rationale = payloadString(payload, 'rationale');
407
455
  const supersedes = readStringArray(payload, ['supersedes']);
456
+ const unresolvedSupersedes = supersedes.find((id) => !availableDecisionIds.has(id));
457
+ if (unresolvedSupersedes) {
458
+ skipped.push({ id: candidate.id, reason: `unresolved_decision_reference: ${unresolvedSupersedes} is not a canonical or selected decision` });
459
+ break;
460
+ }
408
461
  const tags = readStringArray(payload, ['tags']);
409
- decisionAdditions.push({
410
- id: `decision-${stableHash(text)}`,
411
- title: payloadString(payload, 'title') || 'Promoted decision principle',
462
+ const decisionId = `decision-${stableHash(text)}`;
463
+ const title = payloadString(payload, 'title') || 'Promoted decision principle';
464
+ decisionAdditions.push(reuseById(base.decisions, {
465
+ id: decisionId,
466
+ title,
412
467
  decision: text,
413
468
  topic: topic || undefined,
414
469
  supersedes: supersedes.length > 0 ? supersedes : undefined,
@@ -416,15 +471,45 @@ export function planApply(candidates, selection, base, now) {
416
471
  rationale: rationale || undefined,
417
472
  tags: tags.length > 0 ? tags : ['principle'],
418
473
  updatedAt: now
474
+ }));
475
+ writeEntity(graphEntities, canonicalWrites.entities, {
476
+ id: decisionId,
477
+ type: 'decision',
478
+ name: title,
479
+ summary: text,
480
+ tags: tags.length > 0 ? tags : ['principle'],
481
+ validFrom: effectiveAt || undefined
482
+ });
483
+ const governs = buildCanonicalEdge({
484
+ fromId: decisionId,
485
+ relation: 'governs',
486
+ toId: projectId,
487
+ validFrom: effectiveAt || undefined,
488
+ provenance: { sourceKind: provenanceSourceKind, sourceId: candidate.id }
419
489
  });
420
- applied.push({ id: candidate.id, kind: candidate.kind, summary: text, canonicalIds: [`decision-${stableHash(text)}`] });
490
+ writeEdge(graphEdges, canonicalWrites.edges, governs);
491
+ const canonicalIds = [decisionId, governs.id];
492
+ for (const supersededId of supersedes) {
493
+ if (!availableDecisionIds.has(supersededId))
494
+ continue;
495
+ const supersedesEdge = buildCanonicalEdge({
496
+ fromId: decisionId,
497
+ relation: 'supersedes',
498
+ toId: supersededId,
499
+ validFrom: effectiveAt || undefined,
500
+ provenance: { sourceKind: provenanceSourceKind, sourceId: candidate.id }
501
+ });
502
+ writeEdge(graphEdges, canonicalWrites.edges, supersedesEdge);
503
+ canonicalIds.push(supersedesEdge.id);
504
+ }
505
+ applied.push({ id: candidate.id, kind: candidate.kind, summary: text, canonicalIds });
421
506
  break;
422
507
  }
423
508
  default:
424
509
  skipped.push({ id: candidate.id, reason: `unsupported candidate kind: ${candidate.kind}` });
425
510
  }
426
511
  }
427
- return { graphEntities, relationships, personalKgAdditions, decisionAdditions, ownerName, applied, skipped };
512
+ return { graphEntities, graphEdges, canonicalWrites, relationships, personalKgAdditions, decisionAdditions, ownerName, applied, skipped };
428
513
  }
429
514
  // ---------------------------------------------------------------------------
430
515
  // Helpers
@@ -545,12 +630,52 @@ function payloadString(payload, key) {
545
630
  function upsertEntity(entities, entity) {
546
631
  const index = entities.findIndex((candidate) => candidate.id === entity.id);
547
632
  if (index >= 0) {
633
+ assertCompatibleCanonicalIdentity(entities[index], entity);
548
634
  entities[index] = { ...entities[index], ...entity };
549
635
  }
550
636
  else {
551
637
  entities.push(entity);
552
638
  }
553
639
  }
640
+ function assertCompatibleCanonicalIdentity(existing, proposed) {
641
+ const existingIdentity = canonicalIdentity(existing);
642
+ const proposedIdentity = canonicalIdentity(proposed);
643
+ if (existingIdentity === proposedIdentity)
644
+ return;
645
+ const labels = [canonicalIdentityLabel(existing), canonicalIdentityLabel(proposed)].sort((left, right) => left.localeCompare(right, 'en'));
646
+ throw new Error(`canonical_id_collision: ${proposed.id} maps to distinct semantic identities: ${labels.join(' / ')}`);
647
+ }
648
+ function canonicalIdentity(entity) {
649
+ return JSON.stringify(entity.type === 'decision'
650
+ ? { type: entity.type, decision: entity.summary ?? entity.name }
651
+ : { type: entity.type, name: entity.name });
652
+ }
653
+ function canonicalIdentityLabel(entity) {
654
+ return entity.type === 'decision' ? entity.summary ?? entity.name : entity.name;
655
+ }
656
+ function writeEntity(entities, writes, entity) {
657
+ upsertEntity(entities, entity);
658
+ upsertEntity(writes, entity);
659
+ }
660
+ function writeEdge(edges, writes, edge) {
661
+ upsertRecord(edges, edge);
662
+ upsertRecord(writes, edge);
663
+ }
664
+ function upsertRecord(records, record) {
665
+ const index = records.findIndex((candidate) => candidate.id === record.id);
666
+ if (index >= 0)
667
+ records[index] = record;
668
+ else
669
+ records.push(record);
670
+ }
671
+ function reuseById(existing, addition) {
672
+ const found = existing.find((item) => item.id === addition.id);
673
+ if (!found)
674
+ return addition;
675
+ return JSON.stringify({ ...found, updatedAt: undefined }) === JSON.stringify({ ...addition, updatedAt: undefined })
676
+ ? found
677
+ : addition;
678
+ }
554
679
  function truncate(value, max) {
555
680
  if (value.length <= max) {
556
681
  return value;
@@ -45,7 +45,7 @@ export function buildAgentOnboardingProtocol() {
45
45
  questions: [
46
46
  'Optionally run brainbase onboard:demo to preview which saved context should be used.',
47
47
  'Install the MCP config after user approval and restart the selected agent.',
48
- 'Send the real request through that agent and require get_context/search evidence.',
48
+ 'Send the real request through that agent and require resolve_entity/get_context/search evidence.',
49
49
  'Ask the user whether the actual answer was useful. A CLI sample or synthetic evaluator is not onboarding completion.'
50
50
  ]
51
51
  },
@@ -73,7 +73,7 @@ export function buildAgentOnboardingProtocol() {
73
73
  'brainbase onboard:seed --name "<name>" --value "<what should not be re-explained>" --project "<current project>" --relationship "<person>|<role>|<context>"',
74
74
  'brainbase onboard:projects --name "<project>" --goal "<goal>" --status "<status>" --role "<your role>"',
75
75
  'brainbase onboard:install --target codex --dry-run',
76
- 'Restart the selected agent, then send the real request and require Brainbase get_context/search.',
76
+ 'Restart the selected agent, then send the real request and require Brainbase resolve_entity/get_context/search.',
77
77
  'Ask the user whether the actual answer was useful.',
78
78
  'brainbase onboard:demo --scenario "<optional local preview>"',
79
79
  'brainbase onboard:skills --target codex',
@@ -86,9 +86,9 @@ export function buildAgentOnboardingProtocol() {
86
86
  'Personal OS directory exists.',
87
87
  'The approved self, work, and person context are saved locally.',
88
88
  'The CLI sample, if used, is labeled as a preview rather than an onboarding completion signal.',
89
- 'The completion report lists public skills, ohayo/oyasumi/retro routines, real MCP config merge, source allowlists, and MCP get_context/search verification as unfinished operationalization work.',
89
+ 'The completion report lists public skills, ohayo/oyasumi/retro routines, real MCP config merge, source allowlists, and MCP resolve_entity/get_context/search verification as unfinished operationalization work.',
90
90
  'The selected MCP client has a Brainbase config snippet merged into its real config after user approval.',
91
- 'A fresh agent session used Brainbase get_context/search for the real request.',
91
+ 'A fresh agent session used Brainbase resolve_entity/get_context/search for the real request.',
92
92
  'The user personally confirmed that the actual agent answer was useful.',
93
93
  'Source diagnosis is optional follow-up work, but source allowlist/import/candidate review must be explicitly completed or deferred.'
94
94
  ]
@@ -123,7 +123,7 @@ export function buildValueDemo(input) {
123
123
  dataDir: input.os.dataDir,
124
124
  firstValueReady: ready
125
125
  });
126
- const actualAgentPrompt = `Brainbaseのget_contextとsearchを使い、保存済み文脈を根拠に「${scenario}」へ回答してください。使った前提と未確認事項を分けてください。`;
126
+ const actualAgentPrompt = `Brainbaseのresolve_entity、get_context、searchを使い、保存済み文脈を根拠に「${scenario}」へ回答してください。使った正規ID、関係、前提、未確認事項を分けてください。`;
127
127
  return {
128
128
  goal: '実エージェント接続前に、保存した文脈から作れる出力をプレビューする。',
129
129
  ready,
@@ -0,0 +1,30 @@
1
+ import type { DecisionRecord, GraphFile, GraphFileV2, RelationshipsFile } from './types.js';
2
+ export type MigrationIssueCode = 'ambiguous_person' | 'unresolved_person' | 'missing_project_evidence' | 'missing_project_endpoint' | 'ambiguous_decision_project' | 'missing_decision_endpoint' | 'decision_supersedes_self' | 'decision_supersedes_cycle' | 'unsupported_relationship_entity' | 'expected_input_digest_required' | 'input_digest_mismatch';
3
+ export interface MigrationIssue {
4
+ code: MigrationIssueCode;
5
+ recordId: string;
6
+ detail: string;
7
+ }
8
+ export interface CanonicalGraphMigrationInput {
9
+ graph: GraphFile;
10
+ relationships?: RelationshipsFile;
11
+ decisions?: DecisionRecord[];
12
+ }
13
+ export interface CanonicalGraphMigrationPlan {
14
+ status: 'migration_required' | 'up_to_date' | 'blocked';
15
+ graph: GraphFileV2;
16
+ issues: MigrationIssue[];
17
+ inputDigest: string;
18
+ outputDigest: string;
19
+ changes: {
20
+ entitiesAdded: number;
21
+ edgesAdded: number;
22
+ };
23
+ }
24
+ /**
25
+ * Produces a side-effect-free v1 -> v2 migration preview.
26
+ *
27
+ * Only explicit IDs and unique exact-name matches are promoted. Free-form
28
+ * context is never interpreted as a canonical relationship.
29
+ */
30
+ export declare function planCanonicalGraphMigration(input: CanonicalGraphMigrationInput): CanonicalGraphMigrationPlan;