openmerit 0.2.0 → 0.3.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 (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +12 -25
  3. package/dist/terminal/scout-update.json +1 -1
  4. package/dist/terminal/src/browser.mjs +2 -4
  5. package/dist/terminal/src/cli.mjs +4 -56
  6. package/dist/terminal/src/install.mjs +2 -2
  7. package/dist/terminal/src/scout-pi.js +1 -1
  8. package/dist/terminal/src/scout.js +3 -3
  9. package/dist/terminal/src/telemetry.mjs +8 -60
  10. package/docs/agent-evaluations.md +8 -148
  11. package/docs/commands.md +3 -9
  12. package/docs/getting-started.md +2 -16
  13. package/docs/index.md +4 -18
  14. package/docs/inference-terminal.md +13 -594
  15. package/docs/roadmap.md +2 -2
  16. package/docs/scout.md +6 -2
  17. package/docs/security.md +5 -18
  18. package/docs/testing.md +4 -16
  19. package/docs/troubleshooting.md +2 -2
  20. package/package.json +10 -23
  21. package/dist/eval/src/adapters.mjs +0 -112
  22. package/dist/eval/src/cli.mjs +0 -151
  23. package/dist/eval/src/config.mjs +0 -108
  24. package/dist/eval/src/process.mjs +0 -44
  25. package/dist/eval/src/report.mjs +0 -59
  26. package/dist/eval/src/runner.mjs +0 -123
  27. package/dist/eval/src/scout.mjs +0 -48
  28. package/dist/terminal/public/app.js +0 -298
  29. package/dist/terminal/public/brands/anthropic.png +0 -0
  30. package/dist/terminal/public/brands/baai.png +0 -0
  31. package/dist/terminal/public/brands/baseten.png +0 -0
  32. package/dist/terminal/public/brands/cerebras.png +0 -0
  33. package/dist/terminal/public/brands/cohere.png +0 -0
  34. package/dist/terminal/public/brands/deepseek.ico +0 -0
  35. package/dist/terminal/public/brands/google.png +0 -0
  36. package/dist/terminal/public/brands/groq.ico +0 -0
  37. package/dist/terminal/public/brands/huggingface.svg +0 -8
  38. package/dist/terminal/public/brands/lm-studio.png +0 -0
  39. package/dist/terminal/public/brands/meta.ico +0 -0
  40. package/dist/terminal/public/brands/mistral.png +0 -0
  41. package/dist/terminal/public/brands/nomic.png +0 -0
  42. package/dist/terminal/public/brands/ollama.png +0 -0
  43. package/dist/terminal/public/brands/openai.png +0 -0
  44. package/dist/terminal/public/brands/openrouter.png +0 -0
  45. package/dist/terminal/public/brands/qwen.png +0 -0
  46. package/dist/terminal/public/brands/vllm.png +0 -0
  47. package/dist/terminal/public/favicon.svg +0 -1
  48. package/dist/terminal/public/index.html +0 -22
  49. package/dist/terminal/public/styles.css +0 -921
  50. package/dist/terminal/src/activity-merge.mjs +0 -66
  51. package/dist/terminal/src/collect.mjs +0 -370
  52. package/dist/terminal/src/coverage.mjs +0 -33
  53. package/dist/terminal/src/discovery.mjs +0 -99
  54. package/dist/terminal/src/hardware.mjs +0 -57
  55. package/dist/terminal/src/history.mjs +0 -32
  56. package/dist/terminal/src/log-continuity.mjs +0 -26
  57. package/dist/terminal/src/project-activity.mjs +0 -157
  58. package/dist/terminal/src/repository-background.mjs +0 -37
  59. package/dist/terminal/src/repository-index.mjs +0 -267
  60. package/dist/terminal/src/repository-policy.mjs +0 -3
  61. package/dist/terminal/src/repository-syntax.mjs +0 -236
  62. package/dist/terminal/src/repository-worker.mjs +0 -15
  63. package/dist/terminal/src/sample.mjs +0 -171
  64. package/dist/terminal/src/scout-credentials.d.ts +0 -12
  65. package/dist/terminal/src/scout-credentials.ts +0 -44
  66. package/dist/terminal/src/scout-instructions.d.ts +0 -5
  67. package/dist/terminal/src/scout-instructions.ts +0 -119
  68. package/dist/terminal/src/scout-pi.d.ts +0 -44
  69. package/dist/terminal/src/scout-pi.ts +0 -76
  70. package/dist/terminal/src/scout.d.ts +0 -77
  71. package/dist/terminal/src/scout.ts +0 -164
  72. package/dist/terminal/src/server.mjs +0 -95
  73. package/dist/terminal/src/service-registry.mjs +0 -78
  74. package/dist/terminal/src/services.mjs +0 -44
  75. package/dist/terminal/src/topology.mjs +0 -30
  76. package/dist/terminal/src/updates.d.ts +0 -62
  77. package/dist/terminal/src/updates.ts +0 -313
@@ -1,66 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
-
3
- const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 24);
4
- const known = value => value && !['Unattributed', 'Unattributed route'].includes(value);
5
- const scope = record => known(record.route) ? `route:${record.route}` : known(record.provider) ? `provider:${record.provider}` : null;
6
- const exactKey = record => JSON.stringify([record.id, record.occurredAt, record.model, record.provider, record.route]);
7
- const requestKey = record => record.requestId && scope(record) ? JSON.stringify([scope(record), record.model, record.requestId]) : null;
8
- const terminal = record => ['completed', 'failed'].includes(record.status);
9
- const knownField = value => value != null && value !== 'unknown';
10
- const fields = ['status', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'durationMs', 'cost', 'currency', 'costKind', 'agentId', 'taskId'];
11
-
12
- // Sources arrive in precedence order. Only shared recorded identifiers merge;
13
- // similar timestamps, equal token counts, or matching model names never do.
14
- export function reconcileActivity(batches) {
15
- const groups = [], exact = new Map(), requests = new Map();
16
- const counts = batches.map(batch => ({ sourceId: batch.sourceId, records: batch.records.length, included: 0, duplicates: 0, excluded: 0 }));
17
- for (let rank = 0; rank < batches.length; rank++) {
18
- for (const record of batches[rank].records) {
19
- const key = exactKey(record), request = requestKey(record);
20
- let group = exact.get(key) ?? (request ? requests.get(request) : null);
21
- if (!group) { group = { record: { ...record }, rank, sources: new Set(), members: [], conflicts: new Set() }; groups.push(group); }
22
- else {
23
- counts[rank].duplicates++;
24
- const old = group.record;
25
- if (terminal(old) && terminal(record)) for (const field of fields) {
26
- if (knownField(old[field]) && knownField(record[field]) && old[field] !== record[field]) group.conflicts.add(field);
27
- }
28
- if ((old.status === 'running' && terminal(record)) || (old.status === 'unknown' && record.status !== 'unknown')) group.record = { ...record };
29
- }
30
- group.sources.add(record.sourceId); group.members.push({ rank, sourceId: record.sourceId });
31
- exact.set(key, group); if (request) requests.set(request, group);
32
- }
33
- }
34
- // If sources cover the same connection/model but don't supply a common
35
- // identity, don't assert that their unmatched weak records are additive.
36
- const coverage = new Map();
37
- for (const group of groups) {
38
- const key = JSON.stringify([scope(group.record), group.record.model]);
39
- if (!coverage.has(key)) coverage.set(key, []);
40
- coverage.get(key).push(group);
41
- }
42
- const excluded = new Set();
43
- for (const items of coverage.values()) {
44
- const ranks = new Set(items.flatMap(group => group.members.map(member => member.rank)));
45
- if (ranks.size < 2) continue;
46
- const primary = Math.min(...ranks);
47
- const primaryRecords = items.filter(group => group.members.some(member => member.rank === primary));
48
- for (const group of items) {
49
- if (group.members.some(member => member.rank === primary)) continue;
50
- const unresolved = !requestKey(group.record) || primaryRecords.some(item => !requestKey(item.record));
51
- if (unresolved) { excluded.add(group); counts[group.rank].excluded++; }
52
- }
53
- }
54
- const included = groups.filter(group => !excluded.has(group));
55
- const ids = new Map(); for (const group of included) ids.set(group.record.id, (ids.get(group.record.id) ?? 0) + 1);
56
- const records = included.map(group => {
57
- counts[group.rank].included++;
58
- return { ...group.record,
59
- ...(ids.get(group.record.id) > 1 ? { originalId: group.record.id, id: `request:${hash(exactKey(group.record))}` } : {}),
60
- sourceIds: [...group.sources],
61
- ...(group.conflicts.size ? { conflictingFields: [...group.conflicts] } : {}),
62
- };
63
- });
64
- return { records, sources: counts, duplicates: counts.reduce((sum, source) => sum + source.duplicates, 0),
65
- excluded: excluded.size, conflicts: included.filter(group => group.conflicts.size).length };
66
- }
@@ -1,370 +0,0 @@
1
- import { open, readFile, realpath, stat } from 'node:fs/promises';
2
- import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
3
- import os from 'node:os';
4
- import { historyExtent } from './history.mjs';
5
- import { discoverProject, discoveryCandidates } from './discovery.mjs';
6
- import { createBackgroundIndex } from './repository-background.mjs';
7
- import { createProjectActivity } from './project-activity.mjs';
8
- import { reconcileActivity } from './activity-merge.mjs';
9
- import { modelCreators } from './services.mjs';
10
- import { collectionLimits, configurationLimits, hardwareLimits, limitNote } from './coverage.mjs';
11
- import { normalizeHosts, retainHostHistory, hostState } from './hardware.mjs';
12
- import { StringDecoder } from 'node:string_decoder';
13
- import { checkpointForRead, checkpointMatches } from './log-continuity.mjs';
14
- import { normalizePath, normalizeDeployments, normalizePools, normalizeCapacity, poolIds } from './topology.mjs';
15
-
16
- const MAX_READ = 4 * 1024 * 1024;
17
- const MAX_RECORDS = 10_000;
18
- const text = (value, fallback = '') => typeof value === 'string' ? value.slice(0, 160).replace(/[\x00-\x1f]/g, '') : fallback;
19
- const number = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
20
- const date = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
21
- const inside = (root, path) => { const part = relative(root, path); return part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
22
- function localOrigin(endpoint) {
23
- const url = new URL(endpoint);
24
- if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Runtime URLs must be HTTP loopback origins without credentials or paths.');
25
- return url.origin;
26
- }
27
-
28
- // A projection, never a passthrough: prompts, responses, headers and credentials
29
- // cannot reach the terminal through an imported record.
30
- export function normalizeRecord(value, sourceId) {
31
- // Development usage is outside the application's scope, even in an explicit export.
32
- if (!value || typeof value !== 'object' || value.scope === 'development' || !text(value.id) || !text(value.model)) return null;
33
- const occurredAt = date(value.occurredAt);
34
- if (!occurredAt) return null;
35
- let path = normalizePath(value.path);
36
- const route = text(value.route) || path[0]?.name || 'Unattributed route';
37
- const routeKind = ['direct', 'local', 'router', 'gateway', 'unknown'].includes(value.routeKind) ? value.routeKind : path[0]?.kind ?? null;
38
- if (path.length && (path[0].name !== route || path[0].kind !== routeKind)) path = [];
39
- return {
40
- id: text(value.id), sourceId, occurredAt,
41
- requestId: text(value.requestId) || null,
42
- agentId: text(value.agentId) || null, agent: text(value.agent) || null,
43
- taskId: text(value.taskId) || null, task: text(value.task) || null,
44
- deploymentId: text(value.deploymentId) || null, path,
45
- model: text(value.model), modelCreator: text(value.modelCreator) || null, provider: text(value.provider) || 'Unattributed',
46
- workload: text(value.workload) || 'Unattributed workload',
47
- scope: ['application', 'agent', 'evaluation'].includes(value.scope) ? value.scope : 'application',
48
- route, routeKind,
49
- location: ['local', 'private', 'cloud'].includes(value.location) ? value.location : 'unknown',
50
- status: ['completed', 'running', 'failed'].includes(value.status) ? value.status : 'unknown',
51
- inputTokens: number(value.inputTokens), outputTokens: number(value.outputTokens), cacheReadTokens: number(value.cacheReadTokens),
52
- durationMs: number(value.durationMs), cost: number(value.cost),
53
- costKind: ['reported', 'estimated'].includes(value.costKind) ? value.costKind : 'unknown',
54
- currency: value.currency === 'USD' ? 'USD' : 'unknown',
55
- };
56
- }
57
-
58
- export class JsonlReader {
59
- constructor(root, path) { this.root = root; this.path = path; this.offset = 0; this.identity = ''; this.pending = ''; this.decoder = new StringDecoder('utf8'); this.records = new Map(); this.invalid = 0; this.truncated = false; this.mtime = 0; this.checkpoint = null; }
60
- async read(projectAudit = false) {
61
- try {
62
- const canonicalRoot = await realpath(this.root);
63
- const canonical = await realpath(this.path);
64
- if (!inside(canonicalRoot, canonical)) throw new Error('Source must remain inside this project.');
65
- if (!(await stat(canonical)).isFile()) throw new Error('Source must be a regular file.');
66
- const handle = await open(canonical, 'r');
67
- try {
68
- const info = await handle.stat();
69
- if (!info.isFile()) throw new Error('Source must be a regular file.');
70
- const identity = `${info.dev}:${info.ino}`;
71
- if (identity !== this.identity || info.size < this.offset || (info.size === this.offset && info.mtimeMs !== this.mtime) || !await checkpointMatches(handle, this.checkpoint)) {
72
- this.offset = 0; this.pending = ''; this.decoder = new StringDecoder('utf8'); this.records.clear(); this.invalid = 0; this.truncated = false; this.checkpoint = null;
73
- }
74
- this.identity = identity;
75
- let skipFirst = false;
76
- if (info.size - this.offset > MAX_READ) { this.offset = info.size - MAX_READ; this.pending = ''; this.decoder = new StringDecoder('utf8'); this.truncated = true; skipFirst = true; }
77
- const buffer = Buffer.alloc(Math.min(MAX_READ, info.size - this.offset));
78
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.offset);
79
- this.checkpoint = checkpointForRead(buffer.subarray(0, bytesRead), this.offset, this.checkpoint);
80
- this.offset += bytesRead; this.mtime = info.mtimeMs;
81
- const lines = (this.pending + this.decoder.write(buffer.subarray(0, bytesRead))).split('\n');
82
- this.pending = lines.pop() ?? '';
83
- if (skipFirst) lines.shift();
84
- for (const line of lines) {
85
- if (!line.trim()) continue;
86
- try {
87
- let raw = JSON.parse(line);
88
- if (projectAudit) {
89
- if (raw.type !== 'automation_signal_recorded' || raw.data?.signalType !== 'product_task_completed') continue;
90
- const data = raw.data;
91
- raw = { id: data.signalId ?? raw.id, occurredAt: raw.occurredAt, model: data.modelId, workload: data.targetId, ...data.telemetry };
92
- }
93
- const record = normalizeRecord(raw, projectAudit ? 'openmerit' : 'activity');
94
- if (!record) { this.invalid++; continue; }
95
- this.records.delete(record.id);
96
- this.records.set(record.id, projectAudit ? { ...record, kind: 'task-summary', status: 'unknown' } : record);
97
- if (this.records.size > MAX_RECORDS) { this.records.delete(this.records.keys().next().value); this.truncated = true; }
98
- } catch { this.invalid++; }
99
- }
100
- if (this.pending.length > 128 * 1024) { this.pending = ''; this.invalid++; }
101
- return { state: 'connected', records: [...this.records.values()], invalid: this.invalid, truncated: this.truncated };
102
- } finally { await handle.close(); }
103
- } catch (error) {
104
- // Never keep deleted or unreadable source data looking current.
105
- this.records.clear(); this.offset = 0; this.pending = ''; this.identity = ''; this.decoder = new StringDecoder('utf8'); this.checkpoint = null;
106
- return { state: error.code === 'ENOENT' ? 'not_found' : 'unavailable', records: [], invalid: 0, truncated: false };
107
- }
108
- }
109
- }
110
-
111
- export async function loadConfig(root) {
112
- const path = resolve(root, '.openmerit/terminal.json');
113
- let raw;
114
- try {
115
- if (!inside(await realpath(root), await realpath(path))) throw new Error('Terminal configuration must remain inside this project.');
116
- if ((await stat(path)).size > 128 * 1024) throw new Error('Terminal configuration exceeds 128 KiB.');
117
- raw = JSON.parse(await readFile(path, 'utf8'));
118
- } catch (error) { if (error.code === 'ENOENT') raw = {}; else throw new Error('Cannot read .openmerit/terminal.json. Check its JSON and project-local path.'); }
119
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Terminal configuration must be a JSON object.');
120
- const activityFile = text(raw.activityFile, '.openmerit/inference.jsonl');
121
- if (!inside(resolve(root), resolve(root, activityFile))) throw new Error('activityFile must be inside this project.');
122
- const capacityFile = text(raw.capacityFile, '.openmerit/capacity.json');
123
- if (!inside(resolve(root), resolve(root, capacityFile))) throw new Error('capacityFile must be inside this project.');
124
- const hardwareFile = text(raw.hardwareFile, '.openmerit/hosts.json');
125
- if (!inside(resolve(root), resolve(root, hardwareFile))) throw new Error('hardwareFile must be inside this project.');
126
- if (raw.ollama !== undefined && raw.ollama !== false && (typeof raw.ollama !== 'string' || !raw.ollama.trim())) throw new Error('ollama must be a loopback URL or false.');
127
- const endpoint = typeof raw.ollama === 'string' ? text(raw.ollama) : null;
128
- if (endpoint) localOrigin(endpoint);
129
- const runtimes = Array.isArray(raw.runtimes) ? raw.runtimes.slice(0, 8).map((runtime, index) => {
130
- if (!runtime || !['openai-compatible', 'vllm', 'ollama'].includes(runtime.type)) throw new Error('Runtime type must be openai-compatible, vllm, or ollama.');
131
- const url = localOrigin(runtime.url);
132
- return { id: `runtime-${index}`, name: text(runtime.name) || (runtime.type === 'ollama' ? 'Ollama' : url), type: runtime.type, url, ...runtimePlacement(runtime) };
133
- }) : [];
134
- const inventory = Array.isArray(raw.inventory) ? raw.inventory.slice(0, 100).filter(item => item && typeof item === 'object').map(item => ({
135
- name: text(item.name), modelCreator: text(item.modelCreator) || null, provider: text(item.provider, 'Unattributed'),
136
- kind: ['model', 'provider', 'gateway', 'router', 'runtime'].includes(item.kind) ? item.kind : 'model',
137
- serviceType: ['model-api', 'inference-cloud'].includes(item.serviceType) ? item.serviceType : null,
138
- location: ['local', 'private', 'cloud'].includes(item.location) ? item.location : 'unknown',
139
- poolIds: poolIds(item.poolIds),
140
- })).filter(item => item.name) : [];
141
- return { limits: configurationLimits(raw), discovery: raw.discovery !== false, activityFile, capacityFile, hardwareFile, ollama: endpoint, ollamaDisabled: raw.ollama === false, inventory, runtimes, deployments: normalizeDeployments(raw.deployments), capacityPools: normalizePools(raw.capacityPools) };
142
- }
143
-
144
- async function exportSnapshot(root, path, key, normalize, limit = 128 * 1024) {
145
- try {
146
- const canonical = await realpath(resolve(root, path));
147
- if (!inside(await realpath(root), canonical)) throw new Error('Outside project');
148
- const info = await stat(canonical);
149
- if (!info.isFile() || info.size > limit) throw new Error('Invalid export');
150
- const handle = await open(canonical, 'r');
151
- let raw;
152
- try { const buffer = Buffer.alloc(limit + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); if (bytesRead > limit) throw new Error('Too large'); raw = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')); } finally { await handle.close(); }
153
- if (!Array.isArray(raw?.[key])) throw new Error('Missing entries');
154
- const limits = key === 'hosts' ? hardwareLimits(raw) : collectionLimits(raw, { pools: 100 });
155
- return { state: limits.length ? 'partial' : 'connected', limits, truncated: limits.length > 0, [key]: normalize(raw) };
156
- } catch (error) { return { state: error.code === 'ENOENT' ? 'not_found' : 'unavailable', [key]: [] }; }
157
- }
158
-
159
- async function fetchText(url) {
160
- const response = await fetch(url, { signal: AbortSignal.timeout(1800), redirect: 'error' });
161
- if (!response.ok) throw new Error('Source unavailable');
162
- // Bound response size even when content-length is missing.
163
- const reader = response.body.getReader();
164
- const chunks = []; let size = 0;
165
- try {
166
- while (true) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_READ) throw new Error('Response too large'); chunks.push(value); }
167
- return Buffer.concat(chunks).toString('utf8');
168
- } finally { await reader.cancel().catch(() => {}); }
169
- }
170
- async function fetchJson(url) { return JSON.parse(await fetchText(url)); }
171
-
172
- export function parseRuntimeMetrics(body) {
173
- const names = { num_requests_running: 'running', num_requests_waiting: 'waiting', prompt_tokens_total: 'inputTokens', generation_tokens_total: 'outputTokens', request_success_total: 'finished', kv_cache_usage_perc: 'cachePercent' };
174
- const result = {};
175
- for (const line of body.split('\n')) {
176
- const match = line.match(/^vllm:(\w+)(?:\{[^}]*\})?\s+([\d.eE+-]+)(?:\s|$)/);
177
- if (!match || !names[match[1]]) continue;
178
- const value = Number(match[2]); if (!Number.isFinite(value) || value < 0) continue;
179
- const key = names[match[1]];
180
- if (key === 'cachePercent') { if (value <= 1) result[key] = Math.max(result[key] ?? 0, value * 100); }
181
- else result[key] = (result[key] ?? 0) + value;
182
- }
183
- return result;
184
- }
185
-
186
- function runtimePlacement(runtime = {}) {
187
- const hostId = text(runtime.hostId) || null;
188
- return { hostId, location: hostId === 'this-machine' ? 'local' : ['local', 'private', 'cloud'].includes(runtime.location) ? runtime.location : 'unknown', servingMode: ['shared', 'dedicated', 'self-hosted'].includes(runtime.servingMode) ? runtime.servingMode : 'unknown' };
189
- }
190
- async function runtimeSnapshot(runtime) {
191
- const placement = runtimePlacement(runtime);
192
- const source = { id: runtime.id, name: runtime.name, endpointInventory: true, detail: runtime.url, ...placement,
193
- access: runtime.discoverySource ? 'Project declaration' : 'Project configuration', discoverySource: runtime.discoverySource, discoveryField: runtime.discoveryField };
194
- if (runtime.type === 'ollama') {
195
- const snapshot = await ollamaSnapshot(runtime.url, runtime.id, runtime.name);
196
- return { ...snapshot, source: { ...source, kind: 'Model runtime', state: snapshot.state, limits: snapshot.limits, truncated: snapshot.truncated, note: snapshot.note + ' Shared runtime inventory; the endpoint does not identify its physical host.', coverage: ['Model catalog', 'Loaded models'] },
197
- models: snapshot.models.map(model => ({ ...model, location: placement.location })), stats: null };
198
- }
199
- const [catalog, metrics] = await Promise.allSettled([fetchJson(`${runtime.url}/v1/models`), ['vllm', 'discover'].includes(runtime.type) ? fetchText(`${runtime.url}/metrics`) : Promise.resolve(null)]);
200
- const connected = catalog.status === 'fulfilled' && Array.isArray(catalog.value?.data);
201
- const counters = metrics.status === 'fulfilled' && metrics.value ? parseRuntimeMetrics(metrics.value) : {};
202
- const identifiedRuntime = runtime.type === 'vllm' || runtime.identifiedName?.startsWith('vLLM · ') || Object.keys(counters).length > 0;
203
- const name = runtime.identifiedName || (runtime.type === 'discover' && identifiedRuntime ? `vLLM · ${new URL(runtime.url).host}` : runtime.name);
204
- const limits = connected ? collectionLimits({ models: catalog.value.data }, { models: 100 }) : [];
205
- return {
206
- source: { ...source, name, kind: identifiedRuntime ? 'Model runtime' : 'Model endpoint', state: connected ? (limits.length || identifiedRuntime && !Object.keys(counters).length ? 'partial' : 'connected') : Object.keys(counters).length ? 'partial' : 'unavailable', limits, truncated: limits.length > 0,
207
- note: 'Project endpoint inventory. Available models and counters cover all clients; they do not establish application usage or physical host placement.' + limitNote(limits), coverage: ['Model catalog', ...(Object.keys(counters).length ? ['Runtime counters'] : [])] },
208
- models: connected ? catalog.value.data.slice(0, 100).filter(item => item && text(item.id)).map(item => ({ id: `${runtime.id}:${text(item.id)}`, name: text(item.id), provider: name, location: placement.location, state: 'available', sourceId: runtime.id, memoryBytes: null })) : [],
209
- stats: Object.keys(counters).length ? { name, sourceId: runtime.id, hostId: placement.hostId, ...counters } : null,
210
- };
211
- }
212
-
213
- async function ollamaSnapshot(endpoint, sourceId = 'ollama', provider = 'Ollama') {
214
- if (!endpoint) return { state: 'disabled', models: [], note: 'Local runtime discovery is disabled.' };
215
- const [tags, ps] = await Promise.allSettled([fetchJson(`${endpoint.replace(/\/$/, '')}/api/tags`), fetchJson(`${endpoint.replace(/\/$/, '')}/api/ps`)]);
216
- const tagsOk = tags.status === 'fulfilled' && Array.isArray(tags.value?.models);
217
- const psOk = ps.status === 'fulfilled' && Array.isArray(ps.value?.models);
218
- if (!tagsOk && !psOk) return { state: 'unavailable', models: [], note: 'Ollama did not return valid inventory at the configured local address.' };
219
- const installedEntries = tagsOk ? tags.value.models.filter(item => item && typeof item === 'object') : [];
220
- const loadedEntries = psOk ? ps.value.models.filter(item => item && typeof item === 'object') : [];
221
- const installed = installedEntries.slice(0, 100), loaded = loadedEntries.slice(0, 100);
222
- const limits = collectionLimits({ installedModels: installedEntries, loadedModels: loadedEntries }, { installedModels: 100, loadedModels: 100 });
223
- const all = new Map([...installed, ...loaded].map(model => [text(model.name ?? model.model), model]));
224
- return {
225
- state: tagsOk && psOk && !limits.length ? 'connected' : 'partial', limits, truncated: limits.length > 0, residencyReported: psOk,
226
- note: 'Installed and loaded models. Request history is not exposed by this source.' + limitNote(limits),
227
- models: [...all].filter(([name]) => name).map(([name, model]) => ({ id: `${sourceId}:${name}`, name, provider, location: 'unknown', state: loaded.some(item => (item.name ?? item.model) === name) ? 'loaded' : (psOk && !limits.some(item => item.collection === 'loadedModels') ? 'installed' : 'unknown'), memoryBytes: number(model.size_vram), parameters: text(model.details?.parameter_size) || null, quantization: text(model.details?.quantization_level) || null, sourceId })),
228
- };
229
- }
230
-
231
- function cpuTotals() { return os.cpus().reduce((all, cpu) => ({ idle: all.idle + cpu.times.idle, total: all.total + Object.values(cpu.times).reduce((a, b) => a + b, 0) }), { idle: 0, total: 0 }); }
232
-
233
- export function createCollector(root, config, interval = 15, { backgroundIndex = false, indexOptions } = {}) {
234
- const background=config.discovery&&backgroundIndex?createBackgroundIndex(root,indexOptions):null;
235
- let scanner=null;
236
- const activityReader = new JsonlReader(root, resolve(root, config.activityFile));
237
- const auditReader = new JsonlReader(root, resolve(root, '.openmerit/events.jsonl'));
238
- let previousCpu = null;
239
- const hardwareHistory = [];
240
- const hostHistories = new Map();
241
- const projectActivity = createProjectActivity(root, normalizeRecord, { exclude: [resolve(root, config.activityFile)] });
242
- let projectDiscovery = { sources: [], inventory: [], runtimes: [] }, repository = null, discoveredAt = 0;
243
- const knownRuntimes = new Map();
244
- const collect = async function collect() {
245
- const scan = Boolean(config.discovery) && Date.now() - discoveredAt >= 60_000;
246
- background?.start();
247
- if (scan) {
248
- if(!background)scanner??=(await import('./repository-index.mjs')).createRepositoryScanner(root);
249
- [projectDiscovery,repository]=await Promise.all([discoverProject(root),background?null:scanner()]);
250
- discoveredAt=Date.now();
251
- }
252
- const allCandidates = config.discovery ? discoveryCandidates(projectDiscovery, config.runtimes, config.ollama, Infinity).filter(runtime => !(config.ollamaDisabled && runtime.type === 'ollama')) : [];
253
- const candidates = allCandidates.slice(0, 8);
254
- const discoveryLimits = collectionLimits({ endpoints: allCandidates }, { endpoints: 8 });
255
- const localComputeLinked = Boolean(config.runtimes?.some(item => item.hostId === 'this-machine') || config.deployments?.some(item => item.hostId === 'this-machine'));
256
- const candidateIds = new Set(candidates.map(item => item.id));
257
- for (const id of knownRuntimes.keys()) if (!candidateIds.has(id)) knownRuntimes.delete(id);
258
- const autoRuntimes = candidates.filter(item => scan || knownRuntimes.has(item.id)).map(item => ({ ...item, identifiedName: knownRuntimes.get(item.id) }));
259
- const logs = config.discovery ? await projectActivity() : { records: [], sources: [] };
260
- const [activity, audit, ollama, runtimes, capacity, hardware] = await Promise.all([activityReader.read(), auditReader.read(true), ollamaSnapshot(config.ollama), Promise.all([...(config.runtimes ?? []), ...autoRuntimes].map(runtimeSnapshot)), exportSnapshot(root, config.capacityFile ?? '.openmerit/capacity.json', 'pools', normalizeCapacity), exportSnapshot(root, config.hardwareFile ?? '.openmerit/hosts.json', 'hosts', normalizeHosts, 256 * 1024)]);
261
- for (const runtime of runtimes) if (runtime.source.discoverySource && ['connected', 'partial'].includes(runtime.source.state)) knownRuntimes.set(runtime.source.id, runtime.source.name);
262
- // A declared endpoint must respond before its catalog becomes discovered inventory.
263
- for (let i = runtimes.length - 1; i >= 0; i--) if (runtimes[i].source.discoverySource && !knownRuntimes.has(runtimes[i].source.id)) runtimes.splice(i, 1);
264
- const now = new Date().toISOString();
265
- const recorded = records => records.filter(item => Date.parse(item.occurredAt) <= Date.parse(now));
266
- const batches = [
267
- { sourceId: 'activity', records: recorded(activity.records) },
268
- ...logs.sources.map(source => ({ sourceId: source.id, records: recorded(logs.records.filter(record => record.sourceId === source.id)) })),
269
- ].filter(batch => batch.records.length);
270
- const reconciliation = reconcileActivity(batches);
271
- // Task summaries are a different unit, never request-level evidence.
272
- const records = reconciliation.records;
273
- const taskSummaries = recorded(audit.records);
274
- const requestSources = reconciliation.sources.filter(source => source.included > 0).map(source => source.sourceId);
275
- const models = new Map([...ollama.models, ...runtimes.flatMap(runtime => runtime.models)].map(item => [`${item.provider}:${item.name}`, item]));
276
- for (const host of hardware.hosts) for (const model of host.loadedModels) {
277
- const key = `${model.provider || 'Unattributed'}:${model.name}`;
278
- const loaded = hostState(host, interval) === 'sampled';
279
- // A fresh resident on any exported host wins over stale host evidence.
280
- if (!models.has(key) || models.get(key).sourceId === 'hardware' && loaded) models.set(key, { id: key, name: model.name, modelCreator: model.modelCreator, provider: model.provider || 'Unattributed', location: 'private', state: loaded ? 'loaded' : 'unknown', sourceId: 'hardware', memoryBytes: null });
281
- }
282
- for (const item of [...config.inventory.filter(item => item.kind === 'model'), ...(config.deployments ?? []).map(item => ({ ...item, name: item.model }))]) {
283
- const key = `${item.provider}:${item.name}`;
284
- if (!models.has(key)) models.set(key, { ...item, id: key, state: 'configured', sourceId: 'inventory', memoryBytes: null });
285
- }
286
- for (const item of records) {
287
- const key = `${item.provider}:${item.model}`;
288
- if (!models.has(key)) models.set(key, { id: key, name: item.model, provider: item.provider, location: item.location, state: 'observed', sourceId: item.sourceId, memoryBytes: null });
289
- }
290
- const cpu = cpuTotals();
291
- const cpuPercent = previousCpu && cpu.total > previousCpu.total ? Math.max(0, Math.min(100, 100 * (1 - (cpu.idle - previousCpu.idle) / (cpu.total - previousCpu.total)))) : null;
292
- previousCpu = cpu;
293
- const memoryTotal = os.totalmem();
294
- const memoryUsed = memoryTotal - os.freemem();
295
- hardwareHistory.push({ at: now, cpuPercent, memoryUsed });
296
- if (hardwareHistory.length > 240) hardwareHistory.shift();
297
- const sources = [
298
- { id: 'activity', name: 'Inference activity', kind: 'Local file', state: activity.state === 'connected' && (activity.truncated || activity.invalid) ? 'partial' : activity.state, truncated: activity.truncated, invalid: activity.invalid, updatedAt: activity.state === 'connected' ? now : null, detail: config.activityFile, note: activity.truncated ? 'The bounded reader omitted older records. All time covers retained history only.' : activity.invalid ? `${activity.invalid} invalid records skipped.` : 'Application request metadata, including its agents, jobs, evaluations, and gateway.', records: activity.records.length },
299
- { id: 'openmerit', name: 'OpenMerit', kind: 'Project evidence', state: audit.state === 'connected' && (audit.truncated || audit.invalid) ? 'partial' : audit.state, truncated: audit.truncated, invalid: audit.invalid, updatedAt: audit.state === 'connected' ? now : null, detail: '.openmerit/events.jsonl', note: 'Task summaries are retained separately to avoid counting a task summary as an inference request. They do not contribute to request counts, tokens, spend, or request history.', summaries: taskSummaries.length },
300
- ...(config.ollama ? [{ id: 'ollama', name: 'Ollama', kind: 'Model runtime', endpointInventory: true, ...runtimePlacement(), limits: ollama.limits, truncated: ollama.truncated, state: ollama.state, updatedAt: ['connected', 'partial'].includes(ollama.state) ? now : null, detail: config.ollama, access: 'Project configuration', note: ollama.note + ' Explicitly linked in terminal.json. Inventory is shared by all clients of this runtime.' }] : []),
301
- ...(localComputeLinked ? [{ id: 'machine', name: 'This machine', kind: 'Operating system', state: 'connected', updatedAt: now, detail: `${os.platform()} · ${os.arch()}`, note: 'Shown because the project explicitly links a runtime or deployment to this-machine. CPU and memory describe the whole machine, not individual inference requests.' }] : []),
302
- ];
303
- if (config.discovery) {
304
- sources.push(...projectDiscovery.sources);
305
-
306
- sources.push(...logs.sources);
307
- if (discoveryLimits.length) sources.push({ id: 'runtime-discovery', name: 'Endpoint discovery coverage', kind: 'Project discovery', state: 'partial', truncated: true, limits: discoveryLimits, detail: '.env · .env.local', note: limitNote(discoveryLimits).trim() });
308
- }
309
- for (const source of sources) {
310
- const contribution = reconciliation.sources.find(item => item.sourceId === source.id);
311
- if (contribution) {
312
- source.activity = contribution;
313
- source.note += ` ${contribution.included} requests contribute to totals; ${contribution.duplicates} matching records reconciled; ${contribution.excluded} excluded because overlap could not be resolved.`;
314
- if (contribution.excluded) source.state = 'partial';
315
- }
316
- }
317
- sources.push({ id: 'hardware', name: 'Hardware export', kind: 'Local file', state: hardware.state, limits: hardware.limits, truncated: hardware.truncated, updatedAt: ['connected', 'partial'].includes(hardware.state) ? now : null, detail: config.hardwareFile ?? '.openmerit/hosts.json', note: 'Hosts, racks, accelerators, and loaded models from an existing exporter. Host timestamps determine freshness; no network discovery.' + limitNote(hardware.limits) });
318
- sources.push(...runtimes.map(runtime => ({ ...runtime.source, updatedAt: ['connected', 'partial'].includes(runtime.source.state) ? now : null })));
319
- sources.push({ id: 'capacity', name: 'Capacity export', kind: 'Local file', state: capacity.state, limits: capacity.limits, truncated: capacity.truncated, updatedAt: ['connected', 'partial'].includes(capacity.state) ? now : null, detail: config.capacityFile ?? '.openmerit/capacity.json', note: 'Pool readings retain their source timestamps. A file check does not refresh a measurement. Only declared pool IDs are accepted.' + limitNote(capacity.limits) });
320
- if (config.inventory.length || config.runtimes?.length || config.limits?.length || (config.deployments?.length ?? 0) || (config.capacityPools?.length ?? 0)) sources.push({ id: 'inventory', name: 'Declared environment', kind: 'Project configuration', state: config.limits?.length ? 'partial' : 'connected', limits: config.limits, truncated: Boolean(config.limits?.length), updatedAt: now, detail: '.openmerit/terminal.json', note: 'Declared models, services, deployments, and shared pools. Configuration does not prove connectivity or available capacity.' + limitNote(config.limits) });
321
- const capacityPools = (config.capacityPools ?? []).map(pool => ({ ...pool, ...(capacity.pools.find(item => item.id === pool.id) ?? {}) }));
322
- const deployments = [...(config.deployments ?? [])];
323
- for (const source of sources.filter(item => item.endpointInventory && item.state !== 'disabled')) {
324
- const poolId = `runtime:${source.id}`;
325
- const counters = runtimes.find(item => item.source.id === source.id)?.stats;
326
- const metrics = Object.fromEntries(['running', 'waiting', 'cachePercent'].filter(key => counters?.[key] != null).map(key => [key, counters[key]]));
327
- capacityPools.push({ id: poolId, name: source.name, kind: 'runtime', sourceId: source.id, observedAt: Object.keys(metrics).length ? now : null, metrics });
328
- for (const model of [...ollama.models, ...runtimes.flatMap(item => item.models)].filter(item => item.sourceId === source.id)) deployments.push({ id: `runtime:${model.id}`, name: `${model.name} / ${source.name}`, model: model.name, provider: model.provider, servingMode: source.servingMode, location: model.location, sourceId: source.id, state: model.state, hostId: source.hostId, poolIds: [poolId] });
329
- }
330
- const residentRuntimes = runtimes.filter(runtime => runtime.residencyReported && runtime.source.hostId === 'this-machine');
331
- const residentModels = residentRuntimes.flatMap(runtime => runtime.models).filter(model => model.state === 'loaded');
332
- const machine = { id: 'this-machine', projectLinked: localComputeLinked, scope: 'whole-machine', name: os.hostname(), nameField: 'hostname', hostname: os.hostname(), site: '', rack: '', sourceId: 'machine', observedAt: now, chip: text(os.cpus()[0]?.model, os.arch()), cores: os.cpus().length, platform: os.platform(), arch: os.arch(), memoryTotal, memoryUsed, cpuPercent, history: [...hardwareHistory], accelerators: [], residencyReported: residentRuntimes.length > 0, loadedModels: residentModels.map(model => ({ ...model, runtime: model.provider, memoryKind: 'accelerator', deploymentId: `runtime:${model.id}` })) };
333
- const hosts = [...(localComputeLinked ? [machine] : []), ...retainHostHistory(hardware.hosts, hostHistories)];
334
- const inventory = [...config.inventory.filter(item => item.kind !== 'model'), ...projectDiscovery.inventory].map(item => ({ ...item, sourceId: item.sourceId || 'inventory' }));
335
- const creatorReports = [...records, ...(config.deployments ?? []), ...config.inventory.filter(item => item.kind === 'model').map(item => ({ ...item, sourceId: 'inventory' })), ...hardware.hosts.flatMap(host => host.loadedModels.map(model => ({ ...model, sourceId: 'hardware' })))];
336
- const historySources = sources.filter(source => source.id === 'activity' || source.id.startsWith('project-log:') || source.id === 'project-log-coverage');
337
- const gaps = historySources.filter(source => source.state === 'partial' || source.state === 'unavailable' || source.truncated || source.invalid);
338
- const history = { ...historyExtent(records, now), retained: records.length, coverage: gaps.length || reconciliation.excluded ? 'partial' : 'available', sourceIds: historySources.filter(source => source.state !== 'not_found').map(source => source.id) };
339
- const snapshot = {
340
- schemaVersion: 1, mode: 'live', observedAt: now, interval, history,
341
- repository: null,
342
- discovery: { enabled: Boolean(config.discovery), checkedAt: discoveredAt ? new Date(discoveredAt).toISOString() : null, intervalSeconds: 60, runtimeCandidates: candidates.length, requestSource: requestSources.length > 1 ? 'combined' : requestSources[0] ?? null, requestSources, reconciliation: { duplicates: reconciliation.duplicates, excluded: reconciliation.excluded, conflicts: reconciliation.conflicts, sources: reconciliation.sources } },
343
- project: { name: basename(root), scope: 'Current project' },
344
- models: modelCreators([...models.values()], creatorReports), inventory: [...inventory, ...(config.runtimes ?? []).map(runtime => ({ name: runtime.name, kind: runtime.type === 'openai-compatible' ? 'unknown' : 'runtime', location: runtimePlacement(runtime).location, state: 'configured' }))],
345
- records, taskSummaries, sources, deployments, capacityPools, runtimeStats: runtimes.map(runtime => runtime.stats).filter(Boolean),
346
- machine, hosts,
347
- };
348
- return background?snapshot:withRepository(snapshot,repository);
349
- };
350
- let previousBase=null,previousIndex=null,composed=null;
351
- collect.current=snapshot=>{
352
- if(!background||snapshot.loading)return snapshot;
353
- const index=background.get();
354
- if(snapshot!==previousBase||index!==previousIndex){composed=withRepository(snapshot,index);previousBase=snapshot;previousIndex=index;}
355
- return composed;
356
- };
357
- collect.close=()=>background?.close();
358
- return collect;
359
- }
360
- function withRepository(snapshot,repository) {
361
- if(!repository)return snapshot;
362
- const models=new Map(snapshot.models.map(item=>[`${item.provider}:${item.name}`,item]));
363
- for(const item of repository.models){
364
- const key=`${item.provider}:${item.name}`,existing=models.get(key);
365
- models.set(key,existing?{...existing,declarationIds:[...new Set([...(existing.declarationIds??[]),...item.declarationIds])]}:item);
366
- }
367
- const phase=repository.progress?.phase,active=phase&&phase!=='complete';
368
- const source={id:'repository',name:'Repository index',kind:'Code & configuration',state:active||repository.summary.unresolved||repository.summary.skipped.unreadable||repository.summary.skipped.oversized?'partial':'connected',detail:`${repository.summary.filesIndexed} files · ${repository.references.length} declarations${active?' · '+(phase==='failed'?'index interrupted':'indexing'):''}`,updatedAt:repository.checkedAt,coverage:['Endpoint declarations','Model references','Call sites','Coverage gaps'],access:'Selected project',note:'Static syntax only. Source code is never executed. Declarations do not establish usage, reachability, loaded models, or physical hosts. Examples, candidates, benchmarks and fallbacks are separated. Paths and line numbers identify the evidence; prompts, credentials and raw source contents are discarded.'};
369
- return {...snapshot,models:[...models.values()],inventory:[...snapshot.inventory,...repository.inventory],sources:[...snapshot.sources,source],repository:{references:repository.references,issues:repository.issues,summary:repository.summary,checkedAt:repository.checkedAt,revision:repository.revision,progress:repository.progress}};
370
- }
@@ -1,33 +0,0 @@
1
- // Report bounded inventory reads without exposing unprojected source values.
2
- export function collectionLimits(value, fields) {
3
- return Object.entries(fields).flatMap(([collection, limit]) => Array.isArray(value?.[collection]) && value[collection].length > limit
4
- ? [{ collection, limit, omitted: value[collection].length - limit }] : []);
5
- }
6
- export function hardwareLimits(value) {
7
- const limits = collectionLimits(value, { hosts: 64 });
8
- for (const host of (value?.hosts ?? []).slice(0, 64)) {
9
- for (const entry of collectionLimits(host, { accelerators: 16, loadedModels: 100 })) {
10
- const collection = `hosts[].${entry.collection}`, existing = limits.find(item => item.collection === collection);
11
- if (existing) existing.omitted += entry.omitted;
12
- else limits.push({ ...entry, collection });
13
- }
14
- }
15
- return limits;
16
- }
17
- export function limitNote(limits = []) {
18
- return limits.length ? ' Collection limits omitted ' + limits.map(item => `${item.omitted} ${item.collection} (limit ${item.limit})`).join('; ') + '. Inventory is incomplete.' : '';
19
- }
20
-
21
- export function configurationLimits(value) {
22
- const limits = collectionLimits(value, { runtimes: 8, inventory: 100, deployments: 200, capacityPools: 100 });
23
- for (const [field, bound] of [['inventory', 100], ['deployments', 200]]) {
24
- for (const item of (Array.isArray(value?.[field]) ? value[field] : []).slice(0, bound)) {
25
- for (const entry of collectionLimits(item, { poolIds: 16 })) {
26
- const collection = `${field}[].poolIds`, existing = limits.find(limit => limit.collection === collection);
27
- if (existing) existing.omitted += entry.omitted;
28
- else limits.push({ ...entry, collection });
29
- }
30
- }
31
- }
32
- return limits;
33
- }
@@ -1,99 +0,0 @@
1
- import { open, realpath, stat } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
3
- import { relative, resolve, sep, isAbsolute } from 'node:path';
4
- import { configuredServices } from './services.mjs';
5
- import { SERVICE_CREDENTIAL_FIELDS, SERVICE_ENDPOINT_FIELDS } from './service-registry.mjs';
6
-
7
- const LIMIT = 128 * 1024;
8
- const inside = (root, path) => { const part = relative(root, path); return part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
9
- const credentials = new Set(SERVICE_CREDENTIAL_FIELDS);
10
- const endpoints = new Set([...SERVICE_ENDPOINT_FIELDS, 'OLLAMA_HOST', 'OLLAMA_BASE_URL']);
11
- const sdks = new Set(['openai', '@anthropic-ai/sdk', '@google/genai', '@google/generative-ai', 'groq-sdk', '@cerebras/cerebras_cloud_sdk', '@lmstudio/sdk', 'ollama', 'ollama-js', 'ai', 'litellm', 'anthropic', 'groq', 'cerebras-cloud-sdk', 'baseten', '@huggingface/inference', 'huggingface_hub', 'huggingface-hub', '@mistralai/mistralai', 'mistralai', 'cohere-ai', 'cohere', 'together-ai', 'together', 'fireworks-ai', 'replicate', 'voyageai', 'portkey-ai', 'xai-sdk', '@aws-sdk/client-bedrock-runtime', '@aws-sdk/client-sagemaker-runtime', '@google-cloud/vertexai', 'google-cloud-aiplatform', '@azure/ai-inference', 'azure-ai-inference']);
12
-
13
- // Parse literal assignments only. Never source a shell file, expand variables,
14
- // return credential contents, or guess how the application loads its config.
15
- export function envDeclarations(body) {
16
- const result = {};
17
- for (const line of body.split(/\r?\n/)) {
18
- const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
19
- if (!match || !credentials.has(match[1]) && !endpoints.has(match[1])) continue;
20
- let value = match[2];
21
- if (/^["']/.test(value)) { const quote = value[0], end = value.indexOf(quote, 1); if (end < 0 || !/^\s*(?:#.*)?$/.test(value.slice(end + 1))) continue; value = value.slice(1, end); }
22
- else value = value.replace(/\s+#.*$/, '').trim();
23
- if (/[\x00-\x1f$`]/.test(value)) continue;
24
- result[match[1]] = credentials.has(match[1]) ? (value ? 'present' : '') : value.slice(0, 2049);
25
- }
26
- return result;
27
- }
28
- export function loopbackEndpoint(value) {
29
- try {
30
- const url = new URL(value);
31
- if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash || !['/', '/v1', '/v1/'].includes(url.pathname)) return null;
32
- return url.origin.replace('://localhost', '://127.0.0.1');
33
- } catch { return null; }
34
- }
35
- async function boundedFile(root, name) {
36
- const path = await realpath(resolve(root, name));
37
- if (!inside(await realpath(root), path)) throw new Error('Outside project');
38
- if (!(await stat(path)).isFile()) throw new Error('Unsupported file');
39
- const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
40
- try {
41
- const info = await handle.stat();
42
- if (!info.isFile() || info.size > LIMIT) throw new Error('Unsupported file');
43
- const buffer = Buffer.alloc(LIMIT + 1), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
44
- if (bytesRead > LIMIT) throw new Error('Oversized file');
45
- return { body: buffer.subarray(0, bytesRead).toString('utf8'), at: info.mtime.toISOString() };
46
- } finally { await handle.close(); }
47
- }
48
-
49
- export async function discoverProject(root) {
50
- const sources = [], inventory = [], runtimes = new Map();
51
- const local = (values, sourceId) => {
52
- for (const field of endpoints) {
53
- const nativeOllama = field === 'OLLAMA_HOST' || field === 'OLLAMA_BASE_URL';
54
- const value = values[field];
55
- const url = loopbackEndpoint(nativeOllama && value && !value.includes('://') ? `http://${value}` : value);
56
- if (!url) continue;
57
- // A named protocol declaration takes precedence over an API-compatible alias.
58
- if (runtimes.get(url)?.type === 'ollama' && !nativeOllama) continue;
59
- runtimes.set(url, { id: `discovered:${url}`, name: nativeOllama ? `Ollama · ${new URL(url).host}` : url, type: nativeOllama ? 'ollama' : 'discover', url, discoverySource: sourceId, discoveryField: field });
60
- }
61
- };
62
- // Shell-wide endpoint variables are not evidence that this project uses a runtime.
63
- // Only selected-project declarations and explicit terminal configuration authorize probes.
64
- for (const name of ['.env', '.env.local', 'package.json', 'requirements.txt']) {
65
- let file;
66
- try { file = await boundedFile(root, name); }
67
- catch (error) {
68
- if (error.code !== 'ENOENT') sources.push({ id: `project:${name}`, name, kind: 'Project discovery', state: 'unavailable', detail: name, note: 'This supported file could not be read within the project and size limits.', coverage: ['Configuration'], access: 'Selected project' });
69
- continue;
70
- }
71
- const id = `project:${name}`;
72
- if (name.startsWith('.env')) {
73
- const declarations = envDeclarations(file.body), identified = configuredServices(declarations);
74
- local(declarations, id);
75
- const unknown = identified.unrecognizedEndpoints.filter(field => !loopbackEndpoint(declarations[field]));
76
- const fields = Object.keys(declarations);
77
- if (!fields.length) continue;
78
- for (const item of identified.services) inventory.push({ ...item, sourceId: id, evidence: item.evidence.map(entry => ({ ...entry, sourceId: id })) });
79
- sources.push({ id, name, kind: 'Project discovery', state: unknown.length ? 'partial' : 'connected', detail: fields.join(' · '), updatedAt: new Date().toISOString(), modifiedAt: file.at, coverage: ['Declared services', 'Local endpoint candidates'], access: 'Selected project', note: 'Literal declarations only; this does not establish that the application loaded this file. Credential contents, expressions, and unrelated settings are discarded. Only recognized HTTPS service identities and safe loopback origins are retained.' + (unknown.length ? ' Some endpoint declarations could not identify a cloud service.' : '') });
80
- } else {
81
- let matches;
82
- try {
83
- if (name === 'package.json') {
84
- const parsed = JSON.parse(file.body);
85
- matches = Object.keys({ ...parsed.dependencies, ...parsed.devDependencies, ...parsed.optionalDependencies }).filter(key => sdks.has(key));
86
- } else matches = [];
87
- } catch { sources.push({ id, name, kind: 'Project discovery', state: 'unavailable', detail: name, note: 'The package manifest could not be parsed.', coverage: ['SDK declarations'], access: 'Selected project' }); continue; }
88
- // requirements use a deliberately small name/version grammar, never URLs or commands.
89
- if (name === 'requirements.txt') matches = file.body.split(/\r?\n/).map(line => line.match(/^\s*([a-zA-Z0-9_-]+)(?:\s*(?:[<>=!~\[]|$))/)?.[1]).filter(key => sdks.has(key));
90
- if (matches.length) sources.push({ id, name: 'SDK declarations', kind: 'Project discovery', state: 'detected', detail: `${name} · ${[...new Set(matches)].join(' · ')}`, updatedAt: new Date().toISOString(), modifiedAt: file.at, coverage: ['SDK declarations'], access: 'Selected project', note: 'Supported SDKs are declared here. An SDK does not establish a configured provider, running service, request, or cost.' });
91
- }
92
- }
93
- return { sources, inventory, runtimes: [...runtimes.values()] };
94
- }
95
-
96
- export function discoveryCandidates(project, configured = [], ollama, limit = 8) {
97
- const claimed = new Set([ollama, ...configured.map(item => item.url)].filter(Boolean).map(value => loopbackEndpoint(value) || value));
98
- return [...new Map([...project.runtimes].map(item => [item.url, item])).values()].filter(item => !claimed.has(item.url)).slice(0, limit);
99
- }