openmerit 0.1.6-preview.4 → 0.1.6

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.
@@ -797,3 +797,21 @@ body.keyboard .complete-map .map-node rect,body.keyboard .complete-map .map-edge
797
797
  .reference-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 20px; margin-top: 14px; }
798
798
  .reference-pagination > :first-child { margin-right: auto; }
799
799
  .reference-pagination button:disabled { opacity: .35; cursor: default; }
800
+
801
+ /* Background discovery is useful progress, not a blocking splash screen. */
802
+ .index-progress { display:grid;grid-template-columns:minmax(240px,1fr) minmax(120px,240px) auto;align-items:center;gap:12px 24px;padding:18px 0;margin-bottom:24px;border-bottom:1px solid var(--line); }
803
+ .index-progress-copy { display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 16px; }
804
+ .index-progress strong { font-size:13px;font-weight:500; }
805
+ .index-progress-copy span,.index-progress small { font-size:12px;color:var(--muted); }
806
+ .index-progress small { grid-column:1 / -1; }
807
+ .index-progress progress,.terminal-loading progress { appearance:none;display:block;height:2px;border:0;background:#303030;border-radius:2px;width:100%;overflow:hidden; }
808
+ .index-progress progress::-webkit-progress-bar,.terminal-loading progress::-webkit-progress-bar { background:#303030; }
809
+ .index-progress progress::-webkit-progress-value,.terminal-loading progress::-webkit-progress-value { background:#b6b6b6; }
810
+ .index-progress progress::-moz-progress-bar,.terminal-loading progress::-moz-progress-bar { background:#b6b6b6; }
811
+ progress:indeterminate { opacity:.55; }
812
+ .terminal-loading { padding:64px 0;max-width:600px; }
813
+ .terminal-loading h1 { font-size:26px;letter-spacing:-.035em;font-weight:500;margin:0 0 14px; }
814
+ .terminal-loading p { color:var(--muted);font-size:14px;line-height:1.6;margin-bottom:28px; }
815
+ .terminal-loading progress { max-width:260px;margin-bottom:16px; }
816
+ .terminal-loading>span { color:var(--muted);font-size:12px; }
817
+ @media(max-width:700px) { .index-progress { grid-template-columns:1fr auto;gap:14px; }.index-progress progress { grid-column:1 / -1;grid-row:2; }.index-progress small { line-height:1.5; } }
@@ -5,7 +5,6 @@ import { startTerminal } from './server.mjs';
5
5
  import { createCollector, loadConfig } from './collect.mjs';
6
6
  import { sampleSnapshot } from './sample.mjs';
7
7
  import { openBrowser } from './browser.mjs';
8
- import { indexDiagnostics, indexRepository } from './repository-index.mjs';
9
8
 
10
9
  const help = `OpenMerit · Inference Terminal
11
10
 
@@ -50,7 +49,7 @@ async function main() {
50
49
  if (!(await stat(root)).isDirectory()) throw new Error('Choose a project directory.');
51
50
  const config = demo ? null : await loadConfig(root);
52
51
  if (config && !discovery) config.discovery = false;
53
- if (diagnostics) { const index = !demo && config.discovery ? await indexRepository(root) : null; console.log(JSON.stringify(indexDiagnostics(index), null, 2)); return; }
52
+ if (diagnostics) { const {indexRepository,indexDiagnostics}=await import('./repository-index.mjs'); const index = !demo && config.discovery ? await indexRepository(root) : null; console.log(JSON.stringify(indexDiagnostics(index), null, 2)); return; }
54
53
  if (json) { console.log(JSON.stringify(demo ? sampleSnapshot() : await createCollector(root, config, interval)(), null, 2)); return; }
55
54
  const terminal = await startTerminal({ root, port, interval, demo, config, fallbackPort: !explicitPort });
56
55
  console.log(`\n OpenMerit · Inference Terminal\n\n ${terminal.url}\n\n ${demo ? 'Sample environment · fictional data' : 'Current project · passive collection'}\n Updates every ${interval}s. Press Ctrl+C to close.\n`);
@@ -3,7 +3,7 @@ import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { historyExtent } from './history.mjs';
5
5
  import { discoverProject, discoveryCandidates } from './discovery.mjs';
6
- import { indexRepository } from './repository-index.mjs';
6
+ import { createBackgroundIndex } from './repository-background.mjs';
7
7
  import { createProjectActivity } from './project-activity.mjs';
8
8
  import { reconcileActivity } from './activity-merge.mjs';
9
9
  import { modelCreators } from './services.mjs';
@@ -230,7 +230,9 @@ async function ollamaSnapshot(endpoint, sourceId = 'ollama', provider = 'Ollama'
230
230
 
231
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
232
 
233
- export function createCollector(root, config, interval = 15) {
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;
234
236
  const activityReader = new JsonlReader(root, resolve(root, config.activityFile));
235
237
  const auditReader = new JsonlReader(root, resolve(root, '.openmerit/events.jsonl'));
236
238
  let previousCpu = null;
@@ -239,9 +241,14 @@ export function createCollector(root, config, interval = 15) {
239
241
  const projectActivity = createProjectActivity(root, normalizeRecord, { exclude: [resolve(root, config.activityFile)] });
240
242
  let projectDiscovery = { sources: [], inventory: [], runtimes: [] }, repository = null, discoveredAt = 0;
241
243
  const knownRuntimes = new Map();
242
- return async function collect() {
244
+ const collect = async function collect() {
243
245
  const scan = Boolean(config.discovery) && Date.now() - discoveredAt >= 60_000;
244
- if (scan) { [projectDiscovery, repository] = await Promise.all([discoverProject(root), indexRepository(root)]); discoveredAt = Date.now(); }
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
+ }
245
252
  const allCandidates = config.discovery ? discoveryCandidates(projectDiscovery, config.runtimes, config.ollama, Infinity).filter(runtime => !(config.ollamaDisabled && runtime.type === 'ollama')) : [];
246
253
  const candidates = allCandidates.slice(0, 8);
247
254
  const discoveryLimits = collectionLimits({ endpoints: allCandidates }, { endpoints: 8 });
@@ -280,12 +287,6 @@ export function createCollector(root, config, interval = 15) {
280
287
  const key = `${item.provider}:${item.model}`;
281
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 });
282
289
  }
283
- for (const item of repository?.models ?? []) {
284
- const key = `${item.provider}:${item.name}`;
285
- const existing = models.get(key);
286
- if (!existing) models.set(key, item);
287
- else existing.declarationIds = [...new Set([...(existing.declarationIds ?? []), ...item.declarationIds])];
288
- }
289
290
  const cpu = cpuTotals();
290
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;
291
292
  previousCpu = cpu;
@@ -301,7 +302,7 @@ export function createCollector(root, config, interval = 15) {
301
302
  ];
302
303
  if (config.discovery) {
303
304
  sources.push(...projectDiscovery.sources);
304
- if (repository) sources.push({ id: 'repository', name: 'Repository index', kind: 'Code & configuration', state: repository.summary.truncated || repository.issues.length ? 'partial' : 'connected', detail: `${repository.summary.filesRead} files · ${repository.references.length} declarations`, 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.' });
305
+
305
306
  sources.push(...logs.sources);
306
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() });
307
308
  }
@@ -330,19 +331,40 @@ export function createCollector(root, config, interval = 15) {
330
331
  const residentModels = residentRuntimes.flatMap(runtime => runtime.models).filter(model => model.state === 'loaded');
331
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}` })) };
332
333
  const hosts = [...(localComputeLinked ? [machine] : []), ...retainHostHistory(hardware.hosts, hostHistories)];
333
- const inventory = [...config.inventory.filter(item => item.kind !== 'model'), ...projectDiscovery.inventory, ...(repository?.inventory ?? [])].map(item => ({ ...item, sourceId: item.sourceId || 'inventory' }));
334
+ const inventory = [...config.inventory.filter(item => item.kind !== 'model'), ...projectDiscovery.inventory].map(item => ({ ...item, sourceId: item.sourceId || 'inventory' }));
334
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' })))];
335
336
  const historySources = sources.filter(source => source.id === 'activity' || source.id.startsWith('project-log:') || source.id === 'project-log-coverage');
336
337
  const gaps = historySources.filter(source => source.state === 'partial' || source.state === 'unavailable' || source.truncated || source.invalid);
337
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) };
338
- return {
339
+ const snapshot = {
339
340
  schemaVersion: 1, mode: 'live', observedAt: now, interval, history,
340
- repository: repository ? { references: repository.references, issues: repository.issues, summary: repository.summary, checkedAt: repository.checkedAt } : null,
341
+ repository: null,
341
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 } },
342
343
  project: { name: basename(root), scope: 'Current project' },
343
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' }))],
344
345
  records, taskSummaries, sources, deployments, capacityPools, runtimeStats: runtimes.map(runtime => runtime.stats).filter(Boolean),
345
346
  machine, hosts,
346
347
  };
348
+ return background?snapshot:withRepository(snapshot,repository);
347
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}};
348
370
  }
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Worker } from 'node:worker_threads';
3
+ import { emptyIndexSummary } from './repository-policy.mjs';
4
+
5
+ // The HTTP server never parses project code. A failed worker leaves the last
6
+ // usable index visible, explicitly marked incomplete, and retries next cycle.
7
+ export function createBackgroundIndex(root,{intervalMs=60_000,limits}={}) {
8
+ let worker=null,timer=null,closed=false,started=false,revision=0;
9
+ const session=randomUUID(),nextRevision=()=>`${session}:${++revision}`;
10
+ let current={references:[],issues:[],models:[],inventory:[],summary:emptyIndexSummary(),checkedAt:null,revision:`${session}:0`,progress:{phase:'queued',processed:0,total:null,discovered:0,parsed:0,reused:0,elapsedMs:0}};
11
+ const schedule=()=>{if(!closed){clearTimeout(timer);timer=setTimeout(run,intervalMs);timer.unref();}};
12
+ const failed=()=>{if(closed)return;current={...current,revision:nextRevision(),progress:{...current.progress,phase:'failed'}};schedule();};
13
+ function run() {
14
+ if(closed)return;
15
+ if(!worker) {
16
+ worker=new Worker(new URL('./repository-worker.mjs',import.meta.url),{workerData:{root,limits},resourceLimits:{maxOldGenerationSizeMb:256},execArgv:process.execArgv.filter(arg=>!arg.startsWith('--input-type'))});
17
+ worker.on('message',message=>{
18
+ if(closed)return;
19
+ if(message.type==='failed'){failed();return;}
20
+ if(message.type==='complete') {
21
+ current={...message.index,revision:nextRevision()};schedule();
22
+ }else {
23
+ current=message.index?{...message.index,revision:nextRevision()}:{...current,progress:message.progress};
24
+ }
25
+ });
26
+ worker.on('error',()=>{ /* Exit handles termination and the retry. */ });
27
+ worker.on('exit',()=>{worker=null;failed();});
28
+ worker.unref();
29
+ }
30
+ worker.postMessage('scan');
31
+ }
32
+ return {
33
+ start(){if(!started&&!closed){started=true;run();}},
34
+ get(){return current;},
35
+ async close(){closed=true;clearTimeout(timer);const active=worker;worker=null;if(active)await active.terminate();},
36
+ };
37
+ }
@@ -1,17 +1,19 @@
1
- import { opendir, lstat, open, realpath } from 'node:fs/promises';
1
+ import { opendir, open, realpath } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { join, relative, dirname, extname, sep } from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { isIP } from 'node:net';
6
6
  import { parseCode, parseConfig } from './repository-syntax.mjs';
7
7
  import { endpointService } from './service-registry.mjs';
8
+ import { setImmediate as yieldThread } from 'node:timers/promises';
9
+ import { INDEX_LIMITS, emptyIndexSummary } from './repository-policy.mjs';
10
+ export { INDEX_LIMITS } from './repository-policy.mjs';
8
11
 
9
- export const INDEX_LIMITS = Object.freeze({ files:400, entries:8000, bytes:4*1024*1024, fileBytes:128*1024, depth:8, declarations:2000, issues:100 });
10
12
  const excluded = new Set(['node_modules','vendor','dist','build','out','coverage','target','venv','env','__pycache__','site-packages','logs','runs','results']);
11
13
  const extensions = new Set(['.py','.js','.jsx','.ts','.tsx','.mjs','.cjs','.json','.yaml','.yml','.toml']);
12
14
  const privateFile = /(?:^|[._-])(?:credentials?|secrets?|tokens?|passwords?|auth)(?:[._-]|$)|(?:^|\/)id_(?:rsa|ed25519)/i;
13
15
  const digest = value=>createHash('sha256').update(value).digest('hex').slice(0,24);
14
- const safeName = value=>typeof value==='string' && value.length<=160 && !/[\x00-\x1f\x7f]/.test(value) && !/(?:sk[-_]|Bearer\s|(?:api[_-]?key|token|secret|password)\s*[:=])/i.test(value);
16
+ const safeName = value=>typeof value==='string' && value.length<=160 && !/[\x00-\x1f\x7f]/.test(value) && !/(?:\bsk[-_]|Bearer\s|(?:api[_-]?key|token|secret|password)\s*[:=])/i.test(value);
15
17
  const modelValue = value=>safeName(value) && /^[A-Za-z0-9][A-Za-z0-9._:/+@-]*$/.test(value) && !value.includes('://');
16
18
  const contextFor = file=> /(?:^|\/)(?:benchmarks?|evals?|backtests?)(?:\/|[._-])|(?:benchmark|backtest)/i.test(file)?'benchmark':/(?:^|\/)(?:tests?|fixtures?)(?:\/|[._-])|[._](?:test|spec)\./i.test(file)?'test':/(?:^|\/)(?:examples?|samples?)(?:\/|[._-])|(?:^|\/)\.env\.(?:example|sample|template)/i.test(file)?'example':'application';
17
19
  const envFile = file=>/(?:^|\/)\.env(?:\.[\w.-]+)?$/.test(file);
@@ -39,70 +41,158 @@ export function endpointIdentity(value) {
39
41
  export function indexDiagnostics(index) {
40
42
  // Sharing is opt-in by copying this output. Deliberately exclude paths,
41
43
  // names, URLs, values, environment variables and source contents.
42
- const counts={};for(const issue of index?.issues??[])counts[issue.code]=(counts[issue.code]||0)+1;
44
+ const counts=index?.summary?.issueCounts??{};
43
45
  return {schemaVersion:1,scope:'repository-index',enabled:Boolean(index),summary:index?.summary??null,issueCounts:counts,limits:INDEX_LIMITS};
44
46
  }
45
- export async function indexRepository(root, limits=INDEX_LIMITS) {
46
- const canonical=await realpath(root), files=[], issues=[], parsed=[], references=[];
47
- const summary={filesRead:0,bytesRead:0,entriesVisited:0,filesByFormat:{},skipped:{policy:0,unsupported:0,symlink:0,oversized:0,unreadable:0},truncated:false,declarations:0,unresolved:0};
48
- const issueKeys=new Set();
49
- const issue=(code,file,line=1,field)=>{const key=JSON.stringify([code,file,line,field]);if(issueKeys.has(key))return;issueKeys.add(key);summary.unresolved++;if(issues.length<limits.issues)issues.push({code,...(file?{file}:{}),line,...(field&&safeName(field)?{field}: {})});};
50
- async function scan(dir,depth) {
51
- let directory;
52
- try {directory=await opendir(dir);for await(const entry of directory){
53
- if(++summary.entriesVisited>limits.entries){summary.truncated=true;break;}
54
- if(entry.isSymbolicLink()){summary.skipped.symlink++;continue;}
55
- const isEnv=/^\.env(?:\.[\w.-]+)?$/.test(entry.name);
56
- if(entry.name.startsWith('.')&&!isEnv||excluded.has(entry.name)||privateFile.test(entry.name)||/(?:package-lock|pnpm-lock|yarn\.lock|\.min\.|\.d\.ts$)/.test(entry.name)){summary.skipped.policy++;continue;}
57
- const path=join(dir,entry.name);
58
- if(entry.isDirectory()){
59
- if(depth>=limits.depth){summary.truncated=true;continue;}
60
- const st=await lstat(path);if(st.isSymbolicLink()||await realpath(path)!==path){summary.skipped.symlink++;continue;}
61
- await scan(path,depth+1);
62
- }else if(entry.isFile()){
63
- if(isEnv||extensions.has(extname(entry.name)))files.push(path);else summary.skipped.unsupported++;
64
- }
65
- if(summary.entriesVisited>=limits.entries){summary.truncated=true;break;}
66
- }}catch{summary.skipped.unreadable++;issue('unreadable-directory',relative(canonical,dir)||'.');}
67
- }
68
- await scan(canonical,0);
69
- // Deterministic selection prefers environment/configuration before source.
70
- files.sort((a,b)=>Number(!envFile(a))-Number(!envFile(b))||a.localeCompare(b));
71
- if(files.length>limits.files)summary.truncated=true;
72
- for(const path of files.slice(0,limits.files)) {
73
- const file=relative(canonical,path);let handle;
74
- try{
75
- if(await realpath(path)!==path || !path.startsWith(canonical+sep)){summary.skipped.symlink++;continue;}
76
- handle=await open(path,constants.O_RDONLY|(constants.O_NOFOLLOW||0)|(constants.O_NONBLOCK||0));const stat=await handle.stat();
77
- if(!stat.isFile()){summary.skipped.policy++;continue;}
78
- if(stat.size>limits.fileBytes){summary.skipped.oversized++;issue('oversized-file',file);continue;}
79
- if(summary.bytesRead+stat.size>limits.bytes){summary.truncated=true;continue;}
80
- const buffer=Buffer.alloc(Math.min(limits.fileBytes+1,stat.size+1)),{bytesRead}=await handle.read(buffer,0,buffer.length,0);
81
- if(bytesRead>limits.fileBytes){summary.skipped.oversized++;issue('oversized-file',file);continue;}
82
- summary.filesRead++;summary.bytesRead+=bytesRead;
83
- const body=buffer.subarray(0,bytesRead).toString('utf8');
84
- if(body.includes('\0')){issue('binary-file',file);continue;}
85
- const code=/\.(?:py|[cm]?js|jsx|tsx?)$/.test(file),format=envFile(file)?'dotenv':extname(file).slice(1);
86
- summary.filesByFormat[format]=(summary.filesByFormat[format]||0)+1;
87
- const result=code?parseCode(body,file):parseConfig(body,file);
88
- for(const i of result.issues)issue(i.code,file,i.line,i.field);
89
- // Parser input is released after each file; only narrow metadata survives.
90
- parsed.push({file,context:contextFor(file),entries:result.entries});
91
- }catch{summary.skipped.unreadable++;issue('unreadable-file',file);}
92
- finally{await handle?.close();}
47
+ // Cache only parser metadata, never source text, and never write into the project.
48
+ // Endpoint values are sanitized before entering this process-local cache.
49
+ function cacheEntry(entry) {
50
+ const result={...entry};
51
+ if (entry.endpoints) result.endpoints=entry.endpoints.map(cacheEntry);
52
+ if (typeof entry.value==='string' && (entry.kind==='endpoint' || /^https?:\/\//i.test(entry.value))) {
53
+ result.endpointValue=endpointIdentity(entry.value) || {invalid:true};
54
+ result.value=result.endpointValue.address || '';
93
55
  }
56
+ return result;
57
+ }
58
+ function reporter(summary, issues, limit) {
59
+ const seen=new Set();
60
+ return (code,file,line=1,field)=>{
61
+ const key=JSON.stringify([code,file,line,field]);if(seen.has(key))return;seen.add(key);
62
+ summary.unresolved++;summary.issueCounts[code]=(summary.issueCounts[code]||0)+1;
63
+ if(issues.length<limit)issues.push({code,...(file?{file}:{}),line,...(field&&safeName(field)?{field}:{})});
64
+ };
65
+ }
66
+ export function createRepositoryScanner(root, options={}) {
67
+ const limits={...INDEX_LIMITS,...options};
68
+ const cache=new Map();let running=false,scanId=0;
69
+ return async function scanRepository({onProgress=()=>{},signal}={}) {
70
+ if(running)throw new Error('Repository scan already running');
71
+ running=true;
72
+ const started=performance.now(),summary=emptyIndexSummary(),scanIssues=[],parsed=[],files=[],seen=new Set();
73
+ const issue=reporter(summary,scanIssues,limits.issues);
74
+ const progress={phase:'discovering',scan:++scanId,discovered:0,processed:0,parsed:0,reused:0,batches:0,total:null,elapsedMs:0};
75
+ let environmentFiles=Infinity;
76
+ let lastPublished=0,batchFiles=0,batchBytes=0,batchStart=performance.now();
77
+ const check=()=>signal?.throwIfAborted();
78
+ const status=()=>({...progress,elapsedMs:Math.round(performance.now()-started)});
79
+ const publish=async (force=false,results=false)=>{
80
+ check();
81
+ if(!force&&performance.now()-lastPublished<limits.publishMs)return;
82
+ lastPublished=performance.now();
83
+ // On a first scan, usable declarations arrive in batches. Later scans
84
+ // retain the last complete index until replacement, avoiding flicker.
85
+ const index=results?projectIndex(parsed,summary,scanIssues,limits):null;
86
+ await onProgress({progress:status(),summary:{...summary},...(index?{index:{...index,progress:status()}}:{})});
87
+ };
88
+ const batch=async()=>{
89
+ if(batchFiles<limits.batchFiles&&batchBytes<limits.batchBytes&&performance.now()-batchStart<limits.batchMs)return;
90
+ progress.batches++;await publish(false,progress.phase==='indexing'&&progress.processed>=environmentFiles&&scanId===1);
91
+ await yieldThread();check();batchFiles=0;batchBytes=0;batchStart=performance.now();
92
+ };
93
+ try {
94
+ const canonical=await realpath(root),directories=[canonical];
95
+ await publish(true);
96
+ // Iterative traversal has no depth/entry ceiling and does not consume
97
+ // the JS call stack. Symlinks and excluded directories remain excluded.
98
+ while(directories.length) {
99
+ check();const dir=directories.pop();
100
+ try {
101
+ if(await realpath(dir)!==dir){summary.skipped.symlink++;continue;}
102
+ const directory=await opendir(dir);
103
+ for await(const entry of directory) {
104
+ check();summary.entriesVisited++;
105
+ const isEnv=/^\.env(?:\.[\w.-]+)?$/.test(entry.name);
106
+ if(entry.isSymbolicLink())summary.skipped.symlink++;
107
+ else if(entry.name.startsWith('.')&&!isEnv||excluded.has(entry.name)||privateFile.test(entry.name)||/(?:package-lock|pnpm-lock|yarn\.lock|\.min\.|\.d\.ts$)/.test(entry.name))summary.skipped.policy++;
108
+ else {
109
+ const path=join(dir,entry.name);
110
+ if(entry.isDirectory())directories.push(path);
111
+ else if(entry.isFile()) {
112
+ if(isEnv||extensions.has(extname(entry.name))){files.push(path);progress.discovered++;}
113
+ else summary.skipped.unsupported++;
114
+ }
115
+ }
116
+ // Walking a tree full of unsupported files is still cooperative.
117
+ batchFiles++;await batch();
118
+ }
119
+ }catch(error){check();summary.skipped.unreadable++;issue('unreadable-directory',relative(canonical,dir)||'.');}
120
+ }
121
+ const priority=path=>envFile(path)?0:/\.(?:json|ya?ml|toml)$/.test(path)?1:/(?:inference|llm|model|provider|client|agent|router)/i.test(path)?2:3;
122
+ files.sort((a,b)=>priority(a)-priority(b)||a.localeCompare(b));
123
+ environmentFiles=files.filter(envFile).length;
124
+ progress.phase='indexing';progress.total=files.length;await publish(true);
125
+ for(const path of files) {
126
+ check();const file=relative(canonical,path);let handle;
127
+ try {
128
+ if(await realpath(path)!==path||!path.startsWith(canonical+sep)){summary.skipped.symlink++;continue;}
129
+ handle=await open(path,constants.O_RDONLY|(constants.O_NOFOLLOW||0)|(constants.O_NONBLOCK||0));
130
+ const stat=await handle.stat({bigint:true});
131
+ if(!stat.isFile()){summary.skipped.policy++;continue;}
132
+ if(stat.size>BigInt(limits.fileBytes)){summary.skipped.oversized++;issue('oversized-file',file);continue;}
133
+ const fingerprint=[stat.dev,stat.ino,stat.size,stat.mtimeNs,stat.ctimeNs].join(':');
134
+ seen.add(file);
135
+ const cached=cache.get(file);
136
+ let item;
137
+ if(cached?.fingerprint===fingerprint){item=cached;progress.reused++;}
138
+ else {
139
+ const buffer=Buffer.alloc(Math.min(limits.fileBytes+1,Number(stat.size)+1));
140
+ const {bytesRead}=await handle.read(buffer,0,buffer.length,0);
141
+ summary.filesRead++;summary.bytesRead+=bytesRead;batchBytes+=bytesRead;
142
+ if(bytesRead>limits.fileBytes){summary.skipped.oversized++;issue('oversized-file',file);seen.delete(file);continue;}
143
+ const after=await handle.stat({bigint:true});
144
+ if([after.dev,after.ino,after.size,after.mtimeNs,after.ctimeNs].join(':')!==fingerprint){issue('changed-during-read',file);seen.delete(file);continue;}
145
+ const body=buffer.subarray(0,bytesRead).toString('utf8');
146
+ const hash=digest(body);
147
+ if(cached?.hash===hash){item={...cached,fingerprint};progress.reused++;}
148
+ else {
149
+ const code=/\.(?:py|[cm]?js|jsx|tsx?)$/.test(file);
150
+ const result=body.includes('\0')?{entries:[],issues:[{code:'binary-file'}]}:code?parseCode(body,file):parseConfig(body,file);
151
+ // Dotenv bindings are read each scan and discarded after projection;
152
+ // arbitrary unrelated setting values never enter the cache.
153
+ item={file,context:contextFor(file),fingerprint,hash,bytes:bytesRead,entries:result.entries.map(cacheEntry),issues:result.issues};
154
+ progress.parsed++;
155
+ }
156
+ if(!envFile(file))cache.set(file,item);
157
+ }
158
+ parsed.push(item);summary.filesIndexed++;summary.bytesIndexed+=item.bytes;
159
+ const format=envFile(file)?'dotenv':extname(file).slice(1);
160
+ summary.filesByFormat[format]=(summary.filesByFormat[format]||0)+1;
161
+ for(const note of item.issues)issue(note.code,file,note.line,note.field);
162
+ }catch(error){check();seen.delete(file);summary.skipped.unreadable++;issue('unreadable-file',file);}
163
+ finally {await handle?.close();progress.processed++;batchFiles++;await batch();}
164
+ }
165
+ for(const file of cache.keys())if(!seen.has(file))cache.delete(file);
166
+ progress.phase='resolving';await publish(true);
167
+ const index=projectIndex(parsed,summary,scanIssues,limits);
168
+ progress.phase='complete';progress.batches++;
169
+ return {...index,progress:status()};
170
+ }finally {running=false;}
171
+ };
172
+ }
173
+ export async function indexRepository(root,limits=INDEX_LIMITS) {
174
+ return createRepositoryScanner(root,limits)();
175
+ }
176
+ function projectIndex(parsed,scanSummary,scanIssues,limits) {
177
+ const summary={...scanSummary,unresolved:0,issueCounts:{}},issues=[],references=[];
178
+ const issue=reporter(summary,issues,limits.issues);
179
+ // Preserve total issue counts even when the detailed list is bounded.
180
+ for(const note of scanIssues)issue(note.code,note.file,note.line,note.field);
181
+ summary.unresolved=scanSummary.unresolved;summary.issueCounts={...scanSummary.issueCounts};
94
182
  const envValues=[];
95
- for(const item of parsed.filter(p=>envFile(p.file)&&p.context==='application'))for(const e of item.entries)if(e.environmentDeclaration&&e.value)envValues.push({...e,file:item.file});
183
+ for(const item of parsed.filter(p=>envFile(p.file)&&p.context==='application'))for(const e of item.entries)if(e.environmentDeclaration&&(e.value||e.endpointValue))envValues.push({...e,file:item.file});
184
+ const environment=new Map();
185
+ for(const e of envValues){const key=JSON.stringify([dirname(e.file),e.field]);if(!environment.has(key))environment.set(key,[]);environment.get(key).push(e);}
96
186
  const resolveEnv=(entry,file)=>{
97
187
  if(entry.value!==undefined)return [entry];
98
188
  if(!entry.env)return [];
99
189
  let dir=dirname(file);
100
190
  for(;;){
101
- const matching=envValues.filter(e=>e.field===entry.env&&dirname(e.file)===dir);
191
+ const matching=environment.get(JSON.stringify([dir,entry.env]))||[];
102
192
  if(matching.length){
103
- const unique=[...new Set(matching.map(e=>e.value))];
193
+ const unique=[...new Set(matching.map(e=>JSON.stringify([e.value,e.endpointValue])))];
104
194
  if(unique.length!==1){issue('ambiguous-environment',file,entry.line,entry.field);return [];}
105
- return [{...entry,value:unique[0],basis:'environment-reference',via:{file:matching[0].file,line:matching[0].line}}];
195
+ return [{...entry,value:matching[0].value,...(matching[0].endpointValue?{endpointValue:matching[0].endpointValue}:{}),basis:'environment-reference',via:{file:matching[0].file,line:matching[0].line}}];
106
196
  }
107
197
  if(dir==='.')break;dir=dirname(dir);
108
198
  }
@@ -118,10 +208,10 @@ export async function indexRepository(root, limits=INDEX_LIMITS) {
118
208
  let value=entry.value,endpoint;
119
209
  if(kind==='binding')return null;
120
210
  if(kind==='endpoint') {
121
- endpoint=endpointIdentity(value);
211
+ endpoint=entry.endpointValue??endpointIdentity(value);
122
212
  if(endpoint?.nonInference){issue('non-inference-endpoint',item.file,line,field);return null;}
123
213
  if(endpoint?.placeholder){issue('placeholder-endpoint',item.file,line,field);return null;}
124
- if(!endpoint){issue('unsupported-endpoint',item.file,line,field);return null;}
214
+ if(!endpoint||endpoint.invalid){issue('unsupported-endpoint',item.file,line,field);return null;}
125
215
  value=endpoint.address;
126
216
  }else if(!(kind==='task' ? safeName(value) && value.trim() && !value.includes('://') : modelValue(value))){
127
217
  issue(value?.includes('*')?'model-pattern':'unsupported-value',item.file,line,field);return null;
@@ -131,7 +221,6 @@ export async function indexRepository(root, limits=INDEX_LIMITS) {
131
221
  const previous=unique.get(id);
132
222
  const result={id,kind,value,file:item.file,line,field,basis,context,...(entry.via?{via:entry.via}:{}),...(endpoint?{endpoint}:{}),...(entry.localLibrary?{localLibrary:true}:{}),...extra};
133
223
  if(previous){Object.assign(previous,extra);return previous;}
134
- if(unique.size>=limits.declarations){summary.truncated=true;return null;}
135
224
  unique.set(id,result);return result;
136
225
  }
137
226
  for(const item of parsed){
@@ -153,7 +242,6 @@ export async function indexRepository(root, limits=INDEX_LIMITS) {
153
242
  const resolvedSites=new Set([...unique.values()].filter(r=>r.context==='application').map(r=>[r.kind,r.file,r.line,r.value].join('|')));
154
243
  references.push(...[...unique.values()].filter(r=>r.context!=='unlinked'||!resolvedSites.has([r.kind,r.file,r.line,r.value].join('|'))));
155
244
  summary.declarations=references.length;
156
- if(summary.truncated)issue('scan-limit');
157
245
  const inventory=[],models=[], byService=new Map();
158
246
  for(const ref of references.filter(r=>r.kind==='endpoint'&&r.context==='application')) {
159
247
  const endpoint=ref.endpoint,name=endpoint.service;
@@ -162,13 +250,18 @@ export async function indexRepository(root, limits=INDEX_LIMITS) {
162
250
  }
163
251
  inventory.push(...byService.values());
164
252
  const byId=new Map(references.map(r=>[r.id,r]));
253
+ const linkedModels=new Set(references.filter(r=>r.kind==='model'&&r.context==='application'&&r.endpointIds?.length).map(r=>`${r.file}:${r.value}`));
254
+ const byModel=new Map();
165
255
  for(const ref of references.filter(r=>r.kind==='model'&&r.context==='application')) {
166
- if (!ref.endpointIds?.length && references.some(other => other.kind==='model' && other.context==='application' && other.file===ref.file && other.value===ref.value && other.endpointIds?.length)) continue;
256
+ if(!ref.endpointIds?.length&&linkedModels.has(`${ref.file}:${ref.value}`))continue;
167
257
  const endpoints=(ref.endpointIds||[]).map(id=>byId.get(id)).filter(r=>r?.context==='application').map(r=>r.endpoint);
168
258
  // Multiple alternatives do not establish which service supplies this model.
169
259
  const services=[...new Set(endpoints.map(e=>e.service))];
170
260
  const provider=services.length===1?services[0]:'Unattributed';
171
- models.push({id:`declaration:${ref.id}`,name:ref.value,provider,location:'unknown',state:'declared',sourceId:'repository',declarationIds:[ref.id],memoryBytes:null});
261
+ const key=JSON.stringify([provider,ref.value]);
262
+ if(byModel.has(key))byModel.get(key).declarationIds.push(ref.id);
263
+ else byModel.set(key,{id:`declaration:${ref.id}`,name:ref.value,provider,location:'unknown',state:'declared',sourceId:'repository',declarationIds:[ref.id],memoryBytes:null});
172
264
  }
265
+ models.push(...byModel.values());
173
266
  return {references,issues,summary,inventory,models,checkedAt:new Date().toISOString()};
174
267
  }
@@ -0,0 +1,3 @@
1
+ // Work budgets govern each batch, not the total repository coverage.
2
+ export const INDEX_LIMITS=Object.freeze({batchFiles:32,batchBytes:1024*1024,batchMs:40,publishMs:500,fileBytes:128*1024,issues:100});
3
+ export const emptyIndexSummary=()=>({filesRead:0,filesIndexed:0,bytesRead:0,bytesIndexed:0,entriesVisited:0,filesByFormat:{},skipped:{policy:0,unsupported:0,symlink:0,oversized:0,unreadable:0},truncated:false,declarations:0,unresolved:0,issueCounts:{}});
@@ -0,0 +1,15 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { createRepositoryScanner } from './repository-index.mjs';
3
+ const scan=createRepositoryScanner(workerData.root,workerData.limits);
4
+ let busy=false;
5
+ parentPort.on('message',async message=>{
6
+ if(message!=='scan'||busy)return;
7
+ busy=true;
8
+ try {
9
+ const index=await scan({onProgress:update=>parentPort.postMessage({type:'progress',...update})});
10
+ parentPort.postMessage({type:'complete',index});
11
+ }catch {
12
+ // Do not send exception messages: filesystem errors can contain paths.
13
+ parentPort.postMessage({type:'failed'});
14
+ }finally {busy=false;}
15
+ });
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { basename } from 'node:path';
4
5
  import { createCollector, loadConfig } from './collect.mjs';
5
6
  import { sampleSnapshot } from './sample.mjs';
6
7
 
@@ -8,16 +9,16 @@ const assets = new Map([['/', ['index.html', 'text/html']], ['/app.js', ['app.js
8
9
  for (const file of ['baseten.png', 'groq.ico', 'cerebras.png', 'qwen.png', 'meta.ico', 'nomic.png', 'baai.png', 'ollama.png', 'lm-studio.png', 'vllm.png', 'anthropic.png', 'google.png', 'deepseek.ico', 'cohere.png', 'openai.png', 'mistral.png', 'openrouter.png', 'huggingface.svg']) assets.set(`/brands/${file}`, [`brands/${file}`, file.endsWith('.svg') ? 'image/svg+xml' : file.endsWith('.ico') ? 'image/x-icon' : 'image/png']);
9
10
 
10
11
  export async function startTerminal({ root = process.cwd(), port = 4310, interval = 15, demo = false, collect: customCollect, config, fallbackPort = false } = {}) {
11
- const collect = customCollect ?? (demo ? async () => sampleSnapshot() : createCollector(root, config ?? await loadConfig(root), interval));
12
- let snapshot = await collect();
13
- let busy = false;
14
- const timer = setInterval(async () => {
15
- if (busy) return;
16
- busy = true;
17
- try { snapshot = await collect(); } catch { /* Keep timestamp: the interface marks stale data. */ }
18
- finally { busy = false; }
19
- }, interval * 1000);
20
- timer.unref();
12
+ const collect = customCollect ?? (demo ? async () => sampleSnapshot() : createCollector(root, config ?? await loadConfig(root), interval, {backgroundIndex:true}));
13
+ let snapshot={schemaVersion:1,mode:demo?'sample':'live',loading:true,observedAt:null,interval,project:{name:basename(root),scope:'Current project'},models:[],records:[],sources:[],inventory:[],hosts:[],deployments:[],capacityPools:[],taskSummaries:[]};
14
+ let busy=false,closed=false,timer;
15
+ const update=async()=>{
16
+ if(busy||closed)return;
17
+ busy=true;
18
+ try {const next=await collect();if(!closed)snapshot=next;}
19
+ catch {if(snapshot.loading)snapshot={...snapshot,loadError:true};}
20
+ finally {busy=false;}
21
+ };
21
22
  const server = createServer(async (request, response) => {
22
23
  const address = server.address();
23
24
  const validHosts = [`127.0.0.1:${address.port}`, `localhost:${address.port}`];
@@ -29,7 +30,17 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
29
30
  let path;
30
31
  try { path = new URL(request.url, `http://${host}`).pathname; }
31
32
  catch { return send(400, '{"error":"Invalid request URL"}'); }
32
- if (path === '/api/snapshot') return send(200, JSON.stringify(snapshot));
33
+ if (path === '/api/snapshot') {
34
+ const data=collect.current?.(snapshot)??snapshot;
35
+ const revision=new URL(request.url,`http://${host}`).searchParams.get('repositoryRevision');
36
+ // Browser refreshes can reuse the unchanged reference catalog. The plain
37
+ // API still returns a self-contained snapshot for agents and scripts.
38
+ if(data.repository&&revision!==null&&String(data.repository.revision)===revision) {
39
+ const {references,...repository}=data.repository;
40
+ return send(200,JSON.stringify({...data,repository}));
41
+ }
42
+ return send(200,JSON.stringify(data));
43
+ }
33
44
  if (path === '/api/sample') return send(200, JSON.stringify(sampleSnapshot()));
34
45
  if (path === '/api/info') return send(200, JSON.stringify({ version: 1, defaultMode: demo ? 'sample' : 'live', interval }));
35
46
  const asset = assets.get(path);
@@ -37,7 +48,7 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
37
48
  try { return send(200, await readFile(fileURLToPath(new URL(`../public/${asset[0]}`, import.meta.url))), asset[1]); }
38
49
  catch { return send(500, '{"error":"Terminal assets unavailable. Run npm run build."}'); }
39
50
  });
40
- server.on('close', () => clearInterval(timer));
51
+ server.on('close', () => { closed=true;clearInterval(timer);void collect.close?.(); });
41
52
  const listen = selectedPort => new Promise((resolve, reject) => {
42
53
  const onError = error => { server.off('listening', onListening); reject(error); };
43
54
  const onListening = () => { server.off('error', onError); resolve(); };
@@ -51,6 +62,9 @@ export async function startTerminal({ root = process.cwd(), port = 4310, interva
51
62
  await listen(0);
52
63
  }
53
64
  }
54
- catch (error) { clearInterval(timer); throw error; }
55
- return { server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(resolve => { clearInterval(timer); server.close(resolve); server.closeIdleConnections(); }) };
65
+ catch (error) { closed=true;clearInterval(timer);await collect.close?.();throw error; }
66
+ // Listen first. The browser can paint while collection and indexing proceed.
67
+ void update();
68
+ timer=setInterval(update,interval*1000);timer.unref();
69
+ return { server, url: `http://127.0.0.1:${server.address().port}`, close: async () => { closed=true;clearInterval(timer);await collect.close?.();await new Promise(resolve=>{server.close(resolve);server.closeIdleConnections();}); } };
56
70
  }
@@ -46,7 +46,7 @@ Exposes the command surface, supplies private typed intent context, triggers Pi,
46
46
 
47
47
  The Pi package implements `HarnessAdapter`. It does not define canonical intents, per-intent result contracts, stage transitions, or validation rules.
48
48
 
49
- ### Inference Terminal (unreleased)
49
+ ### Inference Terminal (0.1.6)
50
50
 
51
51
  `packages/terminal` supplies a standalone `openmerit dash` executable, passive collectors, a loopback read-only HTTP API, and bundled dark-only browser assets. It does not invoke the coordinator or harness. One collector cycle reads existing metadata and runtime inventory; browser clients receive the same cached snapshot. Imported activity, runtime counters, and sampled whole-machine resources retain their separate scopes. Missing measurements remain unknown, and fictional sample data is isolated from the real snapshot. See the [terminal guide](inference-terminal.md) for the supported sources and JSON contract.
52
52
 
package/docs/commands.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Run these slash commands inside Pi with the OpenMerit extension loaded. Shell commands such as `pi install` run in your terminal instead.
4
4
 
5
- ## Inference Terminal (preview)
5
+ ## Inference Terminal
6
6
 
7
- The `openmerit@preview` npm package adds a shell command, `openmerit dash [path]`, for a passive local view of models, connections, usage, and compute. From this checkout, use `npm run dash -- [path]`. It is independent of Pi setup and does not run model comparisons or inference. Published `openmerit@0.1.5` does not include it.
7
+ Install `openmerit@0.1.6` with `npm install -g openmerit` to use the shell command `openmerit dash [path]`, for a passive local view of models, connections, usage, and compute. From this checkout, use `npm run dash -- [path]`. It is independent of Pi setup and does not run model comparisons or inference. Earlier `openmerit@0.1.5` does not include it.
8
8
 
9
9
  See the [Inference Terminal guide](inference-terminal.md) for local installation, automatic discovery, `--no-discovery`, `--json`, `--demo`, collection intervals, and data limitations. Discovery is part of `dash` and is scoped to application evidence. Coding-harness histories are not collected.
10
10
 
package/docs/index.md CHANGED
@@ -2,7 +2,18 @@
2
2
 
3
3
  OpenMerit helps your coding harness compare models against the work your product needs to do. You define what matters; the harness gathers measurements and tests candidates; OpenMerit coordinates the process and checks the returned results.
4
4
 
5
- The current release is intended for supervised trials with Pi. Start with a representative task and working evaluations before relying on automatic model changes.
5
+ The optional orchestration integration is intended for supervised trials with Pi. Start with a representative task and working evaluations before relying on automatic model changes.
6
+
7
+ ## View your application inference
8
+
9
+ Version `0.1.6` includes the local Inference Terminal as an early starting point for OpenMerit. Requires Node.js 22.19 or newer:
10
+
11
+ ```sh
12
+ npm install -g openmerit
13
+ openmerit dash
14
+ ```
15
+
16
+ Run it from your application's directory. See the [Inference Terminal guide](inference-terminal.md) for supported sources, recorded activity, background indexing, and data limits. Pi setup is separate and optional for terminal users.
6
17
 
7
18
  ## Start with your product
8
19