@yeaft/webchat-agent 0.1.703 → 0.1.705

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.703",
3
+ "version": "0.1.705",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { existsSync, renameSync, rmSync, readdirSync, statSync } from 'fs';
36
36
  import { randomBytes } from 'crypto';
37
+ import { homedir } from 'os';
37
38
  import { join } from 'path';
38
39
  import {
39
40
  openGroup, createGroup, listGroups, loadGroupMeta,
@@ -42,6 +43,40 @@ import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './ro
42
43
  import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
43
44
  import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
44
45
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
46
+ import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
47
+
48
+ /**
49
+ * Default memory root used when callers don't pass `options.memoryRoot`.
50
+ * See `vp/vp-crud.js` for the same default; production code threads
51
+ * `<yeaftDir>/memory` through to keep test/prod isolation honest.
52
+ */
53
+ const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
54
+
55
+ /**
56
+ * Build the group seed summary body. Uses the group display name + roster
57
+ * so even an empty conversation has SOMETHING for engine.#prepareAms to
58
+ * pull into the Layer-A resident summary on the very first turn.
59
+ *
60
+ * Format is intentionally short: Dream-v2 will rewrite it in full once
61
+ * meaningful diffs accumulate.
62
+ *
63
+ * @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
64
+ * @returns {string}
65
+ */
66
+ export function buildGroupSeedSummary(spec) {
67
+ const name = String(spec?.name || '').trim();
68
+ const roster = Array.isArray(spec?.roster) ? spec.roster : [];
69
+ const lines = [];
70
+ if (name) lines.push(`# ${name}`);
71
+ lines.push('', `Group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
72
+ if (roster.length > 0) {
73
+ lines.push('', `**Members:** ${roster.join(', ')}`);
74
+ }
75
+ if (spec?.defaultVpId) {
76
+ lines.push('', `**Default VP:** ${spec.defaultVpId}`);
77
+ }
78
+ return lines.join('\n').trim();
79
+ }
45
80
 
46
81
  export class GroupCrudError extends Error {
47
82
  constructor(code, groupId, message) {
@@ -78,6 +113,7 @@ export function makeGroupId(name) {
78
113
  */
79
114
  export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
80
115
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
116
+ const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
81
117
  const existing = listGroups(groupsRoot(yeaftDir));
82
118
  if (existing.length > 0) {
83
119
  return { seeded: false, groupId: existing[0].id };
@@ -95,6 +131,7 @@ export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
95
131
  name: options.name || 'Default',
96
132
  roster: vps,
97
133
  defaultVpId,
134
+ memoryRoot,
98
135
  });
99
136
  return {
100
137
  seeded: created,
@@ -112,7 +149,8 @@ export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
112
149
  * @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
113
150
  * @returns {{id:string, name:string, roster:string[], defaultVpId:string|null}}
114
151
  */
115
- export function createGroupFromSpec(yeaftDir, spec) {
152
+ export function createGroupFromSpec(yeaftDir, spec, options = {}) {
153
+ const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
116
154
  const name = String(spec && spec.name || '').trim();
117
155
  if (!name) throw new GroupCrudError('invalid_name', null, 'group name required');
118
156
 
@@ -145,6 +183,20 @@ export function createGroupFromSpec(yeaftDir, spec) {
145
183
  const handle = createGroup(root, { id, name, roster, defaultVpId });
146
184
  const meta = handle.getMeta();
147
185
  handle.close();
186
+
187
+ // Seed Layer-A resident summary so the first session has memory content
188
+ // even before Dream-v2 has run. No-op if a summary.md already exists.
189
+ // Best-effort: a memory-root permission failure must NOT break group create.
190
+ try {
191
+ seedSummaryIfMissingSync(
192
+ { kind: 'group', id },
193
+ buildGroupSeedSummary({ name, roster, defaultVpId }),
194
+ { root: memoryRoot },
195
+ );
196
+ } catch (err) {
197
+ console.warn(`[group-crud] failed to seed summary.md for ${id}:`, err?.message || err);
198
+ }
199
+
148
200
  return meta;
149
201
  }
150
202
 
@@ -216,7 +268,8 @@ export function archiveGroup(yeaftDir, groupId) {
216
268
  * behind by the previous soft-archive implementation, so a single
217
269
  * delete cleans up legacy state too.
218
270
  */
219
- export function deleteGroup(yeaftDir, groupId) {
271
+ export function deleteGroup(yeaftDir, groupId, options = {}) {
272
+ const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
220
273
  const root = groupsRoot(yeaftDir);
221
274
  const srcDir = join(root, groupId);
222
275
  const liveExists = existsSync(srcDir) && !!loadGroupMeta(srcDir);
@@ -246,6 +299,14 @@ export function deleteGroup(yeaftDir, groupId) {
246
299
  rmSync(dir, { recursive: true, force: true });
247
300
  }
248
301
 
302
+ // Cascade: drop the group's memory scope so a recreate with the same id
303
+ // starts clean. Best-effort — never let memory cleanup fail the CRUD op.
304
+ try {
305
+ removeScopeDirSync({ kind: 'group', id: groupId }, { root: memoryRoot });
306
+ } catch (err) {
307
+ console.warn(`[group-crud] failed to remove memory dir for ${groupId}:`, err?.message || err);
308
+ }
309
+
249
310
  return { groupId, deleted: true, legacyCleanedUp: legacyDirs.length };
250
311
  }
251
312
 
@@ -14,16 +14,47 @@
14
14
 
15
15
  import { existsSync, mkdirSync } from 'fs';
16
16
  import { join } from 'path';
17
+ import { homedir } from 'os';
17
18
  import { openGroup, createGroup, loadGroupMeta } from './group-store.js';
19
+ import { seedSummaryIfMissingSync } from '../memory/store-v2.js';
18
20
 
19
21
  export const DEFAULT_GROUP_ID = 'grp_default';
20
22
 
23
+ /**
24
+ * Default memory root used when callers don't pass `options.memoryRoot`.
25
+ * See `groups/group-crud.js` and `vp/vp-crud.js` for the same default;
26
+ * production code threads `<yeaftDir>/memory` through to keep test/prod
27
+ * isolation honest.
28
+ */
29
+ const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
30
+
31
+ /**
32
+ * Build the default-group seed summary body. Pulled into a helper so
33
+ * tests can pin the exact format. Mirrors `buildGroupSeedSummary` in
34
+ * `group-crud.js` shape, with the "Default group" wording reserved for
35
+ * the bootstrap path.
36
+ *
37
+ * @param {{ name?: string, roster?: string[], defaultVpId?: string|null }} spec
38
+ * @returns {string}
39
+ */
40
+ export function buildDefaultGroupSeedSummary(spec) {
41
+ const name = String(spec?.name || 'Default').trim();
42
+ const roster = Array.isArray(spec?.roster) ? spec.roster : [];
43
+ const defaultVpId = spec?.defaultVpId || null;
44
+ const lines = [`# ${name}`, ''];
45
+ lines.push(`Default group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
46
+ if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
47
+ if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
48
+ return lines.join('\n').trim();
49
+ }
50
+
21
51
  /**
22
52
  * @param {string} yeaftDir
23
- * @param {{ defaultVpId?: string|null, roster?: string[], name?: string }} [spec]
53
+ * @param {{ defaultVpId?: string|null, roster?: string[], name?: string, memoryRoot?: string }} [spec]
24
54
  * @returns {{ group: import('./group-store.js').GroupHandle, created: boolean }}
25
55
  */
26
56
  export function seedDefaultGroup(yeaftDir, spec = {}) {
57
+ const memoryRoot = spec.memoryRoot || DEFAULT_MEMORY_ROOT;
27
58
  const groupsRoot = join(yeaftDir, 'groups');
28
59
  if (!existsSync(groupsRoot)) mkdirSync(groupsRoot, { recursive: true });
29
60
 
@@ -36,12 +67,29 @@ export function seedDefaultGroup(yeaftDir, spec = {}) {
36
67
  ? spec.roster.slice()
37
68
  : (spec.defaultVpId ? [spec.defaultVpId] : []);
38
69
  const defaultVpId = spec.defaultVpId || roster[0] || null;
70
+ const name = spec.name || 'Default';
39
71
 
40
72
  const group = createGroup(groupsRoot, {
41
73
  id: DEFAULT_GROUP_ID,
42
- name: spec.name || 'Default',
74
+ name,
43
75
  roster,
44
76
  defaultVpId,
45
77
  });
78
+
79
+ // Seed Layer-A resident summary so the very first session — even on a
80
+ // brand-new install where only `grp_default` exists — renders a non-
81
+ // empty memory section in the system prompt. No-op once Dream-v2 (or
82
+ // createGroupFromSpec) has already written one. Best-effort: a memory-
83
+ // root permission failure must NOT break the bootstrap flow.
84
+ try {
85
+ seedSummaryIfMissingSync(
86
+ { kind: 'group', id: DEFAULT_GROUP_ID },
87
+ buildDefaultGroupSeedSummary({ name, roster, defaultVpId }),
88
+ { root: memoryRoot },
89
+ );
90
+ } catch (err) {
91
+ console.warn(`[seed-default] failed to seed summary.md for ${DEFAULT_GROUP_ID}:`, err?.message || err);
92
+ }
93
+
46
94
  return { group, created: true };
47
95
  }
@@ -55,6 +55,7 @@ import { pairSanitize } from './pair-sanitize.js';
55
55
  import {
56
56
  countTurns as countTurnsImpl,
57
57
  indexOfNthTurnFromEnd,
58
+ sliceLastNTurns,
58
59
  } from './turn-utils.js';
59
60
 
60
61
  /**
@@ -65,27 +66,32 @@ import {
65
66
  export const countTurns = countTurnsImpl;
66
67
 
67
68
  /**
68
- * Default trigger thresholds (2026-05-01 policy update):
69
- * - never compact while total tokens < 30K (soft floor — most
69
+ * Default trigger thresholds (2026-05-02 policy update):
70
+ * - never compact while total tokens < 12K (soft floor — most short
70
71
  * conversations under that aren't worth paying the summarizer
71
72
  * cost; the LLM hasn't started feeling the context yet either),
72
73
  * - otherwise compact if ANY of:
74
+ * turnCount > 30 (back-stop for chats with many small turns)
73
75
  * tokens > 40 % of `maxContextTokens` (default 200K → 80K)
74
76
  * tokens > 200K hard ceiling
75
77
  *
76
- * The earlier "turn count > 20" trigger was DROPPED because under
77
- * the new floor it's effectively dead code by the time you've sent
78
- * 20 user prompts that exceed 30K tokens, the fractional threshold
79
- * has already fired. `turnLimit` and the `turn_count` reason code
80
- * are still accepted as overrides so tests / future config can re-
81
- * enable a turn-based trigger if needed; with `turnLimit: Infinity`
82
- * (the new default) the check is simply skipped.
78
+ * Lowered from 30K 12K and re-enabled a turn-count back-stop because
79
+ * the previous "soft floor of 30K, no turn cap" combination is dead in
80
+ * the multi-VP fan-out path: hundreds of small turns happily stay below
81
+ * 30K and never trigger compact, then `runVpTurn` feeds the whole 720+
82
+ * message snapshot to the LLM and trips the provider's context window.
83
+ * The snapshot trim in `web-bridge.js#trimSnapshotForBudget` is the
84
+ * primary defense; this is the second-line trigger that compresses
85
+ * the on-array form so subsequent turns also stay bounded.
86
+ *
87
+ * `turnLimit` and the `turn_count` reason code are still overridable
88
+ * for tests / future config.
83
89
  *
84
90
  * Token thresholds are derived from `maxContextTokens` at evaluation
85
91
  * time so the policy auto-adjusts to the user's configured context.
86
92
  */
87
- export const DEFAULT_TURN_LIMIT = Infinity;
88
- export const DEFAULT_MIN_TOKEN_FLOOR = 30_000;
93
+ export const DEFAULT_TURN_LIMIT = 30;
94
+ export const DEFAULT_MIN_TOKEN_FLOOR = 12_000;
89
95
  export const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
90
96
  export const DEFAULT_TOKEN_FRACTION = 0.4;
91
97
  export const DEFAULT_HARD_TOKEN_CEILING = 200_000;
@@ -106,6 +112,35 @@ export const DEFAULT_TOKEN_LIMIT = Math.min(
106
112
  */
107
113
  export const DEFAULT_KEEP_RECENT_TURNS = 2;
108
114
 
115
+ /**
116
+ * Default cap on the number of turns kept in the per-call snapshot fed
117
+ * to `engine.query` (see `trimSnapshotForBudget` below). A turn here is
118
+ * one user-side prompt — multi-VP `@vp-X` variants of the same prompt
119
+ * collapse into one turn (see `turn-utils.js#countTurns`).
120
+ *
121
+ * Sized in conjunction with `DEFAULT_TURN_LIMIT` (30, the compact-trigger
122
+ * back-stop): trim to 25 leaves a 5-turn buffer below the compact trigger
123
+ * so a typical chat sees its history compacted before the trim starts
124
+ * dropping turns silently. That ordering matters — compact preserves the
125
+ * tail's lossless 2 turns AND a summary of everything older, whereas trim
126
+ * just discards anything beyond the cap.
127
+ *
128
+ * 25 turns at ~5 messages each (user + assistant + a couple tool steps)
129
+ * is roughly 100–125 messages — well under the LLM context window for
130
+ * any reasonable model, and large enough to preserve "what we've been
131
+ * talking about" context for the model. The hard token-budget cap inside
132
+ * `trimSnapshotForBudget` tightens this further when individual turns
133
+ * are large.
134
+ */
135
+ export const DEFAULT_RECENT_TURN_CAP = 25;
136
+
137
+ /**
138
+ * Default per-query token budget for the snapshot (separate from the
139
+ * `tokenLimit` used by compact triggers). Mirrors the historical default
140
+ * carried in `~/.yeaft/config.json`'s `messageTokenBudget` field.
141
+ */
142
+ export const DEFAULT_MESSAGE_TOKEN_BUDGET = 8192;
143
+
109
144
  /**
110
145
  * Estimate the token weight of a single message including role overhead
111
146
  * and any tool-call structure. Mirrors `dream-v2/segment.js` approach: a
@@ -489,3 +524,69 @@ export async function compactHistory(messages, options) {
489
524
  afterTokens: after.tokenCount,
490
525
  };
491
526
  }
527
+
528
+ /**
529
+ * Trim a snapshot of conversation messages so the per-call array fed
530
+ * to `engine.query` stays bounded.
531
+ *
532
+ * Two-stage policy:
533
+ * 1. **Turn cap** — keep at most `recentTurnCap` turns (default 25)
534
+ * via `sliceLastNTurns`. This always cuts at a user-message
535
+ * boundary and walks forward through `@vp-X` variants of the
536
+ * cut turn so the slice is pair-safe.
537
+ * 2. **Token budget** — if the trimmed slice still exceeds
538
+ * `messageTokenBudget` tokens (default 8192 from
539
+ * `~/.yeaft/config.json`), iteratively drop the oldest turn until
540
+ * we're under budget. We never drop below 1 turn — even a single
541
+ * huge turn is preferable to no context.
542
+ *
543
+ * Then run `pairSanitize` as belt-and-suspenders to drop any orphan
544
+ * tool_use/tool_result that survived the cuts. The transform is
545
+ * idempotent and never mutates the input.
546
+ *
547
+ * Why this exists:
548
+ * `runVpTurn` previously fed the entire `conversationMessages` array
549
+ * into `engine.query` for every fan-out. With multi-VP turns the
550
+ * array grows ~5–8 messages per user prompt, so after a few hundred
551
+ * prompts the per-call payload exceeds 100 KB and routinely OOMs the
552
+ * provider's context window. `compactHistory` only fires above its
553
+ * token soft floor — small chats with many turns stay below that
554
+ * floor but still bloat the messages array. This trim is the second-
555
+ * line defense: it ALWAYS runs, before every query, regardless of
556
+ * compact state.
557
+ *
558
+ * Lives in `history-compact.js` alongside `compactHistory` because
559
+ * both functions are part of the same "bound the messages array fed
560
+ * to the LLM" surface — keeping them together makes the relationship
561
+ * between trim (per-call) and compact (global) explicit.
562
+ *
563
+ * @param {Array<object>} snapshot
564
+ * @param {{ messageTokenBudget?: number, recentTurnCap?: number }} [opts]
565
+ * @returns {Array<object>}
566
+ */
567
+ export function trimSnapshotForBudget(snapshot, opts = {}) {
568
+ if (!Array.isArray(snapshot) || snapshot.length === 0) return [];
569
+
570
+ const recentTurnCap = Number.isFinite(opts.recentTurnCap) && opts.recentTurnCap > 0
571
+ ? opts.recentTurnCap
572
+ : DEFAULT_RECENT_TURN_CAP;
573
+ const messageTokenBudget = Number.isFinite(opts.messageTokenBudget) && opts.messageTokenBudget > 0
574
+ ? opts.messageTokenBudget
575
+ : DEFAULT_MESSAGE_TOKEN_BUDGET;
576
+
577
+ // Stage 1: cap by turn count.
578
+ let trimmed = sliceLastNTurns(snapshot, recentTurnCap);
579
+
580
+ // Stage 2: cap by token budget. Drop oldest turn iteratively.
581
+ // We never drop below ~1 turn — pick a safety floor of 1.
582
+ let remainingTurnCap = recentTurnCap;
583
+ let tokens = estimateMessagesTokens(trimmed);
584
+ while (tokens > messageTokenBudget && remainingTurnCap > 1) {
585
+ remainingTurnCap--;
586
+ trimmed = sliceLastNTurns(trimmed, remainingTurnCap);
587
+ tokens = estimateMessagesTokens(trimmed);
588
+ }
589
+
590
+ // Stage 3: pair-sanitize to drop orphan tool_use/tool_result.
591
+ return pairSanitize(trimmed);
592
+ }
@@ -135,6 +135,27 @@ export function redactRawRequest(req) {
135
135
  return { url: req.url, method: req.method, headers, body: req.body };
136
136
  }
137
137
 
138
+ /**
139
+ * Snapshot a Fetch Response's headers into a plain object for the debug
140
+ * panel. Defensive against polyfilled / mocked Response shapes that don't
141
+ * implement `Headers#entries()` — falls back to `{}` rather than throwing.
142
+ *
143
+ * NOTE: multi-valued headers (e.g. `Set-Cookie`) collapse to the last value
144
+ * because `Object.fromEntries` can't represent duplicates. For LLM debug
145
+ * traffic this is fine; if a future use case needs multi-valued capture,
146
+ * switch the return to an array of [k, v] pairs.
147
+ *
148
+ * @param {Response | { headers?: { entries?: () => Iterable<[string, string]> } }} response
149
+ * @returns {Record<string, string>}
150
+ */
151
+ export function safeHeaders(response) {
152
+ const h = response && response.headers;
153
+ if (h && typeof h.entries === 'function') {
154
+ return Object.fromEntries(h.entries());
155
+ }
156
+ return {};
157
+ }
158
+
138
159
  // ─── Base Class ────────────────────────────────────────────────
139
160
 
140
161
  /**
@@ -14,6 +14,7 @@ import {
14
14
  LLMServerError,
15
15
  LLMAbortError,
16
16
  redactRawRequest,
17
+ safeHeaders,
17
18
  } from './adapter.js';
18
19
  import {
19
20
  normalizeEffort,
@@ -202,9 +203,7 @@ export class AnthropicAdapter extends LLMAdapter {
202
203
  rawRequest,
203
204
  rawResponse: {
204
205
  status: response.status,
205
- headers: response.headers && typeof response.headers.entries === 'function'
206
- ? Object.fromEntries(response.headers.entries())
207
- : {},
206
+ headers: safeHeaders(response),
208
207
  body: errorBody,
209
208
  },
210
209
  });
@@ -221,14 +220,11 @@ export class AnthropicAdapter extends LLMAdapter {
221
220
  let currentToolName = null;
222
221
  let currentToolInput = '';
223
222
  // Accumulate raw SSE body verbatim for the debug panel. No truncation:
224
- // a truncated copy of the response is misleading. If the resulting
225
- // payload is too large for the debug store the bound is per-LOOP-COUNT
226
- // on the client (see web/stores/chat.js MAX_UNIFY_DEBUG_LOOPS), not
227
- // per-payload mutilation here.
228
- let rawSseBody = '';
229
- const responseHeaders = response.headers && typeof response.headers.entries === 'function'
230
- ? Object.fromEntries(response.headers.entries())
231
- : {};
223
+ // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
224
+ // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
225
+ // O(n²) string concat).
226
+ const rawSseBodyChunks = [];
227
+ const responseHeaders = safeHeaders(response);
232
228
  const responseStatus = response.status;
233
229
 
234
230
  try {
@@ -238,7 +234,7 @@ export class AnthropicAdapter extends LLMAdapter {
238
234
 
239
235
  const chunkText = decoder.decode(value, { stream: true });
240
236
  buffer += chunkText;
241
- rawSseBody += chunkText;
237
+ rawSseBodyChunks.push(chunkText);
242
238
  const lines = buffer.split('\n');
243
239
  buffer = lines.pop() || ''; // Keep incomplete line
244
240
 
@@ -337,7 +333,7 @@ export class AnthropicAdapter extends LLMAdapter {
337
333
  rawResponse: {
338
334
  status: responseStatus,
339
335
  headers: responseHeaders,
340
- body: rawSseBody,
336
+ body: rawSseBodyChunks.join(''),
341
337
  format: 'sse',
342
338
  },
343
339
  });
@@ -32,6 +32,8 @@ import {
32
32
  LLMContextError,
33
33
  LLMServerError,
34
34
  LLMAbortError,
35
+ redactRawRequest,
36
+ safeHeaders,
35
37
  } from './adapter.js';
36
38
  import {
37
39
  normalizeEffort,
@@ -225,9 +227,16 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
225
227
  // ─── Streaming ──────────────────────────────────────────
226
228
 
227
229
  /**
228
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
230
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
231
+ *
232
+ * NOTE on `extraBody`: any keys you spread here are merged verbatim into
233
+ * the wire body and — because the verbatim debug feature is intentionally
234
+ * non-truncating — will surface in the debug panel via `rawRequest.body`.
235
+ * Do NOT put secrets in `extraBody`. Only `Authorization` / `x-api-key` /
236
+ * `api-key` headers are auto-redacted (see `redactRawRequest` in
237
+ * `adapter.js`); request-body fields are caller-controlled.
229
238
  */
230
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
239
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
231
240
  if (signal?.aborted) throw new LLMAbortError();
232
241
 
233
242
  const body = {
@@ -255,14 +264,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
255
264
 
256
265
  if (extraBody) Object.assign(body, extraBody);
257
266
 
267
+ const url = `${this.#baseUrl}/responses`;
268
+ const headers = {
269
+ 'Content-Type': 'application/json',
270
+ 'Authorization': `Bearer ${this.#apiKey}`,
271
+ };
272
+
273
+ // Expose the raw request (auth-redacted) for the debug panel. See
274
+ // `redactRawRequest` in adapter.js for the verbatim-design rationale.
275
+ const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
276
+
258
277
  let response;
259
278
  try {
260
- response = await fetch(`${this.#baseUrl}/responses`, {
279
+ response = await fetch(url, {
261
280
  method: 'POST',
262
- headers: {
263
- 'Content-Type': 'application/json',
264
- 'Authorization': `Bearer ${this.#apiKey}`,
265
- },
281
+ headers,
266
282
  body: JSON.stringify(body),
267
283
  signal,
268
284
  });
@@ -273,6 +289,19 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
273
289
 
274
290
  if (!response.ok) {
275
291
  const errorBody = await response.text();
292
+ // Capture error response too, then throw. Parity with anthropic.js.
293
+ if (onRawExchange) {
294
+ try {
295
+ onRawExchange({
296
+ rawRequest,
297
+ rawResponse: {
298
+ status: response.status,
299
+ headers: safeHeaders(response),
300
+ body: errorBody,
301
+ },
302
+ });
303
+ } catch { /* ignore */ }
304
+ }
276
305
  throw this.#classifyError(response.status, errorBody);
277
306
  }
278
307
 
@@ -287,11 +316,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
287
316
  const emittedToolCallIds = new Set();
288
317
  let sawToolCall = false;
289
318
 
319
+ // Accumulate raw SSE body verbatim for the debug panel. No truncation:
320
+ // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
321
+ // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
322
+ // O(n²) string concat).
323
+ const rawSseBodyChunks = [];
324
+ const responseHeaders = safeHeaders(response);
325
+ const responseStatus = response.status;
326
+
290
327
  try {
291
328
  while (true) {
292
329
  const { done, value } = await reader.read();
293
330
  if (done) break;
294
- buffer += decoder.decode(value, { stream: true });
331
+ const chunkText = decoder.decode(value, { stream: true });
332
+ buffer += chunkText;
333
+ rawSseBodyChunks.push(chunkText);
295
334
 
296
335
  // SSE events are separated by blank lines; split on \n
297
336
  const lines = buffer.split('\n');
@@ -409,11 +448,33 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
409
448
  throw err;
410
449
  } finally {
411
450
  try { reader.releaseLock(); } catch { /* noop */ }
451
+ // Emit raw exchange after stream completes (or errors). Body is the
452
+ // verbatim SSE — never truncated. Parity with anthropic.js.
453
+ if (onRawExchange) {
454
+ try {
455
+ onRawExchange({
456
+ rawRequest,
457
+ rawResponse: {
458
+ status: responseStatus,
459
+ headers: responseHeaders,
460
+ body: rawSseBodyChunks.join(''),
461
+ format: 'sse',
462
+ },
463
+ });
464
+ } catch { /* ignore */ }
465
+ }
412
466
  }
413
467
  }
414
468
 
415
469
  // ─── Non-streaming call() ───────────────────────────────
416
470
 
471
+ /**
472
+ * Side-query (consolidate / dream / recall / light) entry point. Does
473
+ * NOT accept `onRawExchange` — these calls intentionally don't surface
474
+ * in the user-facing debug panel. If a future product change wants to
475
+ * expose them, mirror the stream() instrumentation. Parity with
476
+ * anthropic.js's `call()`.
477
+ */
417
478
  async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
418
479
  if (signal?.aborted) throw new LLMAbortError();
419
480
 
@@ -0,0 +1,177 @@
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-unify-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.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
19
+ 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
+
25
+ function readIfPresent(path) {
26
+ try {
27
+ if (!existsSync(path)) return '';
28
+ return readFileSync(path, 'utf-8').trim();
29
+ } catch {
30
+ return '';
31
+ }
32
+ }
33
+
34
+ function writeAtomicSync(path, body) {
35
+ mkdirSync(join(path, '..'), { recursive: true });
36
+ writeFileSync(path, (body || '').trim() + '\n', 'utf-8');
37
+ }
38
+
39
+ /**
40
+ * Build a synthetic VP summary from the on-disk role.md.
41
+ *
42
+ * Delegates frontmatter parsing to `vp-store.js#parseRoleMd` so the
43
+ * backfill stays in sync with the production loader. The earlier hand-
44
+ * rolled regex parser silently dropped quoted multi-line scalars and
45
+ * list-shaped fields — `parseRoleMd` covers both.
46
+ *
47
+ * @param {string} libDir
48
+ * @param {string} vpId
49
+ * @returns {string|null}
50
+ */
51
+ function readVpRoleSummary(libDir, vpId) {
52
+ const rolePath = join(libDir, vpId, 'role.md');
53
+ if (!existsSync(rolePath)) return null;
54
+ let raw = '';
55
+ try { raw = readFileSync(rolePath, 'utf-8'); } catch { return null; }
56
+
57
+ const { meta, body } = parseRoleMd(raw);
58
+ const name = String(meta.name || vpId).trim() || vpId;
59
+ const role = typeof meta.role === 'string' ? meta.role.trim() : '';
60
+
61
+ const persona = typeof body === 'string' ? body.trim() : '';
62
+ const lines = [`# ${name}`];
63
+ if (role) lines.push('', `**Role:** ${role}`);
64
+ if (persona) {
65
+ const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
66
+ lines.push('', '**Persona:**', '', truncated);
67
+ }
68
+ return lines.join('\n').trim();
69
+ }
70
+
71
+ /**
72
+ * Walk the VP library and seed `summary.md` for every VP without one.
73
+ *
74
+ * @param {{ libDir: string, root?: string }} opts
75
+ * @returns {{seeded: number, scanned: number}}
76
+ */
77
+ export function backfillVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
78
+ let scanned = 0;
79
+ let seeded = 0;
80
+ if (!existsSync(libDir)) return { scanned, seeded };
81
+ let entries;
82
+ try { entries = readdirSync(libDir); } catch { return { scanned, seeded }; }
83
+ for (const name of entries) {
84
+ const vpDir = join(libDir, name);
85
+ let isDir = false;
86
+ try { isDir = statSync(vpDir).isDirectory(); } catch { /* skip */ }
87
+ if (!isDir) continue;
88
+ if (name.startsWith('.')) continue;
89
+ scanned++;
90
+ const summaryPath = join(root, 'vp', name, 'summary.md');
91
+ if (readIfPresent(summaryPath)) continue;
92
+ const body = readVpRoleSummary(libDir, name);
93
+ if (!body) continue;
94
+ try {
95
+ writeAtomicSync(summaryPath, body);
96
+ seeded++;
97
+ } catch (err) {
98
+ console.warn(`[seed-backfill] vp ${name}: ${err?.message || err}`);
99
+ }
100
+ }
101
+ return { scanned, seeded };
102
+ }
103
+
104
+ /**
105
+ * Build a synthetic group summary from group.json on disk.
106
+ *
107
+ * @param {string} groupDir
108
+ * @returns {string|null}
109
+ */
110
+ function readGroupSummaryBody(groupDir) {
111
+ const metaPath = join(groupDir, 'group.json');
112
+ if (!existsSync(metaPath)) return null;
113
+ let meta;
114
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf-8')); } catch { return null; }
115
+ const name = (meta?.name || '').trim();
116
+ const roster = Array.isArray(meta?.roster) ? meta.roster : [];
117
+ const defaultVpId = meta?.defaultVpId || null;
118
+ const lines = [];
119
+ if (name) lines.push(`# ${name}`);
120
+ lines.push('', `Group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
121
+ if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
122
+ if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
123
+ return lines.join('\n').trim();
124
+ }
125
+
126
+ /**
127
+ * Walk groups/ and seed `summary.md` for every group without one.
128
+ *
129
+ * @param {{ yeaftDir: string, root?: string }} opts
130
+ * @returns {{seeded: number, scanned: number}}
131
+ */
132
+ export function backfillGroupSummaries({ yeaftDir, root = DEFAULT_MEMORY_ROOT }) {
133
+ let scanned = 0;
134
+ let seeded = 0;
135
+ const groupsRoot = join(yeaftDir, 'groups');
136
+ if (!existsSync(groupsRoot)) return { scanned, seeded };
137
+ let entries;
138
+ try { entries = readdirSync(groupsRoot); } catch { return { scanned, seeded }; }
139
+ for (const name of entries) {
140
+ if (name.startsWith('.')) continue;
141
+ const groupDir = join(groupsRoot, name);
142
+ let isDir = false;
143
+ try { isDir = statSync(groupDir).isDirectory(); } catch { /* skip */ }
144
+ if (!isDir) continue;
145
+ scanned++;
146
+ const summaryPath = join(root, 'group', name, 'summary.md');
147
+ if (readIfPresent(summaryPath)) continue;
148
+ const body = readGroupSummaryBody(groupDir);
149
+ if (!body) continue;
150
+ try {
151
+ writeAtomicSync(summaryPath, body);
152
+ seeded++;
153
+ } catch (err) {
154
+ console.warn(`[seed-backfill] group ${name}: ${err?.message || err}`);
155
+ }
156
+ }
157
+ return { scanned, seeded };
158
+ }
159
+
160
+ /**
161
+ * Run all backfills sequentially. Best-effort — any per-step error is
162
+ * logged and the next step still runs.
163
+ *
164
+ * @param {{ yeaftDir: string, libDir: string, root?: string }} opts
165
+ * @returns {{ vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
166
+ */
167
+ export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROOT }) {
168
+ let vp = { scanned: 0, seeded: 0 };
169
+ let group = { scanned: 0, seeded: 0 };
170
+ try { vp = backfillVpSummaries({ libDir, root }); } catch (err) {
171
+ console.warn('[seed-backfill] vp pass failed:', err?.message || err);
172
+ }
173
+ try { group = backfillGroupSummaries({ yeaftDir, root }); } catch (err) {
174
+ console.warn('[seed-backfill] group pass failed:', err?.message || err);
175
+ }
176
+ return { vp, group };
177
+ }
@@ -44,6 +44,9 @@ import {
44
44
  promises as fsp,
45
45
  existsSync,
46
46
  mkdirSync,
47
+ readFileSync,
48
+ writeFileSync,
49
+ rmSync,
47
50
  } from 'fs';
48
51
  import { join, dirname } from 'path';
49
52
  import { homedir } from 'os';
@@ -285,6 +288,78 @@ export async function writeSummary(scope, body, opts = {}) {
285
288
  await atomicWrite(abs, `${(body || '').trim()}\n`);
286
289
  }
287
290
 
291
+ /**
292
+ * Seed a scope's summary.md if (and only if) it is missing or empty. Used
293
+ * at create-time for VPs and groups so a fresh session has SOMETHING for
294
+ * `engine.#prepareAms` to pull into the Layer-A resident summary — the
295
+ * earlier behavior of "no summary.md until Dream-v2 runs" left the memory
296
+ * section empty for the entire first session.
297
+ *
298
+ * Intentionally a no-op if a non-empty summary.md already exists, so this
299
+ * is safe to call from any place that creates the scope (VP create, group
300
+ * create, first-session bootstrap) without clobbering Dream-v2's writes.
301
+ *
302
+ * @param {Scope} scope
303
+ * @param {string} body
304
+ * @param {{ root?: string, currentVpId?: string }} [opts]
305
+ * @returns {Promise<boolean>} true if seeded, false if a non-empty summary already existed
306
+ */
307
+ export async function seedSummaryIfMissing(scope, body, opts = {}) {
308
+ const existing = await readSummary(scope, opts);
309
+ if (existing && existing.trim().length > 0) return false;
310
+ await writeSummary(scope, body, opts);
311
+ return true;
312
+ }
313
+
314
+ /**
315
+ * Sync variant of `seedSummaryIfMissing` for synchronous CRUD entry points
316
+ * (vp-crud.js / group-crud.js / seed-default.js). Same idempotency contract:
317
+ * a non-empty existing `summary.md` blocks the seed; missing or empty
318
+ * triggers an atomic write. Failures are converted into thrown errors so
319
+ * the caller can decide whether to swallow (best-effort seed) or surface.
320
+ *
321
+ * NOTE on `opts.root`: callers MUST pass the configured memory root
322
+ * (typically `<yeaftDir>/memory`) so a non-default `yeaftDir` doesn't end
323
+ * up writing under `~/.yeaft/memory/`. The default is provided only for
324
+ * top-of-tree convenience; production code paths thread the root through.
325
+ *
326
+ * @param {Scope} scope
327
+ * @param {string} body
328
+ * @param {{ root?: string }} [opts]
329
+ * @returns {boolean} true if seeded, false if a non-empty summary already existed
330
+ */
331
+ export function seedSummaryIfMissingSync(scope, body, opts = {}) {
332
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
333
+ const rel = `${scopeDir(scope)}/summary.md`;
334
+ const abs = join(root, rel);
335
+ let existing = '';
336
+ if (existsSync(abs)) {
337
+ try { existing = readFileSync(abs, 'utf8').trim(); }
338
+ catch { /* read race — fall through to seed */ }
339
+ }
340
+ if (existing) return false;
341
+ mkdirSync(dirname(abs), { recursive: true });
342
+ writeFileSync(abs, `${(body || '').trim()}\n`, 'utf8');
343
+ return true;
344
+ }
345
+
346
+ /**
347
+ * Synchronously remove a scope's directory under the memory root. Used by
348
+ * `deleteVp` / `deleteGroup` to cascade memory cleanup so a recreate of the
349
+ * same id doesn't see stale `summary.md` / `memory.md` / `segments/` files.
350
+ *
351
+ * Idempotent — missing directory is a no-op.
352
+ *
353
+ * @param {Scope} scope
354
+ * @param {{ root?: string }} [opts]
355
+ */
356
+ export function removeScopeDirSync(scope, opts = {}) {
357
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
358
+ const abs = join(root, scopeDir(scope));
359
+ if (!existsSync(abs)) return;
360
+ rmSync(abs, { recursive: true, force: true });
361
+ }
362
+
288
363
  // ─── scope discovery ───────────────────────────────────────────
289
364
 
290
365
  /**
package/unify/session.js CHANGED
@@ -41,6 +41,7 @@ import { Engine } from './engine.js';
41
41
  // AMS each turn and to run `memory/adjust.js` post-turn.
42
42
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
43
43
  import { seedDefaultVps } from './vp/seed-defaults.js';
44
+ import { runSummaryBackfill } from './memory/seed-backfill.js';
44
45
  import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
45
46
  import { openSegmentIndex } from './memory/index-db.js';
46
47
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
@@ -236,10 +237,25 @@ export async function loadSession(options = {}) {
236
237
  console.warn(`[Yeaft] seedDefaultVps failed: ${err?.message || err}`);
237
238
  }
238
239
  try {
239
- ensureDefaultGroupIfEmpty(yeaftDir);
240
+ ensureDefaultGroupIfEmpty(yeaftDir, { memoryRoot: join(yeaftDir, 'memory') });
240
241
  } catch (err) {
241
242
  console.warn(`[Yeaft] ensureDefaultGroupIfEmpty failed: ${err?.message || err}`);
242
243
  }
244
+
245
+ // task-fix-memory-load: backfill summary.md for VPs / groups created
246
+ // before the create-time seed was added. Without this, an existing
247
+ // user's `grp_claude` and `steve` VP have an empty Layer-A resident
248
+ // summary every turn (memory section in the system prompt is just
249
+ // the `active_scope` header). Idempotent — only writes when missing.
250
+ try {
251
+ runSummaryBackfill({
252
+ yeaftDir,
253
+ libDir: join(yeaftDir, 'virtual-persons'),
254
+ root: join(yeaftDir, 'memory'),
255
+ });
256
+ } catch (err) {
257
+ console.warn(`[Yeaft] runSummaryBackfill failed: ${err?.message || err}`);
258
+ }
243
259
  }
244
260
 
245
261
  // ─── 6. Load skills ────────────────────────────────────
@@ -19,8 +19,49 @@
19
19
 
20
20
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
21
21
  import { join } from 'path';
22
+ import { homedir } from 'os';
22
23
  import { validateVpId } from '../groups/ids.js';
23
24
  import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
25
+ import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
26
+
27
+ /**
28
+ * Default memory root used when callers don't pass `options.memoryRoot`.
29
+ * Memory lives at `<root>/vp/<id>/{summary.md,memory.md,segments/…}` —
30
+ * see `store-v2.scopeDir`. Production sites should thread the configured
31
+ * `<yeaftDir>/memory` through `options.memoryRoot` so a non-default yeaft
32
+ * directory (e.g. tests, sandboxed CI) doesn't write under `~/.yeaft/`.
33
+ */
34
+ const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
35
+
36
+ /**
37
+ * Build the seed summary body for a freshly-created VP. Pulled into a
38
+ * helper so tests can pin the exact format.
39
+ *
40
+ * @param {object} payload same shape as createVp
41
+ * @returns {string}
42
+ */
43
+ export function buildVpSeedSummary(payload) {
44
+ const id = String(payload?.vpId || '').trim();
45
+ const name = (payload?.displayName != null ? String(payload.displayName) : id).trim();
46
+ const role = (payload?.role != null ? String(payload.role) : '').trim();
47
+ const persona = (typeof payload?.persona === 'string' ? payload.persona : '').trim();
48
+ const traits = Array.isArray(payload?.traits)
49
+ ? payload.traits.map(t => String(t)).filter(Boolean)
50
+ : [];
51
+
52
+ const lines = [];
53
+ lines.push(`# ${name}`);
54
+ if (role) lines.push('', `**Role:** ${role}`);
55
+ if (traits.length > 0) lines.push('', `**Traits:** ${traits.join(', ')}`);
56
+ if (persona) {
57
+ // Keep the persona body terse — first 800 chars is plenty for an
58
+ // initial Layer-A resident summary; Dream-v2 will rewrite it as
59
+ // memory accumulates.
60
+ const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
61
+ lines.push('', '**Persona:**', '', truncated);
62
+ }
63
+ return lines.join('\n').trim();
64
+ }
24
65
 
25
66
  /**
26
67
  * Error thrown by CRUD entry points. Has stable `.code` so callers can map
@@ -106,6 +147,7 @@ function yamlScalar(v) {
106
147
  */
107
148
  export function createVp(payload, options = {}) {
108
149
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
150
+ const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
109
151
  const vpId = payload && payload.vpId;
110
152
 
111
153
  const v = validateVpId(vpId);
@@ -123,6 +165,24 @@ export function createVp(payload, options = {}) {
123
165
  mkdirSync(dir, { recursive: true });
124
166
  mkdirSync(join(dir, 'memory'), { recursive: true });
125
167
  writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
168
+
169
+ // Seed the VP's Layer-A resident summary so the first session has SOMETHING
170
+ // for engine.#loadLayerASummaries to read. Without this, fresh VPs have
171
+ // an empty memory section in the system prompt until Dream-v2 runs (which
172
+ // requires a non-empty diff stream — i.e. several turns of activity).
173
+ // We only seed when the file is missing/empty: this is safe to re-run and
174
+ // never clobbers Dream-v2 writes. Failures are best-effort: a memory-root
175
+ // permission failure must NOT break VP creation.
176
+ try {
177
+ seedSummaryIfMissingSync(
178
+ { kind: 'vp', id: vpId },
179
+ buildVpSeedSummary({ ...payload, vpId }),
180
+ { root: memoryRoot },
181
+ );
182
+ } catch (err) {
183
+ console.warn(`[vp-crud] failed to seed summary.md for ${vpId}:`, err?.message || err);
184
+ }
185
+
126
186
  return { vpId, dir };
127
187
  }
128
188
 
@@ -150,7 +210,9 @@ export function updateVp(payload, options = {}) {
150
210
  }
151
211
 
152
212
  /**
153
- * Delete a VP — removes the entire VP dir (role.md + memory/).
213
+ * Delete a VP — removes the entire VP dir (role.md + memory/) AND the
214
+ * shared memory root's `<root>/vp/<id>/` so a recreate of the same id
215
+ * doesn't see stale `summary.md` / segments / index entries.
154
216
  *
155
217
  * Hard constraint: `memory/` contents are scoped to this VP; removing them
156
218
  * with the role is the intended CRUD semantic (UX rule is the confirm
@@ -158,10 +220,13 @@ export function updateVp(payload, options = {}) {
158
220
  *
159
221
  * @param {string} vpId
160
222
  * @param {object} [options]
223
+ * @param {string} [options.libDir]
224
+ * @param {string} [options.memoryRoot]
161
225
  * @returns {{vpId:string}}
162
226
  */
163
227
  export function deleteVp(vpId, options = {}) {
164
228
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
229
+ const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
165
230
  // We do NOT run validateVpId here — deleting an already-legacy bad id is
166
231
  // legitimate cleanup. But we DO refuse obviously unsafe inputs.
167
232
  if (!vpId || typeof vpId !== 'string' || vpId.includes('/') || vpId.includes('\\') || vpId === '..' || vpId === '.') {
@@ -172,6 +237,13 @@ export function deleteVp(vpId, options = {}) {
172
237
  throw new VpCrudError('not_found', vpId);
173
238
  }
174
239
  rmSync(dir, { recursive: true, force: true });
240
+ // Cascade: drop the VP's memory scope so a recreate with the same id
241
+ // starts clean. Best-effort — never let memory cleanup fail the CRUD op.
242
+ try {
243
+ removeScopeDirSync({ kind: 'vp', id: vpId }, { root: memoryRoot });
244
+ } catch (err) {
245
+ console.warn(`[vp-crud] failed to remove memory dir for ${vpId}:`, err?.message || err);
246
+ }
175
247
  return { vpId };
176
248
  }
177
249
 
@@ -48,6 +48,7 @@ import { seedDefaultGroup } from './groups/seed-default.js';
48
48
  import {
49
49
  shouldCompactHistory,
50
50
  compactHistory,
51
+ trimSnapshotForBudget,
51
52
  } from './history-compact.js';
52
53
 
53
54
  /** @type {import('./session.js').Session | null} */
@@ -168,7 +169,9 @@ export function handleUnifyVpCreate(msg) {
168
169
  const requestId = msg && msg.requestId;
169
170
  const payload = msg && msg.payload;
170
171
  try {
171
- const { vpId } = createVp(payload || {});
172
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
173
+ const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
174
+ const { vpId } = createVp(payload || {}, memoryRoot ? { memoryRoot } : {});
172
175
  sendVpCrudResult({ op: 'create', requestId, ok: true, vpId });
173
176
  } catch (err) {
174
177
  sendVpCrudResult({
@@ -208,7 +211,9 @@ export function handleUnifyVpDelete(msg) {
208
211
  const requestId = msg && msg.requestId;
209
212
  const vpId = msg && msg.vpId;
210
213
  try {
211
- deleteVp(vpId);
214
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
215
+ const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
216
+ deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
212
217
  sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
213
218
  } catch (err) {
214
219
  sendVpCrudResult({
@@ -297,7 +302,8 @@ export function handleUnifyCreateGroup(msg) {
297
302
  const payload = (msg && msg.payload) || {};
298
303
  try {
299
304
  const yeaftDir = ctx.CONFIG?.yeaftDir;
300
- const group = createGroupFromSpec(yeaftDir, payload);
305
+ const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
306
+ const group = createGroupFromSpec(yeaftDir, payload, memoryRoot ? { memoryRoot } : {});
301
307
  sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
302
308
  sendGroupSnapshotBroadcast();
303
309
  } catch (err) {
@@ -376,7 +382,8 @@ export function handleUnifyDeleteGroup(msg) {
376
382
  const groupId = msg && msg.groupId;
377
383
  try {
378
384
  const yeaftDir = ctx.CONFIG?.yeaftDir;
379
- const result = deleteGroup(yeaftDir, groupId);
385
+ const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
386
+ const result = deleteGroup(yeaftDir, groupId, memoryRoot ? { memoryRoot } : {});
380
387
  // Cascade: remove every persisted message stamped with this group id.
381
388
  // Hard delete (per user spec): no soft-archive, the bytes are gone.
382
389
  // Skipped silently if the session/store isn't initialized — the next
@@ -792,7 +799,7 @@ export async function handleUnifyGroupChat(msg) {
792
799
  groupHandle = openGroup(root, groupId);
793
800
  } else if (groupId === 'grp_default') {
794
801
  try {
795
- const seeded = seedDefaultGroup(yeaftDir, {});
802
+ const seeded = seedDefaultGroup(yeaftDir, { memoryRoot: join(yeaftDir, 'memory') });
796
803
  groupHandle = seeded.group;
797
804
  } catch (seedErr) {
798
805
  seedFailed = true;
@@ -1146,9 +1153,16 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
1146
1153
  vpId,
1147
1154
  turnId,
1148
1155
  };
1156
+ // Always trim the snapshot before passing to engine.query. This is
1157
+ // the second-line defense (history-compact only fires above 30K
1158
+ // tokens — small chats with many turns still bloat the messages
1159
+ // array). See `trimSnapshotForBudget` doc-block for policy.
1160
+ const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
1161
+ messageTokenBudget: session?.config?.messageTokenBudget,
1162
+ });
1149
1163
  for await (const event of session.engine.query({
1150
1164
  prompt,
1151
- messages: baseSnapshot,
1165
+ messages: trimmedMessages,
1152
1166
  signal: vpAbort.signal,
1153
1167
  ...queryOpts,
1154
1168
  })) {