@yeaft/webchat-agent 1.0.189 → 1.0.191

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.
Binary file
@@ -16,6 +16,6 @@
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
19
- <script type="module" src="app.bundle.js?v=7cd1e7c0"></script>
19
+ <script type="module" src="app.bundle.js?v=c7eae9cf"></script>
20
20
  </body>
21
21
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.189",
3
+ "version": "1.0.191",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
- import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
14
+ import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
15
  import { normaliseYeaftSection } from './config.js';
16
16
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
17
17
 
@@ -54,6 +54,58 @@ export function getLlmConfig(dir) {
54
54
  }
55
55
  }
56
56
 
57
+ function providerModelIds(provider) {
58
+ return new Set(normalizeProviderModels(provider).map(model => model.id));
59
+ }
60
+
61
+ function bareModelOwners(providers, modelId) {
62
+ const owners = new Set();
63
+ for (const provider of providers) {
64
+ if (provider?.name && providerModelIds(provider).has(modelId)) owners.add(provider.name);
65
+ }
66
+ return owners;
67
+ }
68
+
69
+ function modelSelectionIsValid(selection, providers, authoritativeManagedNames) {
70
+ if (!selection) return false;
71
+ const parsed = parseModelRef(selection);
72
+ if (!parsed.modelId) return false;
73
+ if (!parsed.providerName) return bareModelOwners(providers, parsed.modelId).size === 1;
74
+ if (!authoritativeManagedNames.has(parsed.providerName)) return true;
75
+ const provider = providers.find(item => item?.name === parsed.providerName);
76
+ return !!provider && providerModelIds(provider).has(parsed.modelId);
77
+ }
78
+
79
+ function normalizeManagedModelDefaults(config) {
80
+ const providers = Array.isArray(config.providers) ? config.providers : [];
81
+ const authoritativeManagedProviders = providers.filter(provider =>
82
+ isGitHubCopilotProvider(provider)
83
+ && Array.isArray(provider.models)
84
+ && provider.models.length > 0);
85
+ if (authoritativeManagedProviders.length === 0) return;
86
+
87
+ const authoritativeManagedNames = new Set(authoritativeManagedProviders.map(provider => provider.name));
88
+ const fallbackProvider = authoritativeManagedProviders.find(provider => providerModelIds(provider).size > 0);
89
+ const fallbackModelId = fallbackProvider
90
+ ? providerModelIds(fallbackProvider).values().next().value
91
+ : null;
92
+ const managedFallback = fallbackProvider && fallbackModelId
93
+ ? `${fallbackProvider.name}/${fallbackModelId}`
94
+ : null;
95
+
96
+ if (config.primaryModel
97
+ && !modelSelectionIsValid(config.primaryModel, providers, authoritativeManagedNames)) {
98
+ config.primaryModel = managedFallback;
99
+ }
100
+ const validPrimary = modelSelectionIsValid(config.primaryModel, providers, authoritativeManagedNames)
101
+ ? config.primaryModel
102
+ : managedFallback;
103
+ if (config.fastModel
104
+ && !modelSelectionIsValid(config.fastModel, providers, authoritativeManagedNames)) {
105
+ config.fastModel = validPrimary;
106
+ }
107
+ }
108
+
57
109
  /**
58
110
  * Update the LLM-relevant portion of config.json.
59
111
  * Merges into existing config — preserves fields like debug, maxContextTokens, etc.
@@ -109,13 +161,16 @@ export function updateLlmConfig(update, dir) {
109
161
  });
110
162
  }
111
163
 
112
- // Update model selections
164
+ // Update model selections. A managed provider catalog is authoritative:
165
+ // when it drops the old Agent default, keep no hidden reference to that
166
+ // model in primaryModel or fastModel.
113
167
  if (update.primaryModel !== undefined) {
114
168
  existing.primaryModel = update.primaryModel || null;
115
169
  }
116
170
  if (update.fastModel !== undefined) {
117
171
  existing.fastModel = update.fastModel || null;
118
172
  }
173
+ if (update.providers !== undefined) normalizeManagedModelDefaults(existing);
119
174
  if (update.language !== undefined) {
120
175
  existing.language = update.language;
121
176
  }
package/yeaft/engine.js CHANGED
@@ -683,14 +683,7 @@ export class Engine {
683
683
  conversationId: this.#traceId,
684
684
  });
685
685
 
686
- // Build fast config: uses fastModelId for internal tasks (recall, consolidation, dream)
687
- // Falls back to primary model if no fastModel configured
688
- const fastModelId = config.fastModelId || config.model;
689
- if (fastModelId !== config.model) {
690
- this.#fastConfig = { ...config, model: fastModelId };
691
- } else {
692
- this.#fastConfig = config;
693
- }
686
+ this.refreshConfig(config);
694
687
  }
695
688
 
696
689
  /**
@@ -759,6 +752,21 @@ export class Engine {
759
752
  this.#config.language = lang;
760
753
  }
761
754
 
755
+ /**
756
+ * Replace the effective runtime config for subsequent queries without
757
+ * interrupting a turn that already captured its model.
758
+ *
759
+ * @param {object} config
760
+ */
761
+ refreshConfig(config) {
762
+ if (!config || typeof config !== 'object') return;
763
+ this.#config = config;
764
+ const fastModelId = config.fastModelId || config.model;
765
+ this.#fastConfig = fastModelId !== config.model
766
+ ? { ...config, model: fastModelId }
767
+ : config;
768
+ }
769
+
762
770
  /**
763
771
  * Hot-swap runtime managers for a project-bound Session. The ToolRegistry is
764
772
  * shared at the bridge/session layer; these references only decide which
@@ -27,6 +27,7 @@ import {
27
27
  GITHUB_COPILOT_BASE_URL,
28
28
  GITHUB_COPILOT_CREDENTIAL_PROVIDER,
29
29
  GITHUB_COPILOT_PROVIDER_NAME,
30
+ isGitHubCopilotProvider,
30
31
  normalizeKnownProviderForRuntime,
31
32
  } from './known-providers.js';
32
33
  import { pairSanitize } from '../pair-sanitize.js';
@@ -238,9 +239,12 @@ export class AdapterRouter extends LLMAdapter {
238
239
  /** @type {Map<string, LLMAdapter>} providerName::protocol → cached adapter */
239
240
  #adapterCache;
240
241
 
241
- /** @type {object[]} raw providers array */
242
+ /** @type {object[]} normalized providers array */
242
243
  #providers;
243
244
 
245
+ /** @type {Set<string>} managed providers backed by an explicit model catalog */
246
+ #authoritativeManagedProviders;
247
+
244
248
  /** @type {number} per-SSE-chunk silence budget; <= 0 disables the guard */
245
249
  #streamIdleTimeoutMs;
246
250
 
@@ -267,8 +271,13 @@ export class AdapterRouter extends LLMAdapter {
267
271
  * @param {object[]} providers
268
272
  */
269
273
  refreshProviders(providers) {
270
- this.#providers = (Array.isArray(providers) ? providers : [])
271
- .map(provider => normalizeKnownProviderForRuntime(provider));
274
+ const rawProviders = Array.isArray(providers) ? providers : [];
275
+ this.#authoritativeManagedProviders = new Set(rawProviders
276
+ .filter(provider => isGitHubCopilotProvider(provider)
277
+ && Array.isArray(provider.models)
278
+ && provider.models.length > 0)
279
+ .map(provider => provider.name || GITHUB_COPILOT_PROVIDER_NAME));
280
+ this.#providers = rawProviders.map(provider => normalizeKnownProviderForRuntime(provider));
272
281
  this.#modelToProvider = new Map();
273
282
  this.#adapterCache = new Map();
274
283
 
@@ -306,6 +315,10 @@ export class AdapterRouter extends LLMAdapter {
306
315
 
307
316
  const candidates = this.#providers.filter(p => p && p.name === parsed.providerName);
308
317
  if (candidates.length === 0) return null;
318
+ // An explicit managed catalog is authoritative. A qualified ref is not
319
+ // permission to resurrect a model that catalog just removed. Legacy rows
320
+ // with no models still use the bundled fallback catalog above.
321
+ if (this.#authoritativeManagedProviders.has(parsed.providerName)) return null;
309
322
 
310
323
  const inferred = inferProtocolFromModelId(parsed.modelId);
311
324
  if (!inferred) return null;
@@ -7,30 +7,27 @@
7
7
  * Session config is user-level state. Project `.yeaft` directories may provide
8
8
  * project assets such as skills/MCP config, but must not own Session config.
9
9
  *
10
- * v1 schema (intentionally tiny — extend via additive keys only):
10
+ * Public v1 schema:
11
11
  * {
12
12
  * "model": "my-proxy/claude-sonnet-4-20250514", // optional
13
13
  * "modelEffort": "high" // optional
14
14
  * }
15
15
  *
16
- * Missing file or `{}` no session-level override. Missing field → fall back to user-level
17
- * config (`~/.yeaft/config.json` via loadConfig()). Resolution is a
18
- * shallow overlay for send-time effective config.
19
- *
20
- * Storage layer only — no engine wiring, no validation of model strings
21
- * against the provider registry (that's done lazily at resolve time by
22
- * the engine when it tries to dispatch to AdapterRouter).
16
+ * Missing file or `{}` means no Session-level override. Managed provider
17
+ * catalogs are authoritative: an override removed from such a catalog cannot
18
+ * reach the adapter and falls back to the current Agent default.
23
19
  */
24
20
 
25
21
  import { existsSync, readFileSync } from 'fs';
26
22
  import { join } from 'path';
27
23
  import { writeAtomic } from '../storage/index.js';
24
+ import { isGitHubCopilotProvider } from '../llm/known-providers.js';
28
25
  import { sessionsRoot } from './session-crud.js';
29
26
 
30
27
  const CONFIG_FILE = 'config.json';
31
-
32
- /** Whitelist of persisted session model-override fields. Reject everything else. */
33
- const ALLOWED_KEYS = new Set(['model', 'modelEffort']);
28
+ const MODEL_SOURCE_EXPLICIT = 'explicit';
29
+ const WRITABLE_KEYS = new Set(['model', 'modelEffort']);
30
+ const STORED_KEYS = new Set([...WRITABLE_KEYS, 'modelSource']);
34
31
  const ALLOWED_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
35
32
 
36
33
  export class SessionConfigError extends Error {
@@ -41,36 +38,30 @@ export class SessionConfigError extends Error {
41
38
  }
42
39
  }
43
40
 
44
- /**
45
- * Resolve the on-disk path for a session's config.json. Always uses the
46
- * agent-local Yeaft root (`~/.yeaft` in production), not a project `.yeaft`.
47
- */
41
+ /** Resolve the agent-local path for a Session's config.json. */
48
42
  export function sessionConfigPath(yeaftDir, sessionId) {
49
43
  if (!yeaftDir) return null;
50
44
  return join(sessionsRoot(yeaftDir), sessionId, CONFIG_FILE);
51
45
  }
52
46
 
53
- /**
54
- * Read a session's config.json. Returns `{}` when the file is missing or
55
- * corrupt callers fall back to user-level defaults via
56
- * `resolveSessionConfig`. We never auto-write on read.
57
- *
58
- * @param {string} yeaftDir
59
- * @param {string} sessionId
60
- * @returns {object}
61
- */
62
- export function loadSessionConfig(yeaftDir, sessionId) {
47
+ function publicConfig(stored) {
48
+ const out = {};
49
+ for (const key of WRITABLE_KEYS) {
50
+ if (Object.prototype.hasOwnProperty.call(stored || {}, key)) out[key] = stored[key];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function loadStoredSessionConfig(yeaftDir, sessionId) {
63
56
  if (!sessionId || !yeaftDir) return {};
64
57
  const path = sessionConfigPath(yeaftDir, sessionId);
65
58
  if (!path || !existsSync(path)) return {};
66
59
  try {
67
60
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
68
61
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
69
- // Strip unknown keys at read time too — defensive against
70
- // hand-edited files that predate a key removal.
71
62
  const out = {};
72
- for (const k of Object.keys(parsed)) {
73
- if (ALLOWED_KEYS.has(k)) out[k] = parsed[k];
63
+ for (const key of Object.keys(parsed)) {
64
+ if (STORED_KEYS.has(key)) out[key] = parsed[key];
74
65
  }
75
66
  return out;
76
67
  } catch {
@@ -78,21 +69,27 @@ export function loadSessionConfig(yeaftDir, sessionId) {
78
69
  }
79
70
  }
80
71
 
72
+ function persistStoredSessionConfig(yeaftDir, sessionId, stored) {
73
+ const path = sessionConfigPath(yeaftDir, sessionId);
74
+ writeAtomic(path, `${JSON.stringify(stored, null, 2)}\n`);
75
+ }
76
+
81
77
  /**
82
- * Validate a partial group-config object. Throws SessionConfigError on
83
- * any unknown key or malformed value. Empty / null values for known
84
- * keys are allowed — they signal "fall back to user default".
85
- *
86
- * @param {object} cfg
78
+ * Read public Session config. Internal provenance never crosses this boundary.
87
79
  */
80
+ export function loadSessionConfig(yeaftDir, sessionId) {
81
+ return publicConfig(loadStoredSessionConfig(yeaftDir, sessionId));
82
+ }
83
+
84
+ /** Validate a user/wire supplied partial Session config. */
88
85
  export function validateSessionConfig(cfg) {
89
86
  if (cfg === null || cfg === undefined) return;
90
87
  if (typeof cfg !== 'object' || Array.isArray(cfg)) {
91
88
  throw new SessionConfigError('invalid_shape', 'config must be an object');
92
89
  }
93
- for (const k of Object.keys(cfg)) {
94
- if (!ALLOWED_KEYS.has(k)) {
95
- throw new SessionConfigError('unknown_key', `unknown config key: ${k}`);
90
+ for (const key of Object.keys(cfg)) {
91
+ if (!WRITABLE_KEYS.has(key)) {
92
+ throw new SessionConfigError('unknown_key', `unknown config key: ${key}`);
96
93
  }
97
94
  }
98
95
  if ('model' in cfg && cfg.model !== null && cfg.model !== undefined && cfg.model !== '') {
@@ -108,68 +105,154 @@ export function validateSessionConfig(cfg) {
108
105
  }
109
106
 
110
107
  /**
111
- * Shallow-merge `partial` into the group's existing config.json and
112
- * persist atomically. Returns the resulting object. Passing `null` or
113
- * empty string for a known key removes that key (so the group falls
114
- * back to the user default).
115
- *
116
- * @param {string} yeaftDir
117
- * @param {string} sessionId
118
- * @param {object} partial
119
- * @returns {object}
108
+ * Persist an explicit Session config change. `modelSource` is internal and is
109
+ * set here rather than accepted from clients, so future migrations can
110
+ * distinguish a user choice from the retired automatic default seed.
120
111
  */
121
112
  export function saveSessionConfig(yeaftDir, sessionId, partial) {
122
113
  if (!sessionId) throw new SessionConfigError('missing_group_id', 'sessionId required');
123
114
  validateSessionConfig(partial);
124
- const current = loadSessionConfig(yeaftDir, sessionId);
125
- const next = { ...current };
126
- for (const [k, v] of Object.entries(partial || {})) {
127
- if (!ALLOWED_KEYS.has(k)) continue;
128
- if (v === null || v === undefined || v === '') {
129
- delete next[k];
130
- } else {
131
- next[k] = typeof v === 'string' ? v.trim() : v;
115
+ const next = { ...loadStoredSessionConfig(yeaftDir, sessionId) };
116
+ for (const [key, value] of Object.entries(partial || {})) {
117
+ if (!WRITABLE_KEYS.has(key)) continue;
118
+ if (value === null || value === undefined || value === '') {
119
+ delete next[key];
120
+ if (key === 'model') delete next.modelSource;
121
+ continue;
132
122
  }
123
+ next[key] = typeof value === 'string' ? value.trim() : value;
124
+ if (key === 'model') next.modelSource = MODEL_SOURCE_EXPLICIT;
133
125
  }
134
- const path = sessionConfigPath(yeaftDir, sessionId);
135
- writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
136
- return next;
126
+ persistStoredSessionConfig(yeaftDir, sessionId, next);
127
+ return publicConfig(next);
137
128
  }
138
129
 
139
- /**
140
- * Initialise an empty config.json next to a brand-new group. Idempotent —
141
- * leaves an existing file untouched. Called by `createSessionFromSpec`.
142
- */
130
+ /** Create an empty Session config file without inventing an override. */
143
131
  export function ensureSessionConfigFile(yeaftDir, sessionId) {
144
132
  const path = sessionConfigPath(yeaftDir, sessionId);
145
133
  if (!path || existsSync(path)) return;
146
134
  try {
147
- writeAtomic(path, `${JSON.stringify({}, null, 2)}\n`);
135
+ persistStoredSessionConfig(yeaftDir, sessionId, {});
148
136
  } catch {
149
- // Best-effort — a permission failure here should never break group
150
- // create. Read path returns {} on missing file anyway.
137
+ // Best-effort — a permission failure must not break Session creation.
151
138
  }
152
139
  }
153
140
 
141
+ function modelRefIdentity(value) {
142
+ const text = String(value || '');
143
+ const slash = text.indexOf('/');
144
+ return slash < 0
145
+ ? { provider: '', modelId: text }
146
+ : { provider: text.slice(0, slash), modelId: text.slice(slash + 1) };
147
+ }
148
+
149
+ function modelRefsEquivalent(left, right) {
150
+ if (!left || !right) return false;
151
+ if (left === right) return true;
152
+ const a = modelRefIdentity(left);
153
+ const b = modelRefIdentity(right);
154
+ if (a.modelId !== b.modelId) return false;
155
+ return !a.provider || !b.provider || a.provider === b.provider;
156
+ }
157
+
158
+ function hasAuthoritativeCatalog(provider) {
159
+ if (!provider || typeof provider !== 'object') return false;
160
+ if (provider.managed === true) return true;
161
+ if (typeof provider.managed === 'string' && provider.managed.trim()) return true;
162
+ return isGitHubCopilotProvider(provider);
163
+ }
164
+
165
+ function resolveAllowedModelRef(config, modelRef) {
166
+ const identity = modelRefIdentity(modelRef);
167
+ const providers = Array.isArray(config?.providers) ? config.providers : [];
168
+ const availableModels = Array.isArray(config?.availableModels) ? config.availableModels : [];
169
+
170
+ if (identity.provider) {
171
+ const provider = providers.find(item => item?.name === identity.provider);
172
+ if (!hasAuthoritativeCatalog(provider)) return modelRef;
173
+ const match = availableModels.find(model => model?.ref === modelRef
174
+ || (model?.provider === identity.provider && model?.id === identity.modelId));
175
+ return match?.ref || (match ? `${identity.provider}/${identity.modelId}` : null);
176
+ }
177
+
178
+ const authoritativeProviderNames = new Set(providers
179
+ .filter(hasAuthoritativeCatalog)
180
+ .map(provider => provider.name)
181
+ .filter(Boolean));
182
+ if (authoritativeProviderNames.size === 0) return modelRef;
183
+
184
+ const candidates = new Map();
185
+ for (const model of availableModels) {
186
+ const candidateIdentity = modelRefIdentity(model?.ref || '');
187
+ const candidateId = model?.id || candidateIdentity.modelId;
188
+ const providerName = model?.provider || candidateIdentity.provider;
189
+ if (candidateId !== identity.modelId || !providerName) continue;
190
+ candidates.set(model?.ref || `${providerName}/${candidateId}`, providerName);
191
+ }
192
+ if (candidates.size !== 1) return null;
193
+ const [[ref]] = candidates;
194
+ return ref;
195
+ }
196
+
154
197
  /**
155
- * Resolve the effective config for a group by overlaying group-level
156
- * overrides on top of the user-level config.
157
- *
158
- * Only fields that the per-group schema knows about are overlaid;
159
- * everything else (providers, language, token budgets, ...) is taken
160
- * verbatim from the user config.
198
+ * Normalize persisted config at the Agent/Session boundary.
161
199
  *
162
- * @param {object} userConfig loadConfig() result
163
- * @param {object} sessionConfig loadSessionConfig() result
164
- * @returns {object} A new config object safe to hand to the engine.
200
+ * - Managed catalog misses are always removed: they are no longer valid.
201
+ * - Legacy rows have no source bit. An unmarked value equal to the Agent's
202
+ * previous default is the retired automatic seed and becomes inheritance.
203
+ * - Values written through the explicit update path carry `modelSource` and
204
+ * are preserved while they remain in the authoritative catalog.
165
205
  */
206
+ export function normalizeSessionConfig(
207
+ yeaftDir,
208
+ sessionId,
209
+ userConfig,
210
+ { previousDefaultModel = null } = {},
211
+ ) {
212
+ const stored = loadStoredSessionConfig(yeaftDir, sessionId);
213
+ if (!stored.model || typeof stored.model !== 'string') return publicConfig(stored);
214
+
215
+ const allowedModel = resolveAllowedModelRef(userConfig, stored.model);
216
+ // The retired create path wrote the then-current Agent default without a
217
+ // source bit. New explicit writes always carry modelSource, so this is the
218
+ // only deterministic compatibility rule available for legacy rows.
219
+ const legacyAutoSeed = stored.modelSource !== MODEL_SOURCE_EXPLICIT
220
+ && modelRefsEquivalent(stored.model, previousDefaultModel);
221
+ if (allowedModel && !legacyAutoSeed) {
222
+ const next = { ...stored };
223
+ let changed = false;
224
+ if (next.model !== allowedModel) {
225
+ next.model = allowedModel;
226
+ changed = true;
227
+ }
228
+ // Resolve every legacy row once while the old Agent default is known. A
229
+ // different value could only have come from an explicit Session choice;
230
+ // backfill provenance so a later default change cannot misclassify it.
231
+ if (previousDefaultModel && next.modelSource !== MODEL_SOURCE_EXPLICIT) {
232
+ next.modelSource = MODEL_SOURCE_EXPLICIT;
233
+ changed = true;
234
+ }
235
+ if (changed) persistStoredSessionConfig(yeaftDir, sessionId, next);
236
+ return publicConfig(next);
237
+ }
238
+
239
+ const next = { ...stored };
240
+ delete next.model;
241
+ delete next.modelSource;
242
+ persistStoredSessionConfig(yeaftDir, sessionId, next);
243
+ return publicConfig(next);
244
+ }
245
+
246
+ /** Overlay a valid Session override on the Agent config. */
166
247
  export function resolveSessionConfig(userConfig, sessionConfig) {
167
248
  const base = userConfig ? { ...userConfig } : {};
168
249
  const overrides = sessionConfig && typeof sessionConfig === 'object' ? sessionConfig : {};
169
250
  if (overrides.model && typeof overrides.model === 'string' && overrides.model.trim()) {
170
- const model = overrides.model.trim();
171
- base.model = model;
172
- base.primaryModel = model;
251
+ const model = resolveAllowedModelRef(base, overrides.model.trim());
252
+ if (model) {
253
+ base.model = model;
254
+ base.primaryModel = model;
255
+ }
173
256
  }
174
257
  if (overrides.modelEffort && typeof overrides.modelEffort === 'string' && ALLOWED_EFFORTS.has(overrides.modelEffort)) {
175
258
  base.modelEffort = overrides.modelEffort;