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,195 @@
1
+ import path from 'node:path';
2
+ import { open, unlink } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { createDecisionService, createFixtureTransport } from '../jev/index.mjs';
6
+ import { materializeBundle, buildRelationProposals } from '../core/index.mjs';
7
+ import { defaultDataDir, privateDirectory, canonicalProjectRoot, runtimeError } from './paths.mjs';
8
+
9
+ const DATABASE = `// Offline source fixture. This code never connects to a database.
10
+ export const PostgreSQL = {
11
+ query(statement, values) { return Promise.resolve({ statement, values }); }
12
+ };
13
+ `;
14
+ const REPOSITORY = `import { PostgreSQL } from './database.mjs';
15
+ export function saveNote(note) {
16
+ return PostgreSQL.query('INSERT INTO notes(body) VALUES ($1)', [note.body]);
17
+ }
18
+ `;
19
+
20
+ // Small independent artifacts keep every recording within the real 12-candidate,
21
+ // seven-relation proposal budget. Nothing here is evaluated or imported.
22
+ const FIXTURES = Object.freeze({
23
+ 'database.mjs': DATABASE,
24
+ 'notes.mjs': REPOSITORY,
25
+ 'hierarchy.mjs': `// Offline source fixture: a three-step notes path.
26
+ export function createNote(note) { return persistNote(note); }
27
+ export function persistNote(note) { return NoteCache.put(note); }
28
+ export const NoteCache = { put(note) { return note; } };
29
+ `,
30
+ 'strategies.mjs': `// Offline source fixture: reciprocal strategy references.
31
+ export class WarmGreetingStrategy {
32
+ greet() { return EnthusiasticGreetingStrategy.render(); }
33
+ }
34
+ export class EnthusiasticGreetingStrategy {
35
+ static render() { return WarmGreetingStrategy.prototype.greet(); }
36
+ }
37
+ `,
38
+ 'browser.mjs': `// Offline source fixture: browser UI submits to a local service.
39
+ export const BrowserNotesApplication = {
40
+ submit(note) { return NotesApplicationService.create(note); }
41
+ };
42
+ export const NotesApplicationService = { create(note) { return note; } };
43
+ `,
44
+ 'notifications.mjs': `// Offline source fixture: a worker consumes a queue; no process runs.
45
+ export const NotificationDeliveryService = {
46
+ poll() { return PendingNotificationsQueue.take(); }
47
+ };
48
+ export const PendingNotificationsQueue = { take() { return []; } };
49
+ `,
50
+ 'provider.mjs': `// Offline source fixture: an external adapter depends on configuration.
51
+ export const ExternalGreetingProvider = {
52
+ describe() { return GreetingRuntimeConfiguration.provider; }
53
+ };
54
+ export const GreetingRuntimeConfiguration = { provider: 'offline-example' };
55
+ `,
56
+ 'toolkit.mjs': `// Offline source fixture: a module uses a package facade.
57
+ export const TextFormattingModule = {
58
+ format(text) { return GreetingToolkitPackage.format(text); }
59
+ };
60
+ export const GreetingToolkitPackage = { format(text) { return text; } };
61
+ `,
62
+ 'messages.ts': `// Offline source fixture: disconnected interface and event declarations.
63
+ export interface GreetingStrategyContract { greet(name: string): string; }
64
+ export const GreetingRequestedEvent = { type: 'greeting.requested' };
65
+ `,
66
+ });
67
+ const LIVE_FILE = 'graphlin-demo-live.mjs';
68
+ const LIVE_SOURCE = `// Offline source fixture, added only by an explicit demo trigger.
69
+ export function renderLiveGreetingPreview() { return LivePreviewBrowser.render(); }
70
+ export const LivePreviewBrowser = { render() { return 'offline preview'; } };
71
+ `;
72
+ const MARKER_FILE = '.graphlin-demo-fixture';
73
+ const MARKER = 'graphlin-offline-demo-v1\n';
74
+ export const DEMO_SESSION_ID = 'graphlin-offline-demo';
75
+
76
+ const RECORDED_ROLES = Object.freeze({
77
+ saveNote: 'function', PostgreSQL: 'datastore',
78
+ createNote: 'function', persistNote: 'function', NoteCache: 'datastore',
79
+ WarmGreetingStrategy: 'class', EnthusiasticGreetingStrategy: 'class',
80
+ BrowserNotesApplication: 'client', NotesApplicationService: 'service',
81
+ NotificationDeliveryService: 'service', PendingNotificationsQueue: 'queue',
82
+ ExternalGreetingProvider: 'external', GreetingRuntimeConfiguration: 'configuration',
83
+ TextFormattingModule: 'module', GreetingToolkitPackage: 'package',
84
+ GreetingStrategyContract: 'interface', GreetingRequestedEvent: 'event',
85
+ renderLiveGreetingPreview: 'function', LivePreviewBrowser: 'client',
86
+ });
87
+ const RECORDED_RELATIONS = [
88
+ ['saveNote', 'PostgreSQL', 'writes'],
89
+ ['persistNote', 'NoteCache', 'writes'],
90
+ ['createNote', 'persistNote', 'calls'],
91
+ ['WarmGreetingStrategy', 'EnthusiasticGreetingStrategy', 'calls'],
92
+ ['EnthusiasticGreetingStrategy', 'WarmGreetingStrategy', 'calls'],
93
+ ['BrowserNotesApplication', 'NotesApplicationService', 'calls'],
94
+ ['NotificationDeliveryService', 'PendingNotificationsQueue', 'calls'],
95
+ ['NotificationDeliveryService', 'PendingNotificationsQueue', 'consumes'],
96
+ ['NotificationDeliveryService', 'PendingNotificationsQueue', 'depends_on'],
97
+ ['ExternalGreetingProvider', 'GreetingRuntimeConfiguration', 'depends_on'],
98
+ ['TextFormattingModule', 'GreetingToolkitPackage', 'depends_on'],
99
+ ['renderLiveGreetingPreview', 'LivePreviewBrowser', 'calls'],
100
+ ].map(([sourceLabel, targetLabel, relation]) => ({ sourceLabel, targetLabel, relation, support: 0.97, missingContext: 0.02 }));
101
+
102
+ export function demoDecisionService() {
103
+ // Explicit answers for this synthetic recording. Incidental labels are safe
104
+ // but irrelevant, so they do not quarantine the shared synthetic snippet.
105
+ // Unknown labels still receive the transport's conservative sensitivity=1.
106
+ const incidental = { role: 'unknown', relevant: 0.01, sensitive: 0.01, support: 0.01 };
107
+ return createDecisionService({
108
+ fetchImpl: createFixtureTransport({ mode: 'demo', candidates: {
109
+ ...Object.fromEntries(Object.entries(RECORDED_ROLES).map(([label, role]) =>
110
+ [label, { role, relevant: 0.98, sensitive: 0.01, support: 0.97 }])),
111
+ path: incidental, database: incidental, note: incidental,
112
+ INSERT: incidental, INTO: incidental, VALUES: incidental,
113
+ }, relations: RECORDED_RELATIONS }),
114
+ materializeBundle, buildRelationProposals,
115
+ });
116
+ }
117
+
118
+ async function writeFixture(projectRoot, name, content) {
119
+ const file = await open(path.join(projectRoot, name),
120
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
121
+ try { await file.chmod(0o600); await file.writeFile(content); } finally { await file.close(); }
122
+ }
123
+
124
+ async function verifyDemoProject(projectRoot) {
125
+ const resolved = path.resolve(projectRoot);
126
+ if (await canonicalProjectRoot(resolved) !== resolved) throw runtimeError('invalid_demo_directory');
127
+ const file = await open(path.join(resolved, MARKER_FILE), constants.O_RDONLY | constants.O_NOFOLLOW);
128
+ try {
129
+ if ((await file.stat()).size !== Buffer.byteLength(MARKER) || await file.readFile('utf8') !== MARKER) {
130
+ throw runtimeError('invalid_demo_directory');
131
+ }
132
+ } finally { await file.close(); }
133
+ return resolved;
134
+ }
135
+
136
+ export async function createDemoProject(dataDir = defaultDataDir()) {
137
+ const base = await privateDirectory(path.resolve(dataDir));
138
+ const project = await privateDirectory(path.join(base, 'demo-project'));
139
+ // Never let an enclosing real checkout become the demo's project scope.
140
+ if (await canonicalProjectRoot(project) !== project) throw runtimeError('invalid_demo_directory');
141
+ for (const [name, content] of Object.entries(FIXTURES)) await writeFixture(project, name, content);
142
+ await writeFixture(project, MARKER_FILE, MARKER);
143
+ await unlink(path.join(project, LIVE_FILE)).catch(error => { if (error.code !== 'ENOENT') throw error; });
144
+ return project;
145
+ }
146
+
147
+ export async function replayDemo(pipeline, projectRoot) {
148
+ await verifyDemoProject(projectRoot);
149
+ const event = async (hook_event_name, extra = {}) => {
150
+ await pipeline.ingest({ hook_event_name, cwd: projectRoot, session_id: DEMO_SESSION_ID, ...extra }, { host: 'claude' });
151
+ await pipeline.whenIdle();
152
+ };
153
+ await event('UserPromptSubmit', { prompt: 'Explore the offline architecture fixture. No code is executed and no runtime connection is verified.' });
154
+ await event('SessionStart');
155
+ // Explicit observations after discovery give every small artifact its own
156
+ // complete candidate/proposal budget. No graph or decision is patched here.
157
+ for (const name of Object.keys(FIXTURES)) {
158
+ const tool = { tool_name: 'Read', tool_use_id: `demo-read-${randomUUID()}`,
159
+ tool_input: { file_path: path.join(projectRoot, name) } };
160
+ await event('PreToolUse', tool);
161
+ await event('PostToolUse', { ...tool, tool_response: { success: true } });
162
+ }
163
+ await event('Stop', { last_assistant_message: 'Offline source fixtures are captured. Runtime connectivity has not been verified.' });
164
+ return pipeline.getState();
165
+ }
166
+
167
+ /**
168
+ * Explicit, timer-free live demo trigger. Returns an ordinary existing IPC
169
+ * capture message after changing one owned fixture file. A parent process may
170
+ * send it with requestIPC(socket, message); no new HTTP/control route is needed.
171
+ */
172
+ export async function prepareDemoChange(projectRoot, { action } = {}) {
173
+ if (!['add', 'remove'].includes(action)) throw runtimeError('invalid_demo_action');
174
+ const root = await verifyDemoProject(projectRoot);
175
+ if (action === 'add') await writeFixture(root, LIVE_FILE, LIVE_SOURCE);
176
+ else await unlink(path.join(root, LIVE_FILE)).catch(error => { if (error.code !== 'ENOENT') throw error; });
177
+ return {
178
+ host: 'claude',
179
+ payload: {
180
+ hook_event_name: 'PostToolUse', cwd: root, session_id: DEMO_SESSION_ID,
181
+ tool_name: action === 'add' ? 'Write' : 'apply_patch',
182
+ tool_use_id: `demo-${action}-${randomUUID()}`,
183
+ tool_input: { file_path: path.join(root, LIVE_FILE) },
184
+ tool_response: { success: true },
185
+ },
186
+ };
187
+ }
188
+
189
+ export async function replayDemoChange(pipeline, projectRoot, options) {
190
+ if (pipeline.getState().mode !== 'demo') throw runtimeError('demo_mode_required');
191
+ const { host, payload } = await prepareDemoChange(projectRoot, options);
192
+ await pipeline.ingest(payload, { host });
193
+ await pipeline.whenIdle();
194
+ return pipeline.getState();
195
+ }
@@ -0,0 +1,404 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, lstat, rename } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { CATEGORIES, KINDS, ROLES, RELATIONS, isId, opaque, plain } from '../core/common.mjs';
6
+ import { createPolicy, excluded, privateText, safeLabel } from '../core/privacy.mjs';
7
+ import { ACTIVITIES } from '../jev/questions.mjs';
8
+ import { runtimeError, uid } from './paths.mjs';
9
+
10
+ export const DIAGNOSTIC_LIMITS = Object.freeze({
11
+ records: 300, ringBytes: 512 * 1024, recordBytes: 32 * 1024,
12
+ pendingBytes: 256 * 1024, fileBytes: 1024 * 1024, flushMs: 100,
13
+ });
14
+ const STAGES = new Set(['capture', 'candidates', 'classification', 'apply', 'skip']);
15
+ const STATUSES = new Set(['accepted', 'irrelevant', 'unavailable', 'timeout', 'overloaded', 'invalid',
16
+ 'abstained', 'observed', 'captured', 'queued', 'started', 'completed', 'applied', 'skipped',
17
+ 'deferred', 'stale', 'duplicate', 'failed', 'pending', 'present', 'missing', 'partial', 'unchanged',
18
+ 'ready', 'ok', 'rejected', 'tentative', 'added', 'updated']);
19
+ const REASONS = new Set([
20
+ 'ok', 'unknown', 'closed', 'overloaded', 'invalid', 'duplicate', 'capture_gap', 'capture_failed',
21
+ 'metadata_only', 'missing_key', 'paused', 'no_candidates', 'no_artifacts', 'no_changes',
22
+ 'unchanged', 'tool_requested', 'session_missing', 'session_evicted', 'no_decision_service',
23
+ 'queued', 'applied', 'no_patch', 'stale_evidence', 'policy_mismatch', 'pipeline_failure',
24
+ 'deadline_exceeded', 'queue_full', 'request_budget', 'question_budget', 'request_too_large',
25
+ 'remote_cooldown', 'no_approved_candidates', 'no_accepted_classification', 'insufficient_relevance',
26
+ 'cancelled', 'service_closed', 'core_unavailable', 'transport_failure', 'decision_failure',
27
+ 'authentication_failed', 'request_rejected', 'http_error', 'response_too_large',
28
+ 'invalid_event', 'invalid_candidates', 'invalid_candidate', 'candidate_too_large',
29
+ 'invalid_endpoint', 'invalid_limits', 'invalid_thresholds', 'invalid_bundle', 'invalid_proposals',
30
+ 'invalid_proposal', 'duplicate_proposal', 'invalid_configuration', 'invalid_clock',
31
+ 'invalid_http_response', 'invalid_policy', 'invalid_input', 'invalid_signal', 'invalid_deadline',
32
+ 'invalid_probabilities', 'invalid_probability_sum', 'invalid_response', 'invalid_answer_type',
33
+ 'invalid_noul', 'invalid_confidence', 'invalid_choice', 'invalid_score', 'invalid_question_type',
34
+ 'invalid_response_body', 'invalid_json', 'below_threshold', 'sensitive', 'irrelevant',
35
+ 'unknown_role', 'missing_context', 'unsupported', 'excluded', 'not_admitted',
36
+ 'pipeline_closed', 'classifier_unavailable', 'paused_deferred', 'classification_started',
37
+ 'classifier_exception', 'invalid_result', 'deadline_before_apply', 'deadline_after_revalidation',
38
+ 'patch_applied', 'no_graph_change', 'source_changed_during_classification', 'classification_not_drawable',
39
+ 'classification_pipeline_error', 'capture_queue_full', 'duplicate_event', 'artifacts_observed',
40
+ 'classification_queued', 'classification_queue_full', 'classification_queue_expired',
41
+ 'queued_source_superseded', 'queued_source_refreshed', 'queued_source_unavailable',
42
+ 'source_version_completed', 'source_version_pending',
43
+ 'artifact_changed', 'artifact_unchanged', 'tool_request_has_no_source_outcome', 'unsupported_event',
44
+ 'invalid_capture', 'source_reconciliation', 'no_session',
45
+ 'event_not_classifiable', 'excluded_path', 'file_missing', 'incomplete_artifact', 'artifact_unavailable',
46
+ 'source_withheld', 'empty_source', 'source_not_safe', 'candidate_limit', 'candidates_ready', 'artifact_limit', 'snippet_limit',
47
+ 'approved', 'sensitive_and_irrelevant', 'candidate_incomplete', 'event_incomplete',
48
+ 'node_support_below_min', 'role_probability_below_min', 'role_confidence_below_min',
49
+ 'source_not_accepted', 'target_not_accepted', 'evidence_incomplete', 'edge_support_below_min',
50
+ 'missing_context_above_max', 'inconsistent_evidence', 'unknown_fixture_question',
51
+ 'admitted', 'already_current', 'support_below_floor', 'stale_generation', 'node_limit', 'edge_limit',
52
+ 'graph_byte_limit', 'endpoints_not_drawable', 'reference_limit', 'revision_limit', 'invalid_graph',
53
+ 'decision_not_compilable', 'invalid_judgments', 'judgment_limit', 'duplicate_judgments',
54
+ 'empty_decision', 'no_change', 'no_drawable_change',
55
+ ]);
56
+ const ID_KEYS = ['eventId', 'sourceEventId', 'sessionId'];
57
+ const PATCH_KEYS = ['revisionBefore', 'revisionAfter', 'nodesAdded', 'nodesUpdated', 'nodesRemoved',
58
+ 'edgesAdded', 'edgesUpdated', 'edgesRemoved'];
59
+ const count = value => Number.isSafeInteger(value) && value >= 0;
60
+ const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
61
+ const probability = value => finite(value) && value <= 1;
62
+ const code = value => REASONS.has(value) || STATUSES.has(value) ? value : 'unknown';
63
+ const list = (value, limit) => Array.isArray(value) ? value.slice(0, limit) : [];
64
+ const copyFields = (input, keys, validate) => Object.fromEntries(keys
65
+ .filter(key => validate(input?.[key])).map(key => [key, input[key]]));
66
+
67
+ function relativePath(value, policy) {
68
+ if (typeof value !== 'string' || !value || value.length > 512 ||
69
+ /[\u0000-\u001f\u007f-\u009f\\<>:\u202a-\u202e\u2066-\u2069]/.test(value) ||
70
+ path.isAbsolute(value) || value.split('/').some(part => !part || part === '.' || part === '..') ||
71
+ privateText(value) || excluded(value, policy)) return undefined;
72
+ return value;
73
+ }
74
+
75
+ // Filtering uses the EvidenceStore identity, without touching the named file.
76
+ // Deleted files are therefore still searchable in the retained log.
77
+ export function diagnosticArtifactId(projectRoot, filename, inputRoot = projectRoot) {
78
+ if (typeof filename !== 'string' || !filename || filename.length > 4096 || /[\0\r\n\\]/.test(filename)) {
79
+ throw runtimeError('invalid_log_filter');
80
+ }
81
+ let absolute = path.resolve(projectRoot, filename);
82
+ const aliasRelative = path.relative(path.resolve(inputRoot), absolute);
83
+ if (path.resolve(inputRoot) !== projectRoot && aliasRelative && aliasRelative !== '..' &&
84
+ !aliasRelative.startsWith(`..${path.sep}`) && !path.isAbsolute(aliasRelative)) {
85
+ absolute = path.resolve(projectRoot, aliasRelative);
86
+ }
87
+ const relative = path.relative(projectRoot, absolute);
88
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
89
+ throw runtimeError('invalid_log_filter');
90
+ }
91
+ return opaque('artifact', projectRoot, relative.split(path.sep).join('/'));
92
+ }
93
+
94
+ // Every trace property has a finite schema. Unknown fields, arbitrary map keys,
95
+ // transport bodies, strings posing as numbers, and free-form errors are dropped.
96
+ function traceEntry(input) {
97
+ const result = {
98
+ ...copyFields(input, ['candidateId', 'artifactId', 'proposalId', 'sourceCandidateId', 'targetCandidateId'], isId),
99
+ ...copyFields(input, ['sensitive', 'relevant', 'relevance', 'support', 'supportProbability',
100
+ 'roleProbability', 'roleConfidence', 'missingContext', 'missingContextProbability',
101
+ 'sensitiveMax', 'relevantMin', 'relevanceMin', 'nodeSupportMin', 'roleProbabilityMin',
102
+ 'roleConfidenceMin', 'edgeSupportMin', 'missingContextMax'], probability),
103
+ ...copyFields(input, ['candidateCount', 'approvedCount', 'proposalCount', 'nodeCount', 'edgeCount',
104
+ 'omitted', 'calls', 'questionCount', 'requestBytes', 'responseBytes', 'durationMs'], finite),
105
+ ...copyFields(input, ['approved', 'admitted', 'complete', 'accepted', 'dispatched',
106
+ 'sensitivityPassed', 'relevancePassed'], value => typeof value === 'boolean'),
107
+ };
108
+ for (const key of ['questionCount', 'requestBytes']) if (input[key] === null) result[key] = null;
109
+ if (input.materialized === null || typeof input.materialized === 'boolean') result.materialized = input.materialized;
110
+ for (const key of ['code', 'reason']) if (typeof input[key] === 'string') result[key] = code(input[key]);
111
+ if (Array.isArray(input.reasons)) result.reasons = list(input.reasons, 16).map(code);
112
+ if (STATUSES.has(input.status)) result.status = input.status;
113
+ if (['accepted', 'tentative', 'abstained', 'skipped'].includes(input.classification)) result.classification = input.classification;
114
+ if ([...ROLES, 'unknown'].includes(input.role)) result.role = input.role;
115
+ if (RELATIONS.includes(input.relation)) result.relation = input.relation;
116
+ if (plain(input.roleProbabilities)) result.roleProbabilities = copyFields(input.roleProbabilities, [...ROLES, 'unknown'], probability);
117
+ if (['A', 'B'].includes(input.stage)) result.stage = input.stage;
118
+ if (input.model === 'unknown' || (typeof input.model === 'string' && /^jev-\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(input.model))) result.model = input.model;
119
+ if (typeof input.rubricVersion === 'string' && /^(?:intake|architecture)-v[1-9]\d{0,3}$/.test(input.rubricVersion)) result.rubricVersion = input.rubricVersion;
120
+ if (input.httpStatus === null || (Number.isInteger(input.httpStatus) && input.httpStatus >= 100 && input.httpStatus <= 599)) result.httpStatus = input.httpStatus;
121
+ if (input.usage === null) result.usage = null;
122
+ else if (plain(input.usage)) result.usage = copyFields(input.usage, ['input_tokens', 'output_tokens'], count);
123
+ if (['source', 'public_intent'].includes(input.sourceClass)) result.sourceClass = input.sourceClass;
124
+ for (const key of ['candidateIds', 'evidenceCandidateIds']) {
125
+ if (Array.isArray(input[key])) result[key] = list(input[key], 12).filter(isId);
126
+ }
127
+ return result;
128
+ }
129
+
130
+ function trace(input) {
131
+ const result = {};
132
+ if (input.version === 1) result.version = 1;
133
+ if (input.activity === null) result.activity = null;
134
+ else if (plain(input.activity)) result.activity = {
135
+ ...copyFields(input.activity, ['choice'], value => ACTIVITIES.includes(value)),
136
+ ...copyFields(input.activity, ['confidence'], probability),
137
+ ...(plain(input.activity.probabilities)
138
+ ? { probabilities: copyFields(input.activity.probabilities, ACTIVITIES, probability) } : {}),
139
+ };
140
+ if (input.relevance === null || probability(input.relevance)) result.relevance = input.relevance;
141
+ if (plain(input.outcome)) result.outcome = {
142
+ ...copyFields(input.outcome, ['status'], value => STATUSES.has(value)),
143
+ code: code(input.outcome.code),
144
+ };
145
+ if (plain(input.thresholds)) result.thresholds = {
146
+ intake: copyFields(input.thresholds.intake, ['sensitiveMax', 'relevantMin'], probability),
147
+ admission: copyFields(input.thresholds.admission, ['relevanceMin', 'nodeSupportMin', 'roleProbabilityMin',
148
+ 'roleConfidenceMin', 'edgeSupportMin', 'missingContextMax'], probability),
149
+ };
150
+ for (const key of ['intake', 'nodes', 'edges', 'requests']) {
151
+ if (Array.isArray(input[key])) result[key] = list(input[key], key === 'requests' ? 2 : 12).filter(plain).map(traceEntry);
152
+ }
153
+ return result;
154
+ }
155
+
156
+ function decisionDiagnostics(input) {
157
+ if (!plain(input)) return undefined;
158
+ const result = {
159
+ ...copyFields(input, ['durationMs', 'calls', 'candidatesOmitted', 'proposalsOmitted'], finite),
160
+ ...copyFields(input, ['usageIncomplete'], value => typeof value === 'boolean'),
161
+ };
162
+ if (typeof input.code === 'string') result.code = code(input.code);
163
+ if (Array.isArray(input.codes)) result.codes = list(input.codes, 16).map(code);
164
+ if (['demo', 'live'].includes(input.mode)) result.mode = input.mode;
165
+ for (const key of ['questionCounts', 'stageDurationMs']) {
166
+ if (plain(input[key])) result[key] = copyFields(input[key], ['A', 'B'], finite);
167
+ }
168
+ if (plain(input.usage)) result.usage = copyFields(input.usage, ['input_tokens', 'output_tokens'], count);
169
+ for (const key of ['intakePolicyVersion', 'admissionPolicyVersion']) {
170
+ if (typeof input[key] === 'string' && /^(?:intake|admission)-policy-v[1-9]\d{0,3}$/.test(input[key])) result[key] = input[key];
171
+ }
172
+ if (plain(input.trace)) result.trace = trace(input.trace);
173
+ if (plain(input.queue)) result.queue = copyFields(input.queue,
174
+ ['waitMs', 'depth', 'active', 'capacity', 'omitted'], finite);
175
+ if (Array.isArray(input.extraction)) result.extraction = list(input.extraction, 33).filter(plain).map(value => ({
176
+ ...copyFields(value, ['artifactId'], isId),
177
+ ...copyFields(value, ['available', 'selected'], count),
178
+ ...copyFields(value, ['truncated'], flag => typeof flag === 'boolean'),
179
+ reason: code(value.reason),
180
+ }));
181
+ if (Array.isArray(input.admission)) result.admission = list(input.admission, 32).filter(plain).map(value => ({
182
+ ...copyFields(value, ['candidateId', 'proposalId'], isId),
183
+ ...copyFields(value, ['status'], status => STATUSES.has(status)),
184
+ reason: code(value.reason),
185
+ }));
186
+ return result;
187
+ }
188
+
189
+ function sanitize(input, policy, evidence) {
190
+ if (!plain(input) || input.schemaVersion !== 1 || !STAGES.has(input.stage)) return null;
191
+ const at = typeof input.at === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(input.at)
192
+ && Number.isFinite(Date.parse(input.at)) ? new Date(input.at).toISOString() : new Date().toISOString();
193
+ const result = {
194
+ schemaVersion: 1, at, stage: input.stage,
195
+ ...copyFields(input, ID_KEYS, isId),
196
+ eventKind: KINDS.includes(input.eventKind) ? input.eventKind : 'capture.gap',
197
+ toolCategory: CATEGORIES.includes(input.toolCategory) ? input.toolCategory : 'other',
198
+ status: STATUSES.has(input.status) ? input.status : 'invalid',
199
+ reason: code(input.reason),
200
+ artifacts: list(input.artifacts, 32).filter(plain).filter(value => isId(value.artifactId)).map(value => ({
201
+ artifactId: value.artifactId,
202
+ ...copyFields(value, ['status'], status => ['present', 'missing', 'unavailable', 'partial'].includes(status)),
203
+ ...copyFields(value, ['complete'], complete => typeof complete === 'boolean'),
204
+ ...copyFields(value, ['candidateCount', 'availableCandidates'], count),
205
+ ...(typeof value.reason === 'string' ? { reason: code(value.reason) } : {}),
206
+ ...(evidence && relativePath(value.path, policy) ? { path: value.path } : {}),
207
+ })),
208
+ candidates: list(input.candidates, 12).filter(plain).filter(value => isId(value.candidateId)).map(value => ({
209
+ candidateId: value.candidateId, ...copyFields(value, ['artifactId'], isId),
210
+ ...copyFields(value, ['sourceClass'], source => ['source', 'public_intent'].includes(source)),
211
+ ...copyFields(value, ['complete'], complete => typeof complete === 'boolean'),
212
+ ...(count(value.startLine) && value.startLine > 0 && count(value.endLine) && value.endLine >= value.startLine
213
+ ? { startLine: value.startLine, endLine: value.endLine } : {}),
214
+ ...(evidence && safeLabel(value.label) && !/[`"'{};=]/.test(value.label) ? { label: value.label } : {}),
215
+ })),
216
+ };
217
+ const diagnostics = decisionDiagnostics(input.diagnostics);
218
+ if (diagnostics) result.diagnostics = diagnostics;
219
+ if (plain(input.patch)) result.patch = copyFields(input.patch, PATCH_KEYS, count);
220
+ if (input.truncated === true) result.truncated = true;
221
+ return result;
222
+ }
223
+
224
+ function filenames(directory) {
225
+ return { logPath: path.join(directory, 'diagnostics.jsonl'), backupPath: path.join(directory, 'diagnostics.1.jsonl') };
226
+ }
227
+ async function privateDir(directory) {
228
+ const info = await lstat(directory);
229
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) ||
230
+ (uid() !== undefined && info.uid !== uid())) throw runtimeError('unsafe_diagnostic_file');
231
+ }
232
+ function privateFile(info) {
233
+ if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o077) ||
234
+ info.size > DIAGNOSTIC_LIMITS.fileBytes || (uid() !== undefined && info.uid !== uid())) {
235
+ throw runtimeError('unsafe_diagnostic_file');
236
+ }
237
+ }
238
+ async function inspectFile(filename) {
239
+ try { const info = await lstat(filename); privateFile(info); return info; }
240
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
241
+ }
242
+ async function readLines(filename) {
243
+ let file;
244
+ try {
245
+ file = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
246
+ const info = await file.stat(); privateFile(info);
247
+ const buffer = Buffer.alloc(info.size + 1);
248
+ let offset = 0;
249
+ while (offset < buffer.length) {
250
+ const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
251
+ if (!bytesRead) break;
252
+ offset += bytesRead;
253
+ }
254
+ if (offset > DIAGNOSTIC_LIMITS.fileBytes) throw runtimeError('unsafe_diagnostic_file');
255
+ // A process killed during append can leave one partial last line.
256
+ const body = buffer.subarray(0, offset).toString('utf8');
257
+ return body.slice(0, body.lastIndexOf('\n') + 1).split('\n');
258
+ } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
259
+ finally { await file?.close(); }
260
+ }
261
+
262
+ function retained() {
263
+ const records = [];
264
+ let bytes = 0, evicted = 0;
265
+ return {
266
+ add(record) {
267
+ const size = Buffer.byteLength(JSON.stringify(record));
268
+ if (size > DIAGNOSTIC_LIMITS.recordBytes) return false;
269
+ records.push({ record, size }); bytes += size;
270
+ while (records.length > DIAGNOSTIC_LIMITS.records || bytes > DIAGNOSTIC_LIMITS.ringBytes) {
271
+ bytes -= records.shift().size; evicted++;
272
+ }
273
+ return true;
274
+ },
275
+ values: () => records.map(item => item.record),
276
+ stats: () => ({ recordCount: records.length, ringBytes: bytes, evicted }),
277
+ };
278
+ }
279
+ function envelope(ring, stats, source, artifactId) {
280
+ if (artifactId !== undefined && !/^artifact-[a-f0-9]{32}$/.test(artifactId)) throw runtimeError('invalid_log_filter');
281
+ const records = ring.values().filter(record => artifactId === undefined ||
282
+ record.artifacts.some(item => item.artifactId === artifactId) ||
283
+ record.candidates.some(item => item.artifactId === artifactId));
284
+ return { schemaVersion: 1, source, records: structuredClone(records),
285
+ stats: { ...stats, ...ring.stats(), returned: records.length }, logPath: stats.logPath };
286
+ }
287
+
288
+ async function load({ directory, policy }) {
289
+ const ring = retained(), stats = { ...filenames(directory), readFailures: 0, invalidRecords: 0, lastSeq: 0 };
290
+ try { await privateDir(directory); }
291
+ catch (error) { if (error.code !== 'ENOENT') stats.readFailures++; return { ring, stats }; }
292
+ for (const filename of [stats.backupPath, stats.logPath]) {
293
+ let lines;
294
+ try { lines = await readLines(filename); } catch { stats.readFailures++; continue; }
295
+ for (const line of lines) {
296
+ if (!line) continue;
297
+ try {
298
+ if (Buffer.byteLength(line) > DIAGNOSTIC_LIMITS.recordBytes) throw new Error();
299
+ const input = JSON.parse(line), record = sanitize(input, policy, policy.transmitSource && policy.displayEvidence);
300
+ if (!record || !count(input.seq) || input.seq < 1 || !/^diagnostic-[a-f0-9]{32}$/.test(input.id)) throw new Error();
301
+ if (input.seq <= stats.lastSeq) continue;
302
+ stats.lastSeq = input.seq;
303
+ if (!ring.add({ ...record, id: input.id, seq: input.seq })) throw new Error();
304
+ } catch { stats.invalidRecords++; }
305
+ }
306
+ }
307
+ return { ring, stats };
308
+ }
309
+
310
+ // A stopped caller has no current source/display consent. Explicit internal
311
+ // callers may supply policy; CLI fallback intentionally uses the safe defaults.
312
+ export async function readPersistedDiagnostics({ directory, policy }, { artifactId } = {}) {
313
+ const { ring, stats } = await load({ directory, policy: createPolicy(policy) });
314
+ return envelope(ring, stats, 'persisted', artifactId);
315
+ }
316
+
317
+ export async function createDiagnostics({ directory, projectRoot, policy: options } = {}) {
318
+ const policy = createPolicy(options), { ring, stats } = await load({ directory, policy });
319
+ Object.assign(stats, { accepted: 0, written: 0, dropped: 0, truncated: 0, persistenceFailures: 0, rotations: 0 });
320
+ let pending = [], pendingBytes = 0, inFlightBytes = 0, timer, running, closed = false;
321
+ const instance = randomUUID();
322
+ async function append(body) {
323
+ await privateDir(directory);
324
+ let current = await inspectFile(stats.logPath);
325
+ if (current && current.size + body.length > DIAGNOSTIC_LIMITS.fileBytes) {
326
+ await inspectFile(stats.backupPath);
327
+ await rename(stats.logPath, stats.backupPath);
328
+ stats.rotations++; current = null;
329
+ }
330
+ let file;
331
+ try {
332
+ file = await open(stats.logPath, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT |
333
+ constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
334
+ const info = await file.stat(); privateFile(info);
335
+ if (info.size + body.length > DIAGNOSTIC_LIMITS.fileBytes) throw runtimeError('diagnostic_file_full');
336
+ if (info.size) {
337
+ const tail = Buffer.alloc(Math.min(info.size, DIAGNOSTIC_LIMITS.recordBytes + 1));
338
+ await file.read(tail, 0, tail.length, info.size - tail.length);
339
+ if (tail.at(-1) !== 10) {
340
+ const newline = tail.lastIndexOf(10);
341
+ if (newline < 0 && info.size > tail.length) throw runtimeError('invalid_diagnostic_tail');
342
+ await file.truncate(info.size - tail.length + newline + 1);
343
+ stats.invalidRecords++;
344
+ }
345
+ }
346
+ await file.writeFile(body);
347
+ await file.sync();
348
+ } finally { await file?.close(); }
349
+ }
350
+ function drain() {
351
+ if (running) return running;
352
+ running = (async () => {
353
+ while (pending.length) {
354
+ const batch = pending; pending = [];
355
+ const bytes = pendingBytes; pendingBytes = 0; inFlightBytes = bytes;
356
+ try { await append(Buffer.from(batch.join(''))); stats.written += batch.length; }
357
+ catch { stats.persistenceFailures++; stats.dropped += batch.length; }
358
+ finally { inFlightBytes = 0; }
359
+ }
360
+ })().finally(() => {
361
+ running = null;
362
+ // A record can arrive after the loop settles but before this callback.
363
+ // Include that handoff in the shared promise, even if close() joined it.
364
+ if (pending.length) return drain();
365
+ });
366
+ return running;
367
+ }
368
+ const getStats = () => ({ ...stats, ...ring.stats(), pendingBytes: pendingBytes + inFlightBytes,
369
+ limits: DIAGNOSTIC_LIMITS });
370
+ return {
371
+ record(input) {
372
+ if (closed) return false;
373
+ try {
374
+ const record = sanitize(input, policy, policy.transmitSource);
375
+ if (!record || stats.lastSeq >= Number.MAX_SAFE_INTEGER) { stats.invalidRecords++; return false; }
376
+ const seq = ++stats.lastSeq, id = opaque('diagnostic', projectRoot, instance, seq);
377
+ // Keep an oversized event's identity and reason instead of silently
378
+ // losing it. Normal pipeline records fit without this fallback.
379
+ if (Buffer.byteLength(JSON.stringify(record)) > DIAGNOSTIC_LIMITS.recordBytes - 128) {
380
+ record.truncated = true;
381
+ for (const artifact of record.artifacts) delete artifact.path;
382
+ for (const candidate of record.candidates) delete candidate.label;
383
+ if (Buffer.byteLength(JSON.stringify(record)) > DIAGNOSTIC_LIMITS.recordBytes - 128) {
384
+ if (record.diagnostics) delete record.diagnostics.trace;
385
+ }
386
+ stats.truncated++;
387
+ }
388
+ const live = { ...sanitize(record, policy, policy.transmitSource && policy.displayEvidence), id, seq };
389
+ const persistent = { ...sanitize(record, policy, policy.transmitSource && policy.persistEvidence), id, seq };
390
+ const line = `${JSON.stringify(persistent)}\n`, bytes = Buffer.byteLength(line);
391
+ if (bytes > DIAGNOSTIC_LIMITS.recordBytes || !ring.add(live)) { stats.dropped++; return false; }
392
+ stats.accepted++;
393
+ if (pendingBytes + inFlightBytes + bytes > DIAGNOSTIC_LIMITS.pendingBytes) { stats.dropped++; return true; }
394
+ pending.push(line); pendingBytes += bytes;
395
+ if (!timer && !running) timer = setTimeout(() => { timer = null; void drain(); }, DIAGNOSTIC_LIMITS.flushMs);
396
+ return true;
397
+ } catch { stats.invalidRecords++; return false; }
398
+ },
399
+ snapshot: ({ artifactId } = {}) => envelope(ring, getStats(), 'live', artifactId),
400
+ stats: getStats,
401
+ async flush() { clearTimeout(timer); timer = null; await drain(); },
402
+ async close() { closed = true; clearTimeout(timer); timer = null; await drain(); },
403
+ };
404
+ }
@@ -0,0 +1,7 @@
1
+ // Input is the already sanitized pipeline projection. Export never inherits
2
+ // source-excerpt permission from either viewer display or disk persistence.
3
+ // The recursive copy covers current, historical, and saved-session graphs,
4
+ // including both node and edge references, without mutating viewer state.
5
+ export function exportSnapshot(snapshot) {
6
+ return JSON.parse(JSON.stringify(snapshot, (key, value) => key === 'excerpt' ? undefined : value));
7
+ }
@@ -0,0 +1,28 @@
1
+ import net from 'node:net';
2
+ import { MAX_IPC_BYTES, runtimeError } from './paths.mjs';
3
+
4
+ export function requestIPC(socketPath, value, { timeoutMs = 500, maxResponseBytes = MAX_IPC_BYTES } = {}) {
5
+ return new Promise((resolve, reject) => {
6
+ const body = `${JSON.stringify(value)}\n`;
7
+ if (Buffer.byteLength(body) > MAX_IPC_BYTES) return reject(runtimeError('input_too_large'));
8
+ const socket = net.createConnection(socketPath);
9
+ let parts = [], size = 0, finished = false;
10
+ const timer = setTimeout(() => finish(runtimeError('daemon_unavailable')), timeoutMs);
11
+ function finish(error, result) {
12
+ if (finished) return;
13
+ finished = true; clearTimeout(timer); socket.destroy();
14
+ error ? reject(error) : resolve(result);
15
+ }
16
+ socket.on('error', () => finish(runtimeError('daemon_unavailable')));
17
+ socket.on('connect', () => socket.write(body));
18
+ socket.on('data', (chunk) => {
19
+ size += chunk.length;
20
+ if (size > maxResponseBytes) return finish(runtimeError('response_too_large'));
21
+ parts.push(chunk);
22
+ if (!chunk.includes(10)) return;
23
+ try { finish(null, JSON.parse(Buffer.concat(parts).toString('utf8').split('\n')[0])); }
24
+ catch { finish(runtimeError('invalid_response')); }
25
+ });
26
+ socket.on('end', () => { if (!finished) finish(runtimeError('daemon_unavailable')); });
27
+ });
28
+ }