@yeaft/webchat-agent 0.1.661 → 0.1.662

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.661",
3
+ "version": "0.1.662",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { randomUUID } from 'crypto';
21
21
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
22
22
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
23
- import { runMemoryPreflow } from './groups/pre-flow.js';
23
+ import { runMemoryPreflow, buildRelevantScopes } from './groups/pre-flow.js';
24
24
  import { shouldConsolidate, consolidate, partitionMessages } from './memory/consolidate.js';
25
25
  import { extractMemories } from './memory/extract.js';
26
26
  import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
@@ -29,6 +29,7 @@ import { archiveTurn } from './archive/turn-archive.js';
29
29
  import { archiveToolResults } from './archive/tool-results.js';
30
30
  import { buildMemoryInjection } from './memory/layout.js';
31
31
  import { readSummary as readScopeSummary } from './memory/store-v2.js';
32
+ import { runAdjust } from './memory/adjust.js';
32
33
  import { runStopHooks } from './stop-hooks.js';
33
34
  // H2.f.5: threads/ retired. Persisted messages still carry a `threadId`
34
35
  // field for back-compat with old conversation files; new writes always use
@@ -153,6 +154,9 @@ export class Engine {
153
154
  /** @type {import('./memory/index-db.js').SegmentIndex|null} — GC.1: SQLite FTS5 segment index */
154
155
  #memoryIndex;
155
156
 
157
+ /** @type {import('./memory/ams-registry.js').AmsRegistry|null} — group-keyed AMS cache */
158
+ #amsRegistry;
159
+
156
160
  /** @type {import('./tools/registry.js').ToolRegistry|null} */
157
161
  #toolRegistry;
158
162
 
@@ -214,6 +218,14 @@ export class Engine {
214
218
  #reflectedTurns = new Set();
215
219
  #__queryCounter = 0;
216
220
 
221
+ /**
222
+ * Per-group "adjust has run at least once this engine lifetime" flag.
223
+ * Keyed by groupId (or 'default'). The first turn always runs adjust;
224
+ * subsequent turns only run on budget pressure or new memory.
225
+ * @type {Map<string, boolean>}
226
+ */
227
+ #adjustRanByGroup = new Map();
228
+
217
229
  /** @type {string|null} */
218
230
  #abortReason = null;
219
231
 
@@ -231,7 +243,7 @@ export class Engine {
231
243
  * yeaftDir?: string,
232
244
  * }} params
233
245
  */
234
- constructor({ adapter, trace, config, conversationStore, memoryStore, memoryShardStore, memoryIndex, toolRegistry, skillManager, mcpManager, yeaftDir }) {
246
+ constructor({ adapter, trace, config, conversationStore, memoryStore, memoryShardStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir }) {
235
247
  this.#adapter = adapter;
236
248
  this.#trace = trace;
237
249
  this.#config = config;
@@ -241,6 +253,7 @@ export class Engine {
241
253
  this.#memoryStore = memoryStore || null;
242
254
  this.#memoryShardStore = memoryShardStore || null;
243
255
  this.#memoryIndex = memoryIndex || null;
256
+ this.#amsRegistry = amsRegistry || null;
244
257
  this.#toolRegistry = toolRegistry || null;
245
258
  this.#skillManager = skillManager || null;
246
259
  this.#mcpManager = mcpManager || null;
@@ -378,6 +391,161 @@ export class Engine {
378
391
  return { user: user || '', group: group || '', vp: vp || '' };
379
392
  }
380
393
 
394
+ /**
395
+ * Prepare the per-turn AMS for the active group. Idempotent and safe
396
+ * to call when the AMS registry isn't wired (returns null).
397
+ *
398
+ * @param {{
399
+ * groupId?: string,
400
+ * ownVpId?: string|null,
401
+ * featureId?: string,
402
+ * summaries: { user?: string, group?: string, vp?: string },
403
+ * recallEntries: object[],
404
+ * }} args
405
+ * @returns {{
406
+ * ams: import('./memory/ams.js').ActiveMemorySet,
407
+ * groupKey: string,
408
+ * ownVpId: string|null,
409
+ * scopes: string[],
410
+ * snapshotBlock: string,
411
+ * } | null}
412
+ */
413
+ #prepareAms(args) {
414
+ if (!this.#amsRegistry) return null;
415
+ const groupKey = args.groupId || 'default';
416
+ const ownVpId = args.ownVpId || null;
417
+ const ams = this.#amsRegistry.getOrCreate(groupKey, { ownVpId });
418
+
419
+ // (a) Resident: rebuild from the same scope summaries the worker
420
+ // prompt is already going to see.
421
+ const residentEntries = [];
422
+ if (args.summaries?.user) residentEntries.push({ scope: 'user', summary: args.summaries.user });
423
+ if (args.groupId && args.summaries?.group) {
424
+ residentEntries.push({ scope: `group/${args.groupId}`, summary: args.summaries.group });
425
+ }
426
+ if (ownVpId && args.summaries?.vp) {
427
+ residentEntries.push({ scope: `vp/${ownVpId}`, summary: args.summaries.vp });
428
+ }
429
+ ams.setResident(residentEntries);
430
+
431
+ // (b) onDemand: replace with this turn's FTS hits.
432
+ const segs = Array.isArray(args.recallEntries) ? args.recallEntries : [];
433
+ ams.setOnDemand(segs);
434
+
435
+ // (c) Snapshot — render the AMS layers as a single prompt block.
436
+ const snapshotBlock = this.#renderAmsSnapshot(ams);
437
+
438
+ const scopes = buildRelevantScopes({
439
+ groupId: args.groupId,
440
+ vpId: ownVpId,
441
+ featureId: args.featureId,
442
+ });
443
+
444
+ return { ams, groupKey, ownVpId, scopes, snapshotBlock };
445
+ }
446
+
447
+ /**
448
+ * Render an AMS snapshot as a markdown block suitable for prompt
449
+ * injection. Mirrors the heading style of the existing memory blocks
450
+ * so the LLM sees a consistent layout.
451
+ *
452
+ * @param {import('./memory/ams.js').ActiveMemorySet} ams
453
+ * @returns {string}
454
+ */
455
+ #renderAmsSnapshot(ams) {
456
+ const snap = ams.snapshot();
457
+ if (!snap) return '';
458
+ const parts = [];
459
+ if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
460
+ return '';
461
+ }
462
+ parts.push('## Active Memory Set');
463
+ if (snap.resident.length > 0) {
464
+ parts.push('### Resident');
465
+ for (const r of snap.resident) {
466
+ parts.push(`- **${r.scope}**: ${r.summary}`);
467
+ }
468
+ }
469
+ if (snap.recent.length > 0) {
470
+ parts.push('### Recent');
471
+ for (const s of snap.recent) {
472
+ parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
473
+ }
474
+ }
475
+ if (snap.onDemand.length > 0) {
476
+ parts.push('### OnDemand');
477
+ for (const s of snap.onDemand) {
478
+ parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
479
+ }
480
+ }
481
+ return parts.join('\n');
482
+ }
483
+
484
+ /**
485
+ * Post-turn AMS correction. Decides whether to run via
486
+ * `shouldRunAdjust`, then drives the LLM round-trip through
487
+ * `runAdjust`. Persists the AMS to disk if membership changed.
488
+ *
489
+ * Failure here is intentionally swallowed — adjust is a best-effort
490
+ * memory-quality step; a parse failure or LLM blip should never
491
+ * surface as a turn failure.
492
+ *
493
+ * @param {{
494
+ * amsContext: { ams: import('./memory/ams.js').ActiveMemorySet, groupKey: string, ownVpId: string|null, scopes: string[] }|null,
495
+ * userMsg: string,
496
+ * assistantReply: string,
497
+ * turnTokenUsage: number,
498
+ * }} args
499
+ * @returns {Promise<{ ran: boolean, added: number, evicted: number, reason: string } | null>}
500
+ */
501
+ async #runAdjustHook(args) {
502
+ const ctx = args.amsContext;
503
+ if (!ctx || !this.#amsRegistry || !this.#memoryIndex) return null;
504
+ const totalBudget = ctx.ams.budget?.total || 0;
505
+ if (!totalBudget) return null;
506
+
507
+ const adjustRanThisSession = this.#adjustRanByGroup.get(ctx.groupKey) === true;
508
+ try {
509
+ const result = await runAdjust({
510
+ trigger: {
511
+ newMemoryWritten: false, // dream writes happen async; treat as false here
512
+ onDemandSize: ctx.ams.onDemandIds().length,
513
+ turnTokenUsage: args.turnTokenUsage,
514
+ totalBudget,
515
+ adjustRanThisSession,
516
+ },
517
+ ams: ctx.ams,
518
+ index: this.#memoryIndex,
519
+ scopes: ctx.scopes,
520
+ ownVpId: ctx.ownVpId,
521
+ userMsg: args.userMsg,
522
+ assistantReply: args.assistantReply,
523
+ runLLM: async (prompt) => {
524
+ const out = await this.#adapter.call({
525
+ model: this.#fastConfig.model,
526
+ system: 'You are a memory-management subroutine. Reply with a single JSON object as instructed.',
527
+ messages: [{ role: 'user', content: prompt }],
528
+ maxTokens: 1024,
529
+ });
530
+ return out?.text || '';
531
+ },
532
+ });
533
+ if (result?.ran) {
534
+ this.#adjustRanByGroup.set(ctx.groupKey, true);
535
+ // Always persist when we ran — even with no membership change,
536
+ // the adjustRanThisSession bit is part of the on-disk state we
537
+ // want to preserve.
538
+ this.#amsRegistry.markDirty(ctx.groupKey);
539
+ this.#amsRegistry.persist(ctx.groupKey, {
540
+ adjustRanThisSession: true,
541
+ });
542
+ }
543
+ return result;
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+
381
549
  /**
382
550
  * Build the system prompt with memory, compact summary, skill content,
383
551
  * and (Phase 8 wire-up) Layer-A scope summaries.
@@ -895,6 +1063,34 @@ export class Engine {
895
1063
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
896
1064
  });
897
1065
 
1066
+ // ─── AMS: populate + snapshot ───────────────────────────────
1067
+ // Group-keyed and persisted across session deactivation. Each turn:
1068
+ // (a) resident layer is rebuilt from <scope>/summary.md (the
1069
+ // summaries already loaded above are the same scopes, so
1070
+ // reuse them);
1071
+ // (b) onDemand is replaced with this turn's FTS hits;
1072
+ // (c) we render a budget-aware snapshot block and append it to
1073
+ // memoryInjection. Adjust runs post-turn (see end_turn below).
1074
+ const ownVpIdForAms = vpPersona && typeof vpPersona === 'object'
1075
+ && typeof vpPersona.vpId === 'string'
1076
+ ? vpPersona.vpId
1077
+ : (typeof senderVpId === 'string' ? senderVpId : null);
1078
+ const featureIdForAms = typeof inboundEnvelope === 'object' && inboundEnvelope
1079
+ ? inboundEnvelope.featureId
1080
+ : undefined;
1081
+ const amsContext = this.#prepareAms({
1082
+ groupId,
1083
+ ownVpId: ownVpIdForAms,
1084
+ featureId: featureIdForAms,
1085
+ summaries,
1086
+ recallEntries: recallResult ? (recallResult.entries || []) : [],
1087
+ });
1088
+ if (amsContext && amsContext.snapshotBlock) {
1089
+ memoryInjection = memoryInjection
1090
+ ? memoryInjection + '\n\n' + amsContext.snapshotBlock
1091
+ : amsContext.snapshotBlock;
1092
+ }
1093
+
898
1094
  const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries);
899
1095
 
900
1096
  // Build conversation: existing messages + new user message
@@ -925,6 +1121,8 @@ export class Engine {
925
1121
  let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
926
1122
  let fullResponseText = '';
927
1123
  let currentModel = this.#config.model;
1124
+ let cumulativeInputTokens = 0;
1125
+ let cumulativeOutputTokens = 0;
928
1126
 
929
1127
  while (true) {
930
1128
  turnNumber++;
@@ -1061,6 +1259,8 @@ export class Engine {
1061
1259
  case 'usage':
1062
1260
  totalUsage.inputTokens += event.inputTokens;
1063
1261
  totalUsage.outputTokens += event.outputTokens;
1262
+ cumulativeInputTokens += event.inputTokens || 0;
1263
+ cumulativeOutputTokens += event.outputTokens || 0;
1064
1264
  yield event;
1065
1265
  break;
1066
1266
  case 'stop':
@@ -1269,6 +1469,27 @@ export class Engine {
1269
1469
  }
1270
1470
  }
1271
1471
 
1472
+ // ─── Post-turn AMS adjust ────────────────────────────────
1473
+ // shouldRunAdjust gates the LLM round-trip so most turns are
1474
+ // free; first turn always runs, plus on budget pressure.
1475
+ if (amsContext) {
1476
+ const adjustResult = await this.#runAdjustHook({
1477
+ amsContext,
1478
+ userMsg: prompt,
1479
+ assistantReply: fullResponseText,
1480
+ turnTokenUsage: cumulativeInputTokens + cumulativeOutputTokens,
1481
+ });
1482
+ if (adjustResult && adjustResult.ran) {
1483
+ yield {
1484
+ type: 'ams_adjust',
1485
+ groupKey: amsContext.groupKey,
1486
+ added: adjustResult.added,
1487
+ evicted: adjustResult.evicted,
1488
+ reason: adjustResult.reason,
1489
+ };
1490
+ }
1491
+ }
1492
+
1272
1493
  // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
1273
1494
  // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
1274
1495
  // (5) AND T1 didn't already collapse the arc. Kicks off the
@@ -0,0 +1,233 @@
1
+ /**
2
+ * memory/ams-registry.js — group-keyed AMS lifecycle.
3
+ *
4
+ * The Active Memory Set is conceptually session-scoped, but Yeaft's
5
+ * unit of "session" is a group: a deactivated group can be reactivated
6
+ * later and should resume with the AMS state it had on disconnect (the
7
+ * onDemand segments it had pulled in, the recent LRU touches, whether
8
+ * `adjust` already ran). Sessions come and go; the group's AMS persists.
9
+ *
10
+ * Persistence is identity-only:
11
+ *
12
+ * ~/.yeaft/memory/groups/<groupId>/ams.json
13
+ * {
14
+ * "version": 1,
15
+ * "ownVpId": "alice"|null,
16
+ * "onDemandIds": ["seg_..."],
17
+ * "recentIds": ["seg_..."],
18
+ * "adjustRanThisSession": true|false,
19
+ * "savedAt": "2026-04-29T..."
20
+ * }
21
+ *
22
+ * Bodies are NOT serialised — they're re-hydrated from the SegmentIndex
23
+ * on load, so a body edited by Dream after save still surfaces correctly
24
+ * the next time the group is opened. Resident layer is derived state
25
+ * (rebuilt every turn from `<scope>/summary.md`) — never persisted.
26
+ *
27
+ * For the single-VP Unify path (no group), the registry uses the literal
28
+ * key `"default"` so there's still a stable home for AMS state.
29
+ */
30
+
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
32
+ import { join, dirname } from 'node:path';
33
+
34
+ import { ActiveMemorySet } from './ams.js';
35
+ import { computeBudget } from './budget.js';
36
+
37
+ export const AMS_FILE_VERSION = 1;
38
+ export const DEFAULT_GROUP_KEY = 'default';
39
+
40
+ /**
41
+ * @typedef {object} AmsRegistryDeps
42
+ * @property {string} yeaftDir
43
+ * @property {import('./index-db.js').SegmentIndex|null} memoryIndex
44
+ * @property {object} config
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} AmsCacheEntry
49
+ * @property {ActiveMemorySet} ams
50
+ * @property {string|null} ownVpId
51
+ */
52
+
53
+ /**
54
+ * Group-keyed in-memory cache + disk persistence for AMS instances.
55
+ *
56
+ * Lifecycle:
57
+ * - getOrCreate(groupId, {ownVpId}) — returns the cached AMS or loads
58
+ * from disk; falls through to a fresh empty AMS on cold start.
59
+ * - persist(groupId) — writes the current cached AMS to disk.
60
+ * - persistAll() — convenience for shutdown.
61
+ *
62
+ * The registry is intentionally narrow: it does not mutate the AMS
63
+ * itself (that's the engine's job). It only caches, loads, and saves.
64
+ */
65
+ export class AmsRegistry {
66
+ /** @param {AmsRegistryDeps} deps */
67
+ constructor(deps) {
68
+ this.yeaftDir = deps.yeaftDir;
69
+ this.memoryIndex = deps.memoryIndex || null;
70
+ this.config = deps.config || {};
71
+ /** @type {Map<string, AmsCacheEntry>} */
72
+ this._cache = new Map();
73
+ /** @type {Set<string>} */
74
+ this._dirty = new Set();
75
+ }
76
+
77
+ /**
78
+ * Resolve the on-disk path for a group's ams.json.
79
+ *
80
+ * @param {string} groupId
81
+ * @returns {string}
82
+ */
83
+ amsPath(groupId) {
84
+ const safe = String(groupId || DEFAULT_GROUP_KEY).replace(/[^A-Za-z0-9._-]/g, '_');
85
+ return join(this.yeaftDir, 'memory', 'groups', safe, 'ams.json');
86
+ }
87
+
88
+ /**
89
+ * Compute the BudgetSplit for this session/model.
90
+ *
91
+ * @returns {import('./budget.js').BudgetSplit}
92
+ */
93
+ _budget() {
94
+ const ctx = Number.isFinite(this.config?.maxContextTokens)
95
+ ? this.config.maxContextTokens
96
+ : 200_000;
97
+ return computeBudget(ctx);
98
+ }
99
+
100
+ /**
101
+ * Get the AMS for a group, creating it on first access.
102
+ * Loads persisted state from disk if any; on cold start returns an
103
+ * empty AMS keyed to the supplied ownVpId.
104
+ *
105
+ * @param {string|null|undefined} groupId
106
+ * @param {{ ownVpId?: string|null }} [opts]
107
+ * @returns {ActiveMemorySet}
108
+ */
109
+ getOrCreate(groupId, opts = {}) {
110
+ const key = groupId || DEFAULT_GROUP_KEY;
111
+ const cached = this._cache.get(key);
112
+ if (cached) return cached.ams;
113
+
114
+ const ownVpId = opts.ownVpId || null;
115
+ const budget = this._budget();
116
+ const ams = new ActiveMemorySet({ ownVpId, budget });
117
+ // Best-effort hydrate from disk.
118
+ this._hydrate(key, ams);
119
+ this._cache.set(key, { ams, ownVpId });
120
+ return ams;
121
+ }
122
+
123
+ /**
124
+ * Mark a group's AMS as dirty so the next persist() actually writes.
125
+ * The engine calls this after `runAdjust` mutates membership.
126
+ *
127
+ * @param {string|null|undefined} groupId
128
+ */
129
+ markDirty(groupId) {
130
+ this._dirty.add(groupId || DEFAULT_GROUP_KEY);
131
+ }
132
+
133
+ /**
134
+ * Persist a single group's AMS to disk. No-op when the cached entry
135
+ * is missing or hasn't been marked dirty.
136
+ *
137
+ * @param {string|null|undefined} groupId
138
+ * @param {{ force?: boolean, adjustRanThisSession?: boolean }} [opts]
139
+ * @returns {boolean} true if the file was written
140
+ */
141
+ persist(groupId, opts = {}) {
142
+ const key = groupId || DEFAULT_GROUP_KEY;
143
+ const entry = this._cache.get(key);
144
+ if (!entry) return false;
145
+ if (!opts.force && !this._dirty.has(key)) return false;
146
+
147
+ const path = this.amsPath(key);
148
+ const payload = {
149
+ version: AMS_FILE_VERSION,
150
+ ownVpId: entry.ownVpId,
151
+ onDemandIds: entry.ams.onDemandIds(),
152
+ recentIds: entry.ams.recentIds(),
153
+ adjustRanThisSession: Boolean(opts.adjustRanThisSession),
154
+ savedAt: new Date().toISOString(),
155
+ };
156
+
157
+ try {
158
+ mkdirSync(dirname(path), { recursive: true });
159
+ const tmp = `${path}.tmp`;
160
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
161
+ renameSync(tmp, path);
162
+ this._dirty.delete(key);
163
+ return true;
164
+ } catch {
165
+ // Persistence failure is non-fatal — AMS continues to live in memory.
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Persist every cached, dirty AMS. Called on session shutdown.
172
+ *
173
+ * @returns {number} number of files written
174
+ */
175
+ persistAll() {
176
+ let n = 0;
177
+ for (const key of this._dirty) {
178
+ if (this.persist(key, { force: true })) n += 1;
179
+ }
180
+ return n;
181
+ }
182
+
183
+ /**
184
+ * Best-effort hydrate: read ams.json, re-resolve segment ids via the
185
+ * SegmentIndex (skipping ids that no longer exist), populate AMS.
186
+ * Silent on every error — a corrupt or missing file is the cold-start
187
+ * case, indistinguishable from "first use of this group".
188
+ *
189
+ * @private
190
+ * @param {string} key
191
+ * @param {ActiveMemorySet} ams
192
+ */
193
+ _hydrate(key, ams) {
194
+ const path = this.amsPath(key);
195
+ if (!existsSync(path)) return;
196
+ let payload;
197
+ try { payload = JSON.parse(readFileSync(path, 'utf8') || '{}'); }
198
+ catch { return; }
199
+ if (!payload || typeof payload !== 'object') return;
200
+
201
+ if (!this.memoryIndex) return;
202
+
203
+ const onDemandIds = Array.isArray(payload.onDemandIds) ? payload.onDemandIds : [];
204
+ const recentIds = Array.isArray(payload.recentIds) ? payload.recentIds : [];
205
+
206
+ const onDemandSegs = [];
207
+ for (const id of onDemandIds) {
208
+ try {
209
+ const seg = this.memoryIndex.get(id);
210
+ if (seg) onDemandSegs.push(seg);
211
+ } catch { /* skip unresolvable */ }
212
+ }
213
+ if (onDemandSegs.length > 0) ams.setOnDemand(onDemandSegs);
214
+
215
+ for (const id of recentIds) {
216
+ try {
217
+ const seg = this.memoryIndex.get(id);
218
+ if (seg) ams.touchRecent(seg);
219
+ } catch { /* skip */ }
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Factory for the registry. Kept as a thin function so call sites can
226
+ * stay symmetrical with the other store openers in session.js.
227
+ *
228
+ * @param {AmsRegistryDeps} deps
229
+ * @returns {AmsRegistry}
230
+ */
231
+ export function openAmsRegistry(deps) {
232
+ return new AmsRegistry(deps);
233
+ }
package/unify/session.js CHANGED
@@ -32,15 +32,21 @@ import { Engine } from './engine.js';
32
32
  // SegmentIndex (SQLite FTS5 over memory.md) and passes it to the
33
33
  // Engine. Engine.#recallMemory routes pre-turn recall through
34
34
  // groups/pre-flow.js → memory/preflow.js (the previous per-scope
35
- // file reader recall-v2.js has been deleted). Post-turn AMS
36
- // correction (memory/adjust.js) is implemented but not yet wired —
37
- // requires session-level AMS instance + scope resolution. Tracked
38
- // as a follow-up.
35
+ // file reader recall-v2.js has been deleted).
36
+ //
37
+ // GC.1 follow-up: when memoryIndex is wired we also open an
38
+ // AmsRegistry. The registry caches per-group ActiveMemorySet
39
+ // instances and persists their identity-only state under
40
+ // `~/.yeaft/memory/groups/<gid>/ams.json` so a deactivated group
41
+ // resumes with the same onDemand/recent membership it had on
42
+ // disconnect. Engine.#runQuery uses the registry to populate the
43
+ // AMS each turn and to run `memory/adjust.js` post-turn.
39
44
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
40
45
  import { seedDefaultVps } from './vp/seed-defaults.js';
41
46
  import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
42
47
  import { openSegmentIndex } from './memory/index-db.js';
43
48
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
49
+ import { openAmsRegistry } from './memory/ams-registry.js';
44
50
  import { migrateR6toV2 } from './memory/migrate-r6-to-v2.js';
45
51
  import { join } from 'path';
46
52
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, writeFileSync as writeFileSyncSafe } from 'fs';
@@ -250,6 +256,22 @@ export async function loadSession(options = {}) {
250
256
  }
251
257
  }
252
258
 
259
+ // ─── 5-ams. (GC.1 follow-up) Group-keyed AMS registry ────
260
+ // The registry caches one ActiveMemorySet per groupId and
261
+ // persists their state to disk so a deactivated group can be
262
+ // reactivated with the same onDemand/recent membership it had
263
+ // on disconnect. Without memoryIndex we have nothing to
264
+ // re-hydrate against, so the registry is left null in that case.
265
+ let amsRegistry = null;
266
+ if (memoryIndex && !config._readOnly) {
267
+ try {
268
+ amsRegistry = openAmsRegistry({ yeaftDir, memoryIndex, config });
269
+ } catch (err) {
270
+ console.warn(`[Yeaft] Failed to open AMS registry (adjust disabled): ${err?.message || err}`);
271
+ amsRegistry = null;
272
+ }
273
+ }
274
+
253
275
  // ─── 5a. Initialize feature store ──────────────────────
254
276
  initFeatureStore(yeaftDir, { readOnly: config._readOnly || false });
255
277
 
@@ -315,6 +337,7 @@ export async function loadSession(options = {}) {
315
337
  memoryStore,
316
338
  memoryShardStore,
317
339
  memoryIndex,
340
+ amsRegistry,
318
341
  toolRegistry,
319
342
  skillManager,
320
343
  mcpManager,
@@ -372,6 +395,11 @@ export async function loadSession(options = {}) {
372
395
  } catch {
373
396
  // Best-effort cleanup
374
397
  }
398
+ try {
399
+ if (amsRegistry) amsRegistry.persistAll();
400
+ } catch {
401
+ // Best-effort cleanup
402
+ }
375
403
  }
376
404
 
377
405
  return {
@@ -388,6 +416,7 @@ export async function loadSession(options = {}) {
388
416
  trace,
389
417
  yeaftDir,
390
418
  status,
419
+ amsRegistry,
391
420
  shutdown,
392
421
  // task-325c: user-initiated abort API. Delegates to web-bridge which
393
422
  // owns the single AbortController. Lazy-imported to avoid a hard cycle