@unson/brainbase-mcp 0.15.0 → 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.
@@ -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
+ }
@@ -1,15 +1,18 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import { JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
2
3
  export declare const VALUE_PROOF_REVIEW_HTTP_VERSION: "value-proof-review-http.v1";
3
4
  export declare const VALUE_PROOF_REVIEW_TOKEN_HEADER = "x-brainbase-review-token";
4
5
  export interface ValueProofReviewHttpOptions {
5
6
  /** Judgment journal root. Defaults to `~/.brainbase/personal-os/judgment-journal`. */
6
7
  readonly journalRoot?: string;
7
- /** Personal OS data directory that stores owner feedback. */
8
+ /** Personal OS data directory that stores owner feedback and answers. */
8
9
  readonly dataDir?: string;
9
10
  /** Required on mutating requests. */
10
11
  readonly token: string;
11
12
  readonly basePath?: string;
12
13
  readonly now?: () => Date;
14
+ /** One cache per journal; a host passes the one it shares with other readers of the same journal. */
15
+ readonly journalCache?: JudgmentValueProofJournalCache;
13
16
  }
14
17
  export type ValueProofReviewHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
15
18
  export declare function createValueProofReviewHttpHandler(options: ValueProofReviewHttpOptions): ValueProofReviewHttpHandler;
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { createServer } from 'node:http';
4
4
  import { join, relative } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { buildJudgmentValueProofReviewHome, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
6
+ import { applyJudgmentValueProofFeedback, buildJudgmentValueProofReviewHome, classifyJudgmentValueProof, JudgmentValueProofAnswerConflictError, JudgmentValueProofJournalCache, readJudgmentValueProofAnswers, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, recordJudgmentValueProofAnswer, recordJudgmentValueProofFeedback } from './judgment-value-proof-review.js';
7
7
  import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError as HttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError as writeError, writeJson } from './local-web-security.js';
8
8
  export const VALUE_PROOF_REVIEW_HTTP_VERSION = 'value-proof-review-http.v1';
9
9
  export const VALUE_PROOF_REVIEW_TOKEN_HEADER = 'x-brainbase-review-token';
@@ -22,13 +22,21 @@ function publicHome(home) {
22
22
  return home;
23
23
  const strip = (items) => Object.fromEntries(Object.entries(items).map(([section, list]) => [
24
24
  section,
25
- list.map(({ section: itemSection, proof, feedback_history }) => ({ section: itemSection, proof, feedback_history }))
25
+ list.map(({ section: itemSection, proof, feedback_history, answer, conversation_moved_on_at }) => ({
26
+ section: itemSection,
27
+ proof,
28
+ feedback_history,
29
+ answer: answer ?? null,
30
+ conversation_moved_on_at: conversation_moved_on_at ?? null
31
+ }))
26
32
  ]));
27
33
  return {
28
34
  status: home.status,
29
35
  root: home.root,
30
36
  coverage: home.coverage,
31
37
  sections: strip(home.sections),
38
+ // The screen offers answering only when the host says it records answers (and never resumes work).
39
+ capabilities: { answer: 'record_only' },
32
40
  // Holds only decision IDs, counts and states; no local paths.
33
41
  delegation_map: home.delegation_map,
34
42
  rejected: home.rejected.map((entry) => ({ file: relative(home.root, entry.file), reason: entry.reason }))
@@ -40,22 +48,78 @@ export function createValueProofReviewHttpHandler(options) {
40
48
  }
41
49
  const basePath = (options.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/u, '');
42
50
  const now = options.now ?? (() => new Date());
51
+ // A large journal has thousands of conversation folders; only the ones that changed are listed again.
52
+ const journalCache = options.journalCache ?? new JudgmentValueProofJournalCache();
53
+ const readJournal = () => readJudgmentValueProofJournal({ root: options.journalRoot, cache: journalCache });
43
54
  async function readHome() {
44
- const journal = await readJudgmentValueProofJournal({ root: options.journalRoot });
45
- const feedback = journal.status === 'available'
46
- ? await readJudgmentValueProofFeedback({ dataDir: options.dataDir })
47
- : [];
48
- return buildJudgmentValueProofReviewHome(journal, feedback, { now: now() });
55
+ const journal = await readJournal();
56
+ if (journal.status !== 'available')
57
+ return buildJudgmentValueProofReviewHome(journal, [], { now: now() });
58
+ const [feedback, answers] = await Promise.all([
59
+ readJudgmentValueProofFeedback({ dataDir: options.dataDir }),
60
+ readJudgmentValueProofAnswers({ dataDir: options.dataDir })
61
+ ]);
62
+ return buildJudgmentValueProofReviewHome(journal, feedback, { now: now(), answers });
49
63
  }
50
- async function recordFeedback(request) {
64
+ async function authorizedBody(request) {
51
65
  assertSameOrigin(request);
52
66
  if (!tokenMatches(options.token, request.headers[VALUE_PROOF_REVIEW_TOKEN_HEADER])) {
53
67
  throw new HttpError(403, 'review_token_required', 'A valid review token is required');
54
68
  }
55
- const body = await readJsonObjectBody(request, MAX_BODY_BYTES);
69
+ return readJsonObjectBody(request, MAX_BODY_BYTES);
70
+ }
71
+ async function recordAnswer(request) {
72
+ const body = await authorizedBody(request);
73
+ const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
74
+ const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
75
+ const journal = await readJournal();
76
+ if (journal.status === 'unavailable') {
77
+ throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
78
+ }
79
+ const entry = journal.entries.find((candidate) => candidate.proof.intent_id === intentId
80
+ && candidate.proof.decision_attempt_id === attemptId);
81
+ if (!entry)
82
+ throw new HttpError(404, 'value_proof_not_found', 'No saved value proof matches this decision');
83
+ // Classify the judgment as the home shows it, with the owner's evaluation applied.
84
+ const { proof } = applyJudgmentValueProofFeedback(entry.proof, await readJudgmentValueProofFeedback({ dataDir: options.dataDir }));
85
+ if (classifyJudgmentValueProof(proof) !== 'needs_human' || !proof.human_decision) {
86
+ throw new HttpError(422, 'not_answerable', 'This judgment is not waiting for the owner');
87
+ }
88
+ if (entry.conversation_moved_on_at) {
89
+ // The owner answered in that conversation or moved on; answering here could replay a settled question.
90
+ throw new HttpError(422, 'conversation_moved_on', 'The conversation moved on after this question; continue it there');
91
+ }
92
+ try {
93
+ const result = await recordJudgmentValueProofAnswer({
94
+ dataDir: options.dataDir,
95
+ proof,
96
+ kind: body.kind,
97
+ selected_option_id: typeof body.selected_option_id === 'string' ? body.selected_option_id : null,
98
+ confirmed: body.confirmed === true,
99
+ now: now()
100
+ });
101
+ return { statusCode: result.created ? 201 : 200, body: result };
102
+ }
103
+ catch (error) {
104
+ if (error instanceof JudgmentValueProofAnswerConflictError) {
105
+ return {
106
+ statusCode: 409,
107
+ body: {
108
+ error: { code: 'answer_conflict', message: 'A different answer is already recorded for this judgment' },
109
+ existing: error.existing
110
+ }
111
+ };
112
+ }
113
+ if (error instanceof TypeError)
114
+ throw new HttpError(400, 'invalid_answer', error.message);
115
+ throw error;
116
+ }
117
+ }
118
+ async function recordFeedback(request) {
119
+ const body = await authorizedBody(request);
56
120
  const intentId = typeof body.intent_id === 'string' ? body.intent_id : '';
57
121
  const attemptId = typeof body.decision_attempt_id === 'string' ? body.decision_attempt_id : '';
58
- const journal = await readJudgmentValueProofJournal({ root: options.journalRoot });
122
+ const journal = await readJournal();
59
123
  if (journal.status === 'unavailable') {
60
124
  throw new HttpError(503, 'judgment_journal_unavailable', journal.reason);
61
125
  }
@@ -83,7 +147,7 @@ export function createValueProofReviewHttpHandler(options) {
83
147
  }
84
148
  return async (request, response) => {
85
149
  const path = requestUrl(request).pathname;
86
- if (path !== `${basePath}/home` && path !== `${basePath}/feedback`)
150
+ if (path !== `${basePath}/home` && path !== `${basePath}/feedback` && path !== `${basePath}/answers`)
87
151
  return false;
88
152
  try {
89
153
  if (path === `${basePath}/home`) {
@@ -94,7 +158,7 @@ export function createValueProofReviewHttpHandler(options) {
94
158
  }
95
159
  if (request.method !== 'POST')
96
160
  throw new HttpError(405, 'method_not_allowed', 'Use POST');
97
- const result = await recordFeedback(request);
161
+ const result = path === `${basePath}/answers` ? await recordAnswer(request) : await recordFeedback(request);
98
162
  writeJson(response, result.statusCode, result.body);
99
163
  }
100
164
  catch (error) {