mixdog 1.0.0 → 1.0.2

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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/defaults/skills/computer-use/SKILL.md +13 -12
  3. package/src/defaults/skills/setup/SKILL.md +1 -4
  4. package/src/defaults/skills/setup/references/actions.md +6 -5
  5. package/src/defaults/skills/setup/references/surfaces.md +9 -6
  6. package/src/rules/shared/70-delivery.md +3 -0
  7. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +5 -0
  8. package/src/runtime/agent/orchestrator/config.mjs +8 -1
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +0 -1
  10. package/src/runtime/agent/orchestrator/providers/registry-errors.mjs +9 -0
  11. package/src/runtime/agent/orchestrator/providers/registry.mjs +3 -2
  12. package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +13 -5
  13. package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +3 -2
  14. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +27 -4
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +3 -1
  19. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  20. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +13 -5
  21. package/src/runtime/computer-bridge/error-recovery.mjs +1 -1
  22. package/src/runtime/shared/config-patch.mjs +2 -0
  23. package/src/runtime/shared/user-data-guard.mjs +1 -1
  24. package/src/session-runtime/config-lifecycle.mjs +11 -1
  25. package/src/session-runtime/cwd-plugins/core-memory-context.mjs +1 -1
  26. package/src/session-runtime/cwd-plugins/plugin-status.mjs +24 -4
  27. package/src/session-runtime/native-web-search/tool-shape.mjs +16 -4
  28. package/src/session-runtime/provider-models/catalog-load.mjs +18 -3
  29. package/src/session-runtime/setup-tool/executor.mjs +0 -1
  30. package/src/session-runtime/setup-tool/settings-contract.mjs +2 -13
  31. package/src/session-runtime/setup-tool/tool-defs.mjs +1 -1
  32. package/src/session-runtime/workflow-agents-api/agent-route.mjs +8 -5
  33. package/src/tui/app/doctor.mjs +78 -2
  34. package/src/tui/dist/index.mjs +1 -1
  35. package/src/workflows/default/WORKFLOW.md +0 -2
  36. package/src/workflows/headless/WORKFLOW.md +0 -2
  37. package/scripts/lib/bench-sandbox.test.mjs +0 -54
  38. package/scripts/lib/cli-args.test.mjs +0 -64
  39. package/scripts/lib/embedding-model-bench-core.test.mjs +0 -47
  40. package/scripts/lib/smoke-loop-shared.test.mjs +0 -22
  41. package/scripts/lib/trace-row.test.mjs +0 -47
  42. package/scripts/lib/trace-stats.test.mjs +0 -62
@@ -17,6 +17,18 @@ export function normalizeWebSearchAllowedDomain(site) {
17
17
  }
18
18
  }
19
19
 
20
+ // A site filter may name several domains the way a search box takes them
21
+ // ("a.com OR b.com", "a.com, b.com", "site:a.com site:b.com"); each becomes its
22
+ // own allowed domain instead of one invalid combined entry.
23
+ export function normalizeWebSearchAllowedDomains(site) {
24
+ const domains = clean(site)
25
+ .split(/[\s,|]+/)
26
+ .filter((part) => part && part.toUpperCase() !== 'OR')
27
+ .map((part) => normalizeWebSearchAllowedDomain(part.replace(/^site:/i, '')))
28
+ .filter(Boolean);
29
+ return [...new Set(domains)];
30
+ }
31
+
20
32
  export function nativeWebSearchUserLocation(locale) {
21
33
  if (!locale || typeof locale !== 'object' || Array.isArray(locale)) return null;
22
34
  const location = { type: 'approximate' };
@@ -28,7 +40,7 @@ export function nativeWebSearchUserLocation(locale) {
28
40
  }
29
41
 
30
42
  export function nativeWebSearchTool(args = {}, toolType = 'web_search', providerName = '') {
31
- const domain = normalizeWebSearchAllowedDomain(args.site);
43
+ const domains = normalizeWebSearchAllowedDomains(args.site);
32
44
  const type = clean(toolType) || 'web_search';
33
45
  const location = nativeWebSearchUserLocation(args.locale);
34
46
  if (providerName === 'gemini') {
@@ -40,19 +52,19 @@ export function nativeWebSearchTool(args = {}, toolType = 'web_search', provider
40
52
  name: 'web_search',
41
53
  max_uses: Math.max(1, Math.min(10, Number(args.maxResults) || 5)),
42
54
  };
43
- if (domain) tool.allowed_domains = [domain];
55
+ if (domains.length) tool.allowed_domains = domains;
44
56
  if (location) tool.user_location = location;
45
57
  return tool;
46
58
  }
47
59
  if (providerName === 'grok-oauth' || providerName === 'xai') {
48
60
  const tool = { type };
49
- if (domain) tool.filters = { allowed_domains: [domain] };
61
+ if (domains.length) tool.filters = { allowed_domains: domains };
50
62
  return tool;
51
63
  }
52
64
  const tool = { type };
53
65
  if (type === 'web_search') {
54
66
  tool.search_context_size = clean(args.contextSize) || 'low';
55
- if (domain) tool.filters = { allowed_domains: [domain] };
67
+ if (domains.length) tool.filters = { allowed_domains: domains };
56
68
  if (location) tool.user_location = location;
57
69
  }
58
70
  return tool;
@@ -4,6 +4,7 @@
4
4
  // catalog flattened into deduplicated rows with per-provider profiling.
5
5
  import { isSelectableLlmModel } from '../model-recency.mjs';
6
6
  import { sharedProviderCatalog } from '../provider-catalog-cache.mjs';
7
+ import { NO_ENABLED_PROVIDERS } from '../../runtime/agent/orchestrator/providers/registry-errors.mjs';
7
8
 
8
9
  export function createCatalogLoader({
9
10
  caches,
@@ -36,7 +37,9 @@ export function createCatalogLoader({
36
37
  * for. ensureProvidersReady starts the daemon-wide force refresh without
37
38
  * blocking boot; a foreground full picker load must join that refresh,
38
39
  * otherwise it can snapshot yesterday's provider cache moments before the
39
- * refresh invalidates it, leaving the UI on the stale first-open rows. */
40
+ * refresh invalidates it, leaving the UI on the stale first-open rows.
41
+ * Resolves false when no provider is enabled: a fresh install has nothing
42
+ * to load, and the registry refuses an all-disabled init by design. */
40
43
  async function prepare({ forceRefresh, loadSecrets }) {
41
44
  if (loadSecrets) {
42
45
  const secretsStartedAt = performance.now();
@@ -45,7 +48,12 @@ export function createCatalogLoader({
45
48
  profile('secrets-ready', { ms: (performance.now() - secretsStartedAt).toFixed(1) });
46
49
  }
47
50
  const providersStartedAt = performance.now();
48
- await ensureProvidersReady(config().providers || {});
51
+ try {
52
+ await ensureProvidersReady(config().providers || {});
53
+ } catch (error) {
54
+ if (error?.code === NO_ENABLED_PROVIDERS) return false;
55
+ throw error;
56
+ }
49
57
  profile('providers-ready', { ms: (performance.now() - providersStartedAt).toFixed(1) });
50
58
  const refreshStartedAt = performance.now();
51
59
  if (!forceRefresh && typeof reg().refreshProviderCatalogsOnStartup === 'function') {
@@ -55,6 +63,7 @@ export function createCatalogLoader({
55
63
  await reg().refreshCatalogs({ force: true });
56
64
  }
57
65
  profile('catalog-refresh-ready', { ms: (performance.now() - refreshStartedAt).toFixed(1) });
66
+ return true;
58
67
  }
59
68
 
60
69
  /** `request` ({ seq }) identifies the caller's load; `request.complete`
@@ -62,7 +71,13 @@ export function createCatalogLoader({
62
71
  return async function loadProviderModelsFresh({ forceRefresh = false, loadSecrets = true, request = null } = {}) {
63
72
  const startedAt = performance.now();
64
73
  profile('load:start', { forceRefresh, loadSecrets });
65
- await prepare({ forceRefresh, loadSecrets });
74
+ // No enabled provider is an empty catalog, not a failure. The request
75
+ // stays incomplete so this answer is never cached: connecting a provider
76
+ // is picked up by the next read.
77
+ if (!(await prepare({ forceRefresh, loadSecrets }))) {
78
+ profile('load:done', { ms: (performance.now() - startedAt).toFixed(1), providers: 0, rows: 0 });
79
+ return [];
80
+ }
66
81
  const ownsLoad = request !== null && request.seq === caches.providerModelsLoadSeq;
67
82
  const revision = meta.syncCatalogRevision();
68
83
  // Preparation may legitimately refresh the catalog. Carry that revision
@@ -464,7 +464,6 @@ const READ_ONLY_SETUP_ACTIONS = new Set([
464
464
  'list_models',
465
465
  'get_mcp_server',
466
466
  'read_definition',
467
- 'get_instructions',
468
467
  'search_local_models',
469
468
  'inspect_hf_model',
470
469
  'local_model_details',
@@ -30,8 +30,6 @@ export const SETUP_EXTENDED_ACTION_FIELDS = Object.freeze({
30
30
  set_appearance: 'appearance',
31
31
  save_project: 'project',
32
32
  remove_project: 'projectPath',
33
- get_instructions: 'projectPath',
34
- set_instructions: 'projectPath content expectedContent',
35
33
  revoke_linked_device: 'name',
36
34
  });
37
35
 
@@ -50,8 +48,6 @@ export const SETUP_DESKTOP_ACTIONS = Object.freeze([
50
48
  'set_appearance',
51
49
  'save_project',
52
50
  'remove_project',
53
- 'get_instructions',
54
- 'set_instructions',
55
51
  'revoke_linked_device',
56
52
  ]);
57
53
  export const SETUP_DESKTOP_DOMAINS = Object.freeze(['desktop', 'appearance', 'projects', 'connection']);
@@ -166,6 +162,7 @@ export const SETUP_EXTENDED_PROPERTIES = Object.freeze({
166
162
  }),
167
163
  desktop: object({
168
164
  keepAwake: boolean,
165
+ runInBackground: boolean,
169
166
  usagePinned: boolean,
170
167
  computerObserveOnly: boolean,
171
168
  }),
@@ -179,15 +176,7 @@ export const SETUP_EXTENDED_PROPERTIES = Object.freeze({
179
176
  zoom: { type: 'number', minimum: 0.5, maximum: 2 },
180
177
  }),
181
178
  project: object({ path: text, alias: text }, ['path']),
182
- projectPath: {
183
- type: ['string', 'null'],
184
- description: 'Exact registered Project root; null means Common Instructions (instruction actions only).',
185
- },
186
- content: { ...text, description: 'Complete new Instructions content. Preserve unrelated instructions.' },
187
- expectedContent: {
188
- ...text,
189
- description: 'Unchanged content returned by get_instructions; prevents overwriting a concurrent edit.',
190
- },
179
+ projectPath: { ...text, description: 'Exact registered Project root.' },
191
180
  });
192
181
 
193
182
  export const SETUP_HANDOFFS = Object.freeze({
@@ -204,7 +204,7 @@ export const SETUP_TOOL_DEFS = Object.freeze([
204
204
  },
205
205
  name: {
206
206
  type: 'string',
207
- description: `set_builtin_enabled: ${SETUP_BUILTIN_TOGGLE_FEATURES.join('|')}; install_builtin also accepts memory. Memory toggles use set_memory_enabled. set_first_use_approval: browser|computer; else MCP/plugin/provider id.`,
207
+ description: `set_builtin_enabled: ${SETUP_BUILTIN_TOGGLE_FEATURES.join('|')}; install_builtin also accepts memory. Memory toggles use set_memory_enabled. set_first_use_approval: browser|computer; revoke_linked_device: client id from status connection, not its display name; else MCP/plugin/provider id.`,
208
208
  },
209
209
  modelId: {
210
210
  type: 'string',
@@ -68,8 +68,10 @@ export function createAgentRouteApi(deps) {
68
68
  const requested = { ...(next || {}) };
69
69
  const stored = agentRouteFromConfig(getConfig(), id) || {};
70
70
  // Off is an explicit state, stored apart from the route so the model the
71
- // user picked survives and comes back when the agent is switched on.
72
- if (requested.disabled === true) {
71
+ // user picked survives and comes back when the agent is switched on. An
72
+ // off request that also carries a model saves that model and stays off.
73
+ const keepOff = requested.disabled === true;
74
+ if (keepOff && !(clean(requested.provider) && clean(requested.model))) {
73
75
  saveDisabled(id, true);
74
76
  return { ...stored, id, disabled: true };
75
77
  }
@@ -77,6 +79,7 @@ export function createAgentRouteApi(deps) {
77
79
  saveDisabled(id, false);
78
80
  return { ...stored, id, disabled: false, inherited: !stored.provider };
79
81
  }
82
+ delete requested.disabled;
80
83
  // Tuning-only edits must preserve the selected agent model, not silently inherit Main.
81
84
  const current = stored.provider ? stored : normalizeWorkflowRoute(resolveRoute(getConfig(), {})) || {};
82
85
  if (!hasOwn(requested, 'provider')) requested.provider = current.provider;
@@ -87,11 +90,11 @@ export function createAgentRouteApi(deps) {
87
90
  if (!clean(requested.model)) throw new Error('agent route requires provider and model');
88
91
  const routeToSave = normalizeWorkflowRoute(await resolveSelectedRoute(requested, stored));
89
92
  if (!routeToSave) throw new Error('agent route requires provider and model');
90
- // Picking a model is also the "on" switch — the two states are exclusive.
91
- const nextConfig = withAgentDisabled(getConfig(), id, false);
93
+ // Picking a model is also the "on" switch unless the request keeps it off.
94
+ const nextConfig = withAgentDisabled(getConfig(), id, keepOff);
92
95
  nextConfig.agents = { ...(nextConfig.agents || {}), [id]: routeToSave };
93
96
  saveConfigAndAdopt(canonicalizeAgentRouteStorage(nextConfig));
94
- return routeToSave;
97
+ return keepOff ? { ...routeToSave, id, disabled: true } : routeToSave;
95
98
  }
96
99
 
97
100
  return { setAgentRoute };
@@ -15,6 +15,7 @@
15
15
  import { compareSemver } from '../../runtime/shared/update-checker.mjs';
16
16
  import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
17
17
  import { hasEnabledAutomation } from '../../runtime/shared/automation-presence.mjs';
18
+ import { builtinFeatureActive } from '../../runtime/agent/orchestrator/runtime-core/builtin-features.mjs';
18
19
  import { providerRowUsable } from './provider-usable.mjs';
19
20
  import { constants as fsConstants, readFileSync } from 'node:fs';
20
21
  import { access, readdir, readFile } from 'node:fs/promises';
@@ -247,16 +248,78 @@ function reportRegistry(label) {
247
248
  const broken = active.filter(
248
249
  (entry) => entry.broken || entry.error || entry.invalid || entry.dependencyIssues?.length
249
250
  );
251
+ const missing = Array.isArray(status.missing)
252
+ ? status.missing.filter((entry) => entry.enabled !== false)
253
+ : [];
250
254
  let detail = `${active.length}/${entries.length} active${scopeDetail}`;
251
- if (!broken.length) {
255
+ const issues = [...broken, ...(label === 'plugins' ? missing : [])];
256
+ if (!issues.length) {
252
257
  row('ok', detail);
253
258
  return;
254
259
  }
255
- detail += ` · issues: ${broken.map((entry) => entry.name || entry.id).join(', ')}`;
260
+ detail += ` · issues: ${issues.map((entry) => entry.name || entry.id).join(', ')}`;
256
261
  row('warn', detail, { command: `/${label}` });
257
262
  };
258
263
  }
259
264
 
265
+ function reportLocalProvider(local, row) {
266
+ if (typeof local.installed !== 'boolean') {
267
+ row('warn', 'status unavailable');
268
+ return;
269
+ }
270
+ if (!local.installed) {
271
+ row('ok', 'not installed');
272
+ return;
273
+ }
274
+ if (local.enabled !== true) {
275
+ row('ok', 'installed · disabled');
276
+ return;
277
+ }
278
+ const fix = { command: '/providers' };
279
+ const models = Array.isArray(local.models) ? local.models.filter((model) => model.installed === true).length : 0;
280
+ const jobs = Array.isArray(local.installations) ? local.installations : [];
281
+ const latest = jobs.reduce((a, b) => (a && (a.updatedAt || 0) > (b.updatedAt || 0) ? a : b), null);
282
+ if (latest?.state === 'failed') {
283
+ row('warn', `installed · enabled · ${models} models · latest installation failed`, fix);
284
+ } else if (!models) {
285
+ row('warn', 'installed · enabled · no model installed', fix);
286
+ } else row('ok', `installed · enabled · ${models} models installed`);
287
+ }
288
+
289
+ function reportBuiltins(settings, row) {
290
+ const groups = { enabled: [], disabled: [], 'not installed': [] };
291
+ for (const id of ['git', 'office', 'tidy']) {
292
+ const entry = settings[id];
293
+ if (!entry || typeof entry.installed !== 'boolean') {
294
+ row('warn', 'status unavailable');
295
+ return;
296
+ }
297
+ if (!entry.installed) groups['not installed'].push(id);
298
+ else groups[entry.enabled === false ? 'disabled' : 'enabled'].push(id);
299
+ }
300
+ const parts = Object.entries(groups)
301
+ .filter(([, ids]) => ids.length)
302
+ .map(([state, ids]) => `${state}: ${ids.join(', ')}`);
303
+ const bridge = (id) => (builtinFeatureActive(undefined, id) ? 'bridge active' : 'no bridge');
304
+ row('ok', `${parts.join(' · ')} · browser use ${bridge('browser')} · computer use ${bridge('computer')}`);
305
+ }
306
+
307
+ function reportVoice(voice, row) {
308
+ if (typeof voice.enabled !== 'boolean') {
309
+ row('warn', 'status unavailable');
310
+ return;
311
+ }
312
+ if (!voice.enabled) {
313
+ row('ok', 'disabled');
314
+ return;
315
+ }
316
+ if (voice.installed !== true || voice.components?.model === false) {
317
+ row('warn', 'enabled · runtime or model not installed', { hint: 'reinstall voice from Extensions' });
318
+ return;
319
+ }
320
+ row('ok', 'enabled · runtime and model installed');
321
+ }
322
+
260
323
  function reportHooks(hooks, row) {
261
324
  if (
262
325
  typeof hooks.enabled !== 'boolean' ||
@@ -333,6 +396,19 @@ export async function runDoctorChecks(runtime = {}, getState = () => ({}), optio
333
396
  ['providers', 'providers', () => runtime.getProviderSetup?.(), reportProviders(getState)],
334
397
  ['mcp', 'mcp', () => runtime.mcpStatus?.(), reportMcp],
335
398
  ['memory', 'memory', async () => (await runtime.getToolModuleSettings?.())?.memory, reportMemory(runtime)],
399
+ [
400
+ 'localProvider',
401
+ 'local provider',
402
+ async () => (await runtime.getToolModuleSettings?.())?.localProvider,
403
+ reportLocalProvider,
404
+ ],
405
+ ['builtins', 'built-ins', async () => runtime.getToolModuleSettings?.(), reportBuiltins],
406
+ [
407
+ 'voice',
408
+ 'voice',
409
+ async () => (runtime.getVoiceStatus || (await import('../lib/voice-setup.mjs')).getVoiceStatus)(),
410
+ reportVoice,
411
+ ],
336
412
  [
337
413
  'channels',
338
414
  'channels',
@@ -57382,7 +57382,7 @@ function mixdogConfigBaseDir() {
57382
57382
  function getBackupRoot() {
57383
57383
  return process.env.MIXDOG_USER_DATA_BACKUP_ROOT || join10(mixdogConfigBaseDir(), "backups", "user-data");
57384
57384
  }
57385
- var USER_DATA_FILES = ["mixdog-config.json", "instructions.md", "user-workflow.md"];
57385
+ var USER_DATA_FILES = ["mixdog-config.json"];
57386
57386
  var USER_DATA_DIRS = ["schedules", "webhooks", "workflows"];
57387
57387
  function stamp() {
57388
57388
  return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -4,8 +4,6 @@ name: Default
4
4
  description: "Handle tasks as requested."
5
5
  ---
6
6
 
7
- # Default
8
-
9
7
  Consult the user and build the work plan together. Explicit read-only requests
10
8
  proceed immediately. When file edits, state mutation, or delegation are
11
9
  required, investigate and plan only until the user approves the latest plan.
@@ -5,7 +5,5 @@ description: "No user mid-run; requests are approved. Complete, then report."
5
5
  hidden: true
6
6
  ---
7
7
 
8
- # Headless
9
-
10
8
  The user does not intervene during the run. Treat the request as approved,
11
9
  carry it out to the end, and report the result.
@@ -1,54 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import test from 'node:test';
6
- import {
7
- authArtifactNamesForSandbox,
8
- copyAuthArtifacts,
9
- extractSessionId,
10
- readUnifiedConfig,
11
- } from './bench-sandbox.mjs';
12
-
13
- function withDir(run) {
14
- const dir = mkdtempSync(join(tmpdir(), 'bench-sandbox-'));
15
- try {
16
- return run(dir);
17
- } finally {
18
- rmSync(dir, { recursive: true, force: true });
19
- }
20
- }
21
-
22
- test('extractSessionId prefers the labelled id, then any sess_ token', () => {
23
- assert.equal(extractSessionId('sessionId: sess_abc123 other sess_zzz'), 'sess_abc123');
24
- assert.equal(extractSessionId('noise sess_q9 end'), 'sess_q9');
25
- assert.equal(extractSessionId('nothing here'), null);
26
- assert.equal(extractSessionId(undefined), null);
27
- });
28
-
29
- test('readUnifiedConfig returns an object, and {} for a missing or non-object file', () => {
30
- withDir((dir) => {
31
- assert.deepEqual(readUnifiedConfig(dir), {});
32
- writeFileSync(join(dir, 'mixdog-config.json'), '{"a":1}');
33
- assert.deepEqual(readUnifiedConfig(dir), { a: 1 });
34
- writeFileSync(join(dir, 'mixdog-config.json'), 'null');
35
- assert.deepEqual(readUnifiedConfig(dir), {});
36
- });
37
- });
38
-
39
- test('auth artifacts: provider files plus every oauth/credentials json in the real dir', () => {
40
- withDir((real) => {
41
- writeFileSync(join(real, 'grok-oauth.json'), 'g');
42
- writeFileSync(join(real, 'extra-credentials.json'), 'c');
43
- writeFileSync(join(real, 'unrelated.json'), 'u');
44
- const names = authArtifactNamesForSandbox(real, 'grok-oauth').sort();
45
- assert.deepEqual(names, ['extra-credentials.json', 'grok-oauth-models.json', 'grok-oauth.json']);
46
- const sandbox = join(real, 'sandbox');
47
- mkdirSync(sandbox);
48
- const { copied, skipped } = copyAuthArtifacts(real, sandbox, 'grok-oauth');
49
- assert.deepEqual(copied.sort(), ['extra-credentials.json', 'grok-oauth.json']);
50
- assert.deepEqual(skipped, ['grok-oauth-models.json']);
51
- assert.equal(readFileSync(join(sandbox, 'grok-oauth.json'), 'utf8'), 'g');
52
- assert.equal(existsSync(join(sandbox, 'unrelated.json')), false);
53
- });
54
- });
@@ -1,64 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { argValue, hasFlag, intArg, numArg, positionalArgs } from './cli-args.mjs';
4
-
5
- test('argValue reads both the separated and the joined option form', () => {
6
- const argv = ['node', 'bench.mjs', '--model', 'opus', '--round=3', '--trailing'];
7
- assert.equal(argValue('--model', null, argv), 'opus');
8
- assert.equal(argValue('--round', null, argv), '3');
9
- assert.equal(argValue('--missing', null, argv), null);
10
- assert.equal(argValue('--missing', 'fallback', argv), 'fallback');
11
- // An explicit `undefined` fallback takes the parameter default, so a
12
- // missing option always reads as null rather than undefined.
13
- assert.equal(argValue('--missing', undefined, argv), null);
14
- });
15
-
16
- test('argValue falls back when the separated form has no value left', () => {
17
- assert.equal(argValue('--save', 'default', ['node', 'bench.mjs', '--save']), 'default');
18
- // An empty joined value is a value, not a missing option.
19
- assert.equal(argValue('--save', 'default', ['node', 'bench.mjs', '--save=']), '');
20
- // The next argument is taken verbatim, even when it looks like a flag.
21
- assert.equal(argValue('--save', null, ['node', 'bench.mjs', '--save', '--json']), '--json');
22
- });
23
-
24
- test('hasFlag reports presence only', () => {
25
- const argv = ['node', 'bench.mjs', '--json', '--limit=5'];
26
- assert.equal(hasFlag('--json', argv), true);
27
- assert.equal(hasFlag('--limit', argv), false);
28
- assert.equal(hasFlag('--quiet', argv), false);
29
- });
30
-
31
- test('intArg accepts positive integers and keeps the fallback otherwise', () => {
32
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit', '5']), 5);
33
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit=12']), 12);
34
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs']), 30);
35
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit', 'abc']), 30);
36
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit', '0']), 30);
37
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit', '-4']), 30);
38
- // parseInt stops at the first non-digit, as the CLIs have always accepted.
39
- assert.equal(intArg('--limit', 30, ['node', 'diag.mjs', '--limit', '7x']), 7);
40
- });
41
-
42
- test('numArg keeps a deliberate 0 and falls back only on missing or non-numeric values', () => {
43
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget', '0']), 0);
44
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget=0']), 0);
45
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget', '2500']), 2500);
46
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget', '0.5']), 0.5);
47
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs']), 16000);
48
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget=']), 16000);
49
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget', 'lots']), 16000);
50
- assert.equal(numArg('--budget', 16000, ['node', 'bench.mjs', '--budget']), 16000);
51
- });
52
-
53
- test('positionalArgs never lets a boolean flag or a joined value swallow a positional', () => {
54
- const booleans = new Set(['--render']);
55
- assert.deepEqual(positionalArgs(['--render', 'deck.js', 'deck.pptx', '--mode', 'auto'], booleans), [
56
- 'deck.js',
57
- 'deck.pptx',
58
- ]);
59
- assert.deepEqual(positionalArgs(['--mode=auto', 'deck.js', 'deck.pptx'], booleans), ['deck.js', 'deck.pptx']);
60
- assert.deepEqual(positionalArgs(['deck.js', '--out', 'result.json', 'deck.pptx', '--render'], booleans), [
61
- 'deck.js',
62
- 'deck.pptx',
63
- ]);
64
- });
@@ -1,47 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { selectDeterministicCorpus } from './embedding-model-bench-core.mjs';
4
-
5
- const documents = Object.freeze([6, 3, 2, 5, 4, 1, 3].map((id) => Object.freeze({ id })));
6
-
7
- test('negative sampling preserves SHA-256 order, document order, and duplicate rows', () => {
8
- // SHA-256("4") starts 4b22, then "3" starts 4e07; the other IDs sort after both.
9
- const selection = selectDeterministicCorpus(documents, [{ positiveIdsByTarget: [[6]] }], 3);
10
- assert.deepEqual(selection, {
11
- documents: [{ id: 6 }, { id: 3 }, { id: 4 }, { id: 3 }],
12
- sourceDocuments: 7,
13
- selectedDocuments: 3,
14
- positiveDocuments: 1,
15
- method: 'all-positive-roots + sha256(document-id) negatives',
16
- });
17
- });
18
-
19
- test('positive caps use the same hash ordering without counting duplicate IDs twice', () => {
20
- const selection = selectDeterministicCorpus(documents, [{ positiveIdsByTarget: [[1, 2, 3, 4, 5, 6, 3]] }], 2, 2);
21
- assert.deepEqual(selection, {
22
- documents: [{ id: 3 }, { id: 4 }, { id: 3 }],
23
- sourceDocuments: 7,
24
- selectedDocuments: 2,
25
- positiveDocuments: 2,
26
- method: 'up-to-2-positive-roots-per-target + sha256(document-id) negatives',
27
- });
28
- });
29
-
30
- test('full-corpus selections retain the original array and capped positive count', () => {
31
- for (const limit of [0, 1.5, 7, 9]) {
32
- const selection = selectDeterministicCorpus(
33
- documents,
34
- [{ positiveIdsByTarget: [[1, 2, 3, 4, 5, 6, 3]] }],
35
- limit,
36
- 2
37
- );
38
- assert.equal(selection.documents, documents);
39
- assert.deepEqual(selection, {
40
- documents,
41
- sourceDocuments: 7,
42
- selectedDocuments: 7,
43
- positiveDocuments: 2,
44
- method: 'full-corpus',
45
- });
46
- }
47
- });
@@ -1,22 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { parseDuration, summarize } from './smoke-loop-shared.mjs';
4
-
5
- test('parseDuration reads bare milliseconds and unit suffixes', () => {
6
- assert.equal(parseDuration('1500'), 1500);
7
- assert.equal(parseDuration('30s'), 30_000);
8
- assert.equal(parseDuration('5M'), 300_000);
9
- assert.equal(parseDuration('1.5h'), 5_400_000);
10
- assert.equal(parseDuration('0ms'), 1);
11
- });
12
-
13
- test('parseDuration returns the fallback when empty and throws on garbage', () => {
14
- assert.equal(parseDuration('', 7), 7);
15
- assert.equal(parseDuration(undefined), null);
16
- assert.throws(() => parseDuration('soon'), /invalid duration: soon/);
17
- });
18
-
19
- test('summarize rounds min/max/avg to one decimal, zeros when empty', () => {
20
- assert.deepEqual(summarize([]), { min: 0, max: 0, avg: 0 });
21
- assert.deepEqual(summarize([1.04, 2.06, 3.31]), { min: 1, max: 3.3, avg: 2.1 });
22
- });
@@ -1,47 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { resolve } from 'node:path';
4
- import { defaultTraceFiles, field, num, payload, sessionId } from './trace-row.mjs';
5
-
6
- test('defaultTraceFiles: an explicit path wins, a data dir yields its rotated pair', () => {
7
- assert.deepEqual(defaultTraceFiles({ pathArg: 'x.jsonl' }), [resolve('x.jsonl')]);
8
- assert.deepEqual(defaultTraceFiles({ dataDir: 'd' }), [
9
- resolve('d', 'history', 'agent-trace.jsonl.1'),
10
- resolve('d', 'history', 'agent-trace.jsonl'),
11
- ]);
12
- });
13
-
14
- test('defaultTraceFiles without arguments covers the project and user data dirs, deduplicated', () => {
15
- const files = defaultTraceFiles();
16
- assert.equal(files.length, new Set(files).size);
17
- assert.ok(files.includes(resolve(process.cwd(), '.mixdog', 'data', 'history', 'agent-trace.jsonl')));
18
- });
19
-
20
- test('payload is the row payload object, else an empty object', () => {
21
- assert.deepEqual(payload({ payload: { a: 1 } }), { a: 1 });
22
- assert.deepEqual(payload({ payload: 'text' }), {});
23
- assert.deepEqual(payload(null), {});
24
- });
25
-
26
- test('field prefers the row itself, falls back to the payload, else null', () => {
27
- assert.equal(field({ a: 1, payload: { a: 2 } }, 'a'), 1);
28
- assert.equal(field({ payload: { a: 2 } }, 'a'), 2);
29
- assert.equal(field({ a: null, payload: { a: 3 } }, 'a'), 3);
30
- assert.equal(field({}, 'a'), null);
31
- assert.equal(field(undefined, 'a'), null);
32
- });
33
-
34
- test('num coerces the field and drops non-finite values', () => {
35
- assert.equal(num({ payload: { n: '12' } }, 'n'), 12);
36
- assert.equal(num({ n: 'x' }, 'n'), null);
37
- // A missing field reads as Number(null) === 0, not null (existing behavior
38
- // of every trace CLI that used its own copy).
39
- assert.equal(num({}, 'n'), 0);
40
- });
41
-
42
- test('sessionId reads either spelling, then the payload, as a string', () => {
43
- assert.equal(sessionId({ session_id: 's1' }), 's1');
44
- assert.equal(sessionId({ sessionId: 's2' }), 's2');
45
- assert.equal(sessionId({ payload: { session_id: 's3' } }), 's3');
46
- assert.equal(sessionId({}), '');
47
- });
@@ -1,62 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { distribution, median, percentile, sortedFinite, stats } from './trace-stats.mjs';
4
-
5
- test('distribution coerces samples with Number() and reports nearest-rank p50/p95', () => {
6
- assert.deepEqual(distribution(['10', 20, '30', 40]), { p50: 20, p95: 40 });
7
- assert.deepEqual(distribution([]), { p50: null, p95: null });
8
- assert.deepEqual(distribution([undefined, 'x']), { p50: null, p95: null });
9
- });
10
-
11
- test('percentile picks the nearest rank of an ascending sample', () => {
12
- const sorted = [10, 20, 30, 40];
13
- assert.equal(percentile(sorted, 50), 20);
14
- assert.equal(percentile(sorted, 90), 40);
15
- assert.equal(percentile(sorted, 99), 40);
16
- assert.equal(percentile([5], 50), 5);
17
- assert.equal(percentile([], 50), null);
18
- // Rank 0 and below clamp to the first sample, never to undefined.
19
- assert.equal(percentile(sorted, 0), 10);
20
- });
21
-
22
- test('median is the nearest-rank p50: the lower middle sample, null when empty', () => {
23
- assert.equal(median([10, 20, 30, 40]), 20);
24
- assert.equal(median([10, 20, 30]), 20);
25
- assert.equal(median([7]), 7);
26
- assert.equal(median([]), null);
27
- });
28
-
29
- test('percentile is nearest-rank, not floor(n*p) index truncation', () => {
30
- // floor(n*p) indexing reports 20 for p50 of [10,20] and 20 for p95 of 1..20.
31
- assert.equal(percentile([10, 20], 50), 10);
32
- assert.equal(
33
- percentile(
34
- Array.from({ length: 20 }, (_, i) => i + 1),
35
- 95
36
- ),
37
- 19
38
- );
39
- });
40
-
41
- test('sortedFinite sorts a copy and drops non-finite entries without coercing them', () => {
42
- const input = [3, null, '2', Number.NaN, 1, undefined, Number.NEGATIVE_INFINITY];
43
- assert.deepEqual(sortedFinite(input), [1, 3]);
44
- assert.equal(input[0], 3);
45
- });
46
-
47
- test('stats summarizes finite samples and reports null for an empty set', () => {
48
- assert.deepEqual(stats([30, 10, 20]), { n: 3, sum: 60, avg: 20, p50: 20, p90: 30, p99: 30, max: 30 });
49
- assert.equal(stats([]), null);
50
- assert.equal(stats([Number.NaN, Number.POSITIVE_INFINITY]), null);
51
- });
52
-
53
- test('stats drops non-finite samples and leaves the caller array untouched', () => {
54
- const input = [3, Number.NaN, 1, Number.POSITIVE_INFINITY, 2];
55
- assert.deepEqual(stats(input), { n: 3, sum: 6, avg: 2, p50: 2, p90: 3, p99: 3, max: 3 });
56
- assert.deepEqual(input, [3, Number.NaN, 1, Number.POSITIVE_INFINITY, 2]);
57
- });
58
-
59
- test('stats rounds the average, matching the reported millisecond columns', () => {
60
- assert.equal(stats([1, 2]).avg, 2);
61
- assert.equal(stats([1, 1, 2]).avg, 1);
62
- });