@yeaft/webchat-agent 0.1.802 → 0.1.804

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyUpdateGroupConfig, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -476,6 +476,9 @@ export async function handleMessage(msg) {
476
476
  case 'unify_update_group':
477
477
  handleUnifyUpdateGroup(msg);
478
478
  break;
479
+ case 'unify_update_group_config':
480
+ handleUnifyUpdateGroupConfig(msg);
481
+ break;
479
482
  case 'unify_archive_group':
480
483
  handleUnifyArchiveGroup(msg);
481
484
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.802",
3
+ "version": "0.1.804",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,167 @@
1
+ /**
2
+ * group-config.js — Per-group configuration overrides.
3
+ *
4
+ * Each group may carry its own `config.json` at
5
+ * ~/.yeaft/groups/<groupId>/config.json
6
+ *
7
+ * v1 schema (intentionally tiny — extend via additive keys only):
8
+ * {
9
+ * "model": "my-proxy/claude-sonnet-4-20250514" // optional
10
+ * }
11
+ *
12
+ * Missing file → empty object. Missing field → fall back to user-level
13
+ * config (`~/.yeaft/config.json` via loadConfig()). Resolution is a
14
+ * shallow overlay (group fields override user fields when truthy).
15
+ *
16
+ * Storage layer only — no engine wiring, no validation of model strings
17
+ * against the provider registry (that's done lazily at resolve time by
18
+ * the engine when it tries to dispatch to AdapterRouter).
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { writeAtomic } from '../storage/index.js';
24
+ import { groupsRoot, resolveGroupYeaftDir } from './group-crud.js';
25
+
26
+ const CONFIG_FILE = 'config.json';
27
+
28
+ /** Whitelist of fields a group may override. Reject everything else. */
29
+ const ALLOWED_KEYS = new Set(['model']);
30
+
31
+ export class GroupConfigError extends Error {
32
+ constructor(code, message) {
33
+ super(message || code);
34
+ this.name = 'GroupConfigError';
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Resolve the on-disk path for a group's config.json. Honours the
41
+ * per-group workDir registry so groups bound to a project directory
42
+ * keep their config alongside the group meta.
43
+ */
44
+ export function groupConfigPath(yeaftDir, groupId) {
45
+ if (!yeaftDir) return null;
46
+ const groupYeaftDir = resolveGroupYeaftDir(yeaftDir, groupId);
47
+ return join(groupsRoot(groupYeaftDir), groupId, CONFIG_FILE);
48
+ }
49
+
50
+ /**
51
+ * Read a group's config.json. Returns `{}` when the file is missing or
52
+ * corrupt — callers fall back to user-level defaults via
53
+ * `resolveGroupConfig`. We never auto-write on read.
54
+ *
55
+ * @param {string} yeaftDir
56
+ * @param {string} groupId
57
+ * @returns {object}
58
+ */
59
+ export function loadGroupConfig(yeaftDir, groupId) {
60
+ if (!groupId || !yeaftDir) return {};
61
+ const path = groupConfigPath(yeaftDir, groupId);
62
+ if (!path || !existsSync(path)) return {};
63
+ try {
64
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
65
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
66
+ // Strip unknown keys at read time too — defensive against
67
+ // hand-edited files that predate a key removal.
68
+ const out = {};
69
+ for (const k of Object.keys(parsed)) {
70
+ if (ALLOWED_KEYS.has(k)) out[k] = parsed[k];
71
+ }
72
+ return out;
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Validate a partial group-config object. Throws GroupConfigError on
80
+ * any unknown key or malformed value. Empty / null values for known
81
+ * keys are allowed — they signal "fall back to user default".
82
+ *
83
+ * @param {object} cfg
84
+ */
85
+ export function validateGroupConfig(cfg) {
86
+ if (cfg === null || cfg === undefined) return;
87
+ if (typeof cfg !== 'object' || Array.isArray(cfg)) {
88
+ throw new GroupConfigError('invalid_shape', 'config must be an object');
89
+ }
90
+ for (const k of Object.keys(cfg)) {
91
+ if (!ALLOWED_KEYS.has(k)) {
92
+ throw new GroupConfigError('unknown_key', `unknown config key: ${k}`);
93
+ }
94
+ }
95
+ if ('model' in cfg && cfg.model !== null && cfg.model !== undefined && cfg.model !== '') {
96
+ if (typeof cfg.model !== 'string' || !cfg.model.trim()) {
97
+ throw new GroupConfigError('invalid_model', 'model must be a non-empty string');
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Shallow-merge `partial` into the group's existing config.json and
104
+ * persist atomically. Returns the resulting object. Passing `null` or
105
+ * empty string for a known key removes that key (so the group falls
106
+ * back to the user default).
107
+ *
108
+ * @param {string} yeaftDir
109
+ * @param {string} groupId
110
+ * @param {object} partial
111
+ * @returns {object}
112
+ */
113
+ export function saveGroupConfig(yeaftDir, groupId, partial) {
114
+ if (!groupId) throw new GroupConfigError('missing_group_id', 'groupId required');
115
+ validateGroupConfig(partial);
116
+ const current = loadGroupConfig(yeaftDir, groupId);
117
+ const next = { ...current };
118
+ for (const [k, v] of Object.entries(partial || {})) {
119
+ if (!ALLOWED_KEYS.has(k)) continue;
120
+ if (v === null || v === undefined || v === '') {
121
+ delete next[k];
122
+ } else {
123
+ next[k] = typeof v === 'string' ? v.trim() : v;
124
+ }
125
+ }
126
+ const path = groupConfigPath(yeaftDir, groupId);
127
+ writeAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
128
+ return next;
129
+ }
130
+
131
+ /**
132
+ * Initialise an empty config.json next to a brand-new group. Idempotent —
133
+ * leaves an existing file untouched. Called by `createGroupFromSpec`.
134
+ */
135
+ export function ensureGroupConfigFile(yeaftDir, groupId) {
136
+ const path = groupConfigPath(yeaftDir, groupId);
137
+ if (!path || existsSync(path)) return;
138
+ try {
139
+ writeAtomic(path, `${JSON.stringify({}, null, 2)}\n`);
140
+ } catch {
141
+ // Best-effort — a permission failure here should never break group
142
+ // create. Read path returns {} on missing file anyway.
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve the effective config for a group by overlaying group-level
148
+ * overrides on top of the user-level config.
149
+ *
150
+ * Only fields that the per-group schema knows about are overlaid;
151
+ * everything else (providers, language, token budgets, ...) is taken
152
+ * verbatim from the user config.
153
+ *
154
+ * @param {object} userConfig — loadConfig() result
155
+ * @param {object} groupConfig — loadGroupConfig() result
156
+ * @returns {object} — A new config object safe to hand to the engine.
157
+ */
158
+ export function resolveGroupConfig(userConfig, groupConfig) {
159
+ const base = userConfig ? { ...userConfig } : {};
160
+ const overrides = groupConfig && typeof groupConfig === 'object' ? groupConfig : {};
161
+ if (overrides.model && typeof overrides.model === 'string' && overrides.model.trim()) {
162
+ const model = overrides.model.trim();
163
+ base.model = model;
164
+ base.primaryModel = model;
165
+ }
166
+ return base;
167
+ }
@@ -53,6 +53,7 @@ import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
53
53
  import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
54
54
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
55
55
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
56
+ import { ensureGroupConfigFile, saveGroupConfig, loadGroupConfig } from './group-config.js';
56
57
 
57
58
  /**
58
59
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -263,6 +264,20 @@ export function createGroupFromSpec(yeaftDir, spec, options = {}) {
263
264
  handle.close();
264
265
  if (normalizedWorkDir) registerGroupWorkDir(yeaftDir, id, normalizedWorkDir);
265
266
 
267
+ // Per-group config (v1: model only). We always create an empty
268
+ // config.json so the file's presence signals "owned by this group" and
269
+ // hand-editing tools can find a stub. Initial overrides from the
270
+ // wizard spec (currently just `config.model`) are persisted here so
271
+ // the engine cache picks them up on the very first turn.
272
+ try {
273
+ ensureGroupConfigFile(yeaftDir, id);
274
+ if (spec && spec.config && typeof spec.config === 'object') {
275
+ saveGroupConfig(yeaftDir, id, spec.config);
276
+ }
277
+ } catch (err) {
278
+ console.warn(`[group-crud] failed to seed config.json for ${id}:`, err?.message || err);
279
+ }
280
+
266
281
  // Seed Layer-A resident summary so the first session has memory content
267
282
  // even before Dream-v2 has run. No-op if a summary.md already exists.
268
283
  // Best-effort: a memory-root permission failure must NOT break group create.
@@ -313,6 +328,22 @@ export function updateGroupAnnouncement(yeaftDir, groupId, text) {
313
328
  return next;
314
329
  }
315
330
 
331
+ /**
332
+ * (A.2.c) Update per-group config overrides (v1: just `model`).
333
+ * Returns the persisted config object so the caller can broadcast it.
334
+ *
335
+ * Throws GroupConfigError on validation failure (unknown key, bad type).
336
+ * Group must exist (we call requireGroup to assert).
337
+ */
338
+ export function updateGroupConfig(yeaftDir, groupId, partial) {
339
+ const handle = requireGroup(yeaftDir, groupId);
340
+ try {
341
+ return saveGroupConfig(yeaftDir, groupId, partial || {});
342
+ } finally {
343
+ handle.close();
344
+ }
345
+ }
346
+
316
347
  /**
317
348
  * (A.3) Archive — renames the dir to `.archived-<ts>-<id>`. Directory
318
349
  * prefix `.` keeps `listGroups` from picking it up (readdirSync filter in
@@ -488,6 +519,11 @@ export function snapshotGroups(yeaftDir) {
488
519
  const meta = existsSync(dir) ? loadGroupMeta(dir) : null;
489
520
  if (meta) byId.set(meta.id, meta);
490
521
  }
522
+ // Attach per-group config overrides (v1: just `model`). Frontend can
523
+ // render the effective model without re-querying.
524
+ for (const meta of byId.values()) {
525
+ meta.config = loadGroupConfig(yeaftDir, meta.id);
526
+ }
491
527
  return Array.from(byId.values()).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
492
528
  }
493
529
 
@@ -50,7 +50,16 @@ export {
50
50
  removeMember,
51
51
  setGroupDefaultVp,
52
52
  snapshotGroups,
53
+ updateGroupConfig,
54
+ updateGroupAnnouncement,
53
55
  } from './group-crud.js';
56
+ export {
57
+ loadGroupConfig,
58
+ saveGroupConfig,
59
+ resolveGroupConfig,
60
+ validateGroupConfig,
61
+ GroupConfigError,
62
+ } from './group-config.js';
54
63
  export {
55
64
  nextMsgId,
56
65
  nextGroupId,
@@ -45,6 +45,8 @@ import {
45
45
  groupsRoot,
46
46
  } from './groups/group-crud.js';
47
47
  import { openGroup, loadGroupMeta } from './groups/group-store.js';
48
+ import { loadGroupConfig, resolveGroupConfig, GroupConfigError } from './groups/group-config.js';
49
+ import { updateGroupConfig } from './groups/group-crud.js';
48
50
  import { createCoordinator } from './groups/coordinator.js';
49
51
  import { seedDefaultGroup } from './groups/seed-default.js';
50
52
  import {
@@ -760,10 +762,16 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
760
762
  let eng = vpEngines.get(key);
761
763
  if (eng) return eng;
762
764
  if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
765
+ // Per-group config overlay (v1: model only). Falls back to the
766
+ // session's user-level config when no override is set. The resolver
767
+ // never mutates session.config — it returns a new object.
768
+ const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir;
769
+ const groupCfg = loadGroupConfig(yeaftDir, groupId);
770
+ const effectiveConfig = resolveGroupConfig(session.config, groupCfg);
763
771
  eng = new Engine({
764
772
  adapter: session.adapter,
765
773
  trace: session.trace,
766
- config: session.config,
774
+ config: effectiveConfig,
767
775
  conversationStore: session.conversationStore,
768
776
  memoryIndex: session.memoryIndex || null,
769
777
  amsRegistry: session.amsRegistry,
@@ -1318,8 +1326,11 @@ function sendGroupRosterChanged(group) {
1318
1326
  }
1319
1327
 
1320
1328
  function groupErrorPayload(err) {
1329
+ let code = 'unknown';
1330
+ if (err instanceof GroupCrudError) code = err.code;
1331
+ else if (err instanceof GroupConfigError) code = err.code;
1321
1332
  return {
1322
- code: err instanceof GroupCrudError ? err.code : 'unknown',
1333
+ code,
1323
1334
  groupId: err && err.groupId,
1324
1335
  message: err && err.message,
1325
1336
  };
@@ -1404,6 +1415,36 @@ export function handleUnifyUpdateGroup(msg) {
1404
1415
  }
1405
1416
  }
1406
1417
 
1418
+ /**
1419
+ * Update per-group config overrides (v1: `model`). Cache invalidation:
1420
+ * drop every cached Engine whose key starts with `${groupId}::` so the
1421
+ * next turn picks up the new model. The group meta itself is untouched.
1422
+ *
1423
+ * Payload: { groupId, requestId, config: { model?: string|null } }
1424
+ * - `model: ''` or `null` clears the override (group falls back to user default).
1425
+ */
1426
+ export function handleUnifyUpdateGroupConfig(msg) {
1427
+ const requestId = msg && msg.requestId;
1428
+ const groupId = msg && msg.groupId;
1429
+ const partial = (msg && msg.config && typeof msg.config === 'object') ? msg.config : null;
1430
+ try {
1431
+ if (!groupId) throw new GroupConfigError('missing_group_id', 'groupId required');
1432
+ if (!partial) throw new GroupConfigError('invalid_patch', 'config object required');
1433
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
1434
+ const savedConfig = updateGroupConfig(yeaftDir, groupId, partial);
1435
+ // Drop cached engines so the next VP turn rebuilds with the new model.
1436
+ const prefix = `${groupId}::`;
1437
+ for (const k of Array.from(vpEngines.keys())) {
1438
+ if (k.startsWith(prefix)) vpEngines.delete(k);
1439
+ }
1440
+ invalidateGroupContext(groupId);
1441
+ sendGroupCrudResult({ op: 'update_config', requestId, ok: true, groupId, config: savedConfig });
1442
+ sendGroupSnapshotBroadcast();
1443
+ } catch (err) {
1444
+ sendGroupCrudResult({ op: 'update_config', requestId, ok: false, error: groupErrorPayload(err) });
1445
+ }
1446
+ }
1447
+
1407
1448
  export function handleUnifyArchiveGroup(msg) {
1408
1449
  const requestId = msg && msg.requestId;
1409
1450
  const groupId = msg && msg.groupId;