graphlin 0.1.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/.codex-plugin/plugin.json +29 -0
  3. package/.mcp.json +9 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/adapters/README.md +32 -0
  7. package/adapters/claude/hooks.json +10 -0
  8. package/adapters/claude/profile.json +18 -0
  9. package/adapters/codex/hooks.json +9 -0
  10. package/adapters/codex/profile.json +22 -0
  11. package/adapters/kiro/profile.json +8 -0
  12. package/mcp.json +11 -0
  13. package/package.json +114 -0
  14. package/plugin.json +20 -0
  15. package/runtime/collector/index.mjs +23 -0
  16. package/runtime/core/candidates.mjs +300 -0
  17. package/runtime/core/common.mjs +69 -0
  18. package/runtime/core/evidence.mjs +150 -0
  19. package/runtime/core/graph.mjs +398 -0
  20. package/runtime/core/index.mjs +4 -0
  21. package/runtime/core/lexical.mjs +255 -0
  22. package/runtime/core/privacy.mjs +206 -0
  23. package/runtime/core/tool-discovery.mjs +122 -0
  24. package/runtime/daemon/auth.mjs +50 -0
  25. package/runtime/daemon/connection-info.mjs +249 -0
  26. package/runtime/daemon/demo.mjs +195 -0
  27. package/runtime/daemon/diagnostics.mjs +404 -0
  28. package/runtime/daemon/export.mjs +7 -0
  29. package/runtime/daemon/ipc.mjs +28 -0
  30. package/runtime/daemon/lock.mjs +137 -0
  31. package/runtime/daemon/manager.mjs +320 -0
  32. package/runtime/daemon/paths.mjs +108 -0
  33. package/runtime/daemon/persistence.mjs +64 -0
  34. package/runtime/daemon/server.mjs +292 -0
  35. package/runtime/daemon/settings.mjs +103 -0
  36. package/runtime/jev/fixture.mjs +99 -0
  37. package/runtime/jev/index.mjs +784 -0
  38. package/runtime/jev/questions.mjs +268 -0
  39. package/runtime/jev/wire.mjs +152 -0
  40. package/runtime/pipeline.mjs +1071 -0
  41. package/runtime/web/app.js +2596 -0
  42. package/runtime/web/index.html +265 -0
  43. package/runtime/web/layout.js +336 -0
  44. package/runtime/web/sidebar.js +525 -0
  45. package/runtime/web/sketch.js +347 -0
  46. package/runtime/web/style.css +593 -0
  47. package/schemas/bundle.schema.json +243 -0
  48. package/schemas/event.schema.json +108 -0
  49. package/schemas/graph.schema.json +449 -0
  50. package/schemas/patch.schema.json +111 -0
  51. package/scripts/arguments.mjs +37 -0
  52. package/scripts/build-packages.mjs +160 -0
  53. package/scripts/collect.sh +23 -0
  54. package/scripts/collector.mjs +11 -0
  55. package/scripts/control.mjs +80 -0
  56. package/scripts/daemon.mjs +28 -0
  57. package/scripts/graphlin.mjs +112 -0
  58. package/scripts/onboarding.mjs +413 -0
  59. package/scripts/validate-packages.mjs +118 -0
  60. package/skills/graphlin/SKILL.md +103 -0
@@ -0,0 +1,292 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import { readFile, chmod, rm } from 'node:fs/promises';
4
+ import { createPipeline } from '../pipeline.mjs';
5
+ import { createPolicy, materializeBundle, buildRelationProposals } from '../core/index.mjs';
6
+ import { createDecisionService } from '../jev/index.mjs';
7
+ import { projectPaths, canonicalProjectRoot, MAX_IPC_BYTES, MAX_STATE_BYTES, PROTOCOL, runtimeError } from './paths.mjs';
8
+ import { acquireLock } from './lock.mjs';
9
+ import { createAuth } from './auth.mjs';
10
+ import { createPersistence } from './persistence.mjs';
11
+ import { exportSnapshot } from './export.mjs';
12
+ import { createDiagnostics } from './diagnostics.mjs';
13
+
14
+ const WEB = new URL('../web/', import.meta.url);
15
+ const assets = new Map([
16
+ ['/', ['index.html', 'text/html; charset=utf-8']],
17
+ ['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
18
+ ['/sidebar.js', ['sidebar.js', 'text/javascript; charset=utf-8']],
19
+ ['/layout.js', ['layout.js', 'text/javascript; charset=utf-8']],
20
+ ['/sketch.js', ['sketch.js', 'text/javascript; charset=utf-8']],
21
+ ['/style.css', ['style.css', 'text/css; charset=utf-8']],
22
+ ]);
23
+ const HOSTS = new Set(['claude', 'codex', 'kiro']);
24
+ const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'";
25
+
26
+ function headers(res) {
27
+ res.setHeader('Cache-Control', 'no-store');
28
+ res.setHeader('Content-Security-Policy', CSP);
29
+ res.setHeader('X-Content-Type-Options', 'nosniff');
30
+ res.setHeader('X-Frame-Options', 'DENY');
31
+ res.setHeader('Referrer-Policy', 'no-referrer');
32
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
33
+ }
34
+ function json(res, status, value) {
35
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
36
+ res.end(JSON.stringify(value));
37
+ }
38
+ async function bodyJSON(req) {
39
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) throw runtimeError('invalid_content_type');
40
+ const chunks = []; let bytes = 0;
41
+ const timer = setTimeout(() => req.destroy(), 1000);
42
+ try {
43
+ for await (const chunk of req) {
44
+ bytes += chunk.length;
45
+ if (bytes > 4096) throw runtimeError('input_too_large');
46
+ chunks.push(chunk);
47
+ }
48
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
49
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw runtimeError('invalid_input');
50
+ return value;
51
+ } finally { clearTimeout(timer); }
52
+ }
53
+
54
+ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
55
+ decisionService, apiKey: configuredKey, mode = 'live', port = 0 } = {}) {
56
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw runtimeError('invalid_port');
57
+ if (!['live', 'demo'].includes(mode)) throw runtimeError('invalid_mode');
58
+ const paths = await projectPaths(projectRoot, dataDir, { create: true });
59
+ const lock = await acquireLock(paths);
60
+ let finished;
61
+ const whenClosed = new Promise(resolve => { finished = resolve; });
62
+ let web, ipc, pipeline, auth, diagnostics, interval, ping, closing, reconciling = false;
63
+ let drops = 0, intake = 0;
64
+ const receivedHooks = { claude: 0, codex: 0, kiro: 0 };
65
+ const clients = new Set(), connections = new Set(), ipcConnections = new Set();
66
+ const persistence = createPersistence(paths.state);
67
+ const policy = createPolicy(policyOptions ?? {});
68
+ const apiKey = !decisionService && policy.transmitSource && mode === 'live'
69
+ ? configuredKey ?? process.env.TYPESAFE_API_KEY : undefined;
70
+ const missingKey = !decisionService && policy.transmitSource && mode === 'live' && !apiKey;
71
+ function snapshot(persistent = false) {
72
+ const state = pipeline.getState({ persistent });
73
+ return { ...state, status: { ...state.status,
74
+ classifier: missingKey && !state.paused ? 'missing_key' : state.status.classifier,
75
+ dropped: (state.status?.dropped ?? 0) + drops } };
76
+ }
77
+ function stream(res, state) {
78
+ const message = `event: snapshot\ndata: ${JSON.stringify(state)}\n\n`;
79
+ if (Buffer.byteLength(message) > MAX_STATE_BYTES || res.writableLength > MAX_STATE_BYTES) {
80
+ res.destroy(); clients.delete(res); return;
81
+ }
82
+ res.write(message);
83
+ }
84
+ function notify() {
85
+ if (!pipeline || closing) return;
86
+ persistence.schedule(snapshot(true));
87
+ const state = snapshot();
88
+ for (const res of clients) stream(res, state);
89
+ }
90
+ async function reconcile() {
91
+ if (reconciling || closing || !pipeline) return;
92
+ reconciling = true;
93
+ try { await pipeline.reconcile(); }
94
+ catch { drops++; }
95
+ finally { reconciling = false; }
96
+ }
97
+ async function close() {
98
+ if (closing) return closing;
99
+ closing = (async () => {
100
+ clearInterval(interval); clearInterval(ping);
101
+ auth?.clear();
102
+ for (const res of clients) res.end();
103
+ for (const socket of [...connections, ...ipcConnections]) socket.destroy();
104
+ await Promise.all([web, ipc].filter(Boolean).map(server =>
105
+ new Promise(resolve => server.close(() => resolve()))));
106
+ try {
107
+ await pipeline?.close();
108
+ if (pipeline) persistence.schedule(snapshot(true));
109
+ await persistence.close();
110
+ } finally {
111
+ try { await diagnostics?.close(); }
112
+ finally {
113
+ await rm(paths.socket, { force: true }).catch(() => {});
114
+ await lock.release();
115
+ }
116
+ }
117
+ })();
118
+ closing.then(() => finished({ ok: true }), () => finished({ ok: false, code: 'shutdown_failed' }));
119
+ return closing;
120
+ }
121
+ try {
122
+ diagnostics = await createDiagnostics({ directory: paths.directory, projectRoot: paths.projectRoot, policy });
123
+ // Reading the key is conditional on explicit source-transmission permission.
124
+ // Test/demo services are injected; this module never logs request bodies.
125
+ const service = decisionService ?? createDecisionService({
126
+ apiKey,
127
+ materializeBundle, buildRelationProposals,
128
+ limits: { eventDeadlineMs: 5000 },
129
+ });
130
+ pipeline = createPipeline({ projectRoot: paths.projectRoot, policy, decisionService: service,
131
+ classificationDeadlineMs: 5000,
132
+ mode, restoredState: await persistence.load(), onChange: notify, onDiagnostic: diagnostics.record });
133
+ web = http.createServer({ maxHeaderSize: 8192, requestTimeout: 2000, headersTimeout: 2000 }, (req, res) => {
134
+ headers(res);
135
+ void (async () => {
136
+ if (!auth || !auth.validRequest(req, { mutation: req.method !== 'GET' })) {
137
+ return json(res, 403, { error: 'forbidden_origin' });
138
+ }
139
+ if (typeof req.url !== 'string' || req.url.length > 1024 || req.url.includes('?')) {
140
+ return json(res, 400, { error: 'invalid_route' });
141
+ }
142
+ if (req.method === 'POST' && req.url === '/api/auth') {
143
+ const input = await bodyJSON(req);
144
+ if (Object.keys(input).some(key => key !== 'token')) return json(res, 400, { error: 'invalid_input' });
145
+ const cookie = auth.exchange(input.token);
146
+ if (!cookie) return json(res, 401, { error: 'invalid_token' });
147
+ res.setHeader('Set-Cookie', cookie);
148
+ return json(res, 200, { ok: true });
149
+ }
150
+ if (req.method === 'GET' && assets.has(req.url)) {
151
+ const [filename, contentType] = assets.get(req.url);
152
+ try {
153
+ const body = await readFile(new URL(filename, WEB));
154
+ if (body.length > 1024 * 1024) throw runtimeError('asset_too_large');
155
+ res.writeHead(200, { 'Content-Type': contentType }); res.end(body);
156
+ } catch { json(res, 503, { error: 'viewer_unavailable' }); }
157
+ return;
158
+ }
159
+ if (!auth.authorized(req)) return json(res, 401, { error: 'authentication_required' });
160
+ if (req.method === 'GET' && req.url === '/api/diagnostics') {
161
+ return json(res, 200, diagnostics.snapshot());
162
+ }
163
+ if (req.method === 'GET' && req.url === '/api/connection-info') {
164
+ try {
165
+ const { createConnectionInfo } = await import('./connection-info.mjs');
166
+ const info = await createConnectionInfo({ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode });
167
+ if (Buffer.byteLength(JSON.stringify(info)) > 64 * 1024) throw runtimeError('connection_info_too_large');
168
+ return json(res, 200, info);
169
+ } catch { return json(res, 503, { error: 'connection_info_unavailable' }); }
170
+ }
171
+ if (req.method === 'GET' && ['/api/state', '/api/export'].includes(req.url)) {
172
+ const state = req.url === '/api/export' ? exportSnapshot(snapshot()) : snapshot();
173
+ if (Buffer.byteLength(JSON.stringify(state)) > MAX_STATE_BYTES) return json(res, 503, { error: 'snapshot_too_large' });
174
+ if (req.url === '/api/export') res.setHeader('Content-Disposition', 'attachment; filename="graphlin-export.json"');
175
+ return json(res, 200, state);
176
+ }
177
+ if (req.method === 'GET' && req.url === '/api/events') {
178
+ if (clients.size >= 16) return json(res, 503, { error: 'viewer_limit' });
179
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
180
+ res.flushHeaders(); clients.add(res);
181
+ stream(res, snapshot());
182
+ req.on('close', () => clients.delete(res));
183
+ return;
184
+ }
185
+ if (req.method === 'POST' && req.url === '/api/control') {
186
+ const input = await bodyJSON(req);
187
+ if (Object.keys(input).some(key => !['action', 'sessionId'].includes(key))) return json(res, 400, { error: 'invalid_control' });
188
+ if (input.action === 'pause' || input.action === 'resume') pipeline.setPaused(input.action === 'pause');
189
+ else if (input.action === 'session' && typeof input.sessionId === 'string' &&
190
+ input.sessionId.length <= 100 && pipeline.selectSession(input.sessionId)) { /* selected */ }
191
+ else return json(res, 400, { error: 'invalid_control' });
192
+ return json(res, 200, { ok: true });
193
+ }
194
+ return json(res, 404, { error: 'not_found' });
195
+ })().catch(() => {
196
+ if (!res.headersSent && !res.destroyed) json(res, 400, { error: 'invalid_input' });
197
+ else if (!res.destroyed) res.destroy();
198
+ });
199
+ });
200
+ web.on('connection', socket => {
201
+ if (connections.size >= 64) return socket.destroy();
202
+ connections.add(socket); socket.on('close', () => connections.delete(socket));
203
+ });
204
+ web.on('clientError', (_error, socket) => socket.destroy());
205
+ web.keepAliveTimeout = 5000;
206
+ await new Promise((resolve, reject) => { web.once('error', reject); web.listen(port, '127.0.0.1', resolve); });
207
+ const actualPort = web.address().port, origin = `http://127.0.0.1:${actualPort}`;
208
+ auth = createAuth({ origin, instanceId: lock.owner.instanceId });
209
+ const launchURL = () => `${origin}/#token=${auth.launchToken()}`;
210
+ function describe() {
211
+ return { ok: true, protocol: PROTOCOL, instanceId: lock.owner.instanceId,
212
+ projectId: paths.projectId, pid: process.pid, port: actualPort, mode,
213
+ policy: { transmitSource: policy.transmitSource, displayEvidence: policy.displayEvidence,
214
+ persistEvidence: policy.persistEvidence, version: policy.version },
215
+ status: snapshot().status, ...persistence.stats(), captureDropped: drops,
216
+ observations: { hooks: { ...receivedHooks }, shapes: pipeline.getState().graph.nodes.length },
217
+ logPath: diagnostics.stats().logPath, diagnostics: diagnostics.stats() };
218
+ }
219
+ // Only the exclusive owner may remove a stale socket from a prior process.
220
+ await rm(paths.socket, { force: true });
221
+ ipc = net.createServer(socket => {
222
+ if (ipcConnections.size >= 32) { drops++; socket.destroy(); return; }
223
+ ipcConnections.add(socket);
224
+ let bytes = 0, chunks = [], handled = false;
225
+ const timer = setTimeout(() => { drops++; socket.destroy(); }, 500);
226
+ socket.on('error', () => {});
227
+ socket.on('close', () => { clearTimeout(timer); ipcConnections.delete(socket); });
228
+ const reply = value => { if (!socket.destroyed) socket.end(`${JSON.stringify(value)}\n`); };
229
+ socket.on('data', chunk => {
230
+ if (handled) return;
231
+ bytes += chunk.length;
232
+ if (bytes > MAX_IPC_BYTES) { drops++; socket.destroy(); return; }
233
+ chunks.push(chunk);
234
+ if (!chunk.includes(10)) return;
235
+ handled = true; clearTimeout(timer);
236
+ void (async () => {
237
+ const raw = Buffer.concat(chunks).toString('utf8');
238
+ chunks = [];
239
+ // A connection carries one bounded message, never an unbounded stream.
240
+ if (raw.slice(raw.indexOf('\n') + 1).trim()) throw runtimeError('invalid_input');
241
+ const input = JSON.parse(raw.slice(0, raw.indexOf('\n')));
242
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw runtimeError('invalid_input');
243
+ if (input.op) {
244
+ if (input.instanceId !== lock.owner.instanceId) return reply({ ok: false, code: 'wrong_instance' });
245
+ if (input.op === 'health') return reply(describe());
246
+ if (input.op === 'launch') return reply({ ...describe(), url: launchURL() });
247
+ if (input.op === 'shutdown') {
248
+ reply({ ok: true });
249
+ // whenClosed reports the failure; also observe the outer async
250
+ // close() promise so a rejected shutdown is never unhandled.
251
+ setImmediate(() => { void close().catch(() => {}); });
252
+ return;
253
+ }
254
+ if (input.op === 'export') return reply({ ok: true, snapshot: exportSnapshot(snapshot()) });
255
+ if (input.op === 'diagnostics') {
256
+ if (Object.keys(input).some(key => !['op', 'instanceId', 'artifactId'].includes(key))) {
257
+ return reply({ ok: false, code: 'invalid_input' });
258
+ }
259
+ return reply({ ok: true, ...diagnostics.snapshot({ artifactId: input.artifactId }) });
260
+ }
261
+ return reply({ ok: false, code: 'invalid_operation' });
262
+ }
263
+ if (!HOSTS.has(input.host) || !input.payload || Array.isArray(input.payload) ||
264
+ typeof input.payload !== 'object' || typeof input.payload.cwd !== 'string') throw runtimeError('invalid_input');
265
+ if (intake >= 32) { drops++; return reply({ ok: false, code: 'overloaded' }); }
266
+ intake++;
267
+ try {
268
+ if (await canonicalProjectRoot(input.payload.cwd) !== paths.projectRoot) throw runtimeError('wrong_project');
269
+ const result = await pipeline.ingest(input.payload, { host: input.host });
270
+ if (result?.accepted !== false) receivedHooks[input.host]++;
271
+ reply({ ok: result?.accepted !== false });
272
+ } finally { intake--; }
273
+ })().catch(() => { drops++; reply({ ok: false, code: 'invalid_input' }); });
274
+ });
275
+ });
276
+ await new Promise((resolve, reject) => { ipc.once('error', reject); ipc.listen(paths.socket, resolve); });
277
+ await chmod(paths.socket, 0o600);
278
+ interval = setInterval(() => { void reconcile(); }, 2000);
279
+ ping = setInterval(() => {
280
+ for (const res of clients) {
281
+ if (res.writableLength > MAX_STATE_BYTES) { res.destroy(); clients.delete(res); }
282
+ else res.write(': heartbeat\n\n');
283
+ }
284
+ }, 15_000);
285
+ await reconcile();
286
+ notify();
287
+ return { url: launchURL(), port: actualPort, close, pipeline, whenClosed };
288
+ } catch (error) {
289
+ await close();
290
+ throw error;
291
+ }
292
+ }
@@ -0,0 +1,103 @@
1
+ import path from 'node:path';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { projectPaths, privateDirectory, readPrivateJSON, atomicJSON, runtimeError, uid } from './paths.mjs';
4
+ import { withPublicationGuard } from './lock.mjs';
5
+
6
+ const LIMIT = 16 * 1024;
7
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const validKey = value => typeof value === 'string' && value.length > 0 &&
9
+ value.length <= 4096 && !/[\s\u0000-\u001f\u007f]/u.test(value);
10
+ const validPolicy = value => record(value) && Object.keys(value).length === 3 &&
11
+ ['allowSource', 'persistEvidence', 'displayEvidence'].every(key => typeof value[key] === 'boolean');
12
+ const validHosts = value => Array.isArray(value) && value.length <= 2 &&
13
+ new Set(value).size === value.length && value.every(host => ['claude', 'codex'].includes(host));
14
+ const validInstallation = value => record(value) &&
15
+ Object.keys(value).every(key => ['hosts', 'version', 'pendingHosts'].includes(key)) &&
16
+ validHosts(value.hosts) && (!('pendingHosts' in value) || validHosts(value.pendingHosts)) &&
17
+ typeof value.version === 'string' && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(value.version);
18
+
19
+ async function readFile(filename, allowed) {
20
+ try {
21
+ // Refuse unsafe settings instead of silently losing a user's consent choice.
22
+ // No settings data or filesystem error text becomes a public error.
23
+ const file = await lstat(filename);
24
+ if (!file.isFile() || file.isSymbolicLink()) throw runtimeError('unsafe_settings');
25
+ const info = await lstat(path.dirname(filename));
26
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 ||
27
+ (uid() !== undefined && info.uid !== uid())) throw runtimeError('unsafe_settings');
28
+ const value = await readPrivateJSON(filename, LIMIT);
29
+ if (!record(value) || value.schemaVersion !== 1 ||
30
+ Object.keys(value).some(key => !['schemaVersion', ...Object.keys(allowed)].includes(key)) ||
31
+ Object.entries(allowed).some(([key, validate]) => key in value && !validate(value[key]))) {
32
+ throw runtimeError('unsafe_settings');
33
+ }
34
+ return value;
35
+ } catch (error) {
36
+ if (error.code === 'ENOENT') return {};
37
+ throw runtimeError('unsafe_settings');
38
+ }
39
+ }
40
+
41
+ /**
42
+ * User credentials/installation are shared by projects in one data directory.
43
+ * Consent is scoped to the canonical project. Never serialize this result into
44
+ * a diagnostic, MCP response, snapshot, browser payload, or subprocess argument.
45
+ */
46
+ export async function readSettings(context = {}) {
47
+ const paths = await projectPaths(context.projectRoot, context.dataDir);
48
+ const [user, project] = await Promise.all([
49
+ readFile(path.join(paths.dataDir, 'settings.json'), { apiKey: validKey, installation: validInstallation }),
50
+ readFile(path.join(paths.directory, 'settings.json'), { policy: validPolicy }),
51
+ ]);
52
+ return {
53
+ ...(user.apiKey ? { apiKey: user.apiKey } : {}),
54
+ ...(user.installation ? { installation: user.installation } : {}),
55
+ ...(project.policy ? { policy: project.policy } : {}),
56
+ };
57
+ }
58
+
59
+ export async function saveSettings(context, patch) {
60
+ if (!record(patch) || Object.keys(patch).some(key => !['apiKey', 'policy', 'installation'].includes(key)) ||
61
+ ('apiKey' in patch && patch.apiKey !== null && !validKey(patch.apiKey)) ||
62
+ ('policy' in patch && !validPolicy(patch.policy)) ||
63
+ ('installation' in patch && !validInstallation(patch.installation))) throw runtimeError('invalid_settings');
64
+ const paths = await projectPaths(context.projectRoot, context.dataDir, { create: true });
65
+ await privateDirectory(paths.dataDir);
66
+ // Reuse the daemon's cross-process bakery guard. Each claim has a unique
67
+ // PID/UUID name before its ticket is written, so interrupted setup can be
68
+ // recovered without racing to delete a shared lock pathname.
69
+ await withPublicationGuard({ lock: path.join(paths.dataDir, '.settings') }, async () => {
70
+ const current = await readSettings(paths);
71
+ if ('apiKey' in patch || 'installation' in patch) {
72
+ const user = { schemaVersion: 1 };
73
+ const key = 'apiKey' in patch ? patch.apiKey : current.apiKey;
74
+ const installation = patch.installation ?? current.installation;
75
+ if (key) user.apiKey = key;
76
+ if (installation) user.installation = installation;
77
+ await atomicJSON(path.join(paths.dataDir, 'settings.json'), user, LIMIT);
78
+ }
79
+ if ('policy' in patch) await atomicJSON(path.join(paths.directory, 'settings.json'),
80
+ { schemaVersion: 1, policy: patch.policy }, LIMIT);
81
+ }).catch(error => {
82
+ if (error.code === 'daemon_busy') throw runtimeError('settings_busy');
83
+ throw error;
84
+ });
85
+ }
86
+
87
+ export function savedPolicy(policy) {
88
+ return policy ? {
89
+ allowSource: policy.transmitSource,
90
+ persistEvidence: policy.persistEvidence,
91
+ displayEvidence: policy.displayEvidence,
92
+ } : {};
93
+ }
94
+
95
+ // An omitted field means reuse consent, while false is an explicit opt-out.
96
+ export function resolvePolicy(options, { current, saved } = {}) {
97
+ const fallback = current ? savedPolicy(current) : saved ?? {};
98
+ return {
99
+ allowSource: options.allowSource ?? fallback.allowSource ?? false,
100
+ persistEvidence: options.persistEvidence ?? fallback.persistEvidence ?? false,
101
+ displayEvidence: options.displayEvidence ?? fallback.displayEvidence ?? true,
102
+ };
103
+ }
@@ -0,0 +1,99 @@
1
+ import { ROLES, RELATIONS, ACTIVITIES } from './questions.mjs';
2
+ import { JevFault, isProbability, isRecord } from './wire.mjs';
3
+
4
+ export const FIXTURE_TRANSPORT = Symbol.for('graphlin.jev.recorded-fixture');
5
+
6
+ const recordedCandidates = Object.freeze({
7
+ 'Notes API': { role: 'service', relevant: 0.98, sensitive: 0.01, support: 0.97 },
8
+ 'Notes repository': { role: 'module', relevant: 0.98, sensitive: 0.01, support: 0.97 },
9
+ saveNote: { role: 'function', relevant: 0.98, sensitive: 0.01, support: 0.97 },
10
+ PostgreSQL: { role: 'datastore', relevant: 0.98, sensitive: 0.01, support: 0.97 },
11
+ });
12
+ const recordedRelations = Object.freeze([
13
+ { sourceLabel: 'Notes API', targetLabel: 'Notes repository', relation: 'calls',
14
+ support: 0.97, missingContext: 0.02 },
15
+ { sourceLabel: 'Notes repository', targetLabel: 'PostgreSQL', relation: 'writes',
16
+ support: 0.97, missingContext: 0.02 },
17
+ { sourceLabel: 'saveNote', targetLabel: 'PostgreSQL', relation: 'writes',
18
+ support: 0.97, missingContext: 0.02 },
19
+ ]);
20
+
21
+ /**
22
+ * An OFFLINE DEMO recording lookup, never a source-code classifier.
23
+ * Explicit mode:"demo" is required. Unknown labels are withheld. Source text is
24
+ * never interpreted and this function never delegates to fetch or opens a socket.
25
+ */
26
+ export function createFixtureTransport({
27
+ mode,
28
+ candidates = recordedCandidates,
29
+ relations = recordedRelations,
30
+ activity = 'implement',
31
+ relevance = 0.98,
32
+ } = {}) {
33
+ if (mode !== 'demo') throw new JevFault('fixture_requires_demo_mode');
34
+ if (!isRecord(candidates) || !Array.isArray(relations)
35
+ || !ACTIVITIES.includes(activity) || !isProbability(relevance)) {
36
+ throw new JevFault('invalid_fixture');
37
+ }
38
+ const candidateRecords = structuredClone(candidates);
39
+ const relationRecords = structuredClone(relations);
40
+ for (const record of Object.values(candidateRecords)) {
41
+ if (!isRecord(record) || !ROLES.includes(record.role)
42
+ || !['relevant', 'sensitive', 'support'].every((key) => isProbability(record[key]))) {
43
+ throw new JevFault('invalid_fixture');
44
+ }
45
+ }
46
+ for (const record of relationRecords) {
47
+ if (!isRecord(record) || typeof record.sourceLabel !== 'string'
48
+ || typeof record.targetLabel !== 'string' || !RELATIONS.includes(record.relation)
49
+ || !isProbability(record.support) || !isProbability(record.missingContext)) {
50
+ throw new JevFault('invalid_fixture');
51
+ }
52
+ }
53
+ const fetchImpl = async (_url, options) => {
54
+ const request = JSON.parse(options.body);
55
+ const { entities } = request.state;
56
+ const recordFor = entity => entity && Object.hasOwn(candidateRecords, entity.name)
57
+ ? candidateRecords[entity.name]
58
+ : { role: 'unknown', relevant: 0.01, sensitive: 1, support: 0.01 };
59
+ const answers = {};
60
+ for (const [id, question] of Object.entries(request.questions)) {
61
+ const index = Number(id.slice(id.lastIndexOf('_') + 1));
62
+ const record = recordFor(entities[index]);
63
+ if (question.type === 'choice') {
64
+ const choice = id === 'a_activity' ? activity : record.role;
65
+ answers[id] = {
66
+ type: 'choice', choice, confidence: 0.98,
67
+ probabilities: Object.fromEntries(Object.keys(question.criteria)
68
+ .map((key) => [key, key === choice ? 1 : 0])),
69
+ };
70
+ } else if (question.type === 'noul') {
71
+ let noul;
72
+ if (id.startsWith('a_sensitive_')) {
73
+ // A shared snippet has one safety verdict. Conflicting/unknown demo
74
+ // records withhold the whole snippet; they never certify its contents.
75
+ const members = entities.filter(entity => entity.sourceIndex === index);
76
+ noul = members.length ? Math.max(...members.map(entity => recordFor(entity).sensitive)) : 1;
77
+ }
78
+ else if (id.startsWith('a_relevant_')) noul = record.relevant;
79
+ else if (id === 'b_relevance') noul = relevance;
80
+ else if (id.startsWith('b_support_')) noul = record.support;
81
+ else if (id.startsWith('b_relation_') || id.startsWith('b_context_')) {
82
+ const target = request.state.proposals[index];
83
+ const relation = relationRecords.find((entry) =>
84
+ entry.sourceLabel === entities[target.sourceEntityIndex]?.name
85
+ && entry.targetLabel === entities[target.targetEntityIndex]?.name
86
+ && entry.relation === target.relation);
87
+ noul = id.startsWith('b_relation_')
88
+ ? (relation?.support ?? 0.01) : (relation?.missingContext ?? 1);
89
+ } else throw new JevFault('unknown_fixture_question');
90
+ answers[id] = { type: 'noul', noul };
91
+ } else throw new JevFault('unknown_fixture_question');
92
+ }
93
+ return new Response(JSON.stringify({
94
+ model: request.model, answers, usage: { input_tokens: 0, output_tokens: 0 },
95
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
96
+ };
97
+ Object.defineProperty(fetchImpl, FIXTURE_TRANSPORT, { value: true });
98
+ return fetchImpl;
99
+ }