@unson/brainbase-mcp 0.2.4 → 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.
package/dist/projects.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildCanonicalEdge } from './canonical-edge-builder.js';
1
2
  export function parseProjectSource(value) {
2
3
  const [areaRaw, labelRaw, refRaw] = value.split('|').map((part) => part.trim());
3
4
  const area = parseProjectSourceArea(areaRaw);
@@ -74,6 +75,43 @@ export function buildProjectRegistrationPlan(input) {
74
75
  tags: ['project', 'principle'],
75
76
  updatedAt: now
76
77
  }));
78
+ const decisionEntities = decisions.map((decision) => ({
79
+ id: decision.id,
80
+ type: 'decision',
81
+ name: decision.title,
82
+ summary: decision.decision,
83
+ tags: decision.tags,
84
+ metadata: {
85
+ rationale: decision.rationale,
86
+ projectId
87
+ }
88
+ }));
89
+ const canonicalEntities = [
90
+ { ...projectEntity, type: 'project' },
91
+ ...stakeholderEntities.map((entity) => ({ ...entity, type: 'person' })),
92
+ ...decisionEntities
93
+ ];
94
+ const participationEdges = stakeholders.map((stakeholder) => {
95
+ const personId = `person-${stableHash(stakeholder.person)}`;
96
+ return buildCanonicalEdge({
97
+ fromId: personId,
98
+ relation: 'participates_in',
99
+ toId: projectId,
100
+ role: stakeholder.role,
101
+ context: stakeholder.context,
102
+ provenance: {
103
+ sourceKind: 'onboarding',
104
+ sourceId: `relationship-${stableHash(`${name}|${stakeholder.person}|${stakeholder.context}`)}`
105
+ }
106
+ });
107
+ });
108
+ const governanceEdges = decisions.map((decision) => buildCanonicalEdge({
109
+ fromId: decision.id,
110
+ relation: 'governs',
111
+ toId: projectId,
112
+ context: decision.decision,
113
+ provenance: { sourceKind: 'onboarding', sourceId: decision.id }
114
+ }));
77
115
  return {
78
116
  goal: 'Review project registration before promoting it into canonical Brainbase SSOT.',
79
117
  canonicalWrites: false,
@@ -91,6 +129,8 @@ export function buildProjectRegistrationPlan(input) {
91
129
  stakeholders,
92
130
  writes: {
93
131
  graphEntities: [projectEntity, ...stakeholderEntities],
132
+ canonicalEntities,
133
+ canonicalEdges: [...participationEdges, ...governanceEdges],
94
134
  relationships,
95
135
  personalKg,
96
136
  decisions
@@ -0,0 +1,11 @@
1
+ import type { CanonicalEntityKind, CoreRelation } from './types.js';
2
+ export interface CanonicalRelationDefinition {
3
+ id: CoreRelation;
4
+ from: CanonicalEntityKind;
5
+ to: CanonicalEntityKind;
6
+ meaning: string;
7
+ scopeTraversal: 'none' | 'project_direct' | 'project_transitive';
8
+ traversalDirection: 'none' | 'forward' | 'reverse';
9
+ }
10
+ export declare const canonicalRelationRegistry: Readonly<Record<CoreRelation, Readonly<CanonicalRelationDefinition>>>;
11
+ export declare function getCanonicalRelation(relation: string): Readonly<CanonicalRelationDefinition>;
@@ -0,0 +1,66 @@
1
+ const definitions = {
2
+ member_of: {
3
+ id: 'member_of',
4
+ from: 'person',
5
+ to: 'org',
6
+ meaning: 'A person is a member of an organization.',
7
+ scopeTraversal: 'none',
8
+ traversalDirection: 'none'
9
+ },
10
+ participates_in: {
11
+ id: 'participates_in',
12
+ from: 'person',
13
+ to: 'project',
14
+ meaning: 'A person participates in a project.',
15
+ scopeTraversal: 'project_direct',
16
+ traversalDirection: 'forward'
17
+ },
18
+ accountable_for: {
19
+ id: 'accountable_for',
20
+ from: 'person',
21
+ to: 'project',
22
+ meaning: 'A person is accountable for a project outcome or decision.',
23
+ scopeTraversal: 'project_direct',
24
+ traversalDirection: 'forward'
25
+ },
26
+ owned_by: {
27
+ id: 'owned_by',
28
+ from: 'project',
29
+ to: 'org',
30
+ meaning: 'A project is owned by an organization.',
31
+ scopeTraversal: 'project_direct',
32
+ traversalDirection: 'reverse'
33
+ },
34
+ governs: {
35
+ id: 'governs',
36
+ from: 'decision',
37
+ to: 'project',
38
+ meaning: 'A durable decision or principle governs a project.',
39
+ scopeTraversal: 'project_direct',
40
+ traversalDirection: 'forward'
41
+ },
42
+ supersedes: {
43
+ id: 'supersedes',
44
+ from: 'decision',
45
+ to: 'decision',
46
+ meaning: 'A durable decision explicitly supersedes another decision.',
47
+ scopeTraversal: 'project_transitive',
48
+ traversalDirection: 'forward'
49
+ }
50
+ };
51
+ export const canonicalRelationRegistry = deepFreeze(definitions);
52
+ export function getCanonicalRelation(relation) {
53
+ const definition = canonicalRelationRegistry[relation];
54
+ if (!definition) {
55
+ throw new Error(`ONTOLOGY-RELATION-UNKNOWN: unsupported canonical relation ${JSON.stringify(relation)}`);
56
+ }
57
+ return definition;
58
+ }
59
+ function deepFreeze(value) {
60
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
61
+ Object.freeze(value);
62
+ for (const nested of Object.values(value))
63
+ deepFreeze(nested);
64
+ }
65
+ return value;
66
+ }
package/dist/server.d.ts CHANGED
@@ -8,6 +8,15 @@ export declare const toolDefinitions: readonly [{
8
8
  readonly dataDir: {
9
9
  readonly type: "string";
10
10
  };
11
+ readonly project: {
12
+ readonly type: "string";
13
+ readonly description: "Optional canonical project ID, name, or alias.";
14
+ };
15
+ readonly as_of: {
16
+ readonly type: "string";
17
+ readonly format: "date-time";
18
+ readonly description: "RFC 3339 validity instant. Defaults to now.";
19
+ };
11
20
  };
12
21
  };
13
22
  }, {
@@ -40,6 +49,15 @@ export declare const toolDefinitions: readonly [{
40
49
  readonly limit: {
41
50
  readonly type: "number";
42
51
  };
52
+ readonly project: {
53
+ readonly type: "string";
54
+ readonly description: "Optional canonical project ID, name, or alias.";
55
+ };
56
+ readonly as_of: {
57
+ readonly type: "string";
58
+ readonly format: "date-time";
59
+ readonly description: "RFC 3339 validity instant. Defaults to now.";
60
+ };
43
61
  };
44
62
  };
45
63
  }, {
@@ -380,7 +398,7 @@ export declare const toolDefinitions: readonly [{
380
398
  readonly type: "string";
381
399
  };
382
400
  readonly ontologyVersion: {
383
- readonly enum: readonly ["0.0.0", "1.0.0"];
401
+ readonly enum: readonly ["0.0.0", "1.0.0", "2.0.0"];
384
402
  };
385
403
  };
386
404
  };
@@ -398,7 +416,7 @@ export declare const toolDefinitions: readonly [{
398
416
  readonly format: "date-time";
399
417
  };
400
418
  readonly ontologyVersion: {
401
- readonly enum: readonly ["0.0.0", "1.0.0"];
419
+ readonly enum: readonly ["0.0.0", "1.0.0", "2.0.0"];
402
420
  };
403
421
  };
404
422
  };
@@ -413,6 +431,72 @@ export declare const toolDefinitions: readonly [{
413
431
  };
414
432
  };
415
433
  };
434
+ }, {
435
+ readonly name: "resolve_entity";
436
+ readonly description: "Resolve mentions in text to canonical Graph v2 entity IDs and return a privacy-safe evidence receipt.";
437
+ readonly inputSchema: {
438
+ readonly type: "object";
439
+ readonly required: readonly ["text", "asOf"];
440
+ readonly additionalProperties: false;
441
+ readonly properties: {
442
+ readonly dataDir: {
443
+ readonly type: "string";
444
+ readonly description: "Optional Personal OS directory.";
445
+ };
446
+ readonly text: {
447
+ readonly type: "string";
448
+ readonly description: "Text whose entity mentions should be resolved. The text is hashed, not stored in the receipt.";
449
+ };
450
+ readonly asOf: {
451
+ readonly type: "string";
452
+ readonly format: "date-time";
453
+ readonly description: "RFC 3339 instant used for temporal entity and edge validity.";
454
+ };
455
+ readonly mentionSpans: {
456
+ readonly type: "array";
457
+ readonly items: {
458
+ readonly type: "object";
459
+ readonly required: readonly ["start", "end"];
460
+ readonly additionalProperties: false;
461
+ readonly properties: {
462
+ readonly start: {
463
+ readonly type: "integer";
464
+ readonly minimum: 0;
465
+ };
466
+ readonly end: {
467
+ readonly type: "integer";
468
+ readonly minimum: 1;
469
+ };
470
+ };
471
+ };
472
+ };
473
+ readonly projectScope: {
474
+ readonly type: "object";
475
+ readonly required: readonly ["projectIds"];
476
+ readonly additionalProperties: false;
477
+ readonly properties: {
478
+ readonly projectIds: {
479
+ readonly type: "array";
480
+ readonly minItems: 1;
481
+ readonly items: {
482
+ readonly type: "string";
483
+ readonly minLength: 1;
484
+ };
485
+ };
486
+ readonly policy: {
487
+ readonly enum: readonly ["strict", "prefer_project", "allow_global_fallback"];
488
+ };
489
+ };
490
+ };
491
+ readonly entityTypes: {
492
+ readonly type: "array";
493
+ readonly minItems: 1;
494
+ readonly items: {
495
+ readonly enum: readonly ["person", "org", "project", "decision"];
496
+ };
497
+ };
498
+ };
499
+ };
416
500
  }];
417
501
  export declare function callBrainbaseTool(name: string, rawArgs?: unknown): Promise<unknown>;
418
502
  export declare function createServer(): Server;
package/dist/server.js CHANGED
@@ -1,8 +1,13 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
1
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
7
  import { z } from 'zod';
5
8
  import { ConnectedOnboardingRuntime } from './connected-onboarding.js';
9
+ import { validateCanonicalGraph } from './canonical-graph.js';
10
+ import { resolveText } from './entity-resolution.js';
6
11
  import { resolveDataDir } from './paths.js';
7
12
  import { auditPersonalOsDirectory } from './ontology-ssot.js';
8
13
  import { getOntologyImpact, inferPersonalOs, portableOntology, resolveOntologyVersion } from './ontology.js';
@@ -15,7 +20,34 @@ const argsSchema = z.object({
15
20
  type: z.enum(['person', 'org', 'project', 'relationship', 'decision']).optional(),
16
21
  fromVersion: z.string().optional(),
17
22
  ontologyVersion: z.string().optional(),
18
- asOf: z.string().datetime({ offset: true }).optional()
23
+ asOf: z.string().datetime({ offset: true }).optional(),
24
+ project: z.string().min(1).optional(),
25
+ as_of: z.string().datetime({ offset: true }).optional()
26
+ });
27
+ const mentionSpanSchema = z.object({
28
+ start: z.number().int().nonnegative(),
29
+ end: z.number().int().positive()
30
+ }).strict().refine((span) => span.end > span.start, { message: 'mention span end must be after start' });
31
+ const resolveEntitySchema = z.object({
32
+ dataDir: z.string().optional(),
33
+ text: z.string(),
34
+ asOf: z.string().datetime({ offset: true }),
35
+ mentionSpans: z.array(mentionSpanSchema).optional(),
36
+ projectScope: z.object({
37
+ projectIds: z.array(z.string().min(1)).min(1),
38
+ policy: z.enum(['strict', 'prefer_project', 'allow_global_fallback']).optional()
39
+ }).strict().optional(),
40
+ entityTypes: z.array(z.enum(['person', 'org', 'project', 'decision'])).min(1).optional()
41
+ }).strict().superRefine((value, context) => {
42
+ for (const [index, span] of (value.mentionSpans ?? []).entries()) {
43
+ if (span.end > value.text.length) {
44
+ context.addIssue({
45
+ code: z.ZodIssueCode.custom,
46
+ path: ['mentionSpans', index, 'end'],
47
+ message: 'mention span must be within text'
48
+ });
49
+ }
50
+ }
19
51
  });
20
52
  const sourceInventorySchema = z.object({
21
53
  id: z.string(),
@@ -89,7 +121,9 @@ export const toolDefinitions = [
89
121
  inputSchema: {
90
122
  type: 'object',
91
123
  properties: {
92
- dataDir: { type: 'string' }
124
+ dataDir: { type: 'string' },
125
+ project: { type: 'string', description: 'Optional canonical project ID, name, or alias.' },
126
+ as_of: { type: 'string', format: 'date-time', description: 'RFC 3339 validity instant. Defaults to now.' }
93
127
  }
94
128
  }
95
129
  },
@@ -113,7 +147,9 @@ export const toolDefinitions = [
113
147
  properties: {
114
148
  dataDir: { type: 'string' },
115
149
  query: { type: 'string' },
116
- limit: { type: 'number' }
150
+ limit: { type: 'number' },
151
+ project: { type: 'string', description: 'Optional canonical project ID, name, or alias.' },
152
+ as_of: { type: 'string', format: 'date-time', description: 'RFC 3339 validity instant. Defaults to now.' }
117
153
  }
118
154
  }
119
155
  },
@@ -230,7 +266,7 @@ export const toolDefinitions = [
230
266
  type: 'object',
231
267
  properties: {
232
268
  dataDir: { type: 'string' },
233
- ontologyVersion: { enum: ['0.0.0', '1.0.0'] }
269
+ ontologyVersion: { enum: ['0.0.0', '1.0.0', '2.0.0'] }
234
270
  }
235
271
  }
236
272
  },
@@ -242,7 +278,7 @@ export const toolDefinitions = [
242
278
  properties: {
243
279
  dataDir: { type: 'string' },
244
280
  asOf: { type: 'string', format: 'date-time' },
245
- ontologyVersion: { enum: ['0.0.0', '1.0.0'] }
281
+ ontologyVersion: { enum: ['0.0.0', '1.0.0', '2.0.0'] }
246
282
  }
247
283
  }
248
284
  },
@@ -255,12 +291,52 @@ export const toolDefinitions = [
255
291
  fromVersion: { type: 'string' }
256
292
  }
257
293
  }
294
+ },
295
+ {
296
+ name: 'resolve_entity',
297
+ description: 'Resolve mentions in text to canonical Graph v2 entity IDs and return a privacy-safe evidence receipt.',
298
+ inputSchema: {
299
+ type: 'object',
300
+ required: ['text', 'asOf'],
301
+ additionalProperties: false,
302
+ properties: {
303
+ dataDir: { type: 'string', description: 'Optional Personal OS directory.' },
304
+ text: { type: 'string', description: 'Text whose entity mentions should be resolved. The text is hashed, not stored in the receipt.' },
305
+ asOf: { type: 'string', format: 'date-time', description: 'RFC 3339 instant used for temporal entity and edge validity.' },
306
+ mentionSpans: {
307
+ type: 'array',
308
+ items: {
309
+ type: 'object',
310
+ required: ['start', 'end'],
311
+ additionalProperties: false,
312
+ properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 1 } }
313
+ }
314
+ },
315
+ projectScope: {
316
+ type: 'object',
317
+ required: ['projectIds'],
318
+ additionalProperties: false,
319
+ properties: {
320
+ projectIds: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
321
+ policy: { enum: ['strict', 'prefer_project', 'allow_global_fallback'] }
322
+ }
323
+ },
324
+ entityTypes: {
325
+ type: 'array',
326
+ minItems: 1,
327
+ items: { enum: ['person', 'org', 'project', 'decision'] }
328
+ }
329
+ }
330
+ }
258
331
  }
259
332
  ];
260
333
  export async function callBrainbaseTool(name, rawArgs = {}) {
261
334
  if (name in connectedSchemas) {
262
335
  return callConnectedOnboardingTool(name, rawArgs);
263
336
  }
337
+ if (name === 'resolve_entity') {
338
+ return callResolveEntityTool(rawArgs);
339
+ }
264
340
  const args = argsSchema.parse(rawArgs ?? {});
265
341
  const dataDir = resolveDataDir(args.dataDir);
266
342
  switch (name) {
@@ -316,21 +392,25 @@ export async function callBrainbaseTool(name, rawArgs = {}) {
316
392
  ...portableOntology
317
393
  };
318
394
  case 'audit_ontology':
319
- return auditPersonalOsDirectory(dataDir, { ontologyVersion: resolveOntologyVersion(args.ontologyVersion) });
395
+ return auditPersonalOsDirectory(dataDir, {
396
+ ontologyVersion: args.ontologyVersion === undefined
397
+ ? undefined
398
+ : resolveOntologyVersion(args.ontologyVersion)
399
+ });
320
400
  case 'ontology_impact':
321
401
  return getOntologyImpact(args.fromVersion);
322
402
  }
323
403
  const os = await loadPersonalOs(dataDir);
324
404
  switch (name) {
325
405
  case 'get_context':
326
- return getContext(os);
406
+ return getContext(os, { project: args.project, asOf: args.as_of ?? args.asOf });
327
407
  case 'list_entities':
328
408
  return listEntities(os, args.type);
329
409
  case 'search':
330
410
  if (!args.query) {
331
411
  throw new Error('search requires query');
332
412
  }
333
- return { results: searchAll(os, args.query, args.limit) };
413
+ return { results: searchAll(os, args.query, args.limit, { project: args.project, asOf: args.as_of ?? args.asOf }) };
334
414
  case 'search_personal_kg':
335
415
  if (!args.query) {
336
416
  throw new Error('search_personal_kg requires query');
@@ -341,12 +421,81 @@ export async function callBrainbaseTool(name, rawArgs = {}) {
341
421
  case 'infer_decisions':
342
422
  return inferPersonalOs(os, {
343
423
  asOf: args.asOf,
344
- ontologyVersion: resolveOntologyVersion(args.ontologyVersion)
424
+ ontologyVersion: args.ontologyVersion === undefined
425
+ ? undefined
426
+ : resolveOntologyVersion(args.ontologyVersion)
345
427
  });
346
428
  default:
347
429
  throw new Error(`Unknown tool: ${name}`);
348
430
  }
349
431
  }
432
+ async function callResolveEntityTool(rawArgs) {
433
+ const args = resolveEntitySchema.parse(rawArgs ?? {});
434
+ const dataDir = resolveDataDir(args.dataDir);
435
+ const graphPath = join(dataDir, 'graph.json');
436
+ let serialized;
437
+ try {
438
+ serialized = await readFile(graphPath, 'utf8');
439
+ }
440
+ catch {
441
+ return blockedResolution(args, 'unavailable', 'graph_unavailable');
442
+ }
443
+ let graph;
444
+ try {
445
+ graph = JSON.parse(serialized);
446
+ }
447
+ catch {
448
+ return blockedResolution(args, 'invalid', 'graph_invalid');
449
+ }
450
+ try {
451
+ validateCanonicalGraph(graph);
452
+ }
453
+ catch {
454
+ return blockedResolution(args, 'invalid', 'graph_invalid');
455
+ }
456
+ if (isGraphVersion(graph, 1)) {
457
+ return {
458
+ status: 'migration_required',
459
+ graphSchemaVersion: 1,
460
+ requiredAction: 'migrate_graph_v2'
461
+ };
462
+ }
463
+ if (!isGraphVersion(graph, 2)) {
464
+ return blockedResolution(args, 'invalid', 'graph_invalid');
465
+ }
466
+ const receipt = resolveText({
467
+ text: args.text,
468
+ ...(args.mentionSpans ? { mentionSpans: args.mentionSpans } : {}),
469
+ ...(args.projectScope ? { projectScope: args.projectScope } : {}),
470
+ asOf: args.asOf,
471
+ ...(args.entityTypes ? { entityTypes: args.entityTypes } : {}),
472
+ source: {
473
+ authority: 'local_graph',
474
+ status: 'complete',
475
+ revision: createHash('sha256').update(serialized).digest('hex'),
476
+ graph: graph
477
+ }
478
+ }).receipt;
479
+ return { status: 'verified', receipt };
480
+ }
481
+ function blockedResolution(args, sourceStatus, issueCode) {
482
+ const receipt = resolveText({
483
+ text: args.text,
484
+ ...(args.mentionSpans ? { mentionSpans: args.mentionSpans } : {}),
485
+ ...(args.projectScope ? { projectScope: args.projectScope } : {}),
486
+ asOf: args.asOf,
487
+ ...(args.entityTypes ? { entityTypes: args.entityTypes } : {}),
488
+ source: {
489
+ authority: 'local_graph',
490
+ status: sourceStatus,
491
+ issues: [{ code: issueCode, message: issueCode }]
492
+ }
493
+ }).receipt;
494
+ return { status: 'unverified', receipt };
495
+ }
496
+ function isGraphVersion(graph, version) {
497
+ return Boolean(graph && typeof graph === 'object' && 'version' in graph && graph.version === version);
498
+ }
350
499
  async function callConnectedOnboardingTool(name, rawArgs) {
351
500
  const schema = connectedSchemas[name];
352
501
  const args = schema.parse(rawArgs ?? {});
package/dist/skills.js CHANGED
@@ -27,8 +27,8 @@ const SKILL_DEFINITIONS = {
27
27
  '2. `brainbase onboard:start --target codex|claude|codecode` を実行し、本人、プロジェクト、関係者、判断基準の最小候補を確認する。',
28
28
  '3. ユーザーが明示的に承認した事実だけを `brainbase onboard:seed` で保存する。',
29
29
  '4. `brainbase onboard:install --target codex|claude|codecode --dry-run` でMCP設定を確認し、承認後に実設定へ反映して対象エージェントを再起動する。',
30
- '5. 新しい実際のエージェントで、Brainbaseの `get_context` と `search` を使うよう明示して最初の現実の依頼を実行する。',
31
- '6. 実回答、使われた保存済み文脈、未確認事項をユーザーへ示し、本人に役立ったかを確認する。ここまでを10分以内の初回価値候補ジャーニーとして測る。',
30
+ '5. 新しい実際のエージェントで、Brainbaseの `resolve_entity`、`get_context`、`search` を使うよう明示して最初の現実の依頼を実行する。',
31
+ '6. 実回答、使われた正規IDと関係、保存済み文脈、未確認事項をユーザーへ示し、本人に役立ったかを確認する。ここまでを10分以内の初回価値候補ジャーニーとして測る。',
32
32
  '7. CLIの `onboard:demo` は必要なら接続前のプレビューに使えるが、CLIサンプルは初回価値の達成証拠にしない。',
33
33
  '8. 本人が役立つと確認した後だけ、必要に応じてsource診断、追加候補、Skills、ルーティンへ進む。',
34
34
  '9. 継続利用するプロジェクトの詳細が必要なら、本人確認後に `brainbase onboard:projects --write` で登録する。',
@@ -113,7 +113,7 @@ const SKILL_DEFINITIONS = {
113
113
  '3. どの候補idを承認、却下、修正するかをユーザーに確認する。',
114
114
  '4. まず `brainbase onboard:apply --from <candidate-file> --select <id>` でdry-runする。手入力の事実には `brainbase onboard:seed` を使う。',
115
115
  '5. `--write` は明示的な承認後にだけ使い、canonical filesへ書き込む。',
116
- '6. `brainbase doctor` を実行し、必要に応じてBrainbase MCPの `get_context` または `search` で承認済み事実が見えることを確認する。',
116
+ '6. `brainbase doctor` を実行し、Brainbase MCPの `resolve_entity`、`get_context`、`search` で承認済み事実と正規ID接続が見えることを確認する。',
117
117
  '',
118
118
  '## 安全ルール',
119
119
  '',
package/dist/ssot.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type CanonicalGraphMigrationPlan } from './ontology-migration.js';
1
2
  import type { PersonalOs } from './types.js';
2
3
  export declare function initializePersonalOs(dataDir: string): Promise<void>;
3
4
  export declare function loadPersonalOs(dataDir: string): Promise<PersonalOs>;
@@ -11,3 +12,18 @@ export declare function mutatePersonalOsWithSidecar<T>(dataDir: string, sidecarP
11
12
  sidecarContent: string;
12
13
  result: T;
13
14
  }>): Promise<T>;
15
+ export type CanonicalGraphMigrationExecution = CanonicalGraphMigrationPlan & {
16
+ expectedInputDigest: string;
17
+ written: boolean;
18
+ };
19
+ /**
20
+ * Plans or atomically applies the canonical Graph migration.
21
+ *
22
+ * The input is always recovered, read, and planned while holding the same
23
+ * lock used by every canonical SSOT writer. Omitting `write` is a byte-safe
24
+ * preview; a blocked or already-current plan is never committed.
25
+ */
26
+ export declare function migrateCanonicalGraph(dataDir: string, options?: {
27
+ write?: boolean;
28
+ expectedInputDigest?: string;
29
+ }): Promise<CanonicalGraphMigrationExecution>;
package/dist/ssot.js CHANGED
@@ -4,28 +4,14 @@ import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile
4
4
  import { hostname } from 'node:os';
5
5
  import { dirname, isAbsolute, join, relative, resolve, win32 } from 'node:path';
6
6
  import { z } from 'zod';
7
+ import { validateCanonicalGraph } from './canonical-graph.js';
7
8
  import { assertOntologyValid } from './ontology.js';
9
+ import { planCanonicalGraphMigration } from './ontology-migration.js';
8
10
  import { emptyGraph, emptyRelationships, schemaTemplates } from './templates.js';
9
11
  const canonicalFiles = ['graph.json', 'relationships.json', 'personal-kg.jsonl', 'decisions.jsonl'];
10
12
  const lockName = '.brainbase-ssot.lock';
11
13
  const stagingPrefix = '.brainbase-staging-';
12
14
  const transactionPrefix = '.brainbase-transaction-';
13
- const graphEntitySchema = z.object({
14
- id: z.string().min(1),
15
- type: z.enum(['person', 'org', 'project', 'relationship']),
16
- name: z.string().min(1),
17
- summary: z.string().optional(),
18
- tags: z.array(z.string()).optional(),
19
- metadata: z.record(z.unknown()).optional()
20
- });
21
- const graphSchema = z.object({
22
- version: z.literal(1),
23
- owner: z.object({
24
- name: z.string().optional(),
25
- summary: z.string().optional()
26
- }).optional(),
27
- entities: z.array(graphEntitySchema)
28
- });
29
15
  const personalKgSchema = z.object({
30
16
  id: z.string().min(1),
31
17
  type: z.enum(['self', 'work', 'relationship', 'value', 'judgment', 'experience', 'sns_context']),
@@ -112,8 +98,56 @@ export async function mutatePersonalOsWithSidecar(dataDir, sidecarPath, mutator)
112
98
  return mutation.result;
113
99
  });
114
100
  }
101
+ /**
102
+ * Plans or atomically applies the canonical Graph migration.
103
+ *
104
+ * The input is always recovered, read, and planned while holding the same
105
+ * lock used by every canonical SSOT writer. Omitting `write` is a byte-safe
106
+ * preview; a blocked or already-current plan is never committed.
107
+ */
108
+ export async function migrateCanonicalGraph(dataDir, options = {}) {
109
+ return withSsotLock(dataDir, async () => {
110
+ await recoverTransactions(dataDir);
111
+ assertCompleteCanonicalSet(dataDir, await canonicalPresence(dataDir));
112
+ const current = await loadPersonalOsUnlocked(dataDir);
113
+ const plan = planCanonicalGraphMigration({
114
+ graph: current.graph,
115
+ relationships: current.relationships,
116
+ decisions: current.decisions
117
+ });
118
+ if (options.write && options.expectedInputDigest === undefined) {
119
+ return blockMigrationWrite(plan, {
120
+ code: 'expected_input_digest_required',
121
+ recordId: 'canonical-aggregate',
122
+ detail: 'MIGRATION-EXPECTED-INPUT-DIGEST-REQUIRED: preview first and pass its inputDigest before writing'
123
+ });
124
+ }
125
+ if (options.write && options.expectedInputDigest !== plan.inputDigest) {
126
+ return blockMigrationWrite(plan, {
127
+ code: 'input_digest_mismatch',
128
+ recordId: 'canonical-aggregate',
129
+ detail: `MIGRATION-INPUT-DIGEST-MISMATCH: expected ${options.expectedInputDigest}, replanned ${plan.inputDigest}`
130
+ });
131
+ }
132
+ if (!options.write || plan.status !== 'migration_required') {
133
+ return { ...plan, expectedInputDigest: plan.inputDigest, written: false };
134
+ }
135
+ const next = { ...current, graph: plan.graph };
136
+ await commitAggregate(dataDir, next, 'mutation');
137
+ return { ...plan, expectedInputDigest: plan.inputDigest, written: true };
138
+ });
139
+ }
140
+ function blockMigrationWrite(plan, issue) {
141
+ return {
142
+ ...plan,
143
+ status: 'blocked',
144
+ issues: [...plan.issues, issue].sort((left, right) => (`${left.recordId}\u0000${left.code}`.localeCompare(`${right.recordId}\u0000${right.code}`, 'en'))),
145
+ expectedInputDigest: plan.inputDigest,
146
+ written: false
147
+ };
148
+ }
115
149
  async function loadPersonalOsUnlocked(dataDir) {
116
- const graph = graphSchema.parse(await readJson(join(dataDir, 'graph.json')));
150
+ const graph = parseGraph(await readJson(join(dataDir, 'graph.json')));
117
151
  const relationships = relationshipsSchema.parse(await readJson(join(dataDir, 'relationships.json')));
118
152
  const personalKg = await readJsonl(join(dataDir, 'personal-kg.jsonl'), personalKgSchema, 'personal-kg.jsonl');
119
153
  const decisions = await readJsonl(join(dataDir, 'decisions.jsonl'), decisionSchema, 'decisions.jsonl');
@@ -229,11 +263,27 @@ async function writeAggregate(targetDir, os) {
229
263
  await writeFile(join(targetDir, 'decisions.jsonl'), serializeJsonl(os.decisions));
230
264
  }
231
265
  function validateAggregate(os) {
232
- graphSchema.parse(os.graph);
233
266
  relationshipsSchema.parse(os.relationships);
234
267
  os.personalKg.forEach((entry) => personalKgSchema.parse(entry));
235
268
  os.decisions.forEach((decision) => decisionSchema.parse(decision));
236
269
  assertOntologyValid(os);
270
+ validateCanonicalGraph(os.graph);
271
+ }
272
+ function parseGraph(value) {
273
+ try {
274
+ validateCanonicalGraph(value);
275
+ }
276
+ catch (error) {
277
+ // Duplicate entity IDs are a complete, readable snapshot whose ontology
278
+ // violation must remain available to audit/inference instead of being
279
+ // collapsed into a source-unavailable result. Writers still validate the
280
+ // aggregate strictly before commit.
281
+ if (!(error instanceof Error) || !error.message.startsWith('GRAPH-ENTITY-ID-UNIQUE')) {
282
+ throw error;
283
+ }
284
+ validateCanonicalGraph(value, { allowDuplicateEntityIds: true });
285
+ }
286
+ return value;
237
287
  }
238
288
  function serializeJsonl(values) {
239
289
  return values.length === 0 ? '' : `${values.map((value) => JSON.stringify(value)).join('\n')}\n`;