@yeaft/webchat-agent 0.1.661 → 0.1.663
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 +1 -1
- package/unify/cli.js +0 -10
- package/unify/engine.js +232 -5
- package/unify/memory/ams-registry.js +279 -0
- package/unify/session.js +38 -53
- package/unify/stop-hooks.js +8 -44
- package/unify/memory/dream-prompt.js +0 -272
- package/unify/memory/dream.js +0 -783
- package/unify/memory/migrate-r6-to-v2.js +0 -462
- package/unify/router/vp-planner.js +0 -341
package/package.json
CHANGED
package/unify/cli.js
CHANGED
|
@@ -619,11 +619,6 @@ async function runREPL(config, args) {
|
|
|
619
619
|
process.stderr.write(`[compact] Memory consolidated\n`);
|
|
620
620
|
}
|
|
621
621
|
break;
|
|
622
|
-
case 'dream_triggered':
|
|
623
|
-
if (session.config.debug) {
|
|
624
|
-
process.stderr.write(`[dream] Dream cycle triggered (background)\n`);
|
|
625
|
-
}
|
|
626
|
-
break;
|
|
627
622
|
case 'fallback':
|
|
628
623
|
process.stderr.write(`\n[fallback] ${event.from} → ${event.to}: ${event.reason}\n`);
|
|
629
624
|
break;
|
|
@@ -717,11 +712,6 @@ async function runOnce(config, args) {
|
|
|
717
712
|
process.stderr.write(`[consolidate] archived=${event.archivedCount}, extracted=${event.extractedCount}\n`);
|
|
718
713
|
}
|
|
719
714
|
break;
|
|
720
|
-
case 'dream_triggered':
|
|
721
|
-
if (args.verbose || args.debug) {
|
|
722
|
-
process.stderr.write(`[dream] Dream cycle triggered (background)\n`);
|
|
723
|
-
}
|
|
724
|
-
break;
|
|
725
715
|
case 'fallback':
|
|
726
716
|
process.stderr.write(`\n[fallback] ${event.from} → ${event.to}: ${event.reason}\n`);
|
|
727
717
|
break;
|
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,170 @@ 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
|
+
// Prime #adjustRanByGroup from disk-hydrated state on first access:
|
|
420
|
+
// a reactivated group resumes with whatever adjustRanThisSession bit
|
|
421
|
+
// it had on disconnect, so we don't burn a fresh adjust on every
|
|
422
|
+
// reload. Once set true in this session we never clear it.
|
|
423
|
+
if (!this.#adjustRanByGroup.has(groupKey)
|
|
424
|
+
&& this.#amsRegistry.adjustRanThisSession(groupKey)) {
|
|
425
|
+
this.#adjustRanByGroup.set(groupKey, true);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// (a) Resident: rebuild from the same scope summaries the worker
|
|
429
|
+
// prompt is already going to see.
|
|
430
|
+
const residentEntries = [];
|
|
431
|
+
if (args.summaries?.user) residentEntries.push({ scope: 'user', summary: args.summaries.user });
|
|
432
|
+
if (args.groupId && args.summaries?.group) {
|
|
433
|
+
residentEntries.push({ scope: `group/${args.groupId}`, summary: args.summaries.group });
|
|
434
|
+
}
|
|
435
|
+
if (ownVpId && args.summaries?.vp) {
|
|
436
|
+
residentEntries.push({ scope: `vp/${ownVpId}`, summary: args.summaries.vp });
|
|
437
|
+
}
|
|
438
|
+
ams.setResident(residentEntries);
|
|
439
|
+
|
|
440
|
+
// (b) onDemand: replace with this turn's FTS hits.
|
|
441
|
+
const segs = Array.isArray(args.recallEntries) ? args.recallEntries : [];
|
|
442
|
+
ams.setOnDemand(segs);
|
|
443
|
+
|
|
444
|
+
// (c) Snapshot — render the AMS layers as a single prompt block.
|
|
445
|
+
const snapshotBlock = this.#renderAmsSnapshot(ams);
|
|
446
|
+
|
|
447
|
+
const scopes = buildRelevantScopes({
|
|
448
|
+
groupId: args.groupId,
|
|
449
|
+
vpId: ownVpId,
|
|
450
|
+
featureId: args.featureId,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return { ams, groupKey, ownVpId, scopes, snapshotBlock };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Render an AMS snapshot as a markdown block suitable for prompt
|
|
458
|
+
* injection. Mirrors the heading style of the existing memory blocks
|
|
459
|
+
* so the LLM sees a consistent layout.
|
|
460
|
+
*
|
|
461
|
+
* @param {import('./memory/ams.js').ActiveMemorySet} ams
|
|
462
|
+
* @returns {string}
|
|
463
|
+
*/
|
|
464
|
+
#renderAmsSnapshot(ams) {
|
|
465
|
+
const snap = ams.snapshot();
|
|
466
|
+
if (!snap) return '';
|
|
467
|
+
const parts = [];
|
|
468
|
+
if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
parts.push('## Active Memory Set');
|
|
472
|
+
if (snap.resident.length > 0) {
|
|
473
|
+
parts.push('### Resident');
|
|
474
|
+
for (const r of snap.resident) {
|
|
475
|
+
parts.push(`- **${r.scope}**: ${r.summary}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (snap.recent.length > 0) {
|
|
479
|
+
parts.push('### Recent');
|
|
480
|
+
for (const s of snap.recent) {
|
|
481
|
+
parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (snap.onDemand.length > 0) {
|
|
485
|
+
parts.push('### OnDemand');
|
|
486
|
+
for (const s of snap.onDemand) {
|
|
487
|
+
parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return parts.join('\n');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Post-turn AMS correction. Decides whether to run via
|
|
495
|
+
* `shouldRunAdjust`, then drives the LLM round-trip through
|
|
496
|
+
* `runAdjust`. Persists the AMS to disk if membership changed.
|
|
497
|
+
*
|
|
498
|
+
* Failure here is intentionally swallowed — adjust is a best-effort
|
|
499
|
+
* memory-quality step; a parse failure or LLM blip should never
|
|
500
|
+
* surface as a turn failure.
|
|
501
|
+
*
|
|
502
|
+
* @param {{
|
|
503
|
+
* amsContext: { ams: import('./memory/ams.js').ActiveMemorySet, groupKey: string, ownVpId: string|null, scopes: string[] }|null,
|
|
504
|
+
* userMsg: string,
|
|
505
|
+
* assistantReply: string,
|
|
506
|
+
* turnTokenUsage: number,
|
|
507
|
+
* }} args
|
|
508
|
+
* @returns {Promise<{ ran: boolean, added: number, evicted: number, reason: string } | null>}
|
|
509
|
+
*/
|
|
510
|
+
async #runAdjustHook(args) {
|
|
511
|
+
const ctx = args.amsContext;
|
|
512
|
+
if (!ctx || !this.#amsRegistry || !this.#memoryIndex) return null;
|
|
513
|
+
const totalBudget = ctx.ams.budget?.total || 0;
|
|
514
|
+
if (!totalBudget) return null;
|
|
515
|
+
|
|
516
|
+
const adjustRanThisSession = this.#adjustRanByGroup.get(ctx.groupKey) === true;
|
|
517
|
+
try {
|
|
518
|
+
const result = await runAdjust({
|
|
519
|
+
trigger: {
|
|
520
|
+
newMemoryWritten: false, // dream writes happen async; treat as false here
|
|
521
|
+
onDemandSize: ctx.ams.onDemandIds().length,
|
|
522
|
+
turnTokenUsage: args.turnTokenUsage,
|
|
523
|
+
totalBudget,
|
|
524
|
+
adjustRanThisSession,
|
|
525
|
+
},
|
|
526
|
+
ams: ctx.ams,
|
|
527
|
+
index: this.#memoryIndex,
|
|
528
|
+
scopes: ctx.scopes,
|
|
529
|
+
ownVpId: ctx.ownVpId,
|
|
530
|
+
userMsg: args.userMsg,
|
|
531
|
+
assistantReply: args.assistantReply,
|
|
532
|
+
runLLM: async (prompt) => {
|
|
533
|
+
const out = await this.#adapter.call({
|
|
534
|
+
model: this.#fastConfig.model,
|
|
535
|
+
system: 'You are a memory-management subroutine. Reply with a single JSON object as instructed.',
|
|
536
|
+
messages: [{ role: 'user', content: prompt }],
|
|
537
|
+
maxTokens: 1024,
|
|
538
|
+
});
|
|
539
|
+
return out?.text || '';
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
if (result?.ran) {
|
|
543
|
+
this.#adjustRanByGroup.set(ctx.groupKey, true);
|
|
544
|
+
// Always persist when we ran — even with no membership change,
|
|
545
|
+
// the adjustRanThisSession bit is part of the on-disk state we
|
|
546
|
+
// want to preserve.
|
|
547
|
+
this.#amsRegistry.markDirty(ctx.groupKey);
|
|
548
|
+
this.#amsRegistry.persist(ctx.groupKey, {
|
|
549
|
+
adjustRanThisSession: true,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return result;
|
|
553
|
+
} catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
381
558
|
/**
|
|
382
559
|
* Build the system prompt with memory, compact summary, skill content,
|
|
383
560
|
* and (Phase 8 wire-up) Layer-A scope summaries.
|
|
@@ -895,6 +1072,34 @@ export class Engine {
|
|
|
895
1072
|
: (typeof senderVpId === 'string' ? senderVpId : undefined),
|
|
896
1073
|
});
|
|
897
1074
|
|
|
1075
|
+
// ─── AMS: populate + snapshot ───────────────────────────────
|
|
1076
|
+
// Group-keyed and persisted across session deactivation. Each turn:
|
|
1077
|
+
// (a) resident layer is rebuilt from <scope>/summary.md (the
|
|
1078
|
+
// summaries already loaded above are the same scopes, so
|
|
1079
|
+
// reuse them);
|
|
1080
|
+
// (b) onDemand is replaced with this turn's FTS hits;
|
|
1081
|
+
// (c) we render a budget-aware snapshot block and append it to
|
|
1082
|
+
// memoryInjection. Adjust runs post-turn (see end_turn below).
|
|
1083
|
+
const ownVpIdForAms = vpPersona && typeof vpPersona === 'object'
|
|
1084
|
+
&& typeof vpPersona.vpId === 'string'
|
|
1085
|
+
? vpPersona.vpId
|
|
1086
|
+
: (typeof senderVpId === 'string' ? senderVpId : null);
|
|
1087
|
+
const featureIdForAms = typeof inboundEnvelope === 'object' && inboundEnvelope
|
|
1088
|
+
? inboundEnvelope.featureId
|
|
1089
|
+
: undefined;
|
|
1090
|
+
const amsContext = this.#prepareAms({
|
|
1091
|
+
groupId,
|
|
1092
|
+
ownVpId: ownVpIdForAms,
|
|
1093
|
+
featureId: featureIdForAms,
|
|
1094
|
+
summaries,
|
|
1095
|
+
recallEntries: recallResult ? (recallResult.entries || []) : [],
|
|
1096
|
+
});
|
|
1097
|
+
if (amsContext && amsContext.snapshotBlock) {
|
|
1098
|
+
memoryInjection = memoryInjection
|
|
1099
|
+
? memoryInjection + '\n\n' + amsContext.snapshotBlock
|
|
1100
|
+
: amsContext.snapshotBlock;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
898
1103
|
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries);
|
|
899
1104
|
|
|
900
1105
|
// Build conversation: existing messages + new user message
|
|
@@ -925,6 +1130,8 @@ export class Engine {
|
|
|
925
1130
|
let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
|
|
926
1131
|
let fullResponseText = '';
|
|
927
1132
|
let currentModel = this.#config.model;
|
|
1133
|
+
let cumulativeInputTokens = 0;
|
|
1134
|
+
let cumulativeOutputTokens = 0;
|
|
928
1135
|
|
|
929
1136
|
while (true) {
|
|
930
1137
|
turnNumber++;
|
|
@@ -1061,6 +1268,8 @@ export class Engine {
|
|
|
1061
1268
|
case 'usage':
|
|
1062
1269
|
totalUsage.inputTokens += event.inputTokens;
|
|
1063
1270
|
totalUsage.outputTokens += event.outputTokens;
|
|
1271
|
+
cumulativeInputTokens += event.inputTokens || 0;
|
|
1272
|
+
cumulativeOutputTokens += event.outputTokens || 0;
|
|
1064
1273
|
yield event;
|
|
1065
1274
|
break;
|
|
1066
1275
|
case 'stop':
|
|
@@ -1256,9 +1465,6 @@ export class Engine {
|
|
|
1256
1465
|
if (hookResult.consolidated) {
|
|
1257
1466
|
yield { type: 'consolidate', archivedCount: 0, extractedCount: 0 };
|
|
1258
1467
|
}
|
|
1259
|
-
if (hookResult.dreamTriggered) {
|
|
1260
|
-
yield { type: 'dream_triggered' };
|
|
1261
|
-
}
|
|
1262
1468
|
} else {
|
|
1263
1469
|
// Legacy path (no yeaftDir → use old behavior)
|
|
1264
1470
|
this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, groupId);
|
|
@@ -1269,6 +1475,27 @@ export class Engine {
|
|
|
1269
1475
|
}
|
|
1270
1476
|
}
|
|
1271
1477
|
|
|
1478
|
+
// ─── Post-turn AMS adjust ────────────────────────────────
|
|
1479
|
+
// shouldRunAdjust gates the LLM round-trip so most turns are
|
|
1480
|
+
// free; first turn always runs, plus on budget pressure.
|
|
1481
|
+
if (amsContext) {
|
|
1482
|
+
const adjustResult = await this.#runAdjustHook({
|
|
1483
|
+
amsContext,
|
|
1484
|
+
userMsg: prompt,
|
|
1485
|
+
assistantReply: fullResponseText,
|
|
1486
|
+
turnTokenUsage: cumulativeInputTokens + cumulativeOutputTokens,
|
|
1487
|
+
});
|
|
1488
|
+
if (adjustResult && adjustResult.ran) {
|
|
1489
|
+
yield {
|
|
1490
|
+
type: 'ams_adjust',
|
|
1491
|
+
groupKey: amsContext.groupKey,
|
|
1492
|
+
added: adjustResult.added,
|
|
1493
|
+
evicted: adjustResult.evicted,
|
|
1494
|
+
reason: adjustResult.reason,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1272
1499
|
// PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
|
|
1273
1500
|
// total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
|
|
1274
1501
|
// (5) AND T1 didn't already collapse the arc. Kicks off the
|
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
* @property {boolean} adjustRanThisSession
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Group-keyed in-memory cache + disk persistence for AMS instances.
|
|
56
|
+
*
|
|
57
|
+
* Lifecycle:
|
|
58
|
+
* - getOrCreate(groupId, {ownVpId}) — returns the cached AMS or loads
|
|
59
|
+
* from disk; falls through to a fresh empty AMS on cold start.
|
|
60
|
+
* - persist(groupId) — writes the current cached AMS to disk.
|
|
61
|
+
* - persistAll() — convenience for shutdown.
|
|
62
|
+
*
|
|
63
|
+
* The registry is intentionally narrow: it does not mutate the AMS
|
|
64
|
+
* itself (that's the engine's job). It only caches, loads, and saves.
|
|
65
|
+
*/
|
|
66
|
+
export class AmsRegistry {
|
|
67
|
+
/** @param {AmsRegistryDeps} deps */
|
|
68
|
+
constructor(deps) {
|
|
69
|
+
this.yeaftDir = deps.yeaftDir;
|
|
70
|
+
this.memoryIndex = deps.memoryIndex || null;
|
|
71
|
+
this.config = deps.config || {};
|
|
72
|
+
/** @type {Map<string, AmsCacheEntry>} */
|
|
73
|
+
this._cache = new Map();
|
|
74
|
+
/** @type {Set<string>} */
|
|
75
|
+
this._dirty = new Set();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the on-disk path for a group's ams.json.
|
|
80
|
+
*
|
|
81
|
+
* `groupId` is trusted: `nextGroupId()` (groups/ids.js) emits ids matching
|
|
82
|
+
* `grp_[a-z0-9_-]+`, and the single-VP path uses the literal
|
|
83
|
+
* `DEFAULT_GROUP_KEY`. No defensive escaping is needed.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} groupId
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
amsPath(groupId) {
|
|
89
|
+
const key = String(groupId || DEFAULT_GROUP_KEY);
|
|
90
|
+
return join(this.yeaftDir, 'memory', 'groups', key, 'ams.json');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute the BudgetSplit for this session/model.
|
|
95
|
+
*
|
|
96
|
+
* @returns {import('./budget.js').BudgetSplit}
|
|
97
|
+
*/
|
|
98
|
+
_budget() {
|
|
99
|
+
const ctx = Number.isFinite(this.config?.maxContextTokens)
|
|
100
|
+
? this.config.maxContextTokens
|
|
101
|
+
: 200_000;
|
|
102
|
+
return computeBudget(ctx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the AMS for a group, creating it on first access.
|
|
107
|
+
* Loads persisted state from disk if any; on cold start returns an
|
|
108
|
+
* empty AMS keyed to the supplied ownVpId.
|
|
109
|
+
*
|
|
110
|
+
* @param {string|null|undefined} groupId
|
|
111
|
+
* @param {{ ownVpId?: string|null }} [opts]
|
|
112
|
+
* @returns {ActiveMemorySet}
|
|
113
|
+
*/
|
|
114
|
+
getOrCreate(groupId, opts = {}) {
|
|
115
|
+
const key = groupId || DEFAULT_GROUP_KEY;
|
|
116
|
+
const cached = this._cache.get(key);
|
|
117
|
+
if (cached) return cached.ams;
|
|
118
|
+
|
|
119
|
+
const ownVpId = opts.ownVpId || null;
|
|
120
|
+
const budget = this._budget();
|
|
121
|
+
const ams = new ActiveMemorySet({ ownVpId, budget });
|
|
122
|
+
const entry = { ams, ownVpId, adjustRanThisSession: false };
|
|
123
|
+
// Best-effort hydrate from disk — populates ams + entry flags.
|
|
124
|
+
this._hydrate(key, entry);
|
|
125
|
+
this._cache.set(key, entry);
|
|
126
|
+
return ams;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read the persisted-and-rehydrated `adjustRanThisSession` flag for a
|
|
131
|
+
* group. Engine consults this on first AMS access so a reactivated group
|
|
132
|
+
* doesn't re-run `runAdjust` on its first turn back online.
|
|
133
|
+
*
|
|
134
|
+
* @param {string|null|undefined} groupId
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
adjustRanThisSession(groupId) {
|
|
138
|
+
const key = groupId || DEFAULT_GROUP_KEY;
|
|
139
|
+
return this._cache.get(key)?.adjustRanThisSession === true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Update the cached `adjustRanThisSession` flag (does not persist on its
|
|
144
|
+
* own — call `persist()` to flush). Engine flips this true after
|
|
145
|
+
* `runAdjust` actually ran.
|
|
146
|
+
*
|
|
147
|
+
* @param {string|null|undefined} groupId
|
|
148
|
+
* @param {boolean} value
|
|
149
|
+
*/
|
|
150
|
+
setAdjustRanThisSession(groupId, value) {
|
|
151
|
+
const key = groupId || DEFAULT_GROUP_KEY;
|
|
152
|
+
const entry = this._cache.get(key);
|
|
153
|
+
if (entry) entry.adjustRanThisSession = Boolean(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mark a group's AMS as dirty so the next persist() actually writes.
|
|
158
|
+
* The engine calls this after `runAdjust` mutates membership.
|
|
159
|
+
*
|
|
160
|
+
* @param {string|null|undefined} groupId
|
|
161
|
+
*/
|
|
162
|
+
markDirty(groupId) {
|
|
163
|
+
this._dirty.add(groupId || DEFAULT_GROUP_KEY);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist a single group's AMS to disk. No-op when the cached entry
|
|
168
|
+
* is missing or hasn't been marked dirty.
|
|
169
|
+
*
|
|
170
|
+
* `opts.adjustRanThisSession`, when supplied, also updates the cached
|
|
171
|
+
* entry so subsequent `adjustRanThisSession()` reads see the latest flag
|
|
172
|
+
* without a round-trip through disk.
|
|
173
|
+
*
|
|
174
|
+
* @param {string|null|undefined} groupId
|
|
175
|
+
* @param {{ force?: boolean, adjustRanThisSession?: boolean }} [opts]
|
|
176
|
+
* @returns {boolean} true if the file was written
|
|
177
|
+
*/
|
|
178
|
+
persist(groupId, opts = {}) {
|
|
179
|
+
const key = groupId || DEFAULT_GROUP_KEY;
|
|
180
|
+
const entry = this._cache.get(key);
|
|
181
|
+
if (!entry) return false;
|
|
182
|
+
if (!opts.force && !this._dirty.has(key)) return false;
|
|
183
|
+
|
|
184
|
+
if (typeof opts.adjustRanThisSession === 'boolean') {
|
|
185
|
+
entry.adjustRanThisSession = opts.adjustRanThisSession;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const path = this.amsPath(key);
|
|
189
|
+
const payload = {
|
|
190
|
+
version: AMS_FILE_VERSION,
|
|
191
|
+
ownVpId: entry.ownVpId,
|
|
192
|
+
onDemandIds: entry.ams.onDemandIds(),
|
|
193
|
+
recentIds: entry.ams.recentIds(),
|
|
194
|
+
adjustRanThisSession: Boolean(entry.adjustRanThisSession),
|
|
195
|
+
savedAt: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
200
|
+
const tmp = `${path}.tmp`;
|
|
201
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
202
|
+
renameSync(tmp, path);
|
|
203
|
+
this._dirty.delete(key);
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
// Persistence failure is non-fatal — AMS continues to live in memory.
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Persist every cached, dirty AMS. Called on session shutdown.
|
|
213
|
+
*
|
|
214
|
+
* @returns {number} number of files written
|
|
215
|
+
*/
|
|
216
|
+
persistAll() {
|
|
217
|
+
let n = 0;
|
|
218
|
+
for (const key of this._dirty) {
|
|
219
|
+
if (this.persist(key, { force: true })) n += 1;
|
|
220
|
+
}
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Best-effort hydrate: read ams.json, re-resolve segment ids via the
|
|
226
|
+
* SegmentIndex (skipping ids that no longer exist), populate AMS, and
|
|
227
|
+
* restore the persisted `adjustRanThisSession` flag onto the cache entry.
|
|
228
|
+
* Silent on every error — a corrupt or missing file is the cold-start
|
|
229
|
+
* case, indistinguishable from "first use of this group".
|
|
230
|
+
*
|
|
231
|
+
* @private
|
|
232
|
+
* @param {string} key
|
|
233
|
+
* @param {AmsCacheEntry} entry
|
|
234
|
+
*/
|
|
235
|
+
_hydrate(key, entry) {
|
|
236
|
+
const path = this.amsPath(key);
|
|
237
|
+
if (!existsSync(path)) return;
|
|
238
|
+
let payload;
|
|
239
|
+
try { payload = JSON.parse(readFileSync(path, 'utf8') || '{}'); }
|
|
240
|
+
catch { return; }
|
|
241
|
+
if (!payload || typeof payload !== 'object') return;
|
|
242
|
+
|
|
243
|
+
if (payload.adjustRanThisSession === true) {
|
|
244
|
+
entry.adjustRanThisSession = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!this.memoryIndex) return;
|
|
248
|
+
|
|
249
|
+
const onDemandIds = Array.isArray(payload.onDemandIds) ? payload.onDemandIds : [];
|
|
250
|
+
const recentIds = Array.isArray(payload.recentIds) ? payload.recentIds : [];
|
|
251
|
+
|
|
252
|
+
const onDemandSegs = [];
|
|
253
|
+
for (const id of onDemandIds) {
|
|
254
|
+
try {
|
|
255
|
+
const seg = this.memoryIndex.get(id);
|
|
256
|
+
if (seg) onDemandSegs.push(seg);
|
|
257
|
+
} catch { /* skip unresolvable */ }
|
|
258
|
+
}
|
|
259
|
+
if (onDemandSegs.length > 0) entry.ams.setOnDemand(onDemandSegs);
|
|
260
|
+
|
|
261
|
+
for (const id of recentIds) {
|
|
262
|
+
try {
|
|
263
|
+
const seg = this.memoryIndex.get(id);
|
|
264
|
+
if (seg) entry.ams.touchRecent(seg);
|
|
265
|
+
} catch { /* skip */ }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Factory for the registry. Kept as a thin function so call sites can
|
|
272
|
+
* stay symmetrical with the other store openers in session.js.
|
|
273
|
+
*
|
|
274
|
+
* @param {AmsRegistryDeps} deps
|
|
275
|
+
* @returns {AmsRegistry}
|
|
276
|
+
*/
|
|
277
|
+
export function openAmsRegistry(deps) {
|
|
278
|
+
return new AmsRegistry(deps);
|
|
279
|
+
}
|