@yeaft/webchat-agent 0.1.914 → 0.1.915
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/yeaft/compact/compactor.js +32 -3
- package/yeaft/{memory/consolidate.js → compact/partition.js} +28 -12
- package/yeaft/engine.js +30 -3
- package/yeaft/memory/seed-backfill.js +49 -279
- package/yeaft/session.js +41 -16
- package/yeaft/web-bridge.js +29 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* compact/partition.js — Hot-window budget partitioning utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
51
|
+
export function shouldCompact(conversationStore, budget = DEFAULT_MESSAGE_TOKEN_BUDGET) {
|
|
36
52
|
const hotTokens = conversationStore.hotTokens();
|
|
37
53
|
return hotTokens > budget;
|
|
38
54
|
}
|
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 {
|
|
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
|
-
|
|
261
|
-
|
|
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 —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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,
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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-
|
|
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
|
|
45
|
-
*
|
|
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`
|
|
48
|
-
* `vp/<ownVpId>` summary as a
|
|
49
|
-
* Section 1 (`renderVpPersona`)
|
|
50
|
-
* Dream-v2's eventual real
|
|
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
|
|
300
|
-
* tucks each into `group/<g>/{vp,feature,topic}/...`. Per user
|
|
301
|
-
* "硬切,老的就不要了" — we do NOT migrate per-record, we just
|
|
302
|
-
* top-level dirs to `<root>/.legacy/<kind>/` once. They are never
|
|
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 {
|
|
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
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
/**
|