@aragon-agent/cli 0.6.12 → 0.6.14

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 (119) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -31
  3. package/dist/agent/controller.d.ts +38 -2
  4. package/dist/agent/controller.js +185 -34
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +208 -0
  7. package/dist/agent/queued-messages.d.ts +12 -0
  8. package/dist/agent/queued-messages.js +18 -0
  9. package/dist/agent/reducer.d.ts +13 -1
  10. package/dist/agent/reducer.js +52 -12
  11. package/dist/boot/guard.d.ts +3 -3
  12. package/dist/boot/guard.js +3 -3
  13. package/dist/cli.js +48 -16
  14. package/dist/commands/builtins.js +66 -39
  15. package/dist/commands/queue-command.d.ts +3 -0
  16. package/dist/commands/queue-command.js +5 -0
  17. package/dist/commands/registry.d.ts +3 -0
  18. package/dist/compaction/child.d.ts +4 -2
  19. package/dist/compaction/child.js +8 -1
  20. package/dist/compaction/compactor.d.ts +7 -2
  21. package/dist/compaction/compactor.js +21 -16
  22. package/dist/compaction/context-command.js +10 -3
  23. package/dist/compaction/meter.js +2 -0
  24. package/dist/compaction/pressure.d.ts +1 -0
  25. package/dist/compaction/pressure.js +1 -0
  26. package/dist/compaction/types.d.ts +2 -0
  27. package/dist/compaction/wiring.d.ts +4 -3
  28. package/dist/compaction/wiring.js +10 -2
  29. package/dist/config/cli-commands.d.ts +2 -0
  30. package/dist/config/cli-commands.js +16 -4
  31. package/dist/config/load.d.ts +5 -2
  32. package/dist/config/load.js +23 -12
  33. package/dist/config/model-profile-resolution.d.ts +20 -0
  34. package/dist/config/model-profile-resolution.js +81 -0
  35. package/dist/config/model-profile-store.d.ts +67 -0
  36. package/dist/config/model-profile-store.js +153 -0
  37. package/dist/config/model-profiles.d.ts +44 -0
  38. package/dist/config/model-profiles.js +121 -0
  39. package/dist/config/schema.d.ts +11 -2
  40. package/dist/config/store.d.ts +11 -1
  41. package/dist/config/store.js +43 -27
  42. package/dist/fast/resolve.d.ts +2 -1
  43. package/dist/fast/resolve.js +6 -4
  44. package/dist/fast/wiring.d.ts +5 -3
  45. package/dist/fast/wiring.js +8 -4
  46. package/dist/input/stdin-filter.js +3 -2
  47. package/dist/logging/install.js +4 -2
  48. package/dist/logging/secret-registry.d.ts +2 -0
  49. package/dist/logging/secret-registry.js +18 -0
  50. package/dist/team/runtime.d.ts +2 -1
  51. package/dist/team/subagent.d.ts +3 -1
  52. package/dist/team/subagent.js +3 -1
  53. package/dist/ui/ActivityLine.d.ts +6 -70
  54. package/dist/ui/ActivityLine.js +12 -34
  55. package/dist/ui/App.d.ts +1 -0
  56. package/dist/ui/App.js +116 -215
  57. package/dist/ui/BottomStatusRow.d.ts +6 -86
  58. package/dist/ui/BottomStatusRow.js +11 -26
  59. package/dist/ui/Composer.d.ts +16 -83
  60. package/dist/ui/Composer.js +9 -100
  61. package/dist/ui/Logo.d.ts +3 -5
  62. package/dist/ui/Logo.js +4 -8
  63. package/dist/ui/PromptInput.d.ts +4 -1
  64. package/dist/ui/PromptInput.js +44 -35
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +2 -0
  68. package/dist/ui/QueueStatusRow.js +1 -1
  69. package/dist/ui/StatusBar.d.ts +13 -149
  70. package/dist/ui/StatusBar.js +27 -142
  71. package/dist/ui/capabilities.d.ts +2 -0
  72. package/dist/ui/capabilities.js +6 -0
  73. package/dist/ui/clipboard-task.d.ts +29 -0
  74. package/dist/ui/clipboard-task.js +66 -0
  75. package/dist/ui/clipboard.d.ts +29 -50
  76. package/dist/ui/clipboard.js +141 -75
  77. package/dist/ui/color-runtime.d.ts +5 -0
  78. package/dist/ui/color-runtime.js +29 -0
  79. package/dist/ui/composer-limits.d.ts +3 -4
  80. package/dist/ui/composer-limits.js +4 -9
  81. package/dist/ui/composer-rows.d.ts +1 -1
  82. package/dist/ui/composer-rows.js +24 -137
  83. package/dist/ui/editor-navigation.d.ts +45 -0
  84. package/dist/ui/editor-navigation.js +85 -0
  85. package/dist/ui/editor-reducer.d.ts +4 -0
  86. package/dist/ui/editor-reducer.js +29 -15
  87. package/dist/ui/glyphs.js +10 -8
  88. package/dist/ui/ink-runtime.d.ts +3 -0
  89. package/dist/ui/ink-runtime.js +30 -0
  90. package/dist/ui/interaction-copy.d.ts +77 -0
  91. package/dist/ui/interaction-copy.js +40 -0
  92. package/dist/ui/interaction-hints.d.ts +18 -0
  93. package/dist/ui/interaction-hints.js +92 -0
  94. package/dist/ui/layout/budget.js +1 -1
  95. package/dist/ui/layout/queue-layout.d.ts +19 -0
  96. package/dist/ui/layout/queue-layout.js +26 -0
  97. package/dist/ui/layout/status-layout.d.ts +55 -0
  98. package/dist/ui/layout/status-layout.js +110 -0
  99. package/dist/ui/layout/todo-layout.d.ts +1 -0
  100. package/dist/ui/layout/todo-layout.js +1 -1
  101. package/dist/ui/overlays/HelpOverlay.js +8 -8
  102. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  103. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  104. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  105. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  106. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  107. package/dist/ui/overlays/QueueOverlay.js +79 -0
  108. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  109. package/dist/ui/overlays/SettingsScreen.js +347 -3
  110. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  111. package/dist/ui/overlays/model-profile-state.js +119 -0
  112. package/dist/ui/render-cache.d.ts +2 -2
  113. package/dist/ui/render-cache.js +4 -3
  114. package/dist/ui/run-status-row.d.ts +7 -19
  115. package/dist/ui/run-status-row.js +2 -18
  116. package/dist/ui/selection/selection-controller.d.ts +2 -2
  117. package/dist/ui/selection/selection-controller.js +1 -0
  118. package/dist/ui/use-wheel-routing.js +2 -1
  119. package/package.json +2 -2
@@ -13,6 +13,7 @@ import process from 'node:process';
13
13
  import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, getPromptHistoryPath, getUiStatePath, isHomeOverridden, } from './app-paths.js';
14
14
  import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
15
15
  import { loadPersistedConfig, updatePersistedConfig } from './store.js';
16
+ import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
16
17
  import { clampFastConfig, clampRetryConfig, clampCompactionConfig, parseThresholdInput, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_COMPACTION_CONFIG, DEFAULT_UPDATE_CONFIG, } from './schema.js';
17
18
  import { clampLogLevel } from '../logging/levels.js';
18
19
  /** Config keys under `log.` that `aragon config set` accepts. */
@@ -386,12 +387,23 @@ export function warnIfRedactionDisabled(key, value) {
386
387
  // get / list
387
388
  // ---------------------------------------------------------------------------
388
389
  /** Every secret is masked wherever it is printed, `--json` included (R-12). */
389
- function maskConfig(config) {
390
+ export function maskConfig(config) {
390
391
  const apiKeys = {};
391
392
  for (const [provider, key] of Object.entries(config.apiKeys)) {
392
393
  apiKeys[provider] = maskSecret(key);
393
394
  }
394
- return { ...config, apiKeys };
395
+ const masked = { ...config, apiKeys };
396
+ if (config.modelProfiles !== undefined) {
397
+ if (validateModelProfiles(config.modelProfiles).length)
398
+ masked.modelProfiles = { invalid: true };
399
+ else {
400
+ const profiles = normalizeModelProfiles(config.modelProfiles);
401
+ masked.modelProfiles = { ...profiles, entries: profiles.entries.map((entry) => ({
402
+ ...entry, apiKey: entry.apiKey ? 'Configured' : 'Missing',
403
+ })) };
404
+ }
405
+ }
406
+ return masked;
395
407
  }
396
408
  /**
397
409
  * The human-readable label for a value whose raw form would mislead, or
@@ -503,8 +515,8 @@ export async function runConfigEdit() {
503
515
  JSON.parse(readFileSync(file, 'utf-8'));
504
516
  process.stdout.write(`${file} saved.\n`);
505
517
  }
506
- catch (err) {
507
- process.stderr.write(`${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}).\n` +
518
+ catch {
519
+ process.stderr.write(`${file} is not valid JSON.\n` +
508
520
  `The previous version is still at ${backup}.\n`);
509
521
  process.exitCode = 1;
510
522
  }
@@ -6,6 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import { type CliConfig } from './schema.js';
9
+ import type { ModelRole } from './model-profiles.js';
9
10
  export interface CliFlags {
10
11
  provider?: string;
11
12
  model?: string;
@@ -240,9 +241,11 @@ export interface CliFlags {
240
241
  * Resolve the effective config by merging all layers, then derive the runtime
241
242
  * (non-persisted) fields and enforce the timeout invariant.
242
243
  */
243
- export declare function loadConfig(flags?: CliFlags): CliConfig;
244
+ export declare function loadConfig(flags?: CliFlags, options?: {
245
+ interactive?: boolean;
246
+ }): CliConfig;
244
247
  /**
245
248
  * Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
246
249
  * The one-shot `--api-key` override wins, but only for the active provider.
247
250
  */
248
- export declare function makeGetApiKey(config: CliConfig): (providerId: string) => string | undefined;
251
+ export declare function makeGetApiKey(config: CliConfig, role?: ModelRole): (providerId: string) => string | undefined;
@@ -9,13 +9,14 @@ import process from 'node:process';
9
9
  import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
+ import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey } from './model-profile-resolution.js';
12
13
  import { getConfigPath } from './app-paths.js';
13
14
  import { setHistoryEnabled } from './prompt-history.js';
14
15
  import { setEntryRetain } from '../agent/entry-limits.js';
15
16
  import { readSubmitCount } from './ui-state.js';
16
17
  import { getLogger } from '../logging/logger.js';
17
18
  import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
18
- import { detectCapabilities } from '../ui/capabilities.js';
19
+ import { detectCapabilities, resolveTuiCapabilities } from '../ui/capabilities.js';
19
20
  /** Interpret a boolean-ish env value (`1/true/on/yes`) as `true`. */
20
21
  function envFlagTrue(value) {
21
22
  if (!value)
@@ -435,16 +436,22 @@ function resolveContextWindow(env, file) {
435
436
  * Resolve the effective config by merging all layers, then derive the runtime
436
437
  * (non-persisted) fields and enforce the timeout invariant.
437
438
  */
438
- export function loadConfig(flags = {}) {
439
+ export function loadConfig(flags = {}, options = {}) {
439
440
  const cwd = flags.cwd ? flags.cwd : process.cwd();
440
441
  // `.env` must be loaded before reading env overrides.
441
442
  loadDotenv(cwd);
442
443
  const read = readConfigFile();
443
- const file = read.config ?? {};
444
+ const projection = projectModelProfiles(read.config ?? {});
445
+ const file = projection.file;
444
446
  const env = readEnvConfig();
445
447
  if (read.parseError) {
446
448
  getLogger().error('config', 'config_parse_failed', { error: read.parseError });
447
449
  }
450
+ if (projection.issues.length) {
451
+ getLogger().warn('config', 'model_profiles_invalid', {
452
+ error: 'Invalid model profiles; using custom connections. Repair with aragon config edit.',
453
+ });
454
+ }
448
455
  const provider = pick(flags.provider, env.partial.provider, file.provider) ?? DEFAULT_CONFIG.provider;
449
456
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
450
457
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
@@ -512,13 +519,17 @@ export function loadConfig(flags = {}) {
512
519
  // registering twice costs a `Set.add`, missing once costs a credential.
513
520
  registerSecretsFrom(apiKeys);
514
521
  registerSecret(flags.apiKey);
515
- const color = flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
522
+ const caps = options.interactive
523
+ ? resolveTuiCapabilities(process.env, flags.color)
524
+ : detectCapabilities(process.env, process.stdout);
525
+ const color = options.interactive
526
+ ? caps.colorLevel !== 0
527
+ : flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
516
528
  // `--no-color` implies calmer chrome; env / file may also opt in explicitly.
517
529
  const reducedMotion = envFlagTrue(process.env.ARAGON_REDUCED_MOTION) ||
518
530
  (file.reducedMotion ?? DEFAULT_CONFIG.reducedMotion) ||
519
531
  !color;
520
532
  // Detect terminal capabilities once; force monochrome when color is disabled.
521
- const caps = detectCapabilities(process.env, process.stdout);
522
533
  const colorLevel = color ? caps.colorLevel : 0;
523
534
  // No flag and no env var: a privacy switch you set once, not a per-run knob.
524
535
  const historyEnabled = file.historyEnabled ?? DEFAULT_CONFIG.historyEnabled;
@@ -543,6 +554,7 @@ export function loadConfig(flags = {}) {
543
554
  // it through the same module-singleton channel `setHistoryEnabled` uses.
544
555
  setEntryRetain(transcriptRetain);
545
556
  const config = {
557
+ modelProfiles: projection.issues.length ? undefined : file.modelProfiles,
546
558
  provider,
547
559
  model,
548
560
  baseUrl,
@@ -605,6 +617,10 @@ export function loadConfig(flags = {}) {
605
617
  unicode: caps.unicode,
606
618
  apiKeyOverride: flags.apiKey && flags.apiKey.trim().length > 0 ? flags.apiKey.trim() : undefined,
607
619
  };
620
+ config.modelProfileState = resolveModelProfileState(config, config.modelProfiles, projection.issues.length > 0);
621
+ if (config.apiKeyOverride && (config.modelProfiles?.mainId || config.modelProfiles?.fastId)) {
622
+ config.apiKeyOverrideTarget = { provider, baseUrl: baseUrl?.trim() || null };
623
+ }
608
624
  recordResolvedConfig(config);
609
625
  return config;
610
626
  }
@@ -646,11 +662,6 @@ function recordResolvedConfig(config) {
646
662
  * Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
647
663
  * The one-shot `--api-key` override wins, but only for the active provider.
648
664
  */
649
- export function makeGetApiKey(config) {
650
- return (providerId) => {
651
- if (config.apiKeyOverride && providerId === config.provider) {
652
- return config.apiKeyOverride;
653
- }
654
- return config.apiKeys[providerId];
655
- };
665
+ export function makeGetApiKey(config, role = 'main') {
666
+ return (providerId) => resolveModelRoleKey({ config, role, providerId });
656
667
  }
@@ -0,0 +1,20 @@
1
+ import { type CliConfig, type PersistedConfig } from './schema.js';
2
+ import { type ModelProfilesConfig, type ModelProfilesRuntime, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
3
+ export interface ProfileProjection {
4
+ file: Partial<PersistedConfig>;
5
+ state: ModelProfilesRuntime;
6
+ issues: ProfileValidationIssue[];
7
+ }
8
+ export interface RoleKeyInput {
9
+ config: CliConfig;
10
+ role: ModelRole;
11
+ providerId: string;
12
+ }
13
+ /** Compare configured endpoint spelling strictly, with null and undefined equivalent. */
14
+ export declare function normalizeProfileBaseUrl(value: string | null | undefined): string | null;
15
+ /** Project a valid library onto a copy without rewriting legacy preferences. */
16
+ export declare function projectModelProfiles(file: Partial<PersistedConfig>): ProfileProjection;
17
+ /** Derive binding status from current connections, including session/model overrides. */
18
+ export declare function resolveModelProfileState(config: CliConfig, profiles?: ModelProfilesConfig | undefined, invalid?: boolean): ModelProfilesRuntime;
19
+ /** Resolve only credentials scoped to the requested role and actual connection target. */
20
+ export declare function resolveModelRoleKey(input: RoleKeyInput): string | undefined;
@@ -0,0 +1,81 @@
1
+ import { DEFAULT_CONFIG } from './schema.js';
2
+ import { emptyModelProfileState, normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
3
+ /** Compare configured endpoint spelling strictly, with null and undefined equivalent. */
4
+ export function normalizeProfileBaseUrl(value) {
5
+ return value?.trim() || null;
6
+ }
7
+ /** Project a valid library onto a copy without rewriting legacy preferences. */
8
+ export function projectModelProfiles(file) {
9
+ const issues = validateModelProfiles(file.modelProfiles);
10
+ const state = emptyModelProfileState(issues.length > 0);
11
+ const projected = { ...file };
12
+ if (issues.length || !file.modelProfiles)
13
+ return { file: projected, state, issues };
14
+ const library = normalizeModelProfiles(file.modelProfiles);
15
+ projected.modelProfiles = library;
16
+ for (const role of ['main', 'fast']) {
17
+ const entry = library.entries.find((profile) => profile.id === library[`${role}Id`]);
18
+ if (!entry)
19
+ continue;
20
+ state[role] = { selectedId: entry.id, appliedId: entry.id,
21
+ overriddenFields: [], invalid: false };
22
+ const connection = { provider: entry.provider, model: entry.model, baseUrl: entry.baseUrl };
23
+ if (role === 'main')
24
+ Object.assign(projected, connection);
25
+ else
26
+ projected.fast = { ...DEFAULT_CONFIG.fast, ...file.fast, ...connection,
27
+ baseUrl: entry.baseUrl ?? '' };
28
+ }
29
+ return { file: projected, state, issues };
30
+ }
31
+ /** Derive binding status from current connections, including session/model overrides. */
32
+ export function resolveModelProfileState(config, profiles = config.modelProfiles, invalid = false) {
33
+ const issues = validateModelProfiles(profiles);
34
+ const state = emptyModelProfileState(invalid || issues.length > 0);
35
+ if (issues.length || !profiles)
36
+ return state;
37
+ for (const role of ['main', 'fast']) {
38
+ const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
39
+ if (!entry)
40
+ continue;
41
+ const connection = role === 'main' ? config : config.fast;
42
+ const changed = ['provider', 'model', 'baseUrl'].filter((field) => field === 'baseUrl'
43
+ ? normalizeProfileBaseUrl(connection[field]) !== normalizeProfileBaseUrl(entry[field])
44
+ : connection[field] !== entry[field]);
45
+ state[role] = { selectedId: entry.id,
46
+ appliedId: changed.some((field) => field !== 'model') ? null : entry.id,
47
+ overriddenFields: changed, invalid: false };
48
+ }
49
+ return state;
50
+ }
51
+ /** Resolve only credentials scoped to the requested role and actual connection target. */
52
+ export function resolveModelRoleKey(input) {
53
+ const { config, role, providerId } = input;
54
+ const shared = config.apiKeys[providerId] || undefined;
55
+ const profiles = validateModelProfiles(config.modelProfiles).length
56
+ ? undefined : config.modelProfiles;
57
+ const profileMode = profiles && (profiles.mainId !== null || profiles.fastId !== null);
58
+ if (!profileMode) {
59
+ const target = config.apiKeyOverrideTarget;
60
+ if (target) {
61
+ return role === 'main' && providerId === config.provider
62
+ && target.provider === config.provider
63
+ && normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)
64
+ ? config.apiKeyOverride || shared : shared;
65
+ }
66
+ return config.apiKeyOverride && providerId === config.provider
67
+ ? config.apiKeyOverride : shared;
68
+ }
69
+ const actualProvider = role === 'main'
70
+ ? config.provider : config.fast.provider || config.provider;
71
+ if (providerId !== actualProvider)
72
+ return shared;
73
+ const target = config.apiKeyOverrideTarget;
74
+ if (role === 'main' && config.apiKeyOverride && target?.provider === config.provider
75
+ && normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)) {
76
+ return config.apiKeyOverride;
77
+ }
78
+ const state = resolveModelProfileState(config)[role];
79
+ const entry = profiles.entries.find((profile) => profile.id === state.appliedId);
80
+ return entry?.apiKey || shared;
81
+ }
@@ -0,0 +1,67 @@
1
+ import { type ModelProfilesConfig, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
2
+ import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig } from './schema.js';
3
+ export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log'>> {
4
+ fast?: Partial<FastConfig>;
5
+ compaction?: Partial<CompactionConfig>;
6
+ log?: Partial<LogConfig>;
7
+ }
8
+ export interface ModelSettingsDraft {
9
+ profiles?: ModelProfilesConfig;
10
+ patch: ModelSettingsPatch;
11
+ activateRoles: ModelRole[];
12
+ diskRevision: string;
13
+ liveRevision: number;
14
+ baseline: PersistedConfig;
15
+ liveBaseline: CliConfig;
16
+ readError?: string;
17
+ }
18
+ export interface ModelSettingsSaveResult {
19
+ ok: boolean;
20
+ persisted: boolean;
21
+ status: 'applied' | 'restart_required' | 'rejected' | 'saved_apply_failed';
22
+ code?: 'invalid' | 'busy' | 'conflict' | 'read_failed' | 'write_failed' | 'profile_key_required' | 'legacy_conversion_unsupported' | 'apply_failed';
23
+ issues?: ProfileValidationIssue[];
24
+ restartRequired?: boolean;
25
+ }
26
+ export type ConfigReadError = 'invalid_json' | 'invalid_root' | 'read_failed';
27
+ export interface ModelSettingsDisk {
28
+ ok: boolean;
29
+ config: PersistedConfig;
30
+ parsed: Partial<PersistedConfig> | null;
31
+ raw: Buffer | null;
32
+ revision: string;
33
+ error?: ConfigReadError;
34
+ }
35
+ /** A stable, safe error for adapters; never includes caught exception messages. */
36
+ export declare class ModelProfileConfigError extends Error {
37
+ readonly code: NonNullable<ModelSettingsSaveResult['code']>;
38
+ constructor(code: NonNullable<ModelSettingsSaveResult['code']>);
39
+ }
40
+ /** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
41
+ export declare function readModelSettingsDisk(): ModelSettingsDisk;
42
+ export interface ModelSettingsCommitInput {
43
+ candidate: PersistedConfig;
44
+ diskRevision: string;
45
+ }
46
+ export interface ModelSettingsStoreDependencies {
47
+ read: () => ModelSettingsDisk;
48
+ write: (candidate: PersistedConfig) => void;
49
+ }
50
+ /** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
51
+ export declare function commitModelSettings(input: ModelSettingsCommitInput, dependencies?: ModelSettingsStoreDependencies): ModelSettingsSaveResult;
52
+ export interface LegacyPatchInput {
53
+ current: PersistedConfig;
54
+ patch: ModelSettingsPatch & {
55
+ modelProfiles?: ModelProfilesConfig;
56
+ };
57
+ }
58
+ /** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
59
+ export declare function adaptLegacyConnectionPatch(input: LegacyPatchInput): LegacyPatchInput['patch'];
60
+ /** A detached default endpoint must not acquire a gateway through legacy inheritance. */
61
+ export declare function assertLegacyFastTarget(fast: {
62
+ provider: string;
63
+ baseUrl?: string | null;
64
+ }, main: {
65
+ provider: string;
66
+ baseUrl?: string | null;
67
+ }): void;
@@ -0,0 +1,153 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { getConfigPath } from './app-paths.js';
4
+ import { normalizePersistedConfig, writeConfigFile } from './store.js';
5
+ import { registerProfileSecrets } from '../logging/secret-registry.js';
6
+ import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
7
+ import { normalizeProfileBaseUrl, projectModelProfiles } from './model-profile-resolution.js';
8
+ /** A stable, safe error for adapters; never includes caught exception messages. */
9
+ export class ModelProfileConfigError extends Error {
10
+ code;
11
+ constructor(code) {
12
+ super(code === 'profile_key_required' || code === 'legacy_conversion_unsupported'
13
+ ? `${code}: Use /settings to edit or copy this profile.`
14
+ : `${code}: Check the configuration with aragon config edit.`);
15
+ this.code = code;
16
+ this.name = 'ModelProfileConfigError';
17
+ }
18
+ }
19
+ /** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
20
+ export function readModelSettingsDisk() {
21
+ let raw;
22
+ try {
23
+ raw = readFileSync(getConfigPath());
24
+ }
25
+ catch (error) {
26
+ const missing = error.code === 'ENOENT';
27
+ return { ok: missing, config: normalizePersistedConfig({}), parsed: null, raw: null,
28
+ revision: missing ? 'missing' : 'unreadable',
29
+ ...(missing ? {} : { error: 'read_failed' }) };
30
+ }
31
+ const revision = createHash('sha256').update(raw).digest('hex');
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw.toString('utf8'));
35
+ }
36
+ catch {
37
+ return failedRead(raw, revision, 'invalid_json');
38
+ }
39
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
+ return failedRead(raw, revision, 'invalid_root');
41
+ }
42
+ registerProfileSecrets(parsed);
43
+ return { ok: true, raw, revision, parsed: parsed,
44
+ config: normalizePersistedConfig(parsed) };
45
+ }
46
+ function failedRead(raw, revision, error) {
47
+ return { ok: false, raw, revision, error, config: normalizePersistedConfig({}), parsed: null };
48
+ }
49
+ /** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
50
+ export function commitModelSettings(input, dependencies = {
51
+ read: readModelSettingsDisk, write: writeConfigFile,
52
+ }) {
53
+ const current = dependencies.read();
54
+ if (!current.ok)
55
+ return rejected('read_failed');
56
+ if (current.revision !== input.diskRevision)
57
+ return rejected('conflict');
58
+ if (validateModelProfiles(current.config.modelProfiles).length)
59
+ return rejected('invalid');
60
+ registerProfileSecrets(input.candidate);
61
+ const issues = validateModelProfiles(input.candidate.modelProfiles);
62
+ if (issues.length)
63
+ return { ...rejected('invalid'), issues };
64
+ try {
65
+ dependencies.write(input.candidate);
66
+ }
67
+ catch {
68
+ return rejected('write_failed');
69
+ }
70
+ return { ok: true, persisted: true, status: 'applied' };
71
+ }
72
+ function rejected(code) {
73
+ return { ok: false, persisted: false, status: 'rejected', code };
74
+ }
75
+ function touchesConnection(patch) {
76
+ return Boolean(patch && ['provider', 'model', 'baseUrl'].some((key) => Object.prototype.hasOwnProperty.call(patch, key)));
77
+ }
78
+ /** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
79
+ export function adaptLegacyConnectionPatch(input) {
80
+ const { current, patch } = input;
81
+ if (Object.prototype.hasOwnProperty.call(patch, 'modelProfiles')) {
82
+ if (validateModelProfiles(current.modelProfiles).length
83
+ || validateModelProfiles(patch.modelProfiles).length) {
84
+ throw new ModelProfileConfigError('invalid');
85
+ }
86
+ let result = patch;
87
+ for (const role of ['main', 'fast']) {
88
+ if (!patch.modelProfiles?.[`${role}Id`]) {
89
+ result = adaptUnboundProviders(current, result, role);
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ const roles = ['main', 'fast'].filter((role) => touchesConnection(role === 'main' ? patch : patch.fast));
95
+ if (!roles.length)
96
+ return patch;
97
+ if (!current.modelProfiles)
98
+ return adaptUnboundProviders(current, patch);
99
+ if (validateModelProfiles(current.modelProfiles).length) {
100
+ throw new ModelProfileConfigError('invalid');
101
+ }
102
+ const profiles = normalizeModelProfiles(current.modelProfiles);
103
+ const result = { ...patch, modelProfiles: profiles };
104
+ for (const role of roles)
105
+ adaptRole(current, result, role);
106
+ return result;
107
+ }
108
+ function adaptUnboundProviders(current, patch, role) {
109
+ const result = { ...patch };
110
+ if (role !== 'fast' && patch.provider !== undefined && patch.provider !== current.provider
111
+ && !Object.prototype.hasOwnProperty.call(patch, 'baseUrl'))
112
+ result.baseUrl = null;
113
+ if (role !== 'main' && patch.fast?.provider !== undefined
114
+ && patch.fast.provider !== current.fast.provider
115
+ && !Object.prototype.hasOwnProperty.call(patch.fast, 'baseUrl')) {
116
+ result.fast = { ...patch.fast, baseUrl: '' };
117
+ }
118
+ return result;
119
+ }
120
+ function adaptRole(current, patch, role) {
121
+ const profiles = patch.modelProfiles;
122
+ const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
123
+ if (!entry) {
124
+ Object.assign(patch, adaptUnboundProviders(current, patch, role));
125
+ return;
126
+ }
127
+ if (entry.apiKey)
128
+ throw new ModelProfileConfigError('profile_key_required');
129
+ const explicit = role === 'main' ? patch : patch.fast;
130
+ const provider = explicit.provider ?? entry.provider;
131
+ const changedProvider = provider !== entry.provider;
132
+ const baseUrl = Object.prototype.hasOwnProperty.call(explicit, 'baseUrl')
133
+ ? explicit.baseUrl ?? null : changedProvider ? null : entry.baseUrl;
134
+ const connection = { provider, model: explicit.model ?? entry.model, baseUrl };
135
+ if (role === 'fast') {
136
+ const main = projectModelProfiles(current).file;
137
+ const mainProvider = patch.provider ?? main.provider ?? current.provider;
138
+ const mainUrl = Object.prototype.hasOwnProperty.call(patch, 'baseUrl')
139
+ ? patch.baseUrl : main.baseUrl;
140
+ assertLegacyFastTarget(connection, { provider: mainProvider, baseUrl: mainUrl });
141
+ patch.fast = { ...connection, ...patch.fast, baseUrl: baseUrl ?? '' };
142
+ }
143
+ else
144
+ Object.assign(patch, connection);
145
+ profiles[`${role}Id`] = null;
146
+ }
147
+ /** A detached default endpoint must not acquire a gateway through legacy inheritance. */
148
+ export function assertLegacyFastTarget(fast, main) {
149
+ if (!normalizeProfileBaseUrl(fast.baseUrl) && (fast.provider || main.provider) === main.provider
150
+ && normalizeProfileBaseUrl(main.baseUrl)) {
151
+ throw new ModelProfileConfigError('legacy_conversion_unsupported');
152
+ }
153
+ }
@@ -0,0 +1,44 @@
1
+ export type ModelRole = 'main' | 'fast';
2
+ export interface ModelProfile {
3
+ id: string;
4
+ name: string;
5
+ provider: string;
6
+ model: string;
7
+ baseUrl: string | null;
8
+ apiKey: string | null;
9
+ }
10
+ export interface ModelProfilesConfig {
11
+ version: 1;
12
+ entries: ModelProfile[];
13
+ mainId: string | null;
14
+ fastId: string | null;
15
+ }
16
+ export interface ProfileBindingState {
17
+ selectedId: string | null;
18
+ appliedId: string | null;
19
+ overriddenFields: Array<'provider' | 'model' | 'baseUrl'>;
20
+ invalid: boolean;
21
+ }
22
+ export interface ModelProfilesRuntime {
23
+ main: ProfileBindingState;
24
+ fast: ProfileBindingState;
25
+ }
26
+ export interface ProfileValidationIssue {
27
+ path: string;
28
+ code: 'invalid' | 'duplicate_id' | 'missing_reference' | 'unsupported_version';
29
+ message: string;
30
+ }
31
+ /** Return a fresh empty library without initiating a disk migration. */
32
+ export declare function emptyModelProfiles(): ModelProfilesConfig;
33
+ /** Return independently mutable role state. */
34
+ export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
35
+ /** Validate untrusted input; diagnostics never include supplied values. */
36
+ export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
37
+ /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
38
+ export declare function normalizeModelProfiles(value: unknown): ModelProfilesConfig;
39
+ /** Create an entry with a stable UUID and a frozen default name. */
40
+ export declare function createModelProfile(input: Omit<ModelProfile, 'id'>): ModelProfile;
41
+ /** Duplicate the current draft entry, including its explicitly edited credential. */
42
+ export declare function duplicateModelProfile(entry: ModelProfile): ModelProfile;
43
+ /** Remove an unused entry; binding references must be changed explicitly first. */
44
+ export declare function removeModelProfile(library: ModelProfilesConfig, id: string): ModelProfilesConfig;
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ADAPTER_PROVIDERS } from './schema.js';
3
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
4
+ /** Return a fresh empty library without initiating a disk migration. */
5
+ export function emptyModelProfiles() {
6
+ return { version: 1, entries: [], mainId: null, fastId: null };
7
+ }
8
+ /** Return independently mutable role state. */
9
+ export function emptyModelProfileState(invalid = false) {
10
+ const binding = () => ({ selectedId: null, appliedId: null,
11
+ overriddenFields: [], invalid });
12
+ return { main: binding(), fast: binding() };
13
+ }
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+ function validText(value, max, allowEmpty = false) {
18
+ return typeof value === 'string' && !CONTROL.test(value)
19
+ && (allowEmpty || value.trim().length > 0) && [...value.trim()].length <= max;
20
+ }
21
+ function validUrl(value) {
22
+ if (value === null || (typeof value === 'string' && !value.trim()))
23
+ return true;
24
+ if (typeof value !== 'string' || CONTROL.test(value))
25
+ return false;
26
+ try {
27
+ const url = new URL(value.trim());
28
+ return ['http:', 'https:'].includes(url.protocol) && Boolean(url.hostname)
29
+ && !url.username && !url.password && !value.includes('?') && !value.includes('#');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function validateEntry(value, index) {
36
+ const path = `modelProfiles.entries[${index}]`;
37
+ const invalid = (field, message) => ({
38
+ path: field ? `${path}.${field}` : path, code: 'invalid', message,
39
+ });
40
+ if (!isRecord(value))
41
+ return [invalid('', 'Profile must be an object.')];
42
+ const issues = [];
43
+ if (!validText(value.id, 128))
44
+ issues.push(invalid('id', 'ID must have 1-128 safe characters.'));
45
+ if (!validText(value.name, 80, true)) {
46
+ issues.push(invalid('name', 'Name must have at most 80 safe characters.'));
47
+ }
48
+ if (!ADAPTER_PROVIDERS.includes(value.provider)) {
49
+ issues.push(invalid('provider', 'Select a supported provider.'));
50
+ }
51
+ if (!validText(value.model, 256)) {
52
+ issues.push(invalid('model', 'Model must have 1-256 safe characters.'));
53
+ }
54
+ if (!validUrl(value.baseUrl)) {
55
+ issues.push(invalid('baseUrl', 'Use an HTTP(S) URL without credentials, query or fragment.'));
56
+ }
57
+ if (value.apiKey !== null && !validText(value.apiKey, 8192)) {
58
+ issues.push(invalid('apiKey', 'Enter a nonempty key up to 8192 safe characters.'));
59
+ }
60
+ return issues;
61
+ }
62
+ /** Validate untrusted input; diagnostics never include supplied values. */
63
+ export function validateModelProfiles(value) {
64
+ if (value === undefined)
65
+ return [];
66
+ if (!isRecord(value))
67
+ return [{ path: 'modelProfiles', code: 'invalid',
68
+ message: 'Model profiles must be an object. Repair with aragon config edit.' }];
69
+ if (value.version !== 1)
70
+ return [{ path: 'modelProfiles.version',
71
+ code: 'unsupported_version', message: 'Unsupported model profiles version.' }];
72
+ if (!Array.isArray(value.entries) || value.entries.length > 200) {
73
+ return [{ path: 'modelProfiles.entries', code: 'invalid',
74
+ message: 'Use an array with at most 200 profiles; remove unused profiles to add more.' }];
75
+ }
76
+ const issues = value.entries.flatMap(validateEntry);
77
+ const ids = new Set();
78
+ value.entries.forEach((entry, index) => {
79
+ if (!isRecord(entry) || typeof entry.id !== 'string')
80
+ return;
81
+ if (ids.has(entry.id))
82
+ issues.push({ path: `modelProfiles.entries[${index}].id`,
83
+ code: 'duplicate_id', message: 'Profile IDs must be unique.' });
84
+ ids.add(entry.id);
85
+ });
86
+ for (const field of ['mainId', 'fastId']) {
87
+ if (value[field] !== null && (typeof value[field] !== 'string' || !ids.has(value[field]))) {
88
+ issues.push({ path: `modelProfiles.${field}`, code: 'missing_reference',
89
+ message: 'Select an existing profile or Current custom.' });
90
+ }
91
+ }
92
+ return issues;
93
+ }
94
+ /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
95
+ export function normalizeModelProfiles(value) {
96
+ if (validateModelProfiles(value).length)
97
+ throw new Error('Invalid model profiles.');
98
+ if (value === undefined)
99
+ return emptyModelProfiles();
100
+ const library = value;
101
+ return { version: 1, mainId: library.mainId, fastId: library.fastId,
102
+ entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
103
+ || [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
104
+ model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
105
+ apiKey: entry.apiKey?.trim() || null })) };
106
+ }
107
+ /** Create an entry with a stable UUID and a frozen default name. */
108
+ export function createModelProfile(input) {
109
+ const entry = { ...input, id: randomUUID() };
110
+ return normalizeModelProfiles({ ...emptyModelProfiles(), entries: [entry] }).entries[0];
111
+ }
112
+ /** Duplicate the current draft entry, including its explicitly edited credential. */
113
+ export function duplicateModelProfile(entry) {
114
+ return { ...entry, id: randomUUID(), name: `${[...entry.name].slice(0, 73).join('')} (copy)` };
115
+ }
116
+ /** Remove an unused entry; binding references must be changed explicitly first. */
117
+ export function removeModelProfile(library, id) {
118
+ if (library.mainId === id || library.fastId === id)
119
+ throw new Error('Profile is still in use.');
120
+ return { ...library, entries: library.entries.filter((entry) => entry.id !== id) };
121
+ }