@yeaft/webchat-agent 0.1.914 → 0.1.916

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.914",
3
+ "version": "0.1.916",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -61,7 +61,25 @@ export class Compactor {
61
61
  * the trimmed summary text (or '' on failure — `compactHistory`
62
62
  * treats that as a soft failure).
63
63
  * @param {() => number|undefined} [opts.getMaxContextTokens]
64
- * Returns `config.maxContextTokens` for `shouldCompactHistory`.
64
+ * Returns the model-aware context window (preferred — provided
65
+ * by `session.js` via `resolveContextWindow(model, config)`) or
66
+ * a flat `config.maxContextTokens` fallback. The number is
67
+ * threaded into `shouldCompactHistory` as `maxContextTokens`.
68
+ * @param {() => number|undefined} [opts.getTriggerRatio]
69
+ * Returns the fraction-of-context threshold (e.g. `0.7` for the
70
+ * user-stated "70% of model context"). The application-wide
71
+ * default is 0.7, enforced by the injector in `session.js` — a
72
+ * finite number in (0, 1) wins, anything else falls back to 0.7.
73
+ *
74
+ * When NO injector is wired (typically test fixtures that omit
75
+ * `getTriggerRatio` entirely), this falls through to
76
+ * `shouldCompactHistory`'s library default (`DEFAULT_TOKEN_FRACTION`
77
+ * = 0.5). That gap is intentional: the library default is the
78
+ * documented unit-test contract, the application default is the
79
+ * user-facing product contract, and the injector boundary is what
80
+ * keeps them from drifting in production. Production callers MUST
81
+ * wire `getTriggerRatio` (session.js does). Live-read so a config
82
+ * edit takes effect without reboot.
65
83
  * @param {() => string|undefined} [opts.getLanguage]
66
84
  * Returns the live `config.language`. Threaded into
67
85
  * `compactHistory` so the compactor's summary prompt + the
@@ -72,7 +90,7 @@ export class Compactor {
72
90
  * `yeaft_history_compacted` WS event. Default: no-op. Can be
73
91
  * replaced post-construction via `setOnCompacted`.
74
92
  */
75
- constructor({ summarize, getMaxContextTokens, getLanguage, onCompacted } = {}) {
93
+ constructor({ summarize, getMaxContextTokens, getTriggerRatio, getLanguage, onCompacted } = {}) {
76
94
  if (typeof summarize !== 'function') {
77
95
  throw new TypeError('Compactor: summarize is required');
78
96
  }
@@ -80,6 +98,9 @@ export class Compactor {
80
98
  this._getMaxContextTokens = typeof getMaxContextTokens === 'function'
81
99
  ? getMaxContextTokens
82
100
  : () => undefined;
101
+ this._getTriggerRatio = typeof getTriggerRatio === 'function'
102
+ ? getTriggerRatio
103
+ : () => undefined;
83
104
  this._getLanguage = typeof getLanguage === 'function'
84
105
  ? getLanguage
85
106
  : () => undefined;
@@ -183,12 +204,19 @@ export class Compactor {
183
204
  const snapshotLen = snapshot.length;
184
205
 
185
206
  const maxContextTokens = this._getMaxContextTokens();
207
+ const tokenFraction = this._getTriggerRatio();
186
208
 
187
209
  // Cheap O(n) precheck so we don't bother engaging the LLM at all
188
210
  // when the conversation is still small. `compactHistory` runs the
189
211
  // same check internally, but only after building the summarizer
190
212
  // input — this keeps small chats off the LLM altogether.
191
- const triage = shouldCompactHistory(snapshot, { maxContextTokens });
213
+ //
214
+ // `tokenFraction` is the user-configurable ratio knob — production
215
+ // wiring (session.js) ALWAYS returns a finite (0,1) number defaulting
216
+ // to 0.7. The `undefined` branch (helper falls back to its
217
+ // `DEFAULT_TOKEN_FRACTION = 0.5`) is reachable only when a caller
218
+ // skips `getTriggerRatio` entirely — see constructor JSDoc.
219
+ const triage = shouldCompactHistory(snapshot, { maxContextTokens, tokenFraction });
192
220
  if (!triage.trigger) return;
193
221
 
194
222
  const summarize = ({ system, prompt }) =>
@@ -197,6 +225,7 @@ export class Compactor {
197
225
  const result = await compactHistory(snapshot, {
198
226
  summarize,
199
227
  maxContextTokens,
228
+ tokenFraction,
200
229
  language: this._getLanguage(),
201
230
  });
202
231
  if (!result || !result.compacted) {
@@ -1,17 +1,28 @@
1
1
  /**
2
- * consolidate.js — Hot-window budget partitioning utilities.
2
+ * compact/partition.js — Hot-window budget partitioning utilities.
3
3
  *
4
- * Reduced surface (PR-B rip): the legacy LLM-driven consolidate() pipeline
5
- * (compact summary + entries-store extraction) has been retired. The only
6
- * survivors are the pure functions used by the compact orchestrator:
4
+ * (Renamed from `agent/yeaft/memory/consolidate.js` on 2026-06-09.) The
5
+ * legacy "consolidate" name and the `memory/` location both pointed at
6
+ * a single concept Layer-A memory consolidation that has since been
7
+ * cleanly split:
7
8
  *
8
- * - shouldConsolidate(store, budget) decide when to compact
9
- * - partitionMessages(messages, budget) split hot messages into
10
- * toArchive / toKeep based on token budget
9
+ * - Memory consolidation / system-prompt maintenance is owned by
10
+ * Dream V2 (per-group diff -> triage -> merge by target scope ->
11
+ * apply via segment-store + summary-store). NONE of that lives here.
11
12
  *
12
- * Memory extraction is now owned by Dream V2 (per-group diff -> triage ->
13
- * merge by target scope -> apply via segment-store + summary-store).
14
- * Conversation summarisation lives in compact/orchestrator.js's hooks.
13
+ * - Conversation history compaction (the thing this file ACTUALLY
14
+ * serves) is owned by `compact/orchestrator.js`. The two functions
15
+ * below `shouldCompact` (the "hot window over budget?" predicate
16
+ * that gates `compact/orchestrator.js`) and `partitionMessages`
17
+ * (hot/cold split by token budget) are pure helpers for that
18
+ * orchestrator.
19
+ *
20
+ * Why the move matters: keeping these under `memory/` invited the next
21
+ * person to think "this is part of the memory subsystem" and reach for
22
+ * it during a Dream-v2 patch — the exact category error
23
+ * `DESIGN-COMPACT-VS-DREAM.md` (sibling doc) warns against. Putting
24
+ * them next to `compact/orchestrator.js` makes the ownership obvious
25
+ * from the file tree.
15
26
  */
16
27
 
17
28
  // ─── Constants ──────────────────────────────────────────────────
@@ -26,13 +37,18 @@ export const COMPACT_KEEP_RATIO = 0.4;
26
37
  const MIN_KEEP_MESSAGES = 3;
27
38
 
28
39
  /**
29
- * Check if consolidation should be triggered.
40
+ * Check if a compact pass should be triggered.
41
+ *
42
+ * Semantically: "is the hot window over budget?" — the predicate that
43
+ * gates `compact/orchestrator.js`. Renamed from `shouldConsolidate` on
44
+ * 2026-06-09; the old name leaked Dream V2's vocabulary into a file
45
+ * that exclusively serves Compact.
30
46
  *
31
47
  * @param {import('../conversation/persist.js').ConversationStore} conversationStore
32
48
  * @param {number} [budget] — MESSAGE_TOKEN_BUDGET
33
49
  * @returns {boolean}
34
50
  */
35
- export function shouldConsolidate(conversationStore, budget = DEFAULT_MESSAGE_TOKEN_BUDGET) {
51
+ export function shouldCompact(conversationStore, budget = DEFAULT_MESSAGE_TOKEN_BUDGET) {
36
52
  const hotTokens = conversationStore.hotTokens();
37
53
  return hotTokens > budget;
38
54
  }
@@ -320,6 +320,18 @@ export function parseMessage(raw) {
320
320
  case 'threadId': msg.threadId = value; break;
321
321
  case 'sourceThreadId': msg.sourceThreadId = value; break;
322
322
  case 'sessionId': msg.sessionId = value; break;
323
+ // Legacy alias: pre-rename messages stamped `groupId:` (PR #881
324
+ // groups → sessions sweep). Without this case, files in
325
+ // ~/.yeaft/conversation/messages/*.md from before the rename
326
+ // parse with msg.sessionId undefined and #loadSessionHotMessages'
327
+ // `.filter(m => m?.sessionId)` drops every row — LLM replay sees
328
+ // an empty history. `if (!msg.sessionId)` keeps new-key precedence
329
+ // for hand-edited files that carry both keys. See
330
+ // migrate/sessions-v1.js — that pass moved directories but did
331
+ // NOT rewrite per-message frontmatter, which is why this alias
332
+ // has to live forever (or until a frontmatter-rewrite migration
333
+ // lands and the legacy dir is empty).
334
+ case 'groupId': if (!msg.sessionId) msg.sessionId = value; break;
323
335
  case 'chatId': msg.chatId = value; break;
324
336
  case 'speakerVpId': msg.speakerVpId = value; break;
325
337
  case 'attachmentsB64':
package/yeaft/engine.js CHANGED
@@ -23,7 +23,7 @@ import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
23
23
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
24
24
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
25
25
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
26
- import { shouldConsolidate, partitionMessages } from './memory/consolidate.js';
26
+ import { partitionMessages } from './compact/partition.js';
27
27
  import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
28
28
  import { evaluateCompactTriggers } from './compact/triggers.js';
29
29
  import { archiveTurn } from './archive/turn-archive.js';
@@ -257,8 +257,18 @@ export function buildResidentEntries(args) {
257
257
  if (args.sessionId && summaries.group) {
258
258
  out.push({ scope: `group/${args.sessionId}`, summary: summaries.group });
259
259
  }
260
- if (args.ownVpId && summaries.vp && !isVpSeedBackfillStub(summaries.vp)) {
261
- out.push({ scope: `vp/${args.ownVpId}`, summary: summaries.vp });
260
+ // VP per-session isolation (2026-06-09): the VP summary scope MUST be
261
+ // session-qualified. The legacy bare `vp/<id>` scope was a structural
262
+ // bug — `summaries.vp` is actually loaded from `group/<sessionId>/vp/<id>/summary.md`
263
+ // (see #loadLayerASummaries, kind:'group-vp'), so labelling it `vp/<id>`
264
+ // in the Resident layer (a) collides with the ACL regex in store-v2
265
+ // (which only recognises `<root>/<sid>/vp/...`) and (b) makes the same
266
+ // VP persona leak across DIFFERENT sessions whenever the AMS rehydrates
267
+ // by id rather than by full scope path. The session-qualified form
268
+ // makes the per-session boundary explicit and matches the on-disk
269
+ // layout 1:1.
270
+ if (args.sessionId && args.ownVpId && summaries.vp && !isVpSeedBackfillStub(summaries.vp)) {
271
+ out.push({ scope: `group/${args.sessionId}/vp/${args.ownVpId}`, summary: summaries.vp });
262
272
  }
263
273
  return out;
264
274
  }
@@ -1481,6 +1491,23 @@ export class Engine {
1481
1491
  projectDoc,
1482
1492
  });
1483
1493
 
1494
+ // ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
1495
+ // Compact summary (this block) ONLY lands in the messages array head as
1496
+ // a `<conversation_summary>` user/assistant pair. It MUST NEVER appear
1497
+ // in the system prompt — that was the bug DESIGN-PROMPT §4.3 banned.
1498
+ //
1499
+ // Inversely: Dream V2's output (per-scope `memory.md` / `summary.md`)
1500
+ // flows exclusively through `prompts.js#buildSystemPrompt`'s §6 Memory
1501
+ // section via the AMS Resident layer (see `engine.js#buildResidentEntries`).
1502
+ // It MUST NEVER appear in the messages array.
1503
+ //
1504
+ // Two write roots, two scheduler triggers, two prompt slots — never
1505
+ // mixed. Anyone touching this section must read
1506
+ // `agent/yeaft/DESIGN-COMPACT-VS-DREAM.md` before changing the wiring;
1507
+ // the boundary has been violated twice in this codebase's history and
1508
+ // each time it took an LLM cache-thrash + persona-dup follow-up PR to
1509
+ // unwind.
1510
+ //
1484
1511
  // ─── Compact summary as messages-array head (DESIGN-PROMPT §4.3) ─
1485
1512
  // The previous code placed the compact summary inside the system
1486
1513
  // prompt; that broke prompt-cache hit-rate (any compact update
@@ -1,54 +1,60 @@
1
1
  /**
2
- * memory/seed-backfill.js — Run-once backfill of `summary.md` for VPs and
3
- * groups that were created BEFORE the create-time seed was added (PR
4
- * "fix-yeaft-context-and-memory"). Without backfill, an existing user's
5
- * `grp_claude` group and `steve` VP never get a Layer-A resident summary —
6
- * `engine.#prepareAms` then renders an empty memory section every turn,
7
- * which is the user-visible Bug #2.
8
- *
9
- * Idempotency:
10
- * - Reads `<root>/<scopeDir>/summary.md`. If it already has any non-
11
- * empty content, the backfill is a no-op for that scope.
12
- * - Only seeds when the file is missing OR empty.
13
- *
14
- * This runs sync at session boot. Failures are logged and swallowed —
15
- * a permission error must NEVER prevent the session from loading.
2
+ * memory/seed-backfill.js — stub-marker contract + one-shot legacy archive.
3
+ *
4
+ * Earlier versions of this module shipped `backfillVpSummaries` /
5
+ * `migrateLegacyVpSummaries` / `backfillGroupSummaries` / `runSummaryBackfill`
6
+ * that wrote files into BARE `<root>/vp/<id>/summary.md` and
7
+ * `<root>/group/<id>/summary.md` paths. Those paths are not the layout the
8
+ * Engine actually reads: `engine.#loadLayerASummaries` looks under
9
+ * `group/<sessionId>/vp/<id>/summary.md` (kind: 'group-vp'). The backfill
10
+ * helpers were therefore writing **orphan files** that nothing ever read.
11
+ *
12
+ * Per user directive (2026-06-09 "VP per-session isolation + clean up the
13
+ * dead backfill code"), the orphan writers have been deleted. What remains:
14
+ *
15
+ * - `VP_STUB_MARKER` / `isVpSeedBackfillStub`: still used by `engine.js`
16
+ * and `engine.#prepareAms` to skip the own-VP Resident entry when its
17
+ * summary is just the stub. Section 1 (`renderVpPersona`) is the source
18
+ * of truth for own-VP identity; surfacing the stub in Section 6 would
19
+ * dup the same `# Name / Role` text. New seed paths (vp-crud.js,
20
+ * group-crud.js, seed-default.js) use `seedSummaryIfMissingSync` from
21
+ * store-v2.js to write directly to the correct scope dir.
22
+ *
23
+ * - `archiveLegacyScopes(root)`: one-shot migration that moves the truly
24
+ * dead top-level `vp/`, `feature/`, `topic/` dirs to `.legacy/`. Per
25
+ * user directive "硬切,老的就不要了" — we do NOT migrate per-record,
26
+ * just move once. They're never read again; forensics-only.
27
+ *
28
+ * Anything that *looks* like a backfill function and lived here before is
29
+ * gone. If you need to seed a missing summary today, call
30
+ * `seedSummaryIfMissingSync` directly from the CRUD entry point — that's
31
+ * the only path that writes to the correct (group-scoped) location.
16
32
  */
17
33
 
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync } from 'fs';
34
+ import { existsSync, mkdirSync, renameSync } from 'fs';
19
35
  import { join } from 'path';
20
- import { homedir } from 'os';
21
- import { parseRoleMd } from '../vp/vp-store.js';
22
-
23
- const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
24
36
 
25
37
  /**
26
- * Marker stamped into every VP summary written by this module.
27
- *
28
- * Two consumers care about it:
29
- * 1. `engine.#prepareAms` uses `isVpSeedBackfillStub` to skip the
30
- * `vp/<ownVpId>` Resident entry when its summary is just our stub —
31
- * the real persona is already rendered as Section 1 of the system
32
- * prompt by `renderVpPersona`. Without the skip, AMS Resident dups
33
- * Section 1 with redundant `name + role` labels.
34
- * 2. `migrateLegacyVpSummaries` uses absence-of-marker + presence of
35
- * `**Persona:**` to identify pre-fix summary.md files (which copied
36
- * up to 800 chars of `role.md` body) and rewrite them as stubs.
38
+ * Marker stamped into every VP summary written by `seedSummaryIfMissingSync`
39
+ * for the VP scope. Consumed by `isVpSeedBackfillStub` (below) so
40
+ * `engine.#prepareAms` can skip the `vp/<ownVpId>` Resident entry when the
41
+ * file is just the stub. Real Dream-v2 summaries lack the marker and ARE
42
+ * surfaced in Section 6 normally.
37
43
  *
38
44
  * Bump the version suffix when the stub format changes meaningfully so
39
- * old stamps can be re-migrated if needed.
45
+ * old stamps can be re-detected if needed.
40
46
  */
41
47
  export const VP_STUB_MARKER = '<!-- seed-backfill:vp-stub v1 -->';
42
48
 
43
49
  /**
44
- * True iff the given summary text was produced by this module's VP stub
45
- * writer (i.e. carries the marker comment). Whitespace-tolerant.
50
+ * True iff the given summary text was produced by a VP stub writer
51
+ * (i.e. carries the marker comment). Whitespace-tolerant.
46
52
  *
47
- * Used by `engine.#prepareAms` to decide whether to surface the
48
- * `vp/<ownVpId>` summary as a Resident AMS entry. Stubs are skipped so
49
- * Section 1 (`renderVpPersona`) is the sole rendering of own-VP identity;
50
- * Dream-v2's eventual real summary will lack the marker and be surfaced
51
- * normally.
53
+ * Used by `engine.#prepareAms` (via `buildResidentEntries`) to decide
54
+ * whether to surface the `group/<sessionId>/vp/<ownVpId>` summary as a
55
+ * Resident AMS entry. Stubs are skipped so Section 1 (`renderVpPersona`)
56
+ * is the sole rendering of own-VP identity; Dream-v2's eventual real
57
+ * summary will lack the marker and be surfaced normally.
52
58
  *
53
59
  * @param {string|null|undefined} text
54
60
  * @returns {boolean}
@@ -58,249 +64,14 @@ export function isVpSeedBackfillStub(text) {
58
64
  return text.includes(VP_STUB_MARKER);
59
65
  }
60
66
 
61
- function readIfPresent(path) {
62
- try {
63
- if (!existsSync(path)) return '';
64
- return readFileSync(path, 'utf-8').trim();
65
- } catch {
66
- return '';
67
- }
68
- }
69
-
70
- function writeAtomicSync(path, body) {
71
- mkdirSync(join(path, '..'), { recursive: true });
72
- writeFileSync(path, (body || '').trim() + '\n', 'utf-8');
73
- }
74
-
75
- /**
76
- * Build a synthetic VP summary from the on-disk role.md.
77
- *
78
- * IMPORTANT — this is a STUB that lives until Dream-v2 writes a real
79
- * per-scope summary. Earlier versions copied up to 800 chars of the
80
- * `role.md` body into `summary.md`. That body is *also* rendered as
81
- * Section 1 of the system prompt (`renderVpPersona` in `prompts.js`),
82
- * so the same persona text reappeared in `## Active Memory Set →
83
- * Resident → vp/<id>` — the user-visible "Why is the persona defined
84
- * twice?" bug.
85
- *
86
- * The summary.md placeholder is therefore deliberately minimal: just
87
- * the VP's display name + role label. Layer-A AMS still sees a
88
- * non-empty `vp/<id>` resident entry (so adjust/recall scope wiring
89
- * stays unchanged), but the persona body is rendered exactly once,
90
- * by Section 1.
91
- *
92
- * Once Dream-v2 produces a real summary for this scope it overwrites
93
- * this stub — see `idempotency` note at the top of the file.
94
- *
95
- * Delegates frontmatter parsing to `vp-store.js#parseRoleMd` so the
96
- * backfill stays in sync with the production loader.
97
- *
98
- * @param {string} libDir
99
- * @param {string} vpId
100
- * @returns {string|null}
101
- */
102
- function readVpRoleSummary(libDir, vpId) {
103
- const rolePath = join(libDir, vpId, 'role.md');
104
- if (!existsSync(rolePath)) return null;
105
- let raw = '';
106
- try { raw = readFileSync(rolePath, 'utf-8'); } catch { return null; }
107
-
108
- const { meta } = parseRoleMd(raw);
109
- const name = String(meta.name || vpId).trim() || vpId;
110
- const role = typeof meta.role === 'string' ? meta.role.trim() : '';
111
-
112
- const lines = [VP_STUB_MARKER, '', `# ${name}`];
113
- if (role) lines.push('', `**Role:** ${role}`);
114
- return lines.join('\n').trim();
115
- }
116
-
117
- /**
118
- * Walk the VP library and seed `summary.md` for every VP without one.
119
- *
120
- * @param {{ libDir: string, root?: string }} opts
121
- * @returns {{seeded: number, scanned: number}}
122
- */
123
- export function backfillVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
124
- let scanned = 0;
125
- let seeded = 0;
126
- if (!existsSync(libDir)) return { scanned, seeded };
127
- let entries;
128
- try { entries = readdirSync(libDir); } catch { return { scanned, seeded }; }
129
- for (const name of entries) {
130
- const vpDir = join(libDir, name);
131
- let isDir = false;
132
- try { isDir = statSync(vpDir).isDirectory(); } catch { /* skip */ }
133
- if (!isDir) continue;
134
- if (name.startsWith('.')) continue;
135
- scanned++;
136
- const summaryPath = join(root, 'vp', name, 'summary.md');
137
- if (readIfPresent(summaryPath)) continue;
138
- const body = readVpRoleSummary(libDir, name);
139
- if (!body) continue;
140
- try {
141
- writeAtomicSync(summaryPath, body);
142
- seeded++;
143
- } catch (err) {
144
- console.warn(`[seed-backfill] vp ${name}: ${err?.message || err}`);
145
- }
146
- }
147
- return { scanned, seeded };
148
- }
149
-
150
- /**
151
- * Build a synthetic group summary from group.json on disk.
152
- *
153
- * @param {string} sessionDir
154
- * @returns {string|null}
155
- */
156
- function readGroupSummaryBody(sessionDir) {
157
- const metaPath = join(sessionDir, 'group.json');
158
- if (!existsSync(metaPath)) return null;
159
- let meta;
160
- try { meta = JSON.parse(readFileSync(metaPath, 'utf-8')); } catch { return null; }
161
- const name = (meta?.name || '').trim();
162
- const roster = Array.isArray(meta?.roster) ? meta.roster : [];
163
- const defaultVpId = meta?.defaultVpId || null;
164
- const lines = [];
165
- if (name) lines.push(`# ${name}`);
166
- lines.push('', `Group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
167
- if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
168
- if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
169
- return lines.join('\n').trim();
170
- }
171
-
172
- /**
173
- * Detect the *legacy* (pre-stamp) VP summary shape: a body that lacks
174
- * `VP_STUB_MARKER` AND contains the `**Persona:**` block written by the
175
- * older stub. Tight signature on purpose — we don't want to clobber
176
- * hand-edited or Dream-v2-produced summaries that happen to be missing
177
- * the marker for unrelated reasons.
178
- *
179
- * @param {string} body
180
- * @returns {boolean}
181
- */
182
- function isLegacyVpSummary(body) {
183
- if (typeof body !== 'string' || body.length === 0) return false;
184
- if (body.includes(VP_STUB_MARKER)) return false;
185
- return body.includes('**Persona:**');
186
- }
187
-
188
- /**
189
- * One-shot migration: walk `<root>/vp/<id>/summary.md` and rewrite any
190
- * file matching the legacy shape (`isLegacyVpSummary`) into the current
191
- * stamped stub. Idempotent — a stamped or Dream-v2-produced file is left
192
- * untouched. Safe to run on every session boot.
193
- *
194
- * Existing users whose `summary.md` was written by the pre-stamp stub
195
- * carry the persona body forever, because `backfillVpSummaries` only
196
- * writes when the file is empty/missing. This pass closes that gap.
197
- *
198
- * @param {{ libDir: string, root?: string }} opts
199
- * @returns {{ scanned: number, migrated: number }}
200
- */
201
- export function migrateLegacyVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
202
- let scanned = 0;
203
- let migrated = 0;
204
- const vpRoot = join(root, 'vp');
205
- if (!existsSync(vpRoot)) return { scanned, migrated };
206
- let entries;
207
- try { entries = readdirSync(vpRoot); } catch { return { scanned, migrated }; }
208
- for (const name of entries) {
209
- if (name.startsWith('.')) continue;
210
- const summaryPath = join(vpRoot, name, 'summary.md');
211
- let body = '';
212
- try {
213
- if (!existsSync(summaryPath)) continue;
214
- body = readFileSync(summaryPath, 'utf-8');
215
- } catch { continue; }
216
- scanned++;
217
- if (!isLegacyVpSummary(body)) continue;
218
- const stub = readVpRoleSummary(libDir, name);
219
- if (!stub) continue;
220
- try {
221
- writeAtomicSync(summaryPath, stub);
222
- migrated++;
223
- } catch (err) {
224
- console.warn(`[seed-backfill] migrate vp ${name}: ${err?.message || err}`);
225
- }
226
- }
227
- return { scanned, migrated };
228
- }
229
-
230
- /**
231
- * Walk groups/ and seed `summary.md` for every group without one.
232
- *
233
- * @param {{ yeaftDir: string, root?: string }} opts
234
- * @returns {{seeded: number, scanned: number}}
235
- */
236
- export function backfillGroupSummaries({ yeaftDir, root = DEFAULT_MEMORY_ROOT }) {
237
- let scanned = 0;
238
- let seeded = 0;
239
- const sessionsRoot = join(yeaftDir, 'sessions');
240
- if (!existsSync(sessionsRoot)) return { scanned, seeded };
241
- let entries;
242
- try { entries = readdirSync(sessionsRoot); } catch { return { scanned, seeded }; }
243
- for (const name of entries) {
244
- if (name.startsWith('.')) continue;
245
- const sessionDir = join(sessionsRoot, name);
246
- let isDir = false;
247
- try { isDir = statSync(sessionDir).isDirectory(); } catch { /* skip */ }
248
- if (!isDir) continue;
249
- scanned++;
250
- const summaryPath = join(root, 'group', name, 'summary.md');
251
- if (readIfPresent(summaryPath)) continue;
252
- const body = readGroupSummaryBody(sessionDir);
253
- if (!body) continue;
254
- try {
255
- writeAtomicSync(summaryPath, body);
256
- seeded++;
257
- } catch (err) {
258
- console.warn(`[seed-backfill] group ${name}: ${err?.message || err}`);
259
- }
260
- }
261
- return { scanned, seeded };
262
- }
263
-
264
- /**
265
- * Run all backfills sequentially. Best-effort — any per-step error is
266
- * logged and the next step still runs.
267
- *
268
- * Order:
269
- * 1. Migrate legacy VP summaries (rewrite pre-stamp persona-body stubs
270
- * to current-format stamped stubs). Runs FIRST so that
271
- * `backfillVpSummaries` sees consistent on-disk state and any
272
- * future logic that distinguishes "stamped" vs "free-form" works
273
- * uniformly downstream.
274
- * 2. Backfill missing VP summaries.
275
- * 3. Backfill missing group summaries.
276
- *
277
- * @param {{ yeaftDir: string, libDir: string, root?: string }} opts
278
- * @returns {{ migrate: {scanned:number, migrated:number}, vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
279
- */
280
- export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROOT }) {
281
- let migrate = { scanned: 0, migrated: 0 };
282
- let vp = { scanned: 0, seeded: 0 };
283
- let group = { scanned: 0, seeded: 0 };
284
- try { migrate = migrateLegacyVpSummaries({ libDir, root }); } catch (err) {
285
- console.warn('[seed-backfill] vp migrate failed:', err?.message || err);
286
- }
287
- try { vp = backfillVpSummaries({ libDir, root }); } catch (err) {
288
- console.warn('[seed-backfill] vp pass failed:', err?.message || err);
289
- }
290
- try { group = backfillGroupSummaries({ yeaftDir, root }); } catch (err) {
291
- console.warn('[seed-backfill] group pass failed:', err?.message || err);
292
- }
293
- return { migrate, vp, group };
294
- }
295
-
296
67
  /**
297
68
  * archiveLegacyScopes(root) — one-shot migration for the group-isolated
298
69
  * memory refactor. The legacy flat layout had `vp/<id>/`, `feature/<id>/`,
299
- * and `topic/<l1>[/<l2>]/` directories at the memory root; the new layout
300
- * tucks each into `group/<g>/{vp,feature,topic}/...`. Per user directive
301
- * "硬切,老的就不要了" — we do NOT migrate per-record, we just move the
302
- * top-level dirs to `<root>/.legacy/<kind>/` once. They are never read
303
- * again; this is forensics-only.
70
+ * and `topic/<l1>[/<l2>]/` directories at the memory root; the current
71
+ * layout tucks each into `group/<g>/{vp,feature,topic}/...`. Per user
72
+ * directive "硬切,老的就不要了" — we do NOT migrate per-record, we just
73
+ * move the top-level dirs to `<root>/.legacy/<kind>/` once. They are never
74
+ * read again; this is forensics-only.
304
75
  *
305
76
  * Idempotent: a second invocation is a no-op when no legacy dirs remain at
306
77
  * the root. If `.legacy/<kind>/` already exists, the new move is suffixed
@@ -323,7 +94,6 @@ export function archiveLegacyScopes(root) {
323
94
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
324
95
  dst = `${dst}.${ts}`;
325
96
  }
326
- // eslint-disable-next-line global-require
327
97
  renameSync(src, dst);
328
98
  moved.push(kind);
329
99
  } catch (err) {
package/yeaft/session.js CHANGED
@@ -23,6 +23,7 @@ import { MCPManager } from './mcp.js';
23
23
  import { createFullRegistry } from './tools/index.js';
24
24
  import { Engine } from './engine.js';
25
25
  import { Compactor } from './compact/compactor.js';
26
+ import { resolveContextWindow } from './models.js';
26
27
  import { ToolUsageStats } from './stats/tool-usage.js';
27
28
  // H2.f.5 removed the old user-facing thread pipeline/dispatcher. The base
28
29
  // session still exposes a single default Engine; PR #797 adds group VP thread
@@ -45,7 +46,7 @@ import { ToolUsageStats } from './stats/tool-usage.js';
45
46
  import { ensureDefaultSessionIfEmpty } from './sessions/session-crud.js';
46
47
  import { seedDefaultVps } from './vp/seed-defaults.js';
47
48
  import { topUpDefaultVps } from './vp/seed-topup.js';
48
- import { runSummaryBackfill, archiveLegacyScopes } from './memory/seed-backfill.js';
49
+ import { archiveLegacyScopes } from './memory/seed-backfill.js';
49
50
  import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream-v2/session-wiring.js';
50
51
  import { openSegmentIndex } from './memory/index-db.js';
51
52
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
@@ -53,6 +54,18 @@ import { openAmsRegistry } from './memory/ams-registry.js';
53
54
  import { join } from 'path';
54
55
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, mkdirSync as mkdirSyncSafe } from 'fs';
55
56
 
57
+ /**
58
+ * Application-wide default for `Compactor`'s trigger ratio (the
59
+ * "fraction of model context" gate). The user-stated requirement is
60
+ * "model context 的 70%"; this is the canonical literal for it. Lives
61
+ * in session.js (not in compactor.js) because `Compactor` is also used
62
+ * by test fixtures that intentionally skip the ratio injector to
63
+ * exercise the library default (`history-compact.js#DEFAULT_TOKEN_FRACTION`).
64
+ * The two defaults are kept separate on purpose — see the Compactor
65
+ * constructor JSDoc for the boundary.
66
+ */
67
+ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
68
+
56
69
  /**
57
70
  * @typedef {Object} SessionOptions
58
71
  * @property {string} [dir] — Yeaft data directory override (default: ~/.yeaft)
@@ -342,20 +355,15 @@ export async function loadSession(options = {}) {
342
355
  // the sidebar shows the empty state + "create session" CTA, which
343
356
  // is the explicit behaviour the user asked for.
344
357
 
345
- // task-fix-memory-load: backfill summary.md for VPs / groups created
346
- // before the create-time seed was added. Without this, an existing
347
- // user's `grp_claude` and `steve` VP have an empty Layer-A resident
348
- // summary every turn (memory section in the system prompt is just
349
- // the `active_scope` header). Idempotent only writes when missing.
350
- try {
351
- runSummaryBackfill({
352
- yeaftDir,
353
- libDir: join(yeaftDir, 'virtual-persons'),
354
- root: join(yeaftDir, 'memory'),
355
- });
356
- } catch (err) {
357
- console.warn(`[Yeaft] runSummaryBackfill failed: ${err?.message || err}`);
358
- }
358
+ // 2026-06-09 (VP per-session isolation): `runSummaryBackfill` was
359
+ // removed here. It walked `vp/<id>/` and `group/<id>/` at the memory
360
+ // root, writing `summary.md` files into bare paths the Engine never
361
+ // reads (`engine.#loadLayerASummaries` reads `group/<sid>/vp/<id>/...`
362
+ // kind:'group-vp'). The backfill therefore generated orphan files
363
+ // on every boot. See `memory/seed-backfill.js` for the historical
364
+ // context. Real seeding happens at create time via
365
+ // `seedSummaryIfMissingSync` from `store-v2.js`, called by vp-crud /
366
+ // group-crud / seed-default — those write to the correct scope dirs.
359
367
  }
360
368
 
361
369
  // ─── 6. Load skills ────────────────────────────────────
@@ -430,8 +438,25 @@ export async function loadSession(options = {}) {
430
438
  const compactor = new Compactor({
431
439
  summarize: ({ system, prompt, maxTokens } = {}) =>
432
440
  engine.summarizeForCompact({ system, prompt, maxTokens }),
441
+ // Resolve the model's true context window (GPT-5 256K vs Claude 200K
442
+ // etc.) instead of pinning to a flat `config.maxContextTokens`.
443
+ // The 70% threshold then floats with the model in use — the
444
+ // user-stated requirement ("超过 model context 70% 这一个约束").
433
445
  getMaxContextTokens: () =>
434
- typeof config.maxContextTokens === 'number' ? config.maxContextTokens : undefined,
446
+ resolveContextWindow(
447
+ typeof config.model === 'string' && config.model
448
+ ? config.model
449
+ : (config.primaryModel || ''),
450
+ config
451
+ ),
452
+ // Trigger ratio knob. Defaults to DEFAULT_COMPACT_TRIGGER_RATIO (0.7)
453
+ // per the user directive; a finite number in (0, 1) wins. Anything
454
+ // else (NaN, ≤0, ≥1, missing) falls back to the default so a typo in
455
+ // config.json can't disable compact.
456
+ getTriggerRatio: () => {
457
+ const r = Number(config?.compactTriggerRatio);
458
+ return Number.isFinite(r) && r > 0 && r < 1 ? r : DEFAULT_COMPACT_TRIGGER_RATIO;
459
+ },
435
460
  // Live-read: `config.language` is mutated in place by
436
461
  // `engine.setLanguage()` (which broadcastLanguageChange fans out to
437
462
  // every per-VP engine). The compactor must see the post-broadcast
@@ -2478,6 +2478,35 @@ export async function handleYeaftSessionSend(msg) {
2478
2478
  // Do not wait for every driver in the group: unrelated older threads may keep
2479
2479
  // running for minutes and must not hold this request lifecycle hostage.
2480
2480
  await waitForRoutePromises(report?.message?.id);
2481
+
2482
+ // Post-turn compaction. Fire-and-forget — does NOT block the response
2483
+ // path. The Compactor's own precheck (`shouldCompactHistory`) decides
2484
+ // whether to engage the LLM, using the trigger ratio wired in
2485
+ // `session.js` (default 70% of model context, knob:
2486
+ // `config.compactTriggerRatio`). The single-flight + anti-starvation
2487
+ // logic inside Compactor handles concurrent turns; the entry-gate
2488
+ // `awaitInFlight` at the top of `handleYeaftSessionSend` (~:2291)
2489
+ // ensures a follow-up turn never reads a half-mutated history.
2490
+ //
2491
+ // Why a per-call historyHandle: Compactor must NEVER close over a
2492
+ // frozen snapshot — the array reference can be swapped by
2493
+ // `consolidate`, session reset, or `route_forward` bursts. The handle
2494
+ // re-resolves on each `get` via the same sessionId-keyed helpers used
2495
+ // everywhere else in the bridge.
2496
+ //
2497
+ // Naming asymmetry note: `getOrCreateSessionHistory` and
2498
+ // `setGroupHistory` are intentionally NOT renamed to match. Both are
2499
+ // session-keyed today (the `set` helper's name is a historical alias
2500
+ // from the pre-VP-thread era), but per CLAUDE.md's "不要为了改名而批量
2501
+ // 重命名" guardrail we leave the wire-compat names alone and rely on
2502
+ // co-location to make the symmetry obvious. The source-pinning test
2503
+ // `web-bridge-post-turn-compact-wiring.test.js` matches both names.
2504
+ if (session?.compactor && sessionId) {
2505
+ session.compactor.scheduleAfterTurn(sessionId, {
2506
+ get: () => getOrCreateSessionHistory(sessionId),
2507
+ set: (next) => setGroupHistory(sessionId, next),
2508
+ });
2509
+ }
2481
2510
  }
2482
2511
 
2483
2512
  /**