@unson/brainbase-mcp 0.15.1 → 0.15.2

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.
@@ -1,13 +1,22 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { appendFile, mkdir, open, readdir, readFile, stat, unlink } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { placeJudgmentValueProof, validateJudgmentValueProof } from './judgment-value-proof.js';
5
5
  import { defaultDataDir } from './paths.js';
6
6
  export const JUDGMENT_VALUE_PROOF_FILE_SUFFIX = '.value-proof.json';
7
+ /** The judgment Host's per-turn final record, next to the turn's value proof. */
8
+ const JUDGMENT_FINAL_FILE_SUFFIX = '.final.json';
7
9
  export const JUDGMENT_VALUE_PROOF_FEEDBACK_SCHEMA = 'brainbase-judgment-value-proof-feedback-v1';
8
10
  export const JUDGMENT_VALUE_PROOF_FEEDBACK_FILE = 'judgment-value-proof-feedback.jsonl';
9
11
  export const JUDGMENT_VALUE_PROOF_ANSWER_SCHEMA = 'brainbase-judgment-value-proof-answer-v1';
10
12
  export const JUDGMENT_VALUE_PROOF_ANSWER_FILE = 'judgment-value-proof-answers.jsonl';
13
+ /** Folders stat'ed or listed at once. Node runs file system calls on a small thread pool; this keeps it fed. */
14
+ const JOURNAL_FOLDER_CONCURRENCY = 16;
15
+ /**
16
+ * A folder listed this soon after its last change is listed again on the next read: on file systems with
17
+ * coarse timestamps (HFS+ 1 s, exFAT 2 s) a change right after the listing can keep the same mtime.
18
+ */
19
+ const JOURNAL_LISTING_SETTLE_NS = 3000000000n;
11
20
  const FEEDBACK_SUMMARY_LIMIT = 500;
12
21
  const ANSWER_LOCK_TIMEOUT_MS = 2_000;
13
22
  /** A lock older than this was left by a write that stopped part way. */
@@ -61,9 +70,96 @@ async function readProofFile(file) {
61
70
  return { file, reason: errorMessage(error) };
62
71
  }
63
72
  }
73
+ /** The `finalized_at` of a turn's final record, or null when it cannot be read as a time. */
74
+ async function readFinalizedAt(file) {
75
+ try {
76
+ const value = JSON.parse(await readFile(file, 'utf8')).finalized_at;
77
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * The judgment Host keeps each conversation in its own folder, one `<turn>.final.json` per finished turn.
85
+ * Returns when the first later turn of the same conversation was finalized after the waiting turn (compared
86
+ * with the waiting turn's own final, or the proof's recorded time when that is missing). An unreadable final
87
+ * is not evidence that the conversation moved on.
88
+ */
89
+ async function conversationMovedOnAt(file, proof) {
90
+ const folder = dirname(file);
91
+ const ownFinal = `${basename(file, JUDGMENT_VALUE_PROOF_FILE_SUFFIX)}${JUDGMENT_FINAL_FILE_SUFFIX}`;
92
+ const since = Date.parse(await readFinalizedAt(join(folder, ownFinal)) ?? proof.recorded_at);
93
+ let names;
94
+ try {
95
+ names = await readdir(folder);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ let first = null;
101
+ for (const name of names) {
102
+ if (!name.endsWith(JUDGMENT_FINAL_FILE_SUFFIX) || name === ownFinal)
103
+ continue;
104
+ const at = await readFinalizedAt(join(folder, name));
105
+ if (at && Date.parse(at) > since && (first === null || Date.parse(at) < Date.parse(first)))
106
+ first = at;
107
+ }
108
+ return first;
109
+ }
110
+ /** Maps with at most `limit` calls in flight; results keep the input order. */
111
+ async function mapConcurrently(items, limit, map) {
112
+ const results = new Array(items.length);
113
+ let next = 0;
114
+ const work = async () => {
115
+ while (next < items.length) {
116
+ const index = next++;
117
+ results[index] = await map(items[index]);
118
+ }
119
+ };
120
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, work));
121
+ return results;
122
+ }
123
+ async function listValueProofFiles(folder) {
124
+ return (await readdir(folder, { withFileTypes: true }))
125
+ .filter((child) => child.isFile() && child.name.endsWith(JUDGMENT_VALUE_PROOF_FILE_SUFFIX))
126
+ .map((child) => join(folder, child.name));
127
+ }
128
+ /**
129
+ * Remembers which value proofs each folder of a judgment journal held, so that a later read lists only
130
+ * the folders that changed. A folder is listed again when its inode, mtime or ctime differs from the
131
+ * listing, or when it was listed within a few seconds of its last change. The judgment Host creates
132
+ * value proofs and finals by linking a new file and never rewrites them, so every new or removed file
133
+ * changes the folder's mtime. It keeps no proof contents; every read still reads each proof.
134
+ * Use one cache per journal.
135
+ */
136
+ export class JudgmentValueProofJournalCache {
137
+ #folders = new Map();
138
+ /** The value proof files in `folder`, reusing the last listing while the folder is unchanged. */
139
+ async valueProofFiles(folder) {
140
+ const listedAtNs = BigInt(Date.now()) * 1000000n;
141
+ const { ino, mtimeNs, ctimeNs } = await stat(folder, { bigint: true });
142
+ const listing = this.#folders.get(folder);
143
+ if (listing && listing.ino === ino && listing.mtimeNs === mtimeNs && listing.ctimeNs === ctimeNs
144
+ && listing.listedAtNs - mtimeNs >= JOURNAL_LISTING_SETTLE_NS) {
145
+ return listing.files;
146
+ }
147
+ const files = await listValueProofFiles(folder);
148
+ this.#folders.set(folder, { ino, mtimeNs, ctimeNs, listedAtNs, files });
149
+ return files;
150
+ }
151
+ /** Forgets folders that are no longer in the journal. */
152
+ retainOnly(folders) {
153
+ for (const folder of this.#folders.keys()) {
154
+ if (!folders.has(folder))
155
+ this.#folders.delete(folder);
156
+ }
157
+ }
158
+ }
64
159
  /**
65
160
  * Reads saved judgment value proofs from `<root>/*.value-proof.json` and
66
161
  * `<root>/<session>/*.value-proof.json`. The journal is read-only here.
162
+ * With `cache`, folders unchanged since an earlier read are not listed again; without it every folder is listed.
67
163
  */
68
164
  export async function readJudgmentValueProofJournal(options = {}) {
69
165
  const root = resolve(options.root ?? defaultJudgmentJournalRoot());
@@ -81,23 +177,31 @@ export async function readJudgmentValueProofJournal(options = {}) {
81
177
  return { status: 'unavailable', root, reason };
82
178
  }
83
179
  const files = [];
180
+ const folders = [];
84
181
  for (const entry of topLevel) {
85
182
  const path = join(root, entry.name);
86
183
  if (entry.isFile() && entry.name.endsWith(JUDGMENT_VALUE_PROOF_FILE_SUFFIX)) {
87
184
  files.push(path);
88
185
  }
89
186
  else if (entry.isDirectory()) {
90
- const nested = await readdir(path, { withFileTypes: true });
91
- for (const child of nested) {
92
- if (child.isFile() && child.name.endsWith(JUDGMENT_VALUE_PROOF_FILE_SUFFIX)) {
93
- files.push(join(path, child.name));
94
- }
95
- }
187
+ folders.push(path);
96
188
  }
97
189
  }
190
+ const { cache } = options;
191
+ const nested = await mapConcurrently(folders, JOURNAL_FOLDER_CONCURRENCY, (folder) => cache ? cache.valueProofFiles(folder) : listValueProofFiles(folder));
192
+ cache?.retainOnly(new Set(folders));
193
+ for (const folderFiles of nested)
194
+ files.push(...folderFiles);
98
195
  const results = await Promise.all(files.sort().map(readProofFile));
99
- const entries = results
196
+ const entries = (await Promise.all(results
100
197
  .filter((result) => 'proof' in result)
198
+ .map(async (entry) => ({
199
+ ...entry,
200
+ // Only a judgment inside a conversation folder can tell whether its conversation moved on.
201
+ conversation_moved_on_at: entry.proof.state === 'waiting_human' && dirname(entry.file) !== root
202
+ ? await conversationMovedOnAt(entry.file, entry.proof)
203
+ : null
204
+ }))))
101
205
  .sort((left, right) => compareRecordedAtDesc(left.proof.recorded_at, right.proof.recorded_at)
102
206
  || left.file.localeCompare(right.file));
103
207
  const rejected = results.filter((result) => 'reason' in result);
@@ -429,7 +533,14 @@ export function buildJudgmentValueProofReviewHome(journal, feedback, options = {
429
533
  const { proof, history } = applyJudgmentValueProofFeedback(entry.proof, feedback);
430
534
  const section = classifyJudgmentValueProof(proof);
431
535
  const answer = answers.get(`${proof.intent_id}\u0000${proof.decision_attempt_id}`) ?? null;
432
- sections[section].push({ file: entry.file, section, proof, feedback_history: history, answer });
536
+ sections[section].push({
537
+ file: entry.file,
538
+ section,
539
+ proof,
540
+ feedback_history: history,
541
+ answer,
542
+ conversation_moved_on_at: entry.conversation_moved_on_at ?? null
543
+ });
433
544
  }
434
545
  const delegationMap = buildJudgmentDelegationMap([...sections.needs_human, ...sections.blocked, ...sections.continued], feedback);
435
546
  const now = (options.now ?? new Date()).getTime();
@@ -1,5 +1,8 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
2
  import { type ObjectiveDefinitionBuilder } from './foundation-http.js';
3
+ import type { GraphWebAnySource, InMemoryGraphReader } from './graph-web.js';
4
+ import { JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
5
+ import type { GraphFileV2 } from './types.js';
3
6
  /**
4
7
  * General local single-owner Web host (`brainbase web:serve`).
5
8
  *
@@ -38,6 +41,10 @@ export interface LocalWebModuleContext {
38
41
  readonly journalRoot: string;
39
42
  readonly token: string;
40
43
  readonly now: () => Date;
44
+ /** Present when the host reads the organization Graph (C1). */
45
+ readonly organizationGraph?: LocalWebOrganizationGraph;
46
+ /** The host's one listing cache for the judgment journal, shared by every screen that reads it. */
47
+ readonly journalCache: JudgmentValueProofJournalCache;
41
48
  }
42
49
  /**
43
50
  * Server side of one screen. The host has already rejected a non-loopback
@@ -59,6 +66,8 @@ export interface LocalWebExtension {
59
66
  readonly uiFiles: readonly string[];
60
67
  /** ES module exporting a `screen` compatible with createLocalWebShell. */
61
68
  readonly screenEntry: string;
69
+ /** `first` lists the screen before the public screens and opens it when no screen is named. Defaults to `last`. */
70
+ readonly navPosition?: 'first' | 'last';
62
71
  createModule(context: LocalWebModuleContext): LocalWebModule;
63
72
  }
64
73
  export interface LocalWebHostOptions {
@@ -71,6 +80,20 @@ export interface LocalWebHostOptions {
71
80
  readonly uiDir?: string;
72
81
  readonly now?: () => Date;
73
82
  readonly extensions?: readonly LocalWebExtension[];
83
+ /**
84
+ * Ledger C1: the owner's organization Graph, read only. When given, the Graph
85
+ * screens read it instead of the data directory and the status reports it.
86
+ */
87
+ readonly organizationGraph?: LocalWebOrganizationGraph;
88
+ }
89
+ export interface LocalWebOrganizationGraph {
90
+ /** Organization server origin shown as the source, without credentials. */
91
+ readonly server: string;
92
+ /** The organization's own web (where its records are corrected), when the owner configured it. */
93
+ readonly webUrl?: string;
94
+ read(): Promise<InMemoryGraphReader<GraphWebAnySource>>;
95
+ /** The same Graph as plain records (the world reads project metadata from it). */
96
+ readGraphFile?(): Promise<GraphFileV2>;
74
97
  }
75
98
  export interface LocalWebHost {
76
99
  readonly server: Server;
@@ -8,7 +8,7 @@ import { foundationCatalogOrEmpty, foundationKey } from './foundation-catalog.js
8
8
  import { createFoundationHttpRouter, createObjectiveFoundationRoute, FOUNDATION_HTTP_PREFIX, FOUNDATION_OBJECTIVE_ROUTE_PREFIX } from './foundation-http.js';
9
9
  import { createFoundationRevisionStore, FoundationStoreError } from './foundation-store.js';
10
10
  import { createGraphWebHttpHandler } from './graph-web-http.js';
11
- import { defaultJudgmentJournalRoot } from './judgment-value-proof-review.js';
11
+ import { defaultJudgmentJournalRoot, JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
12
12
  import { nodeRequestToFetch, writeFetchResponse } from './local-web-fetch-bridge.js';
13
13
  import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError, writeJson } from './local-web-security.js';
14
14
  import { resolveDataDir } from './paths.js';
@@ -138,7 +138,9 @@ export function createLocalStatusModule(context) {
138
138
  writeJson(response, 405, { error: { code: 'method_not_allowed', message: 'Use GET' } });
139
139
  return true;
140
140
  }
141
- const graph = await readLocalGraphState(context.dataDir);
141
+ const local = await readLocalGraphState(context.dataDir);
142
+ // Under C1 the Graph screens read the organization Graph, so they are not gated on the local file.
143
+ const graph = context.organizationGraph ? { ...local, status: 'ready', format: 'v2', message: undefined } : local;
142
144
  writeJson(response, 200, {
143
145
  version: LOCAL_WEB_HOST_VERSION,
144
146
  data_dir: context.dataDir,
@@ -149,8 +151,14 @@ export function createLocalStatusModule(context) {
149
151
  ...(graph.message ? { message: graph.message } : {}),
150
152
  commands: graphCommands(graph, context.dataDir)
151
153
  },
152
- // This host reads only the local data directory (C1 is not connected here).
153
- organization_graph: { status: 'not_connected' }
154
+ organization_graph: context.organizationGraph
155
+ ? {
156
+ status: 'connected',
157
+ server: context.organizationGraph.server,
158
+ mode: 'read_only',
159
+ ...(context.organizationGraph.webUrl ? { web_url: context.organizationGraph.webUrl } : {})
160
+ }
161
+ : { status: 'not_connected' }
154
162
  });
155
163
  return true;
156
164
  }
@@ -161,7 +169,8 @@ export function createValueProofModule(context) {
161
169
  journalRoot: context.journalRoot,
162
170
  dataDir: context.dataDir,
163
171
  token: context.token,
164
- now: context.now
172
+ now: context.now,
173
+ journalCache: context.journalCache
165
174
  });
166
175
  return {
167
176
  id: 'value-proofs',
@@ -674,6 +683,7 @@ export function createGraphWebModule(context) {
674
683
  dataDir: context.dataDir,
675
684
  basePath: LOCAL_WEB_GRAPH_PREFIX,
676
685
  now: context.now,
686
+ ...(context.organizationGraph ? { readGraph: context.organizationGraph.read } : {}),
677
687
  assertWriteAllowed(request) {
678
688
  if (isTrustedLocalWrite(request, context.token))
679
689
  return;
@@ -732,6 +742,8 @@ ${stylesheets.map((path) => `<link rel="stylesheet" href="${escapeAttribute(path
732
742
  }
733
743
  function bootstrapJs(extensions) {
734
744
  const imports = extensions.map((extension, index) => `import { screen as extensionScreen${index} } from '/ui/extensions/${extension.id}/${extension.screenEntry}';`).join('\n');
745
+ const leading = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [`extensionScreen${index}`] : [])).join(', ');
746
+ const trailing = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [] : [`extensionScreen${index}`])).join(', ');
735
747
  const screens = extensions.map((_, index) => `extensionScreen${index}`).join(', ');
736
748
  const ids = JSON.stringify(extensions.map((extension) => extension.id));
737
749
  return `import { createLocalWebShell, LOCAL_WEB_SCREENS } from '/ui/local-web-shell.js';
@@ -749,7 +761,7 @@ const shell = createLocalWebShell({
749
761
  root: document.getElementById('brainbase-local-web'),
750
762
  token: meta('brainbase-web-token'),
751
763
  initialScreen: screenFromHash(),
752
- screens: [...LOCAL_WEB_SCREENS, ...extensionScreens]
764
+ screens: ${leading ? `[${leading}, ...LOCAL_WEB_SCREENS${trailing ? `, ${trailing}` : ''}]` : '[...LOCAL_WEB_SCREENS, ...extensionScreens]'}
753
765
  });
754
766
  addEventListener('hashchange', () => shell.show(screenFromHash()));
755
767
  `;
@@ -765,7 +777,14 @@ export function createLocalWebHost(options = {}) {
765
777
  const dataDir = resolveDataDir(options.dataDir);
766
778
  const journalRoot = options.journalRoot ?? defaultJudgmentJournalRoot(dataDir);
767
779
  const uiDir = options.uiDir ?? fileURLToPath(new URL('../ui/', import.meta.url));
768
- const context = { dataDir, journalRoot, token, now: options.now ?? (() => new Date()) };
780
+ const context = {
781
+ dataDir,
782
+ journalRoot,
783
+ token,
784
+ now: options.now ?? (() => new Date()),
785
+ journalCache: new JudgmentValueProofJournalCache(),
786
+ ...(options.organizationGraph ? { organizationGraph: options.organizationGraph } : {})
787
+ };
769
788
  const modules = defaultLocalWebModules(context);
770
789
  const extensions = [...options.extensions ?? []];
771
790
  const extensionModules = [];
@@ -0,0 +1,74 @@
1
+ import type { LocalWebOrganizationGraph } from './local-web-host.js';
2
+ import type { CanonicalEntityKind, GraphFileV2 } from './types.js';
3
+ /**
4
+ * Ledger C1 (adopted 2026-09-24): while the owner validates the Web, the host
5
+ * reads the owner's organization Graph read only. The records are projected
6
+ * into an in-memory Graph v2 so the existing Graph screens read them through
7
+ * the same views as a local Graph. Nothing is written locally or remotely.
8
+ *
9
+ * Relations come only from what the organization Graph states:
10
+ * - `member_of` person → project → `participates_in` (role = role_code)
11
+ * - RACI `assigned_to` raci → person, with the RACI's project:
12
+ * accountable / A / lane decider / decision:* → `accountable_for`
13
+ * any other role → `participates_in`
14
+ * Retired or superseded records are counted, not drawn.
15
+ */
16
+ export declare const ORGANIZATION_GRAPH_WEB_VERSION: "organization-graph-web.v0";
17
+ interface OrganizationRecord {
18
+ id: string;
19
+ entity_type?: string;
20
+ project_id?: string | null;
21
+ project_code?: string | null;
22
+ lifecycle_status?: string;
23
+ payload?: Record<string, unknown>;
24
+ }
25
+ interface OrganizationEdge {
26
+ id: string;
27
+ from_id: string;
28
+ to_id: string;
29
+ rel_type: string;
30
+ project_id?: string | null;
31
+ lifecycle_status?: string;
32
+ payload?: Record<string, unknown> | null;
33
+ }
34
+ export interface OrganizationGraphRecords {
35
+ readonly entities: Readonly<Record<CanonicalEntityKind | 'raci_assignment', readonly OrganizationRecord[]>>;
36
+ readonly memberOf: readonly OrganizationEdge[];
37
+ readonly assignedTo: readonly OrganizationEdge[];
38
+ }
39
+ export interface OrganizationGraphProjection {
40
+ readonly graph: GraphFileV2;
41
+ readonly excluded: {
42
+ readonly inactive: number;
43
+ readonly unnamed: number;
44
+ readonly danglingRelations: number;
45
+ };
46
+ }
47
+ /** Projects organization Graph records into a canonical Graph v2 (pure; tested). */
48
+ export declare function projectOrganizationGraph(records: OrganizationGraphRecords, owner?: {
49
+ id: string;
50
+ name?: string;
51
+ }): OrganizationGraphProjection;
52
+ interface OrganizationAccess {
53
+ readonly server: string;
54
+ readonly token: string;
55
+ }
56
+ /** The owner's Brainbase server and token, read at request time (tokens rotate hourly). Never logged or returned. */
57
+ export declare function readOrganizationAccess(home?: string): Promise<OrganizationAccess | {
58
+ readonly reason: string;
59
+ }>;
60
+ export interface OrganizationGraphSourceOptions {
61
+ readonly home?: string;
62
+ readonly fetch?: typeof fetch;
63
+ readonly now?: () => Date;
64
+ readonly cacheMs?: number;
65
+ }
66
+ /**
67
+ * The organization Graph for the local host (C1). Reads are cached briefly and
68
+ * shared while in flight; a failure is reported as `unavailable`, never as an
69
+ * empty Graph.
70
+ */
71
+ export declare function createOrganizationGraphSource(options?: OrganizationGraphSourceOptions): Promise<LocalWebOrganizationGraph | {
72
+ readonly reason: string;
73
+ }>;
74
+ export {};
@@ -0,0 +1,275 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { GraphWebError, openInMemoryGraph } from './graph-web.js';
5
+ import { canonicalEdgeId } from './canonical-graph.js';
6
+ import { canonicalGraphOntologyRelease } from './templates.js';
7
+ /**
8
+ * Ledger C1 (adopted 2026-09-24): while the owner validates the Web, the host
9
+ * reads the owner's organization Graph read only. The records are projected
10
+ * into an in-memory Graph v2 so the existing Graph screens read them through
11
+ * the same views as a local Graph. Nothing is written locally or remotely.
12
+ *
13
+ * Relations come only from what the organization Graph states:
14
+ * - `member_of` person → project → `participates_in` (role = role_code)
15
+ * - RACI `assigned_to` raci → person, with the RACI's project:
16
+ * accountable / A / lane decider / decision:* → `accountable_for`
17
+ * any other role → `participates_in`
18
+ * Retired or superseded records are counted, not drawn.
19
+ */
20
+ export const ORGANIZATION_GRAPH_WEB_VERSION = 'organization-graph-web.v0';
21
+ const ENTITY_TYPES = ['project', 'person', 'org', 'decision'];
22
+ const FETCH_TIMEOUT_MS = 15_000;
23
+ const CACHE_MS = 60_000;
24
+ function isRecord(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+ function text(value) {
28
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
29
+ }
30
+ function nameOf(type, payload) {
31
+ return type === 'decision'
32
+ ? text(payload.title) ?? text(payload.name) ?? text(payload.decision)?.slice(0, 80) ?? null
33
+ : text(payload.display_name) ?? text(payload.name);
34
+ }
35
+ function isAccountableRole(payload) {
36
+ const role = text(payload.role_code) ?? text(payload.role) ?? '';
37
+ const lane = text(payload.lane) ?? '';
38
+ return role === 'accountable' || role === 'A' || lane === 'decider' || role.startsWith('decision:');
39
+ }
40
+ /** The record that is the project of its scope: its code, or else its id, is the scope's code. */
41
+ function isCatalogProject(record, payload) {
42
+ const scope = text(record.project_code);
43
+ return scope !== null && (text(payload.code) ?? record.id) === scope;
44
+ }
45
+ /** A project's code, parent and repositories as the organization Graph states them. */
46
+ function projectPlacement(record, payload, catalogByCode) {
47
+ const scope = text(record.project_code);
48
+ const isCatalog = isCatalogProject(record, payload);
49
+ const code = text(payload.code) ?? (isCatalog ? scope : null);
50
+ const repositories = Array.isArray(payload.repository_roots)
51
+ ? payload.repository_roots.filter(isRecord).map((root) => text(root.repository)).filter((name) => name !== null)
52
+ : [];
53
+ const parentId = scope ? catalogByCode.get(scope) : undefined;
54
+ return {
55
+ ...(code ? { code } : {}),
56
+ ...(!isCatalog && scope ? { parent_project_code: scope } : {}),
57
+ ...(!isCatalog && parentId && parentId !== record.id ? { parent_project_id: parentId } : {}),
58
+ ...(repositories.length ? { repositories } : {})
59
+ };
60
+ }
61
+ /** Projects organization Graph records into a canonical Graph v2 (pure; tested). */
62
+ export function projectOrganizationGraph(records, owner) {
63
+ const entities = [];
64
+ const ids = new Map();
65
+ let inactive = 0;
66
+ let unnamed = 0;
67
+ // The organization's catalog project for each project code (its own code or id equals its scope);
68
+ // other projects in that scope hang under it (the world draws them as its districts).
69
+ const catalogByCode = new Map();
70
+ for (const record of records.entities.project ?? []) {
71
+ if (!isRecord(record) || typeof record.id !== 'string' || (record.lifecycle_status && record.lifecycle_status !== 'active'))
72
+ continue;
73
+ const scope = text(record.project_code);
74
+ if (scope && isCatalogProject(record, isRecord(record.payload) ? record.payload : {}) && !catalogByCode.has(scope))
75
+ catalogByCode.set(scope, record.id);
76
+ }
77
+ for (const type of ENTITY_TYPES) {
78
+ for (const record of records.entities[type] ?? []) {
79
+ if (!isRecord(record) || typeof record.id !== 'string' || ids.has(record.id))
80
+ continue;
81
+ if (record.lifecycle_status && record.lifecycle_status !== 'active') {
82
+ inactive += 1;
83
+ continue;
84
+ }
85
+ const payload = isRecord(record.payload) ? record.payload : {};
86
+ const name = nameOf(type, payload);
87
+ if (!name) {
88
+ unnamed += 1;
89
+ continue;
90
+ }
91
+ const aliases = Array.isArray(payload.aliases) ? payload.aliases.filter((alias) => typeof alias === 'string' && alias.trim() !== '' && alias !== name) : [];
92
+ const summary = text(payload.purpose) ?? text(payload.summary) ?? text(payload.description);
93
+ const status = text(payload.operational_status) ?? text(payload.status);
94
+ entities.push({
95
+ id: record.id,
96
+ type,
97
+ name,
98
+ ...(aliases.length ? { aliases: [...new Set(aliases)] } : {}),
99
+ ...(summary ? { summary } : {}),
100
+ metadata: {
101
+ source: 'organization_graph',
102
+ ...(record.project_code ? { project_code: record.project_code } : {}),
103
+ ...(type === 'project' && summary ? { goal: summary } : {}),
104
+ ...(type === 'project' && status ? { status } : {}),
105
+ ...(type === 'project' && text(payload.kind) ? { kind: text(payload.kind) } : {}),
106
+ ...(type === 'project' ? projectPlacement(record, payload, catalogByCode) : {})
107
+ }
108
+ });
109
+ ids.set(record.id, type);
110
+ }
111
+ }
112
+ // One canonical relation per person, relation and project (the edge id is derived from them);
113
+ // the organization's role names for that pair are kept together.
114
+ const byKey = new Map();
115
+ let danglingRelations = 0;
116
+ const addEdge = (sourceId, fromId, relation, toId, role) => {
117
+ if (ids.get(fromId) !== 'person' || ids.get(toId) !== 'project') {
118
+ danglingRelations += 1;
119
+ return;
120
+ }
121
+ const key = `${fromId}|${relation}|${toId}`;
122
+ const existing = byKey.get(key);
123
+ if (existing) {
124
+ if (role && !existing.roles.includes(role))
125
+ existing.roles.push(role);
126
+ return;
127
+ }
128
+ byKey.set(key, {
129
+ edge: { id: canonicalEdgeId({ fromId, relation, toId }), fromId, relation, toId, provenance: { sourceKind: 'import', sourceId } },
130
+ roles: role ? [role] : []
131
+ });
132
+ };
133
+ for (const edge of records.memberOf) {
134
+ if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
135
+ continue;
136
+ addEdge(edge.id, edge.from_id, 'participates_in', edge.to_id, text(edge.payload?.role_code));
137
+ }
138
+ const raci = new Map((records.entities.raci_assignment ?? []).filter((record) => !record.lifecycle_status || record.lifecycle_status === 'active').map((record) => [record.id, record]));
139
+ for (const edge of records.assignedTo) {
140
+ if (edge.lifecycle_status && edge.lifecycle_status !== 'active')
141
+ continue;
142
+ const assignment = raci.get(edge.from_id);
143
+ const projectId = assignment?.project_id ?? edge.project_id ?? null;
144
+ if (!assignment || !projectId) {
145
+ danglingRelations += 1;
146
+ continue;
147
+ }
148
+ const payload = isRecord(assignment.payload) ? assignment.payload : {};
149
+ const role = text(payload.role_code) ?? text(payload.role) ?? text(edge.payload?.role_code);
150
+ addEdge(edge.id, edge.to_id, isAccountableRole(payload) ? 'accountable_for' : 'participates_in', projectId, role);
151
+ }
152
+ const edges = [...byKey.values()].map(({ edge, roles }) => (roles.length ? { ...edge, role: roles.join('、') } : edge));
153
+ const ownerEntity = owner && ids.get(owner.id) === 'person' ? owner : undefined;
154
+ return {
155
+ graph: {
156
+ version: 2,
157
+ ontology: { ...canonicalGraphOntologyRelease.binding },
158
+ owner: ownerEntity ? { id: ownerEntity.id, ...(ownerEntity.name ? { name: ownerEntity.name } : {}) } : {},
159
+ entities,
160
+ edges
161
+ },
162
+ excluded: { inactive, unnamed, danglingRelations }
163
+ };
164
+ }
165
+ /** The owner's Brainbase server and token, read at request time (tokens rotate hourly). Never logged or returned. */
166
+ export async function readOrganizationAccess(home = homedir()) {
167
+ try {
168
+ const config = JSON.parse(await readFile(join(home, '.brainbase', 'config.json'), 'utf8'));
169
+ const tokens = JSON.parse(await readFile(join(home, '.brainbase', 'tokens.json'), 'utf8'));
170
+ const server = isRecord(config) ? text(config.server_url) : null;
171
+ const token = isRecord(tokens) ? text(tokens.access_token) : null;
172
+ if (!server || !/^https:\/\/[^/?#@]+$/u.test(server))
173
+ return { reason: 'server_url_missing_or_invalid' };
174
+ if (!token)
175
+ return { reason: 'access_token_missing' };
176
+ return { server, token };
177
+ }
178
+ catch {
179
+ return { reason: 'brainbase_auth_files_unreadable' };
180
+ }
181
+ }
182
+ async function getRecords(request, access, path) {
183
+ const controller = new AbortController();
184
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
185
+ try {
186
+ const response = await request(`${access.server}/api/info/graph/${path}`, {
187
+ method: 'GET',
188
+ headers: { Authorization: `Bearer ${access.token}`, Accept: 'application/json' },
189
+ redirect: 'error',
190
+ signal: controller.signal
191
+ });
192
+ if (response.status === 401 || response.status === 403) {
193
+ throw new GraphWebError('unavailable', 'organization_graph_auth_failed', `The organization Graph refused the owner's token (HTTP ${response.status}); sign in again`);
194
+ }
195
+ if (!response.ok)
196
+ throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph answered HTTP ${response.status} for ${path.split('?')[0]}`);
197
+ const body = await response.json();
198
+ if (!isRecord(body) || !Array.isArray(body.records))
199
+ throw new GraphWebError('unavailable', 'organization_graph_unavailable', 'The organization Graph response has no records');
200
+ return body.records;
201
+ }
202
+ catch (error) {
203
+ if (error instanceof GraphWebError)
204
+ throw error;
205
+ const reason = error instanceof Error && error.name === 'AbortError' ? 'timed out' : 'request failed';
206
+ throw new GraphWebError('unavailable', 'organization_graph_unavailable', `The organization Graph ${reason}`);
207
+ }
208
+ finally {
209
+ clearTimeout(timer);
210
+ }
211
+ }
212
+ /**
213
+ * The organization Graph for the local host (C1). Reads are cached briefly and
214
+ * shared while in flight; a failure is reported as `unavailable`, never as an
215
+ * empty Graph.
216
+ */
217
+ export async function createOrganizationGraphSource(options = {}) {
218
+ const home = options.home ?? homedir();
219
+ const initial = await readOrganizationAccess(home);
220
+ if ('reason' in initial)
221
+ return initial;
222
+ const request = options.fetch ?? fetch;
223
+ const now = options.now ?? (() => new Date());
224
+ const cacheMs = options.cacheMs ?? CACHE_MS;
225
+ let cached = null;
226
+ let inFlight = null;
227
+ async function load() {
228
+ const access = await readOrganizationAccess(home);
229
+ if ('reason' in access)
230
+ throw new GraphWebError('unavailable', 'organization_graph_not_connected', `The organization Graph cannot be read (${access.reason})`);
231
+ const [project, person, org, decision, raci, memberOf, assignedTo] = await Promise.all([
232
+ getRecords(request, access, 'entities?type=project&limit=500'),
233
+ getRecords(request, access, 'entities?type=person&limit=500'),
234
+ getRecords(request, access, 'entities?type=org&limit=500'),
235
+ getRecords(request, access, 'entities?type=decision&limit=500'),
236
+ getRecords(request, access, 'entities?type=raci_assignment&limit=500'),
237
+ getRecords(request, access, 'edges?type=member_of'),
238
+ getRecords(request, access, 'edges?type=assigned_to')
239
+ ]);
240
+ const projection = projectOrganizationGraph({
241
+ entities: {
242
+ project: project,
243
+ person: person,
244
+ org: org,
245
+ decision: decision,
246
+ raci_assignment: raci
247
+ },
248
+ memberOf: memberOf,
249
+ assignedTo: assignedTo
250
+ });
251
+ const source = { dataDir: null, graphFormat: 2, authority: 'organization_graph', server: access.server, readAt: now().toISOString() };
252
+ return { reader: openInMemoryGraph(projection.graph, source), graph: projection.graph };
253
+ }
254
+ async function current() {
255
+ const at = now().getTime();
256
+ if (cached && at - cached.at < cacheMs)
257
+ return cached.loaded;
258
+ inFlight ??= load().then((loaded) => {
259
+ cached = { at: now().getTime(), loaded };
260
+ return loaded;
261
+ }).finally(() => {
262
+ inFlight = null;
263
+ });
264
+ return inFlight;
265
+ }
266
+ return {
267
+ server: initial.server,
268
+ async read() {
269
+ return (await current()).reader;
270
+ },
271
+ async readGraphFile() {
272
+ return (await current()).graph;
273
+ }
274
+ };
275
+ }