@inneranimalmedia/agentsam-sdk 2.6.0 → 2.6.1

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 (124) hide show
  1. package/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
  2. package/docs/RELEASES.md +7 -7
  3. package/docs/SOURCE_ARCHITECTURE.md +58 -0
  4. package/docs/TEST_TIERS.md +26 -0
  5. package/migrations/runtime/0001_cli_runtime.sql +298 -0
  6. package/package.json +28 -7
  7. package/packages/agentsam-repository/README.md +15 -0
  8. package/packages/agentsam-repository/package.json +25 -0
  9. package/packages/agentsam-repository/src/contracts.js +113 -0
  10. package/packages/agentsam-repository/src/index.js +3 -0
  11. package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
  12. package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
  13. package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
  14. package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
  15. package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
  16. package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
  17. package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
  18. package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
  19. package/packages/identity/package.json +1 -1
  20. package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
  21. package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
  22. package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
  23. package/protocol/repository/repository-contract.schema.json +24 -0
  24. package/protocol/repository/repository-dependency.schema.json +24 -0
  25. package/protocol/repository/repository-identity.schema.json +17 -0
  26. package/protocol/rpc/v1/common.proto +16 -0
  27. package/protocol/rpc/v1/errors.proto +35 -0
  28. package/protocol/rpc/v1/knowledge.proto +77 -0
  29. package/services/knowledge/package-lock.json +333 -0
  30. package/services/knowledge/package.json +5 -1
  31. package/src/agent/responses-runner.js +63 -35
  32. package/src/capabilities/repository-snapshot.js +3 -3
  33. package/src/cli.js +23 -6
  34. package/src/commands/context-economics.js +17 -2
  35. package/src/commands/context.js +1 -1
  36. package/src/commands/db.js +20 -3
  37. package/src/commands/env.js +90 -0
  38. package/src/commands/knowledge.js +12 -4
  39. package/src/commands/merkle-persist.js +30 -11
  40. package/src/commands/merkle.js +1 -1
  41. package/src/commands/models.js +123 -65
  42. package/src/commands/ollama.js +26 -0
  43. package/src/commands/preferences.js +53 -26
  44. package/src/commands/shell.js +236 -48
  45. package/src/errors/contract.js +236 -0
  46. package/src/errors/index.js +14 -0
  47. package/src/index.js +13 -1
  48. package/src/knowledge/service/auth.js +13 -0
  49. package/src/knowledge/service/grpc-client.js +115 -0
  50. package/src/knowledge/service/grpc-codec.js +237 -0
  51. package/src/knowledge/service/grpc-server.js +83 -0
  52. package/src/knowledge/service/job-engine.js +248 -0
  53. package/src/knowledge/service/server.js +87 -135
  54. package/src/knowledge/source.js +1 -1
  55. package/src/lib/cli-preferences.js +31 -4
  56. package/src/lib/deploy-receipt/index.js +2 -2
  57. package/src/lib/knowledge-docker.js +6 -3
  58. package/src/lib/local-sessions.js +23 -2
  59. package/src/lib/local-status.js +1 -1
  60. package/src/lib/project-config.js +1 -1
  61. package/src/lib/provider-credentials.js +105 -5
  62. package/src/lib/slash-commands.js +4 -3
  63. package/src/local/migrations.js +93 -0
  64. package/src/local/runtime-store.js +141 -0
  65. package/src/local/sqlite.js +2 -0
  66. package/src/local-pty/server.js +113 -51
  67. package/src/models/discovery.js +292 -0
  68. package/src/providers/anthropic-messages.js +192 -0
  69. package/src/providers/cloudflare-chat.js +183 -0
  70. package/src/providers/factory.js +69 -0
  71. package/src/providers/gemini-generate-content.js +208 -0
  72. package/src/providers/index.js +5 -0
  73. package/src/providers/ollama-chat.js +148 -0
  74. package/src/providers/openai-responses.js +226 -75
  75. package/src/repository/index.js +14 -2
  76. package/src/rpc/generated/common_grpc_pb.js +1 -0
  77. package/src/rpc/generated/common_pb.js +536 -0
  78. package/src/rpc/generated/errors_grpc_pb.js +1 -0
  79. package/src/rpc/generated/errors_pb.js +482 -0
  80. package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
  81. package/src/rpc/generated/knowledge_pb.js +2168 -0
  82. package/src/rpc/generated/package.json +3 -0
  83. package/src/security/trust-boundary.js +2 -2
  84. package/src/telemetry/events.js +4 -1
  85. package/src/ui/cli/activity.js +76 -0
  86. package/src/ui/cli/compaction.js +15 -0
  87. package/src/ui/cli/footer.js +39 -0
  88. package/src/ui/cli/help.js +192 -0
  89. package/src/ui/cli/plan.js +20 -0
  90. package/src/ui/cli/runtime-events.js +110 -0
  91. package/src/ui/cli/waiting.js +16 -0
  92. package/src/ui/merkle/render.js +1 -1
  93. package/test/cli/preferences-runtime.test.mjs +11 -0
  94. package/test/cli/runtime-ui.test.mjs +74 -0
  95. package/test/error-diagnostics.test.mjs +57 -1
  96. package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
  97. package/test/integration/cli-help.test.mjs +37 -0
  98. package/test/integration/knowledge-rpc.test.mjs +112 -0
  99. package/test/integration/merkle-cli.test.mjs +61 -0
  100. package/test/integration/merkle-persistence-identity.test.mjs +48 -0
  101. package/test/integration/provider-env-cli.test.mjs +49 -0
  102. package/test/integration/provider-factory.test.mjs +197 -0
  103. package/test/integration/repository-company-graph.test.mjs +90 -0
  104. package/test/integration/runtime-migrations.test.mjs +82 -0
  105. package/test/knowledge-service.test.mjs +5 -0
  106. package/test/knowledge.test.mjs +16 -0
  107. package/test/live/terminal-transport.live.test.mjs +24 -0
  108. package/test/local-sessions.test.mjs +7 -1
  109. package/test/models.test.mjs +101 -4
  110. package/test/ollama.test.mjs +21 -0
  111. package/test/portable-context.test.mjs +1 -1
  112. package/test/provider-credentials.test.mjs +45 -1
  113. package/test/release-hygiene.test.mjs +13 -5
  114. package/test/responses-runner.test.mjs +3 -1
  115. package/test/shell.test.mjs +50 -8
  116. package/test/terminal/local-pty.mock.test.mjs +151 -0
  117. /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
  118. /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
  119. /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
  120. /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
  121. /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
  122. /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
  123. /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
  124. /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { buildMerkleTree, saveSnapshot, readSnapshot, diffTrees, normalizePolicy } from '../lib/merkle/index.js';
3
+ import { buildMerkleTree, saveSnapshot, readSnapshot, diffTrees, normalizePolicy } from '../../packages/agentsam-repository/src/merkle/index.js';
4
4
  import { renderSummary, safeText } from '../ui/merkle/render.js';
5
5
  import { runMerkleExplorer } from '../ui/merkle/explorer.js';
6
6
  import { runMerklePersist } from './merkle-persist.js';
@@ -1,32 +1,58 @@
1
1
  import pc from 'picocolors';
2
2
  import { probeOllama, resolveOllamaConfig } from './ollama.js';
3
3
  import { listModelCatalog } from '../models/index.js';
4
+ import { discoverProviderModels } from '../models/discovery.js';
4
5
  import { resolveProviderCredential } from '../lib/provider-credentials.js';
5
6
 
6
- const API_PROVIDERS = Object.freeze([
7
+ export const API_PROVIDERS = Object.freeze([
7
8
  { id: 'openai', label: 'OpenAI', credential: 'OPENAI_API_KEY' },
8
- { id: 'gemini', label: 'Gemini', credential: 'GEMINI_API_KEY' },
9
- { id: 'grok', label: 'Grok', credential: 'XAI_API_KEY' },
10
9
  { id: 'anthropic', label: 'Anthropic', credential: 'ANTHROPIC_API_KEY' },
10
+ { id: 'gemini', label: 'Gemini', credential: 'GEMINI_API_KEY' },
11
+ { id: 'grok', label: 'Grok / xAI', credential: 'XAI_API_KEY' },
12
+ { id: 'cloudflare', label: 'Cloudflare', credential: 'CLOUDFLARE_API_TOKEN' },
11
13
  ]);
12
14
 
13
15
  function clean(value) { return value == null ? '' : String(value).trim(); }
14
- function configured(value) { return Boolean(clean(value)); }
15
-
16
- async function discoverOpenAIModels(apiKey, fetchImpl) {
17
- if (!apiKey) return { attempted: false, ok: false, models: [], error: null };
18
- try {
19
- const response = await fetchImpl('https://api.openai.com/v1/models', {
20
- headers: { authorization: `Bearer ${apiKey}` },
21
- signal: typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8_000) : undefined,
16
+
17
+ function emptyDiscovery() {
18
+ return { attempted: false, ok: false, models: [], error: null };
19
+ }
20
+
21
+ function providerSummary(result = {}) {
22
+ return {
23
+ attempted: result.attempted === true,
24
+ ok: result.ok === true,
25
+ error: result.error || null,
26
+ returnedModelCount: Array.isArray(result.models) ? result.models.length : 0,
27
+ };
28
+ }
29
+
30
+ function mergeStaticFallbacks(discovered = []) {
31
+ const byKey = new Map(discovered.map((row) => [row.model_key, row]));
32
+ const out = [...discovered];
33
+ for (const record of listModelCatalog()) {
34
+ if (byKey.has(record.model_key)) continue;
35
+ out.push({
36
+ model_key: record.model_key,
37
+ provider: record.provider,
38
+ provider_model_id: record.provider_model_id,
39
+ label: record.label,
40
+ availability: 'unverified',
41
+ availability_source: 'sdk_reference',
42
+ context_window: record.context_window,
43
+ context_window_source: 'sdk_reference',
44
+ max_output_tokens: record.max_output_tokens,
45
+ max_output_tokens_source: 'sdk_reference',
46
+ reasoning_efforts: [...record.reasoning_efforts],
47
+ service_tiers: [...record.service_tiers],
48
+ capabilities: { ...record.capabilities },
49
+ pricing: record.pricing || null,
50
+ context_policy: record.context_policy || null,
51
+ batch: record.batch || null,
52
+ source: record.source || null,
22
53
  });
23
- if (!response.ok) return { attempted: true, ok: false, models: [], error: `HTTP ${response.status}` };
24
- const body = await response.json();
25
- const models = Array.isArray(body?.data) ? body.data.map((row) => clean(row?.id)).filter(Boolean) : [];
26
- return { attempted: true, ok: true, models, error: null };
27
- } catch (error) {
28
- return { attempted: true, ok: false, models: [], error: error?.message || String(error) };
29
54
  }
55
+ return out;
30
56
  }
31
57
 
32
58
  export async function collectModelsStatus(options = {}) {
@@ -35,7 +61,13 @@ export async function collectModelsStatus(options = {}) {
35
61
  const providerFetchImpl = options.providerFetchImpl || fetch;
36
62
  const ollamaConfig = resolveOllamaConfig({}, env);
37
63
  const ollama = await probeOllama(ollamaConfig, ollamaFetchImpl);
38
- const credentials = new Map(API_PROVIDERS.map((provider) => [provider.id, resolveProviderCredential(provider.id, { env, home: options.home })]));
64
+ const credentials = new Map(
65
+ API_PROVIDERS.map((provider) => [
66
+ provider.id,
67
+ resolveProviderCredential(provider.id, { env, home: options.home }),
68
+ ]),
69
+ );
70
+
39
71
  const providers = API_PROVIDERS.map((provider) => {
40
72
  const credential = credentials.get(provider.id);
41
73
  return {
@@ -46,36 +78,32 @@ export async function collectModelsStatus(options = {}) {
46
78
  };
47
79
  });
48
80
 
49
- const openaiCredential = credentials.get('openai');
50
- const shouldDiscover = options.discoverRemote !== false && Boolean(openaiCredential?.configured);
51
- const openai = shouldDiscover
52
- ? await discoverOpenAIModels(clean(openaiCredential?.value), providerFetchImpl)
53
- : { attempted: false, ok: false, models: [], error: null };
54
- const availableIds = new Set(openai.models);
55
- const catalogModels = listModelCatalog().map((record) => ({
56
- model_key: record.model_key,
57
- provider: record.provider,
58
- provider_model_id: record.provider_model_id,
59
- label: record.label,
60
- availability: record.provider === 'openai' && openai.ok
61
- ? (availableIds.has(record.provider_model_id) ? 'available' : 'unavailable')
62
- : 'unverified',
63
- source: record.source,
81
+ const shouldDiscover = options.discoverRemote !== false;
82
+ const discovery = {};
83
+ const providerModels = {};
84
+
85
+ await Promise.all(API_PROVIDERS.map(async (provider) => {
86
+ const credential = credentials.get(provider.id);
87
+ const result = shouldDiscover && credential?.configured
88
+ ? await discoverProviderModels(provider.id, credential, { fetchImpl: providerFetchImpl })
89
+ : emptyDiscovery();
90
+ discovery[provider.id] = providerSummary(result);
91
+ providerModels[provider.id] = result.models || [];
64
92
  }));
65
93
 
94
+ const discovered = Object.values(providerModels).flat();
95
+ const exactAvailable = discovered.filter((row) => row.availability === 'available');
96
+ const catalogModels = mergeStaticFallbacks(discovered);
97
+
66
98
  return {
67
- schemaVersion: 'agentsam-model-inventory-v2',
99
+ schemaVersion: 'agentsam-model-inventory-v3',
100
+ generatedAt: new Date().toISOString(),
101
+ authority: 'per_credential_provider_discovery',
68
102
  providers,
69
- discovery: {
70
- openai: {
71
- attempted: openai.attempted,
72
- ok: openai.ok,
73
- error: openai.error,
74
- returnedModelCount: openai.models.length,
75
- },
76
- },
103
+ discovery,
104
+ providerModels,
77
105
  catalogModels,
78
- availableModels: catalogModels.filter((row) => row.availability === 'available'),
106
+ availableModels: exactAvailable,
79
107
  local: {
80
108
  provider: 'ollama',
81
109
  configured: ollama.online,
@@ -91,60 +119,90 @@ export async function collectModelsStatus(options = {}) {
91
119
 
92
120
  function statusMark(ok) { return ok ? pc.green('●') : pc.dim('○'); }
93
121
  function writeLine(write, value = '') { write(`${value}\n`); }
122
+ function contextLabel(model) {
123
+ const value = Number(model?.context_window);
124
+ if (!Number.isFinite(value) || value <= 0) return 'ctx unknown';
125
+ return `ctx ${Math.round(value / 1000).toLocaleString('en-US')}k`;
126
+ }
94
127
 
95
128
  export function renderModelsStatus(status) {
96
129
  const lines = [];
97
130
  lines.push('');
98
131
  lines.push(` ${pc.bold('Agent Sam · models')}`);
99
- lines.push(` ${pc.dim('Credential presence is local evidence; exact hosted-model availability is provider-verified when discovery succeeds.')}`);
132
+ lines.push(` ${pc.dim('Availability is verified with this machine\'s own provider credentials. Limits are provider-derived when exposed; otherwise marked unknown/reference.')}`);
100
133
  lines.push('');
101
134
 
102
135
  for (const provider of status.providers) {
103
- const state = provider.configured ? pc.green('configured') : pc.dim(provider.credentialError ? 'blocked' : 'not configured');
104
- const detail = provider.configured ? `credential available · ${provider.source || 'runtime'}` : provider.credentialError ? `${provider.credential} · ${provider.credentialError}` : provider.credential;
105
- lines.push(` ${statusMark(provider.configured)} ${pc.cyan(provider.label.padEnd(10))} ${state.padEnd(20)} ${pc.dim(detail)}`);
136
+ const state = provider.configured
137
+ ? pc.green('configured')
138
+ : pc.dim(provider.credentialError ? 'blocked' : 'not configured');
139
+ const discovery = status.discovery?.[provider.id];
140
+ const detail = provider.configured
141
+ ? discovery?.attempted
142
+ ? discovery.ok
143
+ ? `${discovery.returnedModelCount} account-visible models`
144
+ : `discovery failed · ${discovery.error || 'unknown error'}`
145
+ : `credential available · ${provider.source || 'runtime'}`
146
+ : provider.credentialError
147
+ ? `${provider.credential} · ${provider.credentialError}`
148
+ : provider.credential;
149
+ lines.push(` ${statusMark(provider.configured)} ${pc.cyan(provider.label.padEnd(12))} ${state.padEnd(20)} ${pc.dim(detail)}`);
106
150
  }
107
151
 
108
- const exact = status.availableModels || [];
109
- if (exact.length) {
110
- lines.push('');
111
- lines.push(` ${pc.dim('provider-verified selectable models')}`);
112
- for (const model of exact) lines.push(` ${pc.green('•')} ${model.provider_model_id}`);
113
- } else if (status.discovery?.openai?.attempted) {
152
+ for (const provider of API_PROVIDERS) {
153
+ const models = status.providerModels?.[provider.id] || [];
154
+ if (!models.length) continue;
114
155
  lines.push('');
115
- lines.push(` ${pc.dim(`OpenAI discovery ${status.discovery.openai.ok ? 'completed; no catalog models matched' : `failed: ${status.discovery.openai.error || 'unknown error'}`}`)}`);
156
+ lines.push(` ${pc.dim(`${provider.label} · provider-verified for this credential`)}`);
157
+ const visible = models.slice(0, 30);
158
+ for (const model of visible) {
159
+ const source = model.context_window_source === 'provider_api'
160
+ ? contextLabel(model)
161
+ : model.context_window_source === 'sdk_reference'
162
+ ? `${contextLabel(model)} · reference`
163
+ : 'ctx unknown';
164
+ lines.push(` ${pc.green('•')} ${model.provider_model_id} ${pc.dim('· ' + source)}`);
165
+ }
166
+ if (models.length > visible.length) lines.push(` ${pc.dim(`… ${models.length - visible.length} more`)}`);
116
167
  }
117
168
 
118
169
  const local = status.local;
119
170
  const localState = local.online ? pc.green('online') : pc.dim('offline');
120
- lines.push(` ${statusMark(local.online)} ${pc.cyan('Ollama'.padEnd(10))} ${localState.padEnd(20)} ${pc.dim('local only')}`);
171
+ lines.push('');
172
+ lines.push(` ${statusMark(local.online)} ${pc.cyan('Ollama'.padEnd(12))} ${localState.padEnd(20)} ${pc.dim('local only')}`);
121
173
  if (local.online) {
122
174
  const names = local.models.map((row) => row.name).filter(Boolean);
123
- lines.push('');
124
- lines.push(` ${pc.dim('local models')}`);
125
- if (names.length) for (const name of names) lines.push(` ${pc.green('•')} ${name}`);
126
- else lines.push(` ${pc.dim('no models reported')}`);
127
- lines.push('');
128
- lines.push(` ${pc.dim('chat default')} ${local.chatModel}`);
129
- lines.push(` ${pc.dim('embed default')} ${local.embedModel}`);
175
+ for (const name of names.slice(0, 30)) lines.push(` ${pc.green('•')} ${name}`);
176
+ if (names.length > 30) lines.push(` ${pc.dim(`… ${names.length - 30} more`)}`);
130
177
  }
178
+
131
179
  lines.push('');
132
- lines.push(` ${pc.dim('Use /model inside Agent Sam to choose an exact verified model, reasoning effort, and processing tier.')}`);
180
+ lines.push(` ${pc.dim('Use /model inside Agent Sam. The picker is built from the models visible to your own connected provider credentials.')}`);
133
181
  lines.push('');
134
182
  return lines.join('\n');
135
183
  }
136
184
 
137
185
  export async function runModels(argv = [], options = {}) {
138
186
  if (argv.some((arg) => arg === '--help' || arg === '-h')) {
139
- const text = 'agentsam models [--json] [--no-discover]\n\nShow configured providers, provider-verified known models when available, and local Ollama inventory.\n';
187
+ const text = [
188
+ 'agentsam models [--json] [--no-discover]',
189
+ '',
190
+ 'Probe the provider accounts configured on this machine and list the models visible to those exact credentials.',
191
+ 'Context/output limits are taken from provider metadata when available; otherwise Agent Sam reports unknown or a clearly-labeled SDK reference.',
192
+ '',
193
+ ].join('\n');
140
194
  (options.write || process.stdout.write.bind(process.stdout))(text);
141
195
  return;
142
196
  }
197
+
143
198
  const allowed = new Set(['--json', '--no-discover']);
144
199
  const unknown = argv.filter((arg) => !allowed.has(arg));
145
200
  if (unknown.length) throw new Error(`unknown models option: ${unknown[0]}`);
146
201
 
147
- const status = await collectModelsStatus({ ...options, discoverRemote: !argv.includes('--no-discover') });
202
+ const status = await collectModelsStatus({
203
+ ...options,
204
+ discoverRemote: !argv.includes('--no-discover'),
205
+ });
148
206
  const write = options.write || ((text) => process.stdout.write(text));
149
207
  if (argv.includes('--json')) writeLine(write, JSON.stringify(status, null, 2));
150
208
  else write(renderModelsStatus(status));
@@ -148,6 +148,32 @@ function hasModel(models, desired) {
148
148
  return models.some((row) => normalizeModelName(row.name || row.model) === want);
149
149
  }
150
150
 
151
+ export async function probeOllamaModel(model, config, fetchImpl = fetch) {
152
+ const endpoint = `${config.baseUrl}/api/show`;
153
+ try {
154
+ const response = await fetchImpl(endpoint, {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/json' },
157
+ body: JSON.stringify({ model }),
158
+ signal: AbortSignal.timeout(3000),
159
+ });
160
+ if (!response.ok) return { ok: false, model, context_window: null, error: `HTTP ${response.status}` };
161
+ const body = await response.json();
162
+ const info = body?.model_info && typeof body.model_info === 'object' ? body.model_info : {};
163
+ const contextEntry = Object.entries(info).find(([key, value]) => key.endsWith('.context_length') && Number(value) > 0);
164
+ return {
165
+ ok: true,
166
+ model,
167
+ context_window: contextEntry ? Math.floor(Number(contextEntry[1])) : null,
168
+ context_window_source: contextEntry ? 'local_runtime' : 'unknown',
169
+ capabilities: Array.isArray(body?.capabilities) ? body.capabilities : [],
170
+ details: body?.details || null,
171
+ };
172
+ } catch (error) {
173
+ return { ok: false, model, context_window: null, error: error?.message || String(error) };
174
+ }
175
+ }
176
+
151
177
  export async function probeOllama(config, fetchImpl = fetch) {
152
178
  const endpoint = `${config.baseUrl}/api/tags`;
153
179
  try {
@@ -5,6 +5,7 @@ import { cancel, confirm, intro, isCancel, outro, select, text } from '@clack/pr
5
5
  import { collectModelsStatus } from './models.js';
6
6
  import { getModelRecord } from '../models/index.js';
7
7
  import { detectCliProject, readCliPreferences, writeCliPreferences } from '../lib/cli-preferences.js';
8
+ import { readAccountSession } from '../lib/account-session.js';
8
9
 
9
10
  function stopIfCancelled(value) {
10
11
  if (!isCancel(value)) return value;
@@ -19,6 +20,17 @@ function commandExists(command) {
19
20
  return spawnSync(probe[0], probe[1], { stdio: 'ignore' }).status === 0;
20
21
  }
21
22
 
23
+ export function runtimeOptions({ accountConnected = false } = {}) {
24
+ const rows = [{ value: 'local', label: 'Local machine', hint: 'standalone · real shell + filesystem' }];
25
+ if (accountConnected) {
26
+ rows.push(
27
+ { value: 'remote', label: 'Remote', hint: 'IAM-connected enrolled runtime' },
28
+ { value: 'sandbox', label: 'Sandbox', hint: 'IAM-connected isolated runtime' },
29
+ );
30
+ }
31
+ return rows;
32
+ }
33
+
22
34
  export function availableShells(env = process.env) {
23
35
  const values = [];
24
36
  const detected = path.basename(env.SHELL || env.ComSpec || '').trim();
@@ -28,23 +40,38 @@ export function availableShells(env = process.env) {
28
40
  }
29
41
 
30
42
  export function modelOptions(status) {
31
- const options = [{ value: 'auto', label: 'Automatic', hint: 'runtime chooses; exact economics unavailable until resolved' }];
43
+ const options = [{ value: 'auto', label: 'Automatic', hint: 'runtime chooses from this credential\'s verified inventory', model: null }];
32
44
  for (const model of status.availableModels || []) {
33
- options.push({ value: model.model_key, label: model.label, hint: `${model.provider_model_id} · provider verified` });
45
+ const context = Number(model.context_window) > 0 ? ` · ctx ${Math.round(Number(model.context_window) / 1000)}k` : ' · ctx unknown';
46
+ options.push({
47
+ value: model.model_key,
48
+ label: `${model.provider} · ${model.label}`,
49
+ hint: `${model.provider_model_id}${context} · provider verified`,
50
+ model,
51
+ });
34
52
  }
35
53
  if (status.local?.online) {
36
- for (const row of status.local.models || []) if (row?.name) options.push({ value: `ollama:${row.name}`, label: `Ollama · ${row.name}`, hint: 'local' });
37
- }
38
- for (const provider of status.providers || []) {
39
- if (provider.configured && !options.some((row) => row.value.startsWith(`${provider.id}:`) && row.value !== `${provider.id}:auto`)) {
40
- options.push({ value: `${provider.id}:auto`, label: `${provider.label} · automatic`, hint: 'credential proven; exact model not verified' });
41
- }
54
+ for (const row of status.local.models || []) if (row?.name) options.push({
55
+ value: `ollama:${row.name}`,
56
+ label: `Ollama · ${row.name}`,
57
+ hint: 'local',
58
+ model: {
59
+ model_key: `ollama:${row.name}`, provider: 'ollama', provider_model_id: row.name, label: row.name,
60
+ availability: 'available', availability_source: 'local_runtime', context_window: null, context_window_source: 'unknown',
61
+ max_output_tokens: null, max_output_tokens_source: 'unknown', reasoning_efforts: ['auto'], service_tiers: ['default'], capabilities: { chat: true },
62
+ },
63
+ });
42
64
  }
43
65
  return options;
44
66
  }
45
67
 
46
- function reasoningOptions(modelPreference) {
47
- const record = getModelRecord(modelPreference);
68
+ function resolvedModel(modelPreference, modelSnapshot) {
69
+ if (modelSnapshot?.model_key === modelPreference) return modelSnapshot;
70
+ return getModelRecord(modelPreference);
71
+ }
72
+
73
+ function reasoningOptions(modelPreference, modelSnapshot) {
74
+ const record = resolvedModel(modelPreference, modelSnapshot);
48
75
  if (!record) return [{ value: 'auto', label: 'Automatic', hint: 'runtime/provider default' }];
49
76
  return record.reasoning_efforts.map((value) => ({
50
77
  value,
@@ -53,8 +80,8 @@ function reasoningOptions(modelPreference) {
53
80
  }));
54
81
  }
55
82
 
56
- function serviceTierOptions(modelPreference) {
57
- const record = getModelRecord(modelPreference);
83
+ function serviceTierOptions(modelPreference, modelSnapshot) {
84
+ const record = resolvedModel(modelPreference, modelSnapshot);
58
85
  if (!record) return [{ value: 'default', label: 'Standard', hint: 'default provider processing' }];
59
86
  return record.service_tiers.map((value) => {
60
87
  if (value === 'fast') return { value, label: 'Fast', hint: 'lower latency · 2× applicable token rates for Astra' };
@@ -68,20 +95,22 @@ async function promptModelPreferences(identity, existing, options = {}) {
68
95
  try { status = await collectModelsStatus(options.modelStatusOptions || {}); } catch { /* inventory remains best-effort */ }
69
96
  const models = modelOptions(status);
70
97
  const initialModel = models.some((row) => row.value === existing.modelPreference) ? existing.modelPreference : 'auto';
71
- const modelPreference = stopIfCancelled(await select({ message: 'Model', initialValue: initialModel, options: models }));
98
+ const modelPreference = stopIfCancelled(await select({ message: 'Model', initialValue: initialModel, options: models.map(({ model, ...row }) => row) }));
99
+ const selected = models.find((row) => row.value === modelPreference)?.model || null;
100
+ const modelSnapshot = selected || (existing.modelSnapshot?.model_key === modelPreference ? existing.modelSnapshot : null);
72
101
 
73
- const reasoning = reasoningOptions(modelPreference);
102
+ const reasoning = reasoningOptions(modelPreference, modelSnapshot);
74
103
  const initialReasoning = reasoning.some((row) => row.value === existing.reasoningEffort) ? existing.reasoningEffort : reasoning[0].value;
75
104
  const reasoningEffort = stopIfCancelled(await select({ message: 'Reasoning level', initialValue: initialReasoning, options: reasoning }));
76
105
 
77
- const tiers = serviceTierOptions(modelPreference);
106
+ const tiers = serviceTierOptions(modelPreference, modelSnapshot);
78
107
  const initialTier = tiers.some((row) => row.value === existing.serviceTier) ? existing.serviceTier : tiers[0].value;
79
108
  const serviceTier = stopIfCancelled(await select({ message: 'Processing', initialValue: initialTier, options: tiers }));
80
109
 
81
- return { modelPreference, reasoningEffort, serviceTier };
110
+ return { modelPreference, modelSnapshot, reasoningEffort, serviceTier };
82
111
  }
83
112
 
84
- export async function configureCliPreferences({ cwd = process.cwd(), firstRun = false, section = 'all', modelStatusOptions } = {}) {
113
+ export async function configureCliPreferences({ cwd = process.cwd(), firstRun = false, section = 'all', modelStatusOptions, home, env = process.env } = {}) {
85
114
  let identity = detectCliProject(cwd);
86
115
  const existing = readCliPreferences(identity.root) || {};
87
116
  intro(firstRun ? `You are in ${identity.root}` : section === 'model' ? 'Agent Sam model' : 'Agent Sam settings');
@@ -100,8 +129,10 @@ export async function configureCliPreferences({ cwd = process.cwd(), firstRun =
100
129
  }
101
130
  }
102
131
 
103
- let runtime = existing.runtime || 'local';
104
- let terminal = existing.terminal || availableShells()[0];
132
+ const accountConnected = Boolean(readAccountSession({ home, env })?.account_id);
133
+ const runtimes = runtimeOptions({ accountConnected });
134
+ let runtime = runtimes.some((row) => row.value === existing.runtime) ? existing.runtime : 'local';
135
+ let terminal = existing.terminal || availableShells(env)[0];
105
136
  if (!firstRun && section === 'all') {
106
137
  const projectChoice = stopIfCancelled(await select({
107
138
  message: 'Where should Agent Sam work?',
@@ -124,20 +155,16 @@ export async function configureCliPreferences({ cwd = process.cwd(), firstRun =
124
155
  }
125
156
  runtime = stopIfCancelled(await select({
126
157
  message: 'Runtime', initialValue: runtime,
127
- options: [
128
- { value: 'local', label: 'Local machine', hint: 'real shell + filesystem' },
129
- { value: 'remote', label: 'Remote', hint: 'connected remote runtime' },
130
- { value: 'sandbox', label: 'Sandbox', hint: 'isolated disposable runtime' },
131
- ],
158
+ options: runtimes,
132
159
  }));
133
- const shells = availableShells();
160
+ const shells = availableShells(env);
134
161
  terminal = stopIfCancelled(await select({
135
162
  message: 'Terminal', initialValue: terminal && shells.includes(terminal) ? terminal : shells[0],
136
163
  options: shells.map((shell) => ({ value: shell, label: shell, hint: shell === shells[0] ? 'detected/default' : undefined })),
137
164
  }));
138
165
  }
139
166
 
140
- const model = await promptModelPreferences(identity, existing, { modelStatusOptions });
167
+ const model = await promptModelPreferences(identity, existing, { modelStatusOptions: { home, env, ...(modelStatusOptions || {}) } });
141
168
  const preferences = writeCliPreferences(identity.root, { trustedDirectory, runtime, terminal, ...model });
142
169
  outro(`Ready · ${identity.project}`);
143
170
  return { identity, preferences };