graphlin 0.1.3 → 0.2.1

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 (106) 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 +472 -0
  8. package/docs/visualizer-views.md +240 -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 +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. package/scripts/validate-packages.mjs +54 -8
@@ -1,784 +1,20 @@
1
- import { createHash } from 'node:crypto';
2
- import { isDeepStrictEqual } from 'node:util';
3
- import { buildIntakeRequest, buildGraphRequest, RUBRICS, RELATIONS, ACTIVITIES, ROLES } from './questions.mjs';
4
- import {
5
- JevFault, isRecord, isProbability, validateResponse, readResponse, withAbort, abortFault,
6
- } from './wire.mjs';
7
- import { FIXTURE_TRANSPORT } from './fixture.mjs';
1
+ // Compatibility entry point: existing callers still select the Jev adapter.
2
+ import { createDecisionService as createService } from '../decisions/index.mjs';
3
+ import { DecisionFault } from '../decisions/faults.mjs';
4
+ import { createJevProvider } from './provider.mjs';
5
+ import { JevFault } from './wire.mjs';
8
6
 
7
+ export { DEFAULT_LIMITS, DEFAULT_INTAKE_POLICY, DEFAULT_ADMISSION_POLICY } from '../decisions/index.mjs';
9
8
  export { createFixtureTransport } from './fixture.mjs';
9
+ export { createJevProvider } from './provider.mjs';
10
10
 
11
- const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
12
- export const DEFAULT_LIMITS = Object.freeze({
13
- concurrency: 2,
14
- maxQueue: 32,
15
- eventDeadlineMs: 2000,
16
- maxRequestsPerEvent: 2,
17
- maxCandidates: 12,
18
- maxQuestionsPerStage: 40,
19
- maxRelationProposals: 12,
20
- maxRequestBytes: 64 * 1024,
21
- maxResponseBytes: 256 * 1024,
22
- maxCandidateBytes: 8192,
23
- maxLabelBytes: 256,
24
- cooldownMs: 1000,
25
- maxCooldownMs: 30_000,
26
- });
27
- export const DEFAULT_INTAKE_POLICY = Object.freeze({
28
- version: 'intake-policy-v1', sensitiveMax: 0.1, relevantMin: 0.5,
29
- });
30
- export const DEFAULT_ADMISSION_POLICY = Object.freeze({
31
- version: 'admission-policy-v1',
32
- relevanceMin: 0.3,
33
- nodeSupportMin: 0.85,
34
- roleProbabilityMin: 0.8,
35
- roleConfidenceMin: 0.6,
36
- edgeSupportMin: 0.85,
37
- missingContextMax: 0.1,
38
- });
39
-
40
- const kinds = new Set([
41
- 'session.started', 'turn.prompted', 'intent.observed', 'tool.requested',
42
- 'tool.succeeded', 'tool.failed', 'tool.interrupted', 'tool.denied',
43
- 'tool.unresolved', 'batch.completed', 'artifact.changed',
44
- 'verification.observed', 'agent.started', 'agent.stopped', 'turn.stopped',
45
- 'session.ended', 'capture.gap',
46
- ]);
47
- const categories = new Set(['read', 'write', 'edit', 'search', 'shell', 'test', 'other']);
48
- const outcomes = new Set(['succeeded', 'failed', 'interrupted', 'denied', 'unresolved',
49
- 'pending', 'running', 'observed', 'unknown']);
50
- const boundedId = (value) => typeof value === 'string'
51
- && /^[A-Za-z0-9_.:-]{1,160}$/.test(value);
52
- const validVersion = (value) => boundedId(value)
53
- || (Number.isSafeInteger(value) && value >= 0);
54
- const hash = (value) => createHash('sha256').update(value).digest('hex');
55
-
56
- // Audit data is a separate allowlisted projection, never a copy of inputs or
57
- // transport errors. Core's opaque IDs retain their identity; arbitrary IDs from
58
- // injected adapters are hashed so labels/relative paths cannot become log text.
59
- const auditId = (value, prefix = 'candidate') =>
60
- new RegExp(`^${prefix}-[a-f0-9]{32}$`).test(value)
61
- ? value : `${prefix}-${hash(value).slice(0, 32)}`;
62
- const auditStatuses = new Set([
63
- 'ok', 'accepted', 'irrelevant', 'abstained', 'invalid', 'unavailable', 'timeout', 'overloaded',
64
- ]);
65
- const auditCodes = new Set([
66
- 'ok', 'invalid_event', 'invalid_candidates', 'invalid_candidate', 'candidate_too_large',
67
- 'invalid_bundle', 'invalid_proposals', 'invalid_proposal', 'duplicate_proposal',
68
- 'core_unavailable', 'deadline_exceeded', 'request_budget', 'question_budget',
69
- 'request_too_large', 'invalid_http_response', 'remote_cooldown', 'authentication_failed',
70
- 'request_rejected', 'http_error', 'transport_failure', 'no_approved_candidates',
71
- 'insufficient_relevance', 'no_accepted_classification', 'decision_failure',
72
- 'service_closed', 'invalid_input', 'invalid_signal', 'metadata_only', 'missing_key',
73
- 'cancelled', 'invalid_deadline', 'queue_full', 'invalid_policy', 'no_candidates',
74
- 'inconsistent_evidence', 'invalid_probabilities', 'invalid_probability_sum',
75
- 'invalid_response', 'invalid_answer_type', 'invalid_noul', 'invalid_confidence',
76
- 'invalid_choice', 'invalid_score', 'invalid_question_type', 'invalid_response_body',
77
- 'response_too_large', 'invalid_json', 'unknown_fixture_question',
78
- ]);
79
- const auditOutcome = (status, code) => ({
80
- status: auditStatuses.has(status) ? status : 'unavailable',
81
- code: auditCodes.has(code) ? code : 'decision_failure',
82
- });
83
- const auditDuration = (start, end) => Number.isFinite(end - start)
84
- ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, end - start)) : 0;
85
-
86
- function freeze(value) {
87
- if (value && typeof value === 'object' && !Object.isFrozen(value)) {
88
- Object.values(value).forEach(freeze);
89
- Object.freeze(value);
90
- }
91
- return value;
92
- }
93
-
94
- function safeEvent(event) {
95
- if (!isRecord(event)) throw new JevFault('invalid_event');
96
- return {
97
- kind: kinds.has(event.kind) ? event.kind : 'capture.gap',
98
- toolCategory: categories.has(event.toolCategory) ? event.toolCategory : 'other',
99
- outcome: outcomes.has(event.outcome) ? event.outcome : 'unknown',
100
- incomplete: event.incomplete !== false,
101
- };
102
- }
103
-
104
- function validateSourceRef(ref) {
105
- if (!isRecord(ref) || !boundedId(ref.hash)) return false;
106
- if (ref.type === 'artifact') {
107
- return boundedId(ref.artifactId)
108
- && Number.isSafeInteger(ref.generation) && ref.generation >= 0;
109
- }
110
- return ref.type === 'message' && boundedId(ref.messageId)
111
- && validVersion(ref.contentVersion);
112
- }
113
-
114
- function snapshotCandidates(input, limits) {
115
- if (!Array.isArray(input)) throw new JevFault('invalid_candidates');
116
- // Candidate overflow is coverage loss, not another request or unbounded cloning.
117
- const maximum = Math.min(limits.maxCandidates,
118
- Math.max(0, Math.floor((limits.maxQuestionsPerStage - 1) / 2)));
119
- const candidates = [];
120
- const ids = new Set();
121
- for (const candidate of input.slice(0, maximum)) {
122
- if (!isRecord(candidate) || !boundedId(candidate.id) || ids.has(candidate.id)
123
- || !boundedId(candidate.digest) || !boundedId(candidate.entityKey)
124
- || typeof candidate.label !== 'string'
125
- || Buffer.byteLength(candidate.label) > limits.maxLabelBytes
126
- || typeof candidate.text !== 'string'
127
- || Buffer.byteLength(candidate.text) > limits.maxCandidateBytes
128
- || !['source', 'public_intent'].includes(candidate.sourceClass)
129
- || typeof candidate.complete !== 'boolean'
130
- || !Number.isSafeInteger(candidate.startLine) || candidate.startLine < 1
131
- || !Number.isSafeInteger(candidate.endLine) || candidate.endLine < candidate.startLine
132
- || !validateSourceRef(candidate.sourceRef)
133
- || (candidate.sourceClass === 'source' && candidate.sourceRef.type !== 'artifact')
134
- || (candidate.sourceClass === 'public_intent' && candidate.sourceRef.type !== 'message')) {
135
- throw new JevFault('invalid_candidate');
136
- }
137
- // Bound local metadata too; it is retained for core, never sent on the wire.
138
- let encoded;
139
- try { encoded = JSON.stringify(candidate); } catch { throw new JevFault('invalid_candidate'); }
140
- if (Buffer.byteLength(encoded) > limits.maxCandidateBytes + limits.maxLabelBytes + 4096) {
141
- throw new JevFault('candidate_too_large');
142
- }
143
- candidates.push(freeze(JSON.parse(encoded)));
144
- ids.add(candidate.id);
145
- }
146
- return { candidates: freeze(candidates), omitted: input.length - candidates.length };
147
- }
148
-
149
- function endpointFor(value, injected) {
150
- let url;
151
- try { url = new URL(value); } catch { throw new JevFault('invalid_endpoint'); }
152
- if (url.href === ENDPOINT) return ENDPOINT;
153
- if (!injected || !['http:', 'https:'].includes(url.protocol)
154
- || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)
155
- || url.username || url.password || url.search || url.hash
156
- || url.pathname !== '/v1/systemone') {
157
- throw new JevFault('invalid_endpoint');
158
- }
159
- return url.href;
160
- }
161
-
162
- function normalizeLimits(options = {}) {
163
- if (!isRecord(options) || Object.keys(options).some((key) => !(key in DEFAULT_LIMITS))) {
164
- throw new JevFault('invalid_limits');
165
- }
166
- const limits = { ...DEFAULT_LIMITS, ...options };
167
- for (const [key, value] of Object.entries(limits)) {
168
- const minimum = ['maxQueue', 'maxRequestsPerEvent', 'maxRelationProposals'].includes(key) ? 0 : 1;
169
- if (!Number.isSafeInteger(value) || value < minimum || value > 16 * 1024 * 1024) {
170
- throw new JevFault('invalid_limits');
171
- }
172
- }
173
- if (limits.concurrency > 64 || limits.maxQueue > 4096 || limits.maxCandidates > 128
174
- || limits.maxQuestionsPerStage > 1024 || limits.eventDeadlineMs > 60_000
175
- || limits.maxCooldownMs > 300_000 || limits.cooldownMs > limits.maxCooldownMs) {
176
- throw new JevFault('invalid_limits');
177
- }
178
- return freeze(limits);
179
- }
180
-
181
- function normalizePolicy(input, defaults) {
182
- const result = { ...defaults, ...input };
183
- if (!validVersion(result.version)
184
- || Object.keys(defaults).some((key) => key !== 'version' && !isProbability(result[key]))) {
185
- throw new JevFault('invalid_thresholds');
186
- }
187
- return freeze(result);
188
- }
189
-
190
- function validateBundle(bundle, candidates, verdicts, policy, intakePolicy) {
191
- if (!isRecord(bundle) || !boundedId(bundle.id) || bundle.policyVersion !== policy.version
192
- || !Array.isArray(bundle.candidates) || !Array.isArray(bundle.readSet)
193
- || bundle.candidates.length > candidates.length || bundle.readSet.length > candidates.length) {
194
- throw new JevFault('invalid_bundle');
195
- }
196
- const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
197
- const allowed = new Set(verdicts.filter((verdict) =>
198
- verdict.sensitive <= intakePolicy.sensitiveMax && verdict.relevant >= intakePolicy.relevantMin)
199
- .map((verdict) => verdict.candidateId));
200
- const seen = new Set();
201
- for (const candidate of bundle.candidates) {
202
- if (!isRecord(candidate) || seen.has(candidate.id) || !allowed.has(candidate.id)
203
- || !isDeepStrictEqual(candidate, byId.get(candidate.id))) {
204
- throw new JevFault('invalid_bundle');
205
- }
206
- seen.add(candidate.id);
207
- }
208
- const refMatches = (ref, candidate) => {
209
- const source = candidate.sourceRef;
210
- if (source.type === 'message') return isDeepStrictEqual(ref, source);
211
- return isDeepStrictEqual(ref, source) || isDeepStrictEqual(ref, {
212
- artifactId: source.artifactId, hash: source.hash, generation: source.generation,
213
- });
214
- };
215
- if (!bundle.readSet.every((ref) => bundle.candidates.some((candidate) => refMatches(ref, candidate)))
216
- || !bundle.candidates.every((candidate) => bundle.readSet.some((ref) => refMatches(ref, candidate)))) {
217
- throw new JevFault('invalid_bundle');
218
- }
219
- return freeze(bundle); // Preserve the exact core-owned bundle, including object identity.
220
- }
221
-
222
- function validateProposals(result, bundle, maximum) {
223
- if (!isRecord(result) || !Array.isArray(result.proposals)
224
- || !Number.isSafeInteger(result.omitted) || result.omitted < 0) {
225
- throw new JevFault('invalid_proposals');
226
- }
227
- const ids = new Set(bundle.candidates.map((candidate) => candidate.id));
228
- const seen = new Set();
229
- const propositions = new Set();
230
- const proposals = result.proposals.slice(0, maximum).map((proposal) => {
231
- if (!isRecord(proposal) || !boundedId(proposal.id) || seen.has(proposal.id)
232
- || !ids.has(proposal.sourceCandidateId) || !ids.has(proposal.targetCandidateId)
233
- || proposal.sourceCandidateId === proposal.targetCandidateId
234
- || !RELATIONS.includes(proposal.relation) || !Array.isArray(proposal.evidenceCandidateIds)
235
- || proposal.evidenceCandidateIds.length < 2 || proposal.evidenceCandidateIds.length > ids.size
236
- || ![proposal.sourceCandidateId, proposal.targetCandidateId]
237
- .every((id) => proposal.evidenceCandidateIds.includes(id))
238
- || new Set(proposal.evidenceCandidateIds).size !== proposal.evidenceCandidateIds.length
239
- || !proposal.evidenceCandidateIds.every((id) => ids.has(id))) {
240
- throw new JevFault('invalid_proposal');
241
- }
242
- const identity = JSON.stringify([proposal.sourceCandidateId, proposal.targetCandidateId,
243
- proposal.relation, proposal.evidenceCandidateIds]);
244
- if (propositions.has(identity)) throw new JevFault('duplicate_proposal');
245
- seen.add(proposal.id);
246
- propositions.add(identity);
247
- return freeze({
248
- id: proposal.id,
249
- sourceCandidateId: proposal.sourceCandidateId,
250
- targetCandidateId: proposal.targetCandidateId,
251
- relation: proposal.relation,
252
- evidenceCandidateIds: [...proposal.evidenceCandidateIds],
253
- });
254
- });
255
- return {
256
- proposals: freeze(proposals),
257
- omitted: result.omitted + result.proposals.length - proposals.length,
258
- };
259
- }
260
-
261
- /**
262
- * Dependency-free, per-daemon two-stage classifier. Only supplied apiKey is used;
263
- * environment credentials are never read. Core functions can be injected, and
264
- * otherwise are lazily imported from ../core/index.mjs.
265
- */
266
11
  export function createDecisionService(options = {}) {
267
- const {
268
- apiKey, model = 'jev-1.13.0', fetchImpl = globalThis.fetch,
269
- endpoint = ENDPOINT,
270
- } = options;
271
- if (typeof fetchImpl !== 'function' || !/^jev-\d+\.\d+\.\d+$/.test(model)
272
- || (apiKey !== undefined && (typeof apiKey !== 'string' || apiKey.length > 4096
273
- || /[\r\n]/.test(apiKey)))) {
274
- throw new JevFault('invalid_configuration');
275
- }
276
- const target = endpointFor(endpoint, Object.hasOwn(options, 'fetchImpl'));
277
- const limits = normalizeLimits(options.limits);
278
- const intakePolicy = normalizePolicy(options.intakePolicy, DEFAULT_INTAKE_POLICY);
279
- const admissionPolicy = normalizePolicy(options.admissionPolicy, DEFAULT_ADMISSION_POLICY);
280
- if (intakePolicy.sensitiveMax >= 0.5 || admissionPolicy.missingContextMax >= 0.5) {
281
- throw new JevFault('invalid_thresholds');
282
- }
283
- const auditThresholds = freeze({
284
- intake: Object.fromEntries(Object.keys(DEFAULT_INTAKE_POLICY)
285
- .filter((key) => key !== 'version').map((key) => [key, intakePolicy[key]])),
286
- admission: Object.fromEntries(Object.keys(DEFAULT_ADMISSION_POLICY)
287
- .filter((key) => key !== 'version').map((key) => [key, admissionPolicy[key]])),
288
- });
289
- const auditModel = typeof model === 'string' && model.length <= 64 ? model : 'unknown';
290
- const clock = options.clock ?? {
291
- now: Date.now, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout,
292
- };
293
- if (!['now', 'setTimeout', 'clearTimeout'].every((key) => typeof clock[key] === 'function')) {
294
- throw new JevFault('invalid_clock');
295
- }
296
- const mode = fetchImpl[FIXTURE_TRANSPORT] === true ? 'demo' : 'live';
297
- const key = mode === 'demo' ? 'graphlin-offline-fixture' : apiKey;
298
- let corePromise;
299
- async function coreFunctions() {
300
- if (options.materializeBundle && options.buildRelationProposals) {
301
- return {
302
- materializeBundle: options.materializeBundle,
303
- buildRelationProposals: options.buildRelationProposals,
304
- };
305
- }
306
- corePromise ??= import('../core/index.mjs');
307
- let core;
308
- try { core = await corePromise; } catch { throw new JevFault('core_unavailable', 'unavailable'); }
309
- const result = {
310
- materializeBundle: options.materializeBundle ?? core.materializeBundle,
311
- buildRelationProposals: options.buildRelationProposals ?? core.buildRelationProposals,
312
- };
313
- if (Object.values(result).some((value) => typeof value !== 'function')) {
314
- throw new JevFault('core_unavailable', 'unavailable');
12
+ try {
13
+ return createService({ ...options, provider: createJevProvider(options) });
14
+ } catch (error) {
15
+ if (error instanceof DecisionFault && !(error instanceof JevFault)) {
16
+ throw new JevFault(error.code, error.status);
315
17
  }
316
- return result;
18
+ throw error;
317
19
  }
318
-
319
- let closed = false;
320
- let active = 0;
321
- let cooldownUntil = 0;
322
- let wakeTimer;
323
- const queue = [];
324
- const running = new Set();
325
- const counters = {
326
- submitted: 0, completed: 0, calls: 0, callsA: 0, callsB: 0,
327
- inputTokens: 0, outputTokens: 0, rejected: 0,
328
- };
329
- const statuses = {};
330
-
331
- function check(job) {
332
- if (!job.controller.signal.aborted && clock.now() >= job.deadlineAt) {
333
- job.controller.abort(new JevFault('deadline_exceeded', 'timeout'));
334
- }
335
- if (job.controller.signal.aborted) throw abortFault(job.controller.signal);
336
- }
337
-
338
- // Trace v1: intake.approved is the A threshold decision; materialized is null
339
- // until core returns a valid bundle, then records actual bundle membership.
340
- // Null activity/relevance and empty judgments mean no validated answers were
341
- // available. Choice distributions contain only the known activity/role keys.
342
- // "skipped" judgments retain B scores vetoed by overall relevance. Request
343
- // duration includes any cooldown wait; dispatched distinguishes network calls
344
- // from local rejections/waits. Existing diagnostics retain omission counts.
345
- function traceFor(job, status, code) {
346
- const outcome = auditOutcome(status, code ?? 'ok');
347
- return {
348
- version: 1,
349
- outcome,
350
- thresholds: auditThresholds,
351
- activity: job.trace.activity ? {
352
- ...job.trace.activity, probabilities: { ...job.trace.activity.probabilities },
353
- } : null,
354
- intake: job.trace.intake.map((entry) => ({ ...entry })),
355
- relevance: job.trace.relevance,
356
- nodes: job.trace.nodes.map((entry) => ({
357
- ...entry, roleProbabilities: { ...entry.roleProbabilities }, reasons: [...entry.reasons],
358
- })),
359
- edges: job.trace.edges.map((entry) => ({ ...entry, reasons: [...entry.reasons] })),
360
- // Abort listeners settle results before request catch/finally handlers.
361
- // Snapshot unfinished attempts with the terminal outcome here; never freeze
362
- // the mutable request records or leave a published "pending" record behind.
363
- requests: job.trace.requests.map((entry) => ({
364
- stage: entry.stage,
365
- model: auditModel,
366
- rubricVersion: RUBRICS[entry.stage],
367
- ...(entry.outcome ?? outcome),
368
- durationMs: auditDuration(entry.startedAt, entry.finishedAt ?? clock.now()),
369
- dispatched: entry.dispatched,
370
- questionCount: entry.questionCount,
371
- requestBytes: entry.requestBytes,
372
- httpStatus: entry.httpStatus,
373
- usage: entry.usage ? { ...entry.usage } : null,
374
- })),
375
- };
376
- }
377
-
378
- function resultFor(job, status, code, nodes = [], edges = []) {
379
- return freeze({
380
- status,
381
- activity: job.activity ?? 'other',
382
- bundle: job.bundle ?? null,
383
- nodes,
384
- edges,
385
- stages: { ...job.stages },
386
- diagnostics: {
387
- code: code ?? 'ok',
388
- codes: code ? [code] : [],
389
- mode,
390
- durationMs: Math.max(0, clock.now() - job.startedAt),
391
- calls: job.calls ?? 0,
392
- candidatesOmitted: job.candidatesOmitted ?? 0,
393
- proposalsOmitted: job.proposalsOmitted ?? 0,
394
- questionCounts: { ...job.questionCounts },
395
- stageDurationMs: Object.fromEntries(Object.entries(job.stageStartedAt ?? {})
396
- .map(([stage, startedAt]) => [stage, job.stageDurationMs?.[stage]
397
- ?? Math.max(0, clock.now() - startedAt)])),
398
- usage: {
399
- input_tokens: Object.values(job.stages ?? {})
400
- .reduce((sum, stage) => sum + stage.usage.input_tokens, 0),
401
- output_tokens: Object.values(job.stages ?? {})
402
- .reduce((sum, stage) => sum + stage.usage.output_tokens, 0),
403
- },
404
- // Failed requests may have been billed even when no usage reached us.
405
- usageIncomplete: (job.calls ?? 0) > Object.keys(job.stages ?? {}).length,
406
- intakePolicyVersion: intakePolicy.version,
407
- admissionPolicyVersion: admissionPolicy.version,
408
- trace: traceFor(job, status, code),
409
- },
410
- });
411
- }
412
-
413
- function finish(job, result) {
414
- if (job.settled) return;
415
- job.settled = true;
416
- clock.clearTimeout(job.timer);
417
- job.externalSignal?.removeEventListener('abort', job.cancel);
418
- job.controller.signal.removeEventListener('abort', job.onAbort);
419
- const index = queue.indexOf(job);
420
- if (index >= 0) queue.splice(index, 1);
421
- counters.completed += 1;
422
- statuses[result.status] = (statuses[result.status] ?? 0) + 1;
423
- job.resolve(result);
424
- }
425
-
426
- function cooldown(response) {
427
- let delay = limits.cooldownMs;
428
- const milliseconds = response.headers?.get('retry-after-ms');
429
- const seconds = response.headers?.get('retry-after');
430
- if (milliseconds && milliseconds.length <= 128 && Number.isFinite(Number(milliseconds))) {
431
- delay = Number(milliseconds);
432
- } else if (seconds && seconds.length <= 128) {
433
- delay = /^\d+(?:\.\d+)?$/.test(seconds)
434
- ? Number(seconds) * 1000 : Date.parse(seconds) - clock.now();
435
- }
436
- if (!Number.isFinite(delay) || delay <= 0) delay = limits.cooldownMs;
437
- cooldownUntil = Math.max(cooldownUntil, clock.now() + Math.min(delay, limits.maxCooldownMs));
438
- }
439
-
440
- async function waitForCooldown(job) {
441
- while (clock.now() < cooldownUntil) {
442
- check(job);
443
- let timer;
444
- try {
445
- await withAbort(() => new Promise((resolve) => {
446
- timer = clock.setTimeout(resolve, cooldownUntil - clock.now());
447
- }), job.controller.signal);
448
- } finally {
449
- clock.clearTimeout(timer);
450
- }
451
- }
452
- check(job);
453
- }
454
-
455
- async function request(job, stage, payload) {
456
- const attempt = {
457
- stage, startedAt: clock.now(), finishedAt: null, outcome: null,
458
- dispatched: false, questionCount: null, requestBytes: null, httpStatus: null, usage: null,
459
- };
460
- job.trace.requests.push(attempt); // At most one A and one B attempt per job.
461
- try {
462
- check(job);
463
- if (job.calls >= limits.maxRequestsPerEvent) throw new JevFault('request_budget', 'abstained');
464
- const questionCount = Object.keys(payload.questions).length;
465
- attempt.questionCount = questionCount;
466
- if (questionCount < 1 || questionCount > limits.maxQuestionsPerStage) {
467
- throw new JevFault('question_budget', 'abstained');
468
- }
469
- const body = JSON.stringify(payload);
470
- attempt.requestBytes = Buffer.byteLength(body);
471
- if (attempt.requestBytes > limits.maxRequestBytes) {
472
- throw new JevFault('request_too_large', 'abstained');
473
- }
474
- await waitForCooldown(job);
475
- check(job);
476
- job.calls += 1;
477
- job.questionCounts[stage] = questionCount;
478
- job.stageStartedAt[stage] = clock.now();
479
- attempt.dispatched = true;
480
- counters.calls += 1;
481
- counters[`calls${stage}`] += 1;
482
- let response;
483
- try {
484
- response = await withAbort(() => fetchImpl(target, {
485
- method: 'POST',
486
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
487
- body,
488
- signal: job.controller.signal,
489
- redirect: 'error',
490
- credentials: 'omit',
491
- }), job.controller.signal);
492
- check(job);
493
- if (!response || !Number.isInteger(response.status)) throw new JevFault('invalid_http_response');
494
- if (response.status >= 100 && response.status <= 599) attempt.httpStatus = response.status;
495
- if (response.status !== 200) {
496
- if (response.body?.cancel) Promise.resolve(response.body.cancel()).catch(() => {});
497
- if ([429, 529].includes(response.status)) {
498
- cooldown(response);
499
- throw new JevFault('remote_cooldown', 'overloaded');
500
- }
501
- if (response.status === 401 || response.status === 403) {
502
- throw new JevFault('authentication_failed', 'unavailable');
503
- }
504
- if (response.status === 400 || response.status === 422) {
505
- throw new JevFault('request_rejected');
506
- }
507
- throw new JevFault('http_error', 'unavailable');
508
- }
509
- const value = await readResponse(response, limits.maxResponseBytes, job.controller.signal);
510
- check(job);
511
- const validated = validateResponse(value, payload);
512
- job.stages[stage] = freeze({
513
- model: validated.model,
514
- rubricVersion: RUBRICS[stage],
515
- inputHash: hash(body),
516
- usage: validated.usage,
517
- mode,
518
- });
519
- attempt.outcome = auditOutcome('ok', 'ok');
520
- attempt.usage = validated.usage;
521
- counters.inputTokens += validated.usage.input_tokens;
522
- counters.outputTokens += validated.usage.output_tokens;
523
- return validated.answers;
524
- } catch (error) {
525
- check(job);
526
- if (error instanceof JevFault) throw error;
527
- throw new JevFault('transport_failure', 'unavailable');
528
- }
529
- } catch (error) {
530
- attempt.outcome = error instanceof JevFault
531
- ? auditOutcome(error.status, error.code) : auditOutcome('unavailable', 'decision_failure');
532
- throw error;
533
- } finally {
534
- attempt.finishedAt = clock.now();
535
- if (attempt.dispatched) {
536
- job.stageDurationMs[stage] = Math.max(0, clock.now() - job.stageStartedAt[stage]);
537
- }
538
- }
539
- }
540
-
541
- async function run(job) {
542
- check(job);
543
- const core = await withAbort(coreFunctions, job.controller.signal);
544
- check(job);
545
- const intakeRequest = buildIntakeRequest(model, job.event, job.candidates);
546
- const a = await request(job, 'A', intakeRequest);
547
- job.activity = a.a_activity.choice;
548
- job.trace.activity = {
549
- choice: a.a_activity.choice,
550
- confidence: a.a_activity.confidence,
551
- probabilities: Object.fromEntries(ACTIVITIES.map((activity) =>
552
- [activity, a.a_activity.probabilities[activity]])),
553
- };
554
- const verdicts = job.candidates.map((candidate, i) => ({
555
- candidateId: candidate.id,
556
- digest: candidate.digest,
557
- relevant: a[`a_relevant_${i}`].noul,
558
- sensitive: a[`a_sensitive_${intakeRequest.state.entities[i].sourceIndex}`].noul,
559
- }));
560
- job.trace.intake = verdicts.map(({ candidateId, relevant, sensitive }) => {
561
- const tooSensitive = sensitive > intakePolicy.sensitiveMax;
562
- const irrelevant = relevant < intakePolicy.relevantMin;
563
- return {
564
- candidateId: auditId(candidateId), relevant, sensitive,
565
- approved: !tooSensitive && !irrelevant,
566
- reason: tooSensitive ? (irrelevant ? 'sensitive_and_irrelevant' : 'sensitive')
567
- : (irrelevant ? 'irrelevant' : 'approved'),
568
- materialized: null,
569
- };
570
- });
571
- const materialized = await withAbort(() => core.materializeBundle({
572
- candidates: job.candidates, verdicts: freeze(verdicts),
573
- policy: job.policy, intakePolicy,
574
- }), job.controller.signal);
575
- check(job);
576
- job.bundle = validateBundle(materialized, job.candidates, verdicts, job.policy, intakePolicy);
577
- const materializedIds = new Set(job.bundle.candidates.map((candidate) => auditId(candidate.id)));
578
- job.trace.intake.forEach((entry) => { entry.materialized = materializedIds.has(entry.candidateId); });
579
- if (!job.bundle.candidates.length) return resultFor(job, 'irrelevant', 'no_approved_candidates');
580
-
581
- const maximum = Math.min(limits.maxRelationProposals,
582
- Math.max(0, Math.floor((limits.maxQuestionsPerStage - 1 - 2 * job.bundle.candidates.length) / 2)));
583
- const proposed = await withAbort(() => core.buildRelationProposals(job.bundle, {
584
- ...limits, maxProposals: maximum, maxRelationProposals: maximum,
585
- }), job.controller.signal);
586
- check(job);
587
- const { proposals, omitted } = validateProposals(proposed, job.bundle, maximum);
588
- job.proposalsOmitted = omitted;
589
- const b = await request(job, 'B', buildGraphRequest(model, job.event, job.bundle, proposals));
590
- job.trace.relevance = b.b_relevance.noul;
591
- const irrelevant = b.b_relevance.noul < admissionPolicy.relevanceMin;
592
- const nodes = job.bundle.candidates.map((candidate, i) => {
593
- const role = b[`b_role_${i}`];
594
- const support = b[`b_support_${i}`].noul;
595
- const accepted = candidate.complete && !job.event.incomplete && role.choice !== 'unknown'
596
- && support >= admissionPolicy.nodeSupportMin
597
- && role.probabilities[role.choice] >= admissionPolicy.roleProbabilityMin
598
- && role.confidence >= admissionPolicy.roleConfidenceMin;
599
- job.trace.nodes.push({
600
- candidateId: auditId(candidate.id), role: role.choice,
601
- supportProbability: support, roleProbability: role.probabilities[role.choice],
602
- roleConfidence: role.confidence,
603
- roleProbabilities: Object.fromEntries(ROLES.map((kind) => [kind, role.probabilities[kind]])),
604
- classification: irrelevant ? 'skipped' : accepted ? 'accepted' : 'tentative',
605
- reasons: [
606
- ...(irrelevant ? ['insufficient_relevance'] : []),
607
- ...(!candidate.complete ? ['candidate_incomplete'] : []),
608
- ...(job.event.incomplete ? ['event_incomplete'] : []),
609
- ...(role.choice === 'unknown' ? ['unknown_role'] : []),
610
- ...(support < admissionPolicy.nodeSupportMin ? ['node_support_below_min'] : []),
611
- ...(role.probabilities[role.choice] < admissionPolicy.roleProbabilityMin
612
- ? ['role_probability_below_min'] : []),
613
- ...(role.confidence < admissionPolicy.roleConfidenceMin ? ['role_confidence_below_min'] : []),
614
- ],
615
- });
616
- return {
617
- candidateId: candidate.id,
618
- role: role.choice,
619
- supportProbability: support,
620
- roleProbability: role.probabilities[role.choice],
621
- roleConfidence: role.confidence,
622
- roleProbabilities: role.probabilities,
623
- classification: accepted ? 'accepted' : 'tentative',
624
- };
625
- });
626
- const acceptedNodes = new Set(nodes.filter((node) => !irrelevant && node.classification === 'accepted')
627
- .map((node) => node.candidateId));
628
- const candidateById = new Map(job.bundle.candidates.map((candidate) => [candidate.id, candidate]));
629
- const edges = proposals.map((proposal, i) => {
630
- const support = b[`b_relation_${i}`].noul;
631
- const missing = b[`b_context_${i}`].noul;
632
- const accepted = acceptedNodes.has(proposal.sourceCandidateId)
633
- && acceptedNodes.has(proposal.targetCandidateId)
634
- && proposal.evidenceCandidateIds.every((id) => candidateById.get(id).complete)
635
- && support >= admissionPolicy.edgeSupportMin && missing <= admissionPolicy.missingContextMax;
636
- job.trace.edges.push({
637
- proposalId: auditId(proposal.id, 'proposal'),
638
- sourceCandidateId: auditId(proposal.sourceCandidateId),
639
- targetCandidateId: auditId(proposal.targetCandidateId),
640
- relation: proposal.relation, supportProbability: support, missingContextProbability: missing,
641
- classification: irrelevant ? 'skipped' : accepted ? 'accepted' : 'tentative',
642
- reasons: [
643
- ...(irrelevant ? ['insufficient_relevance'] : []),
644
- ...(!acceptedNodes.has(proposal.sourceCandidateId) ? ['source_not_accepted'] : []),
645
- ...(!acceptedNodes.has(proposal.targetCandidateId) ? ['target_not_accepted'] : []),
646
- ...(!proposal.evidenceCandidateIds.every((id) => candidateById.get(id).complete)
647
- ? ['evidence_incomplete'] : []),
648
- ...(support < admissionPolicy.edgeSupportMin ? ['edge_support_below_min'] : []),
649
- ...(missing > admissionPolicy.missingContextMax ? ['missing_context_above_max'] : []),
650
- ],
651
- });
652
- return {
653
- proposalId: proposal.id,
654
- sourceCandidateId: proposal.sourceCandidateId,
655
- targetCandidateId: proposal.targetCandidateId,
656
- relation: proposal.relation,
657
- evidenceCandidateIds: [...proposal.evidenceCandidateIds],
658
- supportProbability: support,
659
- missingContextProbability: missing,
660
- classification: accepted ? 'accepted' : 'tentative',
661
- };
662
- });
663
- if (irrelevant) return resultFor(job, 'irrelevant', 'insufficient_relevance');
664
- check(job);
665
- const status = acceptedNodes.size ? 'accepted' : 'abstained';
666
- return resultFor(job, status, status === 'accepted' ? null : 'no_accepted_classification', nodes, edges);
667
- }
668
-
669
- function pump() {
670
- if (closed) return;
671
- clock.clearTimeout(wakeTimer);
672
- wakeTimer = undefined;
673
- if (queue.length && clock.now() < cooldownUntil) {
674
- wakeTimer = clock.setTimeout(pump, cooldownUntil - clock.now());
675
- return;
676
- }
677
- while (active < limits.concurrency && queue.length) {
678
- const job = queue.shift();
679
- if (job.settled) continue;
680
- active += 1;
681
- running.add(job);
682
- // One workflow owns a slot through A and B; there is no nested request queue.
683
- run(job)
684
- .then((result) => finish(job, result))
685
- .catch((error) => {
686
- const fault = error instanceof JevFault ? error : new JevFault('decision_failure', 'unavailable');
687
- finish(job, resultFor(job, fault.status, fault.code));
688
- })
689
- .finally(() => {
690
- active -= 1;
691
- running.delete(job);
692
- pump();
693
- });
694
- }
695
- }
696
-
697
- function classify(input = {}) {
698
- counters.submitted += 1;
699
- const startedAt = clock.now();
700
- const base = {
701
- startedAt, stages: {}, questionCounts: {}, stageStartedAt: {}, stageDurationMs: {},
702
- trace: { activity: null, intake: [], relevance: null, nodes: [], edges: [], requests: [] },
703
- };
704
- const immediate = (status, code) => {
705
- counters.completed += 1;
706
- counters.rejected += 1;
707
- statuses[status] = (statuses[status] ?? 0) + 1;
708
- return Promise.resolve(resultFor(base, status, code));
709
- };
710
- if (closed) return immediate('abstained', 'service_closed');
711
- if (!isRecord(input)) return immediate('invalid', 'invalid_input');
712
- if (input.signal !== undefined && (!input.signal
713
- || typeof input.signal.aborted !== 'boolean'
714
- || typeof input.signal.addEventListener !== 'function'
715
- || typeof input.signal.removeEventListener !== 'function')) {
716
- return immediate('invalid', 'invalid_signal');
717
- }
718
- if (input.policy?.transmitSource !== true) return immediate('abstained', 'metadata_only');
719
- if (!key) return immediate('unavailable', 'missing_key');
720
- if (input.signal?.aborted) return immediate('abstained', 'cancelled');
721
- if (limits.maxRequestsPerEvent < 2) return immediate('abstained', 'request_budget');
722
- const deadlineAt = Math.min(input.deadlineAt ?? startedAt + limits.eventDeadlineMs,
723
- startedAt + limits.eventDeadlineMs);
724
- if (!Number.isFinite(deadlineAt)) return immediate('invalid', 'invalid_deadline');
725
- if (deadlineAt <= startedAt) return immediate('timeout', 'deadline_exceeded');
726
- if (queue.length >= limits.maxQueue
727
- && (active >= limits.concurrency || clock.now() < cooldownUntil)) {
728
- return immediate('overloaded', 'queue_full');
729
- }
730
- let snapshot;
731
- let event;
732
- let policy;
733
- try {
734
- if (!validVersion(input.policy.version)) throw new JevFault('invalid_policy');
735
- policy = freeze(structuredClone(input.policy));
736
- event = freeze(safeEvent(input.event));
737
- snapshot = snapshotCandidates(input.candidates, limits);
738
- base.candidatesOmitted = snapshot.omitted;
739
- if (snapshot.candidates.length === 0) {
740
- return immediate(input.candidates.length ? 'abstained' : 'irrelevant',
741
- input.candidates.length ? 'question_budget' : 'no_candidates');
742
- }
743
- } catch (error) {
744
- return immediate('invalid', error instanceof JevFault ? error.code : 'invalid_input');
745
- }
746
- return new Promise((resolve) => {
747
- const job = {
748
- ...base, ...snapshot, candidatesOmitted: snapshot.omitted,
749
- event, policy, deadlineAt, resolve, calls: 0,
750
- externalSignal: input.signal, controller: new AbortController(),
751
- };
752
- job.cancel = () => job.controller.abort(new JevFault('cancelled', 'abstained'));
753
- job.onAbort = () => {
754
- const fault = abortFault(job.controller.signal);
755
- finish(job, resultFor(job, fault.status, fault.code));
756
- pump();
757
- };
758
- job.controller.signal.addEventListener('abort', job.onAbort, { once: true });
759
- job.externalSignal?.addEventListener('abort', job.cancel, { once: true });
760
- job.timer = clock.setTimeout(() => job.controller.abort(
761
- new JevFault('deadline_exceeded', 'timeout')), deadlineAt - clock.now());
762
- queue.push(job);
763
- if (job.externalSignal?.aborted) job.cancel();
764
- pump();
765
- });
766
- }
767
-
768
- return Object.freeze({
769
- classify,
770
- stats: () => ({
771
- ...counters, active, queued: queue.length, closed, mode,
772
- cooldownRemainingMs: Math.max(0, cooldownUntil - clock.now()),
773
- statuses: { ...statuses },
774
- }),
775
- close() {
776
- if (closed) return;
777
- closed = true;
778
- clock.clearTimeout(wakeTimer);
779
- for (const job of [...queue, ...running]) {
780
- job.controller.abort(new JevFault('service_closed', 'abstained'));
781
- }
782
- },
783
- });
784
20
  }