@yeaft/webchat-agent 0.1.925 → 0.1.927

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.925",
3
+ "version": "0.1.927",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -529,7 +529,7 @@ export class DebugTrace {
529
529
  const target = typeof data.target === 'string' ? data.target : '';
530
530
  if (sessionId) {
531
531
  const isBroadcast = !evtGroupId && !target;
532
- const isThisGroup = evtGroupId === sessionId || target === `group/${sessionId}`;
532
+ const isThisGroup = evtGroupId === sessionId || target === `group/${sessionId}` || target === `session/${sessionId}`;
533
533
  if (!isBroadcast && !isThisGroup) continue;
534
534
  }
535
535
  dreamEvents.push({
@@ -6,7 +6,7 @@
6
6
  * 1. Per-group control state (used to decide whether a group enters
7
7
  * triage and how far to advance the cursor):
8
8
  *
9
- * ~/.yeaft/memory/group/<id>/.dream-state
9
+ * ~/.yeaft/memory/session/<id>/.dream-state
10
10
  *
11
11
  * A 3-line text file:
12
12
  *
@@ -18,7 +18,7 @@
18
18
  * empty / null / 0. The file is rewritten atomically every dream.
19
19
  *
20
20
  * The virtual `_no-group/` group lives at the same path layout
21
- * (`group/_no-group/.dream-state`) and uses the same accessor.
21
+ * (`session/_no-group/.dream-state`) and uses the same accessor.
22
22
  *
23
23
  * 2. Per-scope observability marker, embedded inside the scope's
24
24
  * `memory.md` between two HTML comments at the file's tail:
@@ -63,11 +63,19 @@ const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
63
63
  * @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number }>}
64
64
  */
65
65
  export async function readGroupState(root, sessionId) {
66
- const abs = join(root, 'group', sessionId, STATE_FILE);
66
+ const abs = join(root, 'session', sessionId, STATE_FILE);
67
+ const legacyAbs = join(root, 'group', sessionId, STATE_FILE);
67
68
  const empty = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
68
69
  let raw;
69
70
  try { raw = await fsp.readFile(abs, 'utf8'); }
70
- catch (err) { if (err && err.code === 'ENOENT') return empty; throw err; }
71
+ catch (err) {
72
+ if (!err || err.code !== 'ENOENT') throw err;
73
+ try { raw = await fsp.readFile(legacyAbs, 'utf8'); }
74
+ catch (legacyErr) {
75
+ if (legacyErr && legacyErr.code === 'ENOENT') return empty;
76
+ throw legacyErr;
77
+ }
78
+ }
71
79
  return parseGroupState(raw);
72
80
  }
73
81
 
@@ -80,7 +88,7 @@ export async function readGroupState(root, sessionId) {
80
88
  * @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number }} state
81
89
  */
82
90
  export async function writeGroupState(root, sessionId, state) {
83
- const dir = join(root, 'group', sessionId);
91
+ const dir = join(root, 'session', sessionId);
84
92
  await fsp.mkdir(dir, { recursive: true });
85
93
  const abs = join(dir, STATE_FILE);
86
94
  const body =
package/yeaft/init.js CHANGED
@@ -82,7 +82,6 @@ const SUBDIRS = [
82
82
  'chat/messages',
83
83
  'chat/cold',
84
84
  'chat/blobs',
85
- 'groups',
86
85
  'sessions',
87
86
  'memory/entries',
88
87
  'tasks',
@@ -193,7 +193,21 @@ export class AdapterRouter extends LLMAdapter {
193
193
  */
194
194
  constructor({ providers }) {
195
195
  super();
196
- this.#providers = providers;
196
+ this.#providers = [];
197
+ this.#modelToProvider = new Map();
198
+ this.#adapterCache = new Map();
199
+ this.refreshProviders(providers);
200
+ }
201
+
202
+ /**
203
+ * Replace the provider/model index after config.json changes. Existing
204
+ * provider adapters are dropped because credentials, baseUrl, protocol,
205
+ * or model ownership may have changed along with the list.
206
+ *
207
+ * @param {object[]} providers
208
+ */
209
+ refreshProviders(providers) {
210
+ this.#providers = Array.isArray(providers) ? providers : [];
197
211
  this.#modelToProvider = new Map();
198
212
  this.#adapterCache = new Map();
199
213
 
@@ -201,7 +215,7 @@ export class AdapterRouter extends LLMAdapter {
201
215
  // model id appears in multiple providers. Each model entry may declare
202
216
  // its own `protocol`; we keep the normalized entry so #effectiveProtocol
203
217
  // can consult it later without re-parsing.
204
- for (const provider of providers) {
218
+ for (const provider of this.#providers) {
205
219
  if (!Array.isArray(provider.models)) continue;
206
220
  for (const raw of provider.models) {
207
221
  const entry = normalizeModelEntry(raw);
@@ -43,6 +43,7 @@ import {
43
43
  readFileSync,
44
44
  renameSync,
45
45
  statSync,
46
+ rmSync,
46
47
  unlinkSync,
47
48
  writeFileSync,
48
49
  } from 'fs';
@@ -50,7 +51,7 @@ import { join } from 'path';
50
51
  import { openSegmentIndex } from '../memory/index-db.js';
51
52
 
52
53
  const SENTINEL = '.yeaft-migration.done';
53
- const SENTINEL_VERSION = 2;
54
+ const SENTINEL_VERSION = 3;
54
55
 
55
56
  /**
56
57
  * Run the sessions migration. No-op when sentinel exists.
@@ -216,9 +217,13 @@ export function migrateSessions(yeaftDir) {
216
217
  // so that any pre-rename row still on disk gets the new key shape.
217
218
  const frontmatterRewrites = rewriteAllMessageFrontmatter(yeaftDir, warnings);
218
219
 
219
- // 8. Sentinel version 2 = this consolidated migration (covers what the
220
- // old `.session-migration-v1.done` sentinel covered plus the frontmatter
221
- // rewrite).
220
+ // 8. Cleanup: if this is rerunning after a v2 sentinel, legacy groups/ or
221
+ // chats/ directories may have been recreated. Merge non-duplicate files
222
+ // into sessions/<id>, then remove empty legacy directories.
223
+ const cleanup = cleanupLegacySessionDirs(yeaftDir, warnings);
224
+ moved += cleanup.moved;
225
+
226
+ // 9. Sentinel — version 3 = consolidated migration plus legacy cleanup.
222
227
  writeFileSync(sentinel, JSON.stringify({
223
228
  version: SENTINEL_VERSION,
224
229
  migratedAt: new Date().toISOString(),
@@ -410,6 +415,58 @@ function listDirs(root) {
410
415
  return out;
411
416
  }
412
417
 
418
+ function cleanupLegacySessionDirs(yeaftDir, warnings) {
419
+ const sessionsRoot = join(yeaftDir, 'sessions');
420
+ let moved = 0;
421
+ for (const legacyName of ['groups', 'chats']) {
422
+ const legacyRoot = join(yeaftDir, legacyName);
423
+ if (!existsSync(legacyRoot)) continue;
424
+ for (const id of listDirs(legacyRoot)) {
425
+ const src = join(legacyRoot, id);
426
+ const dst = join(sessionsRoot, id);
427
+ if (!existsSync(dst)) continue;
428
+ mergeLegacyDirIntoSession(src, dst, warnings, `${legacyName}/${id}`);
429
+ removeDirIfEmpty(src, warnings, `${legacyName}/${id}`);
430
+ if (!existsSync(src)) moved++;
431
+ }
432
+ removeDirIfEmpty(legacyRoot, warnings, legacyName);
433
+ }
434
+ return { moved };
435
+ }
436
+
437
+ function mergeLegacyDirIntoSession(src, dst, warnings, label) {
438
+ if (!existsSync(src) || !existsSync(dst)) return;
439
+ let entries = [];
440
+ try { entries = readdirSync(src, { withFileTypes: true }); }
441
+ catch (err) { warnings.push(`${label}: failed to scan legacy dir: ${err.message}`); return; }
442
+
443
+ for (const ent of entries) {
444
+ const from = join(src, ent.name);
445
+ const to = join(dst, ent.name);
446
+ if (!existsSync(to)) {
447
+ try { renameSync(from, to); }
448
+ catch (err) { warnings.push(`${label}: failed to move ${ent.name}: ${err.message}`); }
449
+ continue;
450
+ }
451
+ if (ent.isDirectory()) {
452
+ mergeLegacyDirIntoSession(from, to, warnings, `${label}/${ent.name}`);
453
+ removeDirIfEmpty(from, warnings, `${label}/${ent.name}`);
454
+ } else {
455
+ warnings.push(`${label}: kept duplicate legacy file ${ent.name}`);
456
+ }
457
+ }
458
+ }
459
+
460
+ function removeDirIfEmpty(dir, warnings, label) {
461
+ if (!existsSync(dir)) return;
462
+ try {
463
+ const entries = readdirSync(dir);
464
+ if (entries.length === 0) rmSync(dir, { recursive: true, force: true });
465
+ } catch (err) {
466
+ warnings.push(`${label}: failed to remove empty legacy dir: ${err.message}`);
467
+ }
468
+ }
469
+
413
470
  function rewriteGroupMetaToSessionMeta(sessionDir, warnings) {
414
471
  const oldPath = join(sessionDir, 'group.json');
415
472
  const newPath = join(sessionDir, 'meta.json');
@@ -40,13 +40,13 @@ export function nextMsgId() {
40
40
  export function nextSessionId(slug = 'default') {
41
41
  // Slug-tolerant: lowercase a-z0-9_- only, capped at 24 chars so the
42
42
  // total id stays compact after the suffix is appended.
43
- const safe = String(slug).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 24) || 'group';
43
+ const safe = String(slug).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 24) || 'session';
44
44
  // Append 8 crockford-base32 chars (~40 bits) so re-creating a session
45
45
  // with the same display name yields a fresh id instead of throwing
46
46
  // `duplicate` on the existsSync check in session-crud.js. The
47
47
  // `duplicate` branch is now a true defensive guard rather than the
48
48
  // first-collision footgun it used to be.
49
- return `grp_${safe}_${randEncoded(8)}`;
49
+ return `session_${safe}_${randEncoded(8)}`;
50
50
  }
51
51
 
52
52
  /**
@@ -1,14 +1,14 @@
1
1
  /**
2
- * seed-default.js — First-boot default group (architecture §10 D1).
2
+ * seed-default.js — First-boot default session (architecture §10 D1).
3
3
  *
4
- * When multi-VP mode is first enabled for a user, seed a default group with
5
- * the provided roster (typically `[defaultVpId]`). Idempotent: if the group
4
+ * When multi-VP mode is first enabled for a user, seed a default session with
5
+ * the provided roster (typically `[defaultVpId]`). Idempotent: if the session
6
6
  * already exists on disk, returns the existing handle without overwriting.
7
7
  *
8
8
  * Separation from group-store.createSession:
9
9
  * - createSession throws on duplicate; seed returns the existing handle.
10
- * - seed picks a stable id `grp_default` so UI can deep-link to it.
11
- * - seed is the only place that writes the "default group exists" side
10
+ * - seed picks a stable id `session_default` so UI can deep-link to it.
11
+ * - seed is the only place that writes the "default session exists" side
12
12
  * effect during the bootstrap flow.
13
13
  */
14
14
 
@@ -18,20 +18,20 @@ import { homedir } from 'os';
18
18
  import { openSession, createSession, loadSessionMeta } from './session-store.js';
19
19
  import { seedSummaryIfMissingSync } from '../memory/store.js';
20
20
 
21
- export const DEFAULT_SESSION_ID = 'grp_default';
21
+ export const DEFAULT_SESSION_ID = 'session_default';
22
22
 
23
23
  /**
24
24
  * Default memory root used when callers don't pass `options.memoryRoot`.
25
- * See `groups/group-crud.js` and `vp/vp-crud.js` for the same default;
25
+ * See `sessions/session-crud.js` and `vp/vp-crud.js` for the same default;
26
26
  * production code threads `<yeaftDir>/memory` through to keep test/prod
27
27
  * isolation honest.
28
28
  */
29
29
  const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
30
30
 
31
31
  /**
32
- * Build the default-group seed summary body. Pulled into a helper so
32
+ * Build the default-session seed summary body. Pulled into a helper so
33
33
  * tests can pin the exact format. Mirrors `buildSessionSeedSummary` in
34
- * `group-crud.js` shape, with the "Default group" wording reserved for
34
+ * `session-crud.js` shape, with the "Default session" wording reserved for
35
35
  * the bootstrap path.
36
36
  *
37
37
  * @param {{ name?: string, roster?: string[], defaultVpId?: string|null }} spec
@@ -42,7 +42,7 @@ export function buildDefaultSessionSeedSummary(spec) {
42
42
  const roster = Array.isArray(spec?.roster) ? spec.roster : [];
43
43
  const defaultVpId = spec?.defaultVpId || null;
44
44
  const lines = [`# ${name}`, ''];
45
- lines.push(`Default group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
45
+ lines.push(`Default session with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
46
46
  if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
47
47
  if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
48
48
  return lines.join('\n').trim();
@@ -77,7 +77,7 @@ export function seedDefaultSession(yeaftDir, spec = {}) {
77
77
  });
78
78
 
79
79
  // Seed Layer-A resident summary so the very first session — even on a
80
- // brand-new install where only `grp_default` exists — renders a non-
80
+ // brand-new install where only `session_default` exists — renders a non-
81
81
  // empty memory section in the system prompt. No-op once Dream-v2 (or
82
82
  // createSessionFromSpec) has already written one. Best-effort: a memory-
83
83
  // root permission failure must NOT break the bootstrap flow.
@@ -1,17 +1,17 @@
1
1
  /**
2
- * group-crud.js — High-level Group CRUD API (task-334m).
2
+ * session-crud.js — High-level Session CRUD API (task-334m).
3
3
  *
4
- * Wraps the primitives from group-store.js + roster.js into the 5 operations
4
+ * Wraps the primitives from session-store.js + roster.js into the 5 operations
5
5
  * wired to WS events (§Δ10 334m + R6 §Δ31.2):
6
- * createSessionFromSpec — wizard "create new group" (empty or user-picked roster)
6
+ * createSessionFromSpec — wizard "create new session" (empty or user-picked roster)
7
7
  * renameSession — update meta.name; preserves roster / defaultVpId
8
8
  * archiveSession — rename dir to `.archived-<ts>-<id>` (soft delete)
9
9
  * addMember — roster.addVp + save; sets defaultVpId if first
10
10
  * removeMember — roster.removeVp + save; clears/rotates defaultVpId
11
11
  *
12
12
  * Plus the D1 bootstrap helper:
13
- * ensureDefaultSessionIfEmpty(yeaftDir, {libDir}) — if NO group exists on
14
- * disk, seed `grp_default` with roster = every VP in the library, and
13
+ * ensureDefaultSessionIfEmpty(yeaftDir, {libDir}) — if NO session exists on
14
+ * disk, seed `session_default` with roster = every VP in the library, and
15
15
  * defaultVpId = alphabetically first vpId. No-op when ≥1 group present.
16
16
  *
17
17
  * Hard constraints (PM):
@@ -250,21 +250,21 @@ export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
250
250
 
251
251
  /** Build a safe group id from a display name (slug + ulid-lite suffix). */
252
252
  export function makeSessionId(name) {
253
- const slug = String(name || 'group')
253
+ const slug = String(name || 'session')
254
254
  .toLowerCase()
255
255
  .replace(/[^a-z0-9]+/g, '-')
256
256
  .replace(/^-+|-+$/g, '')
257
- .slice(0, 24) || 'group';
257
+ .slice(0, 24) || 'session';
258
258
  return nextSessionId(slug);
259
259
  }
260
260
 
261
261
  /**
262
262
  * (B) D1 seed — called at boot (or when multi-VP is first enabled). Idempotent:
263
- * returns `{seeded:false}` if any group already exists on disk (including
264
- * `grp_default`). When empty, seeds with roster = full VP library, sorted
263
+ * returns `{seeded:false}` if any session already exists on disk (including
264
+ * `session_default`). When empty, seeds with roster = full VP library, sorted
265
265
  * alphabetically; defaultVpId = roster[0].
266
266
  *
267
- * When the VP library is also empty, we still seed an empty-roster group so
267
+ * When the VP library is also empty, we still seed an empty-roster session so
268
268
  * the UI has somewhere to land — but defaultVpId is null and downstream
269
269
  * message send will return `no_default_vp` until the user adds a VP.
270
270
  */
@@ -355,7 +355,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
355
355
  saveSessionConfig(yeaftDir, id, spec.config);
356
356
  }
357
357
  } catch (err) {
358
- console.warn(`[group-crud] failed to seed config.json for ${id}:`, err?.message || err);
358
+ console.warn(`[session-crud] failed to seed config.json for ${id}:`, err?.message || err);
359
359
  }
360
360
 
361
361
  // Seed Layer-A resident summary so the first session has memory content
@@ -363,12 +363,12 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
363
363
  // Best-effort: a memory-root permission failure must NOT break group create.
364
364
  try {
365
365
  seedSummaryIfMissingSync(
366
- { kind: 'group', id },
366
+ { kind: 'session', id },
367
367
  buildSessionSeedSummary({ name, roster, defaultVpId }),
368
368
  { root: memoryRoot },
369
369
  );
370
370
  } catch (err) {
371
- console.warn(`[group-crud] failed to seed summary.md for ${id}:`, err?.message || err);
371
+ console.warn(`[session-crud] failed to seed summary.md for ${id}:`, err?.message || err);
372
372
  }
373
373
 
374
374
  return meta;
@@ -501,9 +501,12 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
501
501
  // starts clean. Best-effort — never let memory cleanup fail the CRUD op.
502
502
  // Runs unconditionally so the idempotent path also clears stale memory.
503
503
  try {
504
+ removeScopeDirSync({ kind: 'session', id: sessionId }, { root: memoryRoot });
505
+ // Legacy pre-session memory scopes used memory/group/<id>. Delete both so
506
+ // idempotent removal clears stale summaries for old grp_* sessions too.
504
507
  removeScopeDirSync({ kind: 'group', id: sessionId }, { root: memoryRoot });
505
508
  } catch (err) {
506
- console.warn(`[group-crud] failed to remove memory dir for ${sessionId}:`, err?.message || err);
509
+ console.warn(`[session-crud] failed to remove memory dir for ${sessionId}:`, err?.message || err);
507
510
  }
508
511
 
509
512
  unregisterSessionWorkDir(yeaftDir, sessionId);
@@ -516,9 +519,9 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
516
519
  }
517
520
 
518
521
  /**
519
- * Sweep any leftover `.archived-*` directories under groups/ that are
522
+ * Sweep any leftover `.archived-*` directories under sessions/ that are
520
523
  * orphans of the old soft-archive flow. Used at boot so users don't see
521
- * ghost groups in subsequent loads. Returns the list of removed paths.
524
+ * ghost sessions in subsequent loads. Returns the list of removed paths.
522
525
  */
523
526
  export function purgeArchivedSessions(yeaftDir) {
524
527
  const root = sessionsRoot(yeaftDir);
@@ -23,6 +23,7 @@ import { existsSync } from 'node:fs';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { Engine } from './engine.js';
25
25
  import { loadSession } from './session.js';
26
+ import { loadConfig } from './config.js';
26
27
  import { sendToServer } from '../connection/buffer.js';
27
28
  import ctx from '../context.js';
28
29
  import { hydrateYeaftStatusFromSession } from './status-cache.js';
@@ -69,6 +70,26 @@ let session = null;
69
70
 
70
71
  let threadClassifier = defaultClassifyThread;
71
72
 
73
+ function refreshLiveSessionConfig() {
74
+ if (!session) return;
75
+ try {
76
+ const freshConfig = loadConfig({ dir: session.yeaftDir || ctx.CONFIG?.yeaftDir });
77
+ const freshModels = Array.isArray(freshConfig.availableModels) ? freshConfig.availableModels : [];
78
+ session.config.availableModels = freshModels;
79
+ if (freshConfig.model && !freshModels.some(m => m?.id === session.config.model)) {
80
+ session.config.model = freshConfig.model;
81
+ }
82
+ if (freshConfig.providers) {
83
+ session.config.providers = freshConfig.providers;
84
+ if (typeof session.adapter?.refreshProviders === 'function') {
85
+ session.adapter.refreshProviders(freshConfig.providers);
86
+ }
87
+ }
88
+ } catch (err) {
89
+ console.warn('[Yeaft] refresh live session config failed:', err?.message || err);
90
+ }
91
+ }
92
+
72
93
  /** Test-only: replace the lightweight VP thread classifier. */
73
94
  export function __testSetThreadClassifier(fn) {
74
95
  threadClassifier = typeof fn === 'function' ? fn : defaultClassifyThread;
@@ -3728,6 +3749,7 @@ export function handleYeaftModeSwitch(_msg) {
3728
3749
  /** Handle model switch from the web UI. */
3729
3750
  export function handleYeaftModelSwitch(msg) {
3730
3751
  if (!session || !msg.model) return;
3752
+ refreshLiveSessionConfig();
3731
3753
 
3732
3754
  const available = session.config.availableModels || [];
3733
3755
  const found = available.some(m => m.id === msg.model);
@@ -3774,6 +3796,7 @@ export async function handleYeaftLoadHistory(msg) {
3774
3796
  installYeaftRuntimeBridge(session);
3775
3797
 
3776
3798
  yeaftConversationId = `yeaft-${Date.now()}`;
3799
+ refreshLiveSessionConfig();
3777
3800
  hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
3778
3801
 
3779
3802
  // Per-group history hydrates lazily via getOrCreateSessionHistory.
@@ -3782,7 +3805,12 @@ export async function handleYeaftLoadHistory(msg) {
3782
3805
  // it doesn't (legacy callers), do nothing — the per-group lazy
3783
3806
  // hydration handles it.
3784
3807
  if (sessionId) setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
3785
- } else if (sessionId) {
3808
+ } else {
3809
+ refreshLiveSessionConfig();
3810
+ hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
3811
+ }
3812
+
3813
+ if (sessionId) {
3786
3814
  // Re-entering an existing session with a (possibly new) group filter:
3787
3815
  // re-seed THIS group's history from disk so it doesn't carry stale
3788
3816
  // in-memory state into the next turn's context.