graphlin 0.1.2 → 0.2.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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +465 -0
  8. package/docs/visualizer-views.md +199 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +74 -9
  34. package/plugin.json +4 -2
  35. package/runtime/core/evidence.mjs +43 -9
  36. package/runtime/core/graph.mjs +11 -6
  37. package/runtime/core/privacy.mjs +1 -0
  38. package/runtime/daemon/auth.mjs +7 -3
  39. package/runtime/daemon/diagnostics.mjs +1 -1
  40. package/runtime/daemon/extension-api.mjs +203 -0
  41. package/runtime/daemon/lineage.mjs +70 -0
  42. package/runtime/daemon/manager.mjs +9 -6
  43. package/runtime/daemon/model-api.mjs +728 -0
  44. package/runtime/daemon/model-persistence.mjs +220 -0
  45. package/runtime/daemon/server.mjs +70 -12
  46. package/runtime/daemon/settings.mjs +11 -3
  47. package/runtime/decisions/broker.mjs +349 -0
  48. package/runtime/decisions/contracts.mjs +179 -0
  49. package/runtime/decisions/evaluation.mjs +305 -0
  50. package/runtime/decisions/faults.mjs +32 -0
  51. package/runtime/decisions/index.mjs +818 -0
  52. package/runtime/decisions/profiles.mjs +93 -0
  53. package/runtime/decisions/questions.mjs +268 -0
  54. package/runtime/discovery/index.mjs +2 -0
  55. package/runtime/discovery/inventory.mjs +160 -0
  56. package/runtime/discovery/parser.mjs +40 -0
  57. package/runtime/discovery/structure.mjs +232 -0
  58. package/runtime/extensions/contracts.mjs +59 -0
  59. package/runtime/extensions/frame.mjs +64 -0
  60. package/runtime/extensions/index.mjs +9 -0
  61. package/runtime/extensions/manifest.mjs +95 -0
  62. package/runtime/extensions/packages.mjs +222 -0
  63. package/runtime/extensions/profiles.mjs +36 -0
  64. package/runtime/extensions/projection.mjs +130 -0
  65. package/runtime/extensions/registry.mjs +285 -0
  66. package/runtime/extensions/scene.mjs +105 -0
  67. package/runtime/extensions/sdk.d.ts +205 -0
  68. package/runtime/extensions/sdk.mjs +88 -0
  69. package/runtime/jev/index.mjs +13 -777
  70. package/runtime/jev/provider.mjs +101 -0
  71. package/runtime/jev/questions.mjs +16 -258
  72. package/runtime/jev/wire.mjs +17 -25
  73. package/runtime/model/changes.mjs +42 -0
  74. package/runtime/model/history.mjs +124 -0
  75. package/runtime/model/index.mjs +2 -0
  76. package/runtime/model/project-model.mjs +889 -0
  77. package/runtime/model/records.mjs +239 -0
  78. package/runtime/pipeline.mjs +127 -48
  79. package/runtime/platform.mjs +254 -0
  80. package/runtime/visualizers/blocks.mjs +5 -0
  81. package/runtime/visualizers/c4.mjs +52 -0
  82. package/runtime/visualizers/changes.mjs +24 -0
  83. package/runtime/visualizers/code.mjs +5 -0
  84. package/runtime/visualizers/index.mjs +23 -0
  85. package/runtime/visualizers/structure.mjs +120 -0
  86. package/runtime/visualizers/timeline.mjs +66 -0
  87. package/runtime/web/app.js +369 -86
  88. package/runtime/web/extension-frame.js +128 -0
  89. package/runtime/web/index.html +123 -80
  90. package/runtime/web/model-client.js +162 -0
  91. package/runtime/web/platform.js +337 -0
  92. package/runtime/web/scene.js +111 -0
  93. package/runtime/web/style.css +152 -142
  94. package/schemas/graph.schema.json +4 -1
  95. package/scripts/arguments.mjs +5 -1
  96. package/scripts/build-packages.mjs +6 -2
  97. package/scripts/control.mjs +1 -1
  98. package/scripts/daemon.mjs +2 -1
  99. package/scripts/extensions.mjs +44 -0
  100. package/scripts/graphlin.mjs +23 -3
  101. package/scripts/onboarding.mjs +10 -3
  102. package/scripts/validate-packages.mjs +54 -8
@@ -0,0 +1,220 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, realpath, rename, rm } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { readPrivateJSON } from './paths.mjs';
6
+
7
+ export const MAX_MODEL_STATE_BYTES = 48 * 1024 * 1024;
8
+ const MAX_NODES = 2_000_000, MAX_DEPTH = 64;
9
+ const COLLECTIONS = ['entities', 'relations', 'interpretations', 'activity', 'sessions', 'checkpoints'];
10
+ const ROOT_FIELDS = new Set(['schemaVersion', 'projectId', 'revision', 'sequence', 'coverage', 'storage', ...COLLECTIONS]);
11
+ const plain = value => value !== null && typeof value === 'object' &&
12
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
13
+ const natural = value => Number.isSafeInteger(value) && value >= 0;
14
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(value);
15
+ const invalid = () => { throw new Error('invalid_model_state'); };
16
+
17
+ function validateSnapshot(value, projectId, depth = 0) {
18
+ if (!plain(value) || depth > 8 || value.schemaVersion !== 2 || value.projectId !== projectId ||
19
+ !natural(value.revision) || !natural(value.sequence) || !plain(value.coverage) ||
20
+ Object.keys(value).some(key => !ROOT_FIELDS.has(key)) ||
21
+ COLLECTIONS.some(key => !Array.isArray(value[key])) ||
22
+ (value.storage !== undefined && !plain(value.storage))) invalid();
23
+ for (const checkpoint of value.checkpoints) {
24
+ if (!plain(checkpoint) || !id(checkpoint.id) ||
25
+ (checkpoint.projectId !== undefined && checkpoint.projectId !== projectId)) invalid();
26
+ if (checkpoint.state !== undefined) validateSnapshot(checkpoint.state, projectId, depth + 1);
27
+ }
28
+ }
29
+
30
+ // Count exact UTF-8 JSON bytes before allocating the complete encoded snapshot.
31
+ // Reject cycles, getters and non-JSON values instead of executing toJSON hooks or
32
+ // silently losing Map/BigInt values. Undefined object fields are omitted, as in
33
+ // JSON.stringify: current policy uses them to withhold display/path metadata.
34
+ function boundedJSON(value, limit) {
35
+ let size = 0, nodes = 0;
36
+ const ancestors = new Set();
37
+ const add = count => { size += count; if (size > limit) invalid(); };
38
+ function string(value) {
39
+ add(2);
40
+ for (let i = 0; i < value.length; i++) {
41
+ const code = value.charCodeAt(i);
42
+ if (code === 34 || code === 92) add(2);
43
+ else if (code < 32) add([8, 9, 10, 12, 13].includes(code) ? 2 : 6);
44
+ else if (code < 128) add(1);
45
+ else if (code < 2048) add(2);
46
+ else if (code >= 0xd800 && code <= 0xdbff &&
47
+ value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) { add(4); i++; }
48
+ else if (code >= 0xd800 && code <= 0xdfff) add(6);
49
+ else add(3);
50
+ }
51
+ }
52
+ function visit(value, depth) {
53
+ if (++nodes > MAX_NODES || depth > MAX_DEPTH) invalid();
54
+ if (value === null) { add(4); return; }
55
+ if (typeof value === 'string') { string(value); return; }
56
+ if (typeof value === 'boolean') { add(value ? 4 : 5); return; }
57
+ if (typeof value === 'number' && Number.isFinite(value)) { add(JSON.stringify(value).length); return; }
58
+ if ((!plain(value) && !Array.isArray(value)) || ancestors.has(value)) invalid();
59
+ ancestors.add(value);
60
+ const keys = Reflect.ownKeys(value);
61
+ if (keys.some(key => typeof key !== 'string')) invalid();
62
+ add(2);
63
+ if (Array.isArray(value)) {
64
+ if (value.length > MAX_NODES || keys.length !== value.length + 1) invalid();
65
+ for (let i = 0; i < value.length; i++) {
66
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
67
+ if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
68
+ if (i) add(1);
69
+ visit(descriptor.value, depth + 1);
70
+ }
71
+ } else {
72
+ let emitted = 0;
73
+ for (let i = 0; i < keys.length; i++) {
74
+ const key = keys[i], descriptor = Object.getOwnPropertyDescriptor(value, key);
75
+ if (!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
76
+ if (descriptor.value === undefined) continue;
77
+ if (emitted++) add(1);
78
+ string(key); add(1); visit(descriptor.value, depth + 1);
79
+ }
80
+ }
81
+ ancestors.delete(value);
82
+ }
83
+ visit(value, 0);
84
+ const result = JSON.stringify(value);
85
+ if (Buffer.byteLength(result) !== size || size > limit) invalid();
86
+ return result;
87
+ }
88
+
89
+ function owned(info) {
90
+ return process.getuid === undefined || info.uid === process.getuid();
91
+ }
92
+ async function safeDirectory(directory) {
93
+ const info = await lstat(directory);
94
+ if (!info.isDirectory() || info.isSymbolicLink() || !owned(info) ||
95
+ (info.mode & 0o077) !== 0 || await realpath(directory) !== directory) invalid();
96
+ return info;
97
+ }
98
+ async function safeDestination(filename) {
99
+ try {
100
+ const info = await lstat(filename);
101
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || !owned(info) ||
102
+ (info.mode & 0o077) !== 0) invalid();
103
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
104
+ }
105
+
106
+ /**
107
+ * The parent MUST pass model.snapshot({ persistent: true }) under current policy.
108
+ * This storage layer preserves that JSON (including storage/checkpoint state);
109
+ * it is not an export projector, source reader, migration, or policy authority.
110
+ *
111
+ * Use a separate, canonical private path such as <project-data>/model-state.json.
112
+ * The parent retains its existing state.json writer and owns daemon locking.
113
+ * An omitted projectId binds to the first successfully loaded/scheduled model.
114
+ */
115
+ export function createModelPersistence(filename, {
116
+ projectId, maxBytes = MAX_MODEL_STATE_BYTES, debounceMs = 100, now = Date.now,
117
+ } = {}) {
118
+ if (typeof filename !== 'string' || !filename || filename.includes('\0') || !path.isAbsolute(filename) ||
119
+ path.resolve(filename) !== filename || path.basename(filename).toLowerCase() === 'state.json' ||
120
+ (projectId !== undefined && !id(projectId)) || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_MODEL_STATE_BYTES ||
121
+ !Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > 1000 || typeof now !== 'function') {
122
+ throw new TypeError('invalid_model_persistence_options');
123
+ }
124
+ const directory = path.dirname(filename);
125
+ let pending = null, timer = null, running = null, closing = null, closed = false;
126
+ let failures = 0, writes = 0;
127
+
128
+ async function write(body) {
129
+ const parent = await safeDirectory(directory);
130
+ await safeDestination(filename);
131
+ const temporary = path.join(directory, `.${path.basename(filename)}.${randomUUID()}.tmp`);
132
+ let file, directoryHandle;
133
+ try {
134
+ directoryHandle = await open(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0));
135
+ const opened = await directoryHandle.stat();
136
+ if (opened.dev !== parent.dev || opened.ino !== parent.ino) invalid();
137
+ file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL |
138
+ (constants.O_NOFOLLOW ?? 0), 0o600);
139
+ await file.writeFile(body, 'utf8');
140
+ await file.sync();
141
+ await file.close(); file = null;
142
+ const current = await safeDirectory(directory);
143
+ if (current.dev !== parent.dev || current.ino !== parent.ino) invalid();
144
+ await safeDestination(filename);
145
+ await rename(temporary, filename);
146
+ try { await directoryHandle.sync(); }
147
+ catch (error) { if (!['EINVAL', 'ENOTSUP'].includes(error.code)) throw error; }
148
+ writes++;
149
+ } finally {
150
+ await file?.close().catch(() => {});
151
+ await directoryHandle?.close().catch(() => {});
152
+ await rm(temporary, { force: true }).catch(() => {});
153
+ }
154
+ }
155
+ function drain() {
156
+ if (running) return running;
157
+ running = (async () => {
158
+ while (pending !== null) {
159
+ const body = pending; pending = null;
160
+ try { await write(body); } catch { failures++; }
161
+ }
162
+ })().finally(() => { running = null; });
163
+ return running;
164
+ }
165
+ async function flush() {
166
+ do {
167
+ clearTimeout(timer); timer = null;
168
+ await drain();
169
+ } while (pending !== null || running !== null);
170
+ }
171
+ async function load() {
172
+ try {
173
+ await safeDirectory(directory);
174
+ const envelope = await readPrivateJSON(filename, maxBytes);
175
+ if (!plain(envelope) || envelope.schemaVersion !== 2 || !id(envelope.projectId) ||
176
+ (projectId !== undefined && envelope.projectId !== projectId) ||
177
+ !natural(envelope.savedAt) || envelope.savedAt > now() + 60_000 ||
178
+ Object.keys(envelope).some(key => !['schemaVersion', 'savedAt', 'projectId', 'snapshot'].includes(key))) invalid();
179
+ // Revalidate the same depth, JSON and schema limits when reading a file
180
+ // made by an older daemon. Never delete an incompatible or invalid file.
181
+ boundedJSON(envelope, maxBytes);
182
+ validateSnapshot(envelope.snapshot, envelope.projectId);
183
+ projectId ??= envelope.projectId;
184
+ return envelope.snapshot;
185
+ } catch (error) {
186
+ if (error?.code !== 'ENOENT') failures++;
187
+ return undefined;
188
+ }
189
+ }
190
+ function schedule(snapshot) {
191
+ if (closed) return false;
192
+ try {
193
+ const savedAt = now();
194
+ if (!natural(savedAt)) invalid();
195
+ const candidateProjectId = projectId ?? (plain(snapshot)
196
+ ? Object.getOwnPropertyDescriptor(snapshot, 'projectId')?.value : undefined);
197
+ if (!id(candidateProjectId)) invalid();
198
+ // Bounded serialization runs first, before reading any schema properties
199
+ // that could otherwise invoke a caller-provided getter.
200
+ const body = boundedJSON({ schemaVersion: 2, savedAt, projectId: candidateProjectId, snapshot }, maxBytes);
201
+ validateSnapshot(snapshot, candidateProjectId);
202
+ projectId ??= candidateProjectId;
203
+ pending = body; // An immutable serialization, never a caller-owned object.
204
+ if (!timer && !running) {
205
+ timer = setTimeout(() => { timer = null; void drain(); }, debounceMs);
206
+ timer.unref?.();
207
+ }
208
+ return true;
209
+ } catch {
210
+ failures++;
211
+ return false;
212
+ }
213
+ }
214
+ function close() {
215
+ if (!closing) { closed = true; closing = flush(); }
216
+ return closing;
217
+ }
218
+ return { load, schedule, flush, close,
219
+ stats: () => ({ persistenceFailures: failures, writes, pending: pending !== null || running !== null, maxBytes }) };
220
+ }
@@ -1,16 +1,24 @@
1
1
  import http from 'node:http';
2
2
  import net from 'node:net';
3
+ import path from 'node:path';
3
4
  import { readFile, chmod, rm } from 'node:fs/promises';
4
5
  import { createPipeline } from '../pipeline.mjs';
5
6
  import { createPolicy, materializeBundle, buildRelationProposals } from '../core/index.mjs';
6
- import { createDecisionService } from '../jev/index.mjs';
7
+ import { createDecisionService } from '../decisions/index.mjs';
8
+ import { createJevProvider } from '../jev/provider.mjs';
9
+ import { createAnalysisBroker } from '../decisions/broker.mjs';
7
10
  import { projectPaths, canonicalProjectRoot, MAX_IPC_BYTES, MAX_STATE_BYTES, PROTOCOL, runtimeError } from './paths.mjs';
8
11
  import { acquireLock } from './lock.mjs';
9
12
  import { createAuth } from './auth.mjs';
10
13
  import { createPersistence } from './persistence.mjs';
14
+ import { createModelPersistence } from './model-persistence.mjs';
15
+ import { createModelAPI } from './model-api.mjs';
16
+ import { createExtensionAPI } from './extension-api.mjs';
17
+ import { createExtensionRegistry } from '../extensions/index.mjs';
11
18
  import { exportSnapshot } from './export.mjs';
12
19
  import { createDiagnostics } from './diagnostics.mjs';
13
20
  import { createDashboardInfoProvider } from './dashboard-info.mjs';
21
+ import { createLineageReader } from './lineage.mjs';
14
22
 
15
23
  const WEB = new URL('../web/', import.meta.url);
16
24
  const assets = new Map([
@@ -21,8 +29,17 @@ const assets = new Map([
21
29
  ['/sketch.js', ['sketch.js', 'text/javascript; charset=utf-8']],
22
30
  ['/style.css', ['style.css', 'text/css; charset=utf-8']],
23
31
  ]);
32
+ for (const filename of ['platform.js', 'model-client.js', 'scene.js', 'extension-frame.js']) {
33
+ assets.set(`/${filename}`, [filename, 'text/javascript; charset=utf-8']);
34
+ }
35
+ for (const filename of [
36
+ 'visualizers/index.mjs', 'visualizers/structure.mjs', 'visualizers/code.mjs',
37
+ 'visualizers/blocks.mjs', 'visualizers/c4.mjs', 'visualizers/changes.mjs', 'visualizers/timeline.mjs',
38
+ 'model/changes.mjs', 'extensions/scene.mjs', 'extensions/contracts.mjs',
39
+ 'extensions/sdk.mjs', 'extensions/profiles.mjs',
40
+ ]) assets.set(`/${filename}`, [`../${filename}`, 'text/javascript; charset=utf-8']);
24
41
  const HOSTS = new Set(['claude', 'codex', 'kiro']);
25
- 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'";
42
+ const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'";
26
43
 
27
44
  function headers(res) {
28
45
  res.setHeader('Cache-Control', 'no-store');
@@ -53,25 +70,27 @@ async function bodyJSON(req) {
53
70
  }
54
71
 
55
72
  export async function startServer({ projectRoot, dataDir, policy: policyOptions,
56
- decisionService, apiKey: configuredKey, mode = 'live', port = 0, dashboardInfoDependencies } = {}) {
73
+ decisionService, decisionProvider, apiKey: configuredKey, mode = 'live', port = 0, dashboardInfoDependencies } = {}) {
57
74
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw runtimeError('invalid_port');
58
75
  if (!['live', 'demo'].includes(mode)) throw runtimeError('invalid_mode');
59
76
  const paths = await projectPaths(projectRoot, dataDir, { create: true });
77
+ const readLineage = createLineageReader({ projectRoot: paths.projectRoot, projectId: paths.projectId });
60
78
  const dashboardInfo = createDashboardInfoProvider({
61
79
  projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode,
62
80
  }, dashboardInfoDependencies);
63
81
  const lock = await acquireLock(paths);
64
82
  let finished;
65
83
  const whenClosed = new Promise(resolve => { finished = resolve; });
66
- let web, ipc, pipeline, auth, diagnostics, interval, ping, closing, reconciling = false;
84
+ let web, ipc, pipeline, auth, diagnostics, modelAPI, extensionAPI, modelFlush, interval, ping, closing, reconciling = false;
67
85
  let drops = 0, intake = 0;
68
86
  const receivedHooks = { claude: 0, codex: 0, kiro: 0 };
69
87
  const clients = new Set(), connections = new Set(), ipcConnections = new Set();
70
88
  const persistence = createPersistence(paths.state);
89
+ const modelPersistence = createModelPersistence(path.join(paths.directory, 'model-state.json'), { projectId: paths.projectId });
71
90
  const policy = createPolicy(policyOptions ?? {});
72
- const apiKey = !decisionService && policy.transmitSource && mode === 'live'
91
+ const apiKey = !decisionService && !decisionProvider && policy.transmitSource && mode === 'live'
73
92
  ? configuredKey ?? process.env.TYPESAFE_API_KEY : undefined;
74
- const missingKey = !decisionService && policy.transmitSource && mode === 'live' && !apiKey;
93
+ const missingKey = !decisionService && !decisionProvider && policy.transmitSource && mode === 'live' && !apiKey;
75
94
  function snapshot(persistent = false) {
76
95
  const state = pipeline.getState({ persistent });
77
96
  return { ...state, status: { ...state.status,
@@ -88,13 +107,25 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
88
107
  function notify() {
89
108
  if (!pipeline || closing) return;
90
109
  persistence.schedule(snapshot(true));
110
+ if (!modelFlush) {
111
+ // Coalesce model serialization outside the hook intake path.
112
+ modelFlush = setTimeout(() => {
113
+ modelFlush = null;
114
+ if (!closing) modelPersistence.schedule(pipeline.getModelState({ persistent: true }));
115
+ }, 100);
116
+ modelFlush.unref?.();
117
+ }
118
+ modelAPI?.notify();
91
119
  const state = snapshot();
92
120
  for (const res of clients) stream(res, state);
93
121
  }
94
122
  async function reconcile() {
95
123
  if (reconciling || closing || !pipeline) return;
96
124
  reconciling = true;
97
- try { await pipeline.reconcile(); }
125
+ try {
126
+ await pipeline.observeLineage(await readLineage());
127
+ await pipeline.reconcile();
128
+ }
98
129
  catch { drops++; }
99
130
  finally { reconciling = false; }
100
131
  }
@@ -102,6 +133,8 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
102
133
  if (closing) return closing;
103
134
  closing = (async () => {
104
135
  clearInterval(interval); clearInterval(ping);
136
+ clearTimeout(modelFlush);
137
+ modelAPI?.close();
105
138
  auth?.clear();
106
139
  for (const res of clients) res.end();
107
140
  for (const socket of [...connections, ...ipcConnections]) socket.destroy();
@@ -109,8 +142,11 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
109
142
  new Promise(resolve => server.close(() => resolve()))));
110
143
  try {
111
144
  await pipeline?.close();
112
- if (pipeline) persistence.schedule(snapshot(true));
113
- await persistence.close();
145
+ if (pipeline) {
146
+ persistence.schedule(snapshot(true));
147
+ modelPersistence.schedule(pipeline.getModelState({ persistent: true }));
148
+ }
149
+ await Promise.all([persistence.close(), modelPersistence.close()]);
114
150
  } finally {
115
151
  try { await diagnostics?.close(); }
116
152
  finally {
@@ -127,19 +163,40 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
127
163
  // Reading the key is conditional on explicit source-transmission permission.
128
164
  // Test/demo services are injected; this module never logs request bodies.
129
165
  const service = decisionService ?? createDecisionService({
130
- apiKey,
166
+ provider: decisionProvider ?? createJevProvider({ apiKey }),
131
167
  materializeBundle, buildRelationProposals,
132
168
  limits: { eventDeadlineMs: 5000 },
133
169
  });
134
170
  pipeline = createPipeline({ projectRoot: paths.projectRoot, policy, decisionService: service,
135
171
  classificationDeadlineMs: 5000,
136
- mode, restoredState: await persistence.load(), onChange: notify, onDiagnostic: diagnostics.record });
172
+ mode, restoredState: await persistence.load(), restoredModel: await modelPersistence.load(),
173
+ onChange: notify, onDiagnostic: diagnostics.record });
174
+ modelAPI = createModelAPI({ projectId: paths.projectId, getSnapshot: pipeline.getModelState,
175
+ createCheckpoint: options => {
176
+ const marker = pipeline.createCheckpoint(options);
177
+ notify();
178
+ return marker;
179
+ } });
180
+ const registry = await createExtensionRegistry({ dataDir: paths.dataDir, projectId: paths.projectId });
181
+ const analyze = typeof service.evaluate === 'function'
182
+ ? createAnalysisBroker({ service, model: pipeline.model, policy, projectId: paths.projectId, registry })
183
+ : async () => ({ status: 'unavailable' });
184
+ extensionAPI = createExtensionAPI({ registry, projectId: paths.projectId, getSnapshot: pipeline.getModelState,
185
+ runAnalysis: async input => { const result = await analyze(input); notify(); return result; } });
137
186
  web = http.createServer({ maxHeaderSize: 8192, requestTimeout: 2000, headersTimeout: 2000 }, (req, res) => {
138
187
  headers(res);
139
188
  void (async () => {
189
+ if (req.url?.startsWith('/api/model/v1/')) {
190
+ if (!auth?.validTransport(req)) return json(res, 403, { error: 'forbidden_origin' });
191
+ await modelAPI.handle(req, res, {
192
+ viewerAuthorized: auth.validRequest(req, { mutation: req.method !== 'GET' }) && auth.authorized(req),
193
+ });
194
+ return;
195
+ }
140
196
  if (!auth || !auth.validRequest(req, { mutation: req.method !== 'GET' })) {
141
197
  return json(res, 403, { error: 'forbidden_origin' });
142
198
  }
199
+ if (await extensionAPI.handle(req, res, { viewerAuthorized: auth.authorized(req) })) return;
143
200
  if (typeof req.url !== 'string' || req.url.length > 1024 || req.url.includes('?')) {
144
201
  return json(res, 400, { error: 'invalid_route' });
145
202
  }
@@ -218,10 +275,11 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
218
275
  function describe() {
219
276
  return { ok: true, protocol: PROTOCOL, instanceId: lock.owner.instanceId,
220
277
  projectId: paths.projectId, pid: process.pid, port: actualPort, mode,
221
- policy: { transmitSource: policy.transmitSource, displayEvidence: policy.displayEvidence,
278
+ policy: { readSource: policy.readSource, transmitSource: policy.transmitSource, displayEvidence: policy.displayEvidence,
222
279
  persistEvidence: policy.persistEvidence, version: policy.version },
223
280
  status: snapshot().status, ...persistence.stats(), captureDropped: drops,
224
281
  observations: { hooks: { ...receivedHooks }, shapes: pipeline.getState().graph.nodes.length },
282
+ model: pipeline.model.stats(), modelPersistence: modelPersistence.stats(),
225
283
  logPath: diagnostics.stats().logPath, diagnostics: diagnostics.stats() };
226
284
  }
227
285
  // Only the exclusive owner may remove a stale socket from a prior process.
@@ -7,8 +7,11 @@ const LIMIT = 16 * 1024;
7
7
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
8
  const validKey = value => typeof value === 'string' && value.length > 0 &&
9
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');
10
+ const validPolicy = value => record(value) &&
11
+ Object.keys(value).every(key => ['allowSource', 'localSource', 'persistEvidence', 'displayEvidence'].includes(key)) &&
12
+ ['allowSource', 'persistEvidence', 'displayEvidence'].every(key => typeof value[key] === 'boolean') &&
13
+ (value.localSource === undefined || typeof value.localSource === 'boolean') &&
14
+ !(value.localSource && value.allowSource);
12
15
  const validHosts = value => Array.isArray(value) && value.length <= 2 &&
13
16
  new Set(value).size === value.length && value.every(host => ['claude', 'codex'].includes(host));
14
17
  const validInstallation = value => record(value) &&
@@ -89,14 +92,19 @@ export function savedPolicy(policy) {
89
92
  allowSource: policy.transmitSource,
90
93
  persistEvidence: policy.persistEvidence,
91
94
  displayEvidence: policy.displayEvidence,
95
+ ...(policy.readSource && !policy.transmitSource ? { localSource: true } : {}),
92
96
  } : {};
93
97
  }
94
98
 
95
99
  // An omitted field means reuse consent, while false is an explicit opt-out.
96
100
  export function resolvePolicy(options, { current, saved } = {}) {
97
101
  const fallback = current ? savedPolicy(current) : saved ?? {};
102
+ const allowSource = options.allowSource ?? fallback.allowSource ?? false;
103
+ const localSource = !allowSource && (options.localSource ??
104
+ (options.allowSource === false ? false : fallback.localSource) ?? false);
98
105
  return {
99
- allowSource: options.allowSource ?? fallback.allowSource ?? false,
106
+ allowSource,
107
+ ...(localSource ? { localSource: true } : {}),
100
108
  persistEvidence: options.persistEvidence ?? fallback.persistEvidence ?? false,
101
109
  displayEvidence: options.displayEvidence ?? fallback.displayEvidence ?? true,
102
110
  };