@yeaft/webchat-agent 0.1.704 → 0.1.706

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.704",
3
+ "version": "0.1.706",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -151,7 +151,15 @@ export async function archiveToolResults({
151
151
  };
152
152
  }
153
153
 
154
- function formatSize(bytes) {
154
+ /**
155
+ * Format a byte count as a short human-readable string ("1.5KB", "2.0MB").
156
+ * Exported so other size-aware helpers (tool-result truncation in the
157
+ * registry) format identically.
158
+ *
159
+ * @param {number} bytes
160
+ * @returns {string}
161
+ */
162
+ export function formatSize(bytes) {
155
163
  if (bytes < 1024) return `${bytes}B`;
156
164
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
157
165
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
package/unify/engine.js CHANGED
@@ -34,9 +34,11 @@ import { runStopHooks } from './stop-hooks.js';
34
34
  // the constant 'main'.
35
35
  const MAIN_THREAD_ID = 'main';
36
36
  import { pickEffort, parseEffortPrefix } from './effort.js';
37
- import { normalizeEffort } from './models.js';
37
+ import { normalizeEffort, resolveContextWindow } from './models.js';
38
38
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
39
39
  import { resolveThinking } from './router/thinking.js';
40
+ import { approxTokens } from './memory/budget.js';
41
+ import { truncateToolResultIfNeeded } from './tools/registry.js';
40
42
  import {
41
43
  TOOL_BATCH_SIZE,
42
44
  TURN_SUMMARY_THRESHOLD,
@@ -100,6 +102,64 @@ export function mapDebugMessage(m) {
100
102
  return out;
101
103
  }
102
104
 
105
+ /**
106
+ * task-704b — estimate the total token cost of a system prompt + a
107
+ * messages array. Used by the pre-flight guard before adapter.stream()
108
+ * to decide whether to run an emergency archive sweep.
109
+ *
110
+ * Why estimate, not exact: a real tokenizer (tiktoken, claude-tokenizer)
111
+ * adds a heavy dep + per-turn cost for what is fundamentally a guard
112
+ * rail. `approxTokens` (char/4 with CJK weighting) is the same
113
+ * estimator the AMS budget code uses; it is monotonic in payload size
114
+ * and that is the only property the guard rail needs. False positives
115
+ * cost an unnecessary archive sweep (cheap); false negatives let a
116
+ * runaway request through (expensive — that is exactly the bug we are
117
+ * fixing).
118
+ *
119
+ * Multi-modal messages: `content` may be an array of content parts
120
+ * (Anthropic / OpenAI Responses shape). Text parts use approxTokens;
121
+ * image parts get a fixed 1024-token estimate — a rough average across
122
+ * vision pricing models. Exact pricing isn't the goal; "this is roughly
123
+ * how much of the window the message will consume" is.
124
+ *
125
+ * @param {string} system
126
+ * @param {Array<{role:string, content?:any, toolCalls?:Array}>} messages
127
+ * @returns {number}
128
+ */
129
+ export function estimateMessagesTokens(system, messages) {
130
+ let total = approxTokens(typeof system === 'string' ? system : '');
131
+ if (!Array.isArray(messages)) return total;
132
+ for (const m of messages) {
133
+ if (!m) continue;
134
+ const c = m.content;
135
+ if (typeof c === 'string') {
136
+ total += approxTokens(c);
137
+ } else if (Array.isArray(c)) {
138
+ for (const part of c) {
139
+ if (!part) continue;
140
+ if (part.type === 'text') {
141
+ // Coerce defensively — a non-string `text` (number, Buffer,
142
+ // object) would otherwise throw inside approxTokens and abort
143
+ // the pre-flight estimate, defeating the guard rail.
144
+ total += approxTokens(typeof part.text === 'string' ? part.text : '');
145
+ } else if (part.type === 'image') {
146
+ total += 1024;
147
+ }
148
+ // Other multi-modal parts (audio etc.) — skip; not produced today.
149
+ }
150
+ }
151
+ if (Array.isArray(m.toolCalls)) {
152
+ for (const tc of m.toolCalls) {
153
+ try {
154
+ total += approxTokens(JSON.stringify(tc.input || {}));
155
+ } catch { /* circular — ignore */ }
156
+ total += approxTokens(typeof tc.name === 'string' ? tc.name : '');
157
+ }
158
+ }
159
+ }
160
+ return total;
161
+ }
162
+
103
163
  // ─── Engine Events (superset of adapter events) ──────────────────
104
164
 
105
165
  /**
@@ -603,6 +663,11 @@ export class Engine {
603
663
  conversationStore: this.#conversationStore,
604
664
  adapter: this.#adapter,
605
665
  config: this.#config,
666
+ // task-704b: per-tool-result hard cap derives from this. Threaded
667
+ // from the live model (resolveModel(currentModel)) every turn so
668
+ // fallbackModel switches see the new window. Falls back to
669
+ // config.maxContextTokens, then 200K, in registry.js.
670
+ contextWindow: vpCtx?.contextWindow,
606
671
  // ViewImage (task-333b PR-B rev-3 P1-A): expose size cap + allowlist
607
672
  // via tool ctx so hosts can override via ~/.yeaft/config.json without
608
673
  // touching the tool impl.
@@ -1152,6 +1217,17 @@ export class Engine {
1152
1217
  if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
1153
1218
  };
1154
1219
 
1220
+ // task-704b: resolve the live model's context window for this turn.
1221
+ // Used by the per-tool-result cap (passed via toolCtx) and the
1222
+ // pre-flight total-token guard inside the try-block. Hoisted out of
1223
+ // the try so toolCtx (built after the adapter stream) can see it.
1224
+ // Re-resolved every turn because fallbackModel switches change
1225
+ // `currentModel` mid query() — the cap MUST track the model we're
1226
+ // actually about to call. Single resolver in models.js owns the
1227
+ // fallback ladder (registry → config → default) so engine.js and
1228
+ // tools/registry.js can never disagree.
1229
+ const currentContextWindow = resolveContextWindow(currentModel, this.#config);
1230
+
1155
1231
  yield { type: 'turn_start', turnNumber };
1156
1232
 
1157
1233
  try {
@@ -1203,6 +1279,7 @@ export class Engine {
1203
1279
  // message_trace can fetch it on demand. The stub keeps the
1204
1280
  // OpenAI/Anthropic toolCallId pairing intact.
1205
1281
  let wireMessages = stripMetaForWire([...conversationMessages]);
1282
+
1206
1283
  if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
1207
1284
  try {
1208
1285
  const swept = await archiveToolResults({
@@ -1224,6 +1301,52 @@ export class Engine {
1224
1301
  } catch { /* best-effort */ }
1225
1302
  }
1226
1303
 
1304
+ // task-704b: pre-flight total-token guard. Even with the per-tool
1305
+ // cap (registry.js: 10% of contextWindow per result), N tool
1306
+ // results plus history can still breach the wire limit before we
1307
+ // ever call adapter.stream(). Estimate the total token cost; if
1308
+ // it exceeds PREFLIGHT_RATIO of the live context window, run an
1309
+ // emergency archive sweep with `turnAgeMin: 0` so even
1310
+ // current-turn-but-not-this-call bulky results get stubbed. The
1311
+ // normal sweep above only stubs results older than 5 user turns
1312
+ // — that's the wrong cadence when the *current* turn already has
1313
+ // 4 large grep results.
1314
+ //
1315
+ // PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
1316
+ // own output tokens + tools metadata + light future history.
1317
+ // The estimator (`estimateMessagesTokens`) is approxTokens
1318
+ // (char/4 with CJK weighting) — good enough for a guard rail; a
1319
+ // real tokenizer would be exact but adds a heavy dep.
1320
+ if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
1321
+ const PREFLIGHT_RATIO = 0.85;
1322
+ const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
1323
+ const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
1324
+ if (estimate > threshold) {
1325
+ try {
1326
+ const sweep = await archiveToolResults({
1327
+ root: `${this.#yeaftDir}/memory`,
1328
+ scopeDir: 'user',
1329
+ messages: wireMessages,
1330
+ turnAgeMin: 0,
1331
+ lengthMin: this.#config?.archive?.lengthMin ?? 2000,
1332
+ });
1333
+ wireMessages = sweep.nextMessages;
1334
+ if (sweep.archivedCount > 0) {
1335
+ for (let i = 0; i < conversationMessages.length; i += 1) {
1336
+ conversationMessages[i] = wireMessages[i];
1337
+ }
1338
+ this.#trace.log?.('preflight_sweep', {
1339
+ archivedCount: sweep.archivedCount,
1340
+ archivedBytes: sweep.archivedBytes,
1341
+ estimateBefore: estimate,
1342
+ threshold,
1343
+ contextWindow: currentContextWindow,
1344
+ });
1345
+ }
1346
+ } catch { /* best-effort */ }
1347
+ }
1348
+ }
1349
+
1227
1350
  // Stream from adapter
1228
1351
  for await (const event of this.#adapter.stream({
1229
1352
  model: currentModel,
@@ -1555,7 +1678,7 @@ export class Engine {
1555
1678
  }
1556
1679
 
1557
1680
  // Execute tool calls and feed results back
1558
- const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona });
1681
+ const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona, contextWindow: currentContextWindow });
1559
1682
 
1560
1683
  // task-325a: track whether we aborted mid tool-loop so we can
1561
1684
  // break out of the outer while-loop cleanly once the current
@@ -1624,7 +1747,14 @@ export class Engine {
1624
1747
  output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
1625
1748
  } else {
1626
1749
  const tool = this.#tools.get(tc.name);
1627
- output = await tool.execute(tc.input, { signal });
1750
+ const rawOutput = await tool.execute(tc.input, { signal });
1751
+ // task-704b: legacy #tools branch must apply the same per-tool
1752
+ // cap as ToolRegistry.execute. Otherwise a deployment using
1753
+ // the legacy registration path bypasses the defense entirely.
1754
+ output = truncateToolResultIfNeeded(rawOutput, {
1755
+ contextWindow: currentContextWindow,
1756
+ toolName: tc.name,
1757
+ });
1628
1758
  }
1629
1759
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
1630
1760
  } catch (err) {
@@ -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
+ }
@@ -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/models.js CHANGED
@@ -213,6 +213,43 @@ export function resolveModel(modelName) {
213
213
  return info ? { ...info } : null;
214
214
  }
215
215
 
216
+ /**
217
+ * Default context window when neither the model registry nor the engine
218
+ * config has a value. 200K is a conservative middle-ground — most modern
219
+ * production models (Claude, GPT-5, Gemini) have ≥ 128K.
220
+ *
221
+ * Single source of truth: callers (engine.js pre-flight guard,
222
+ * tools/registry.js per-result cap) MUST use this constant or
223
+ * {@link resolveContextWindow} instead of hardcoding 200_000.
224
+ */
225
+ export const DEFAULT_CONTEXT_WINDOW = 200_000;
226
+
227
+ /**
228
+ * Resolve the live context window for a model, with an explicit fallback
229
+ * ladder:
230
+ * 1. MODEL_REGISTRY entry's `contextWindow` (most accurate)
231
+ * 2. caller-supplied config override (e.g. `config.maxContextTokens`)
232
+ * 3. {@link DEFAULT_CONTEXT_WINDOW}
233
+ *
234
+ * Used by the per-tool-result cap and the pre-flight token guard so the
235
+ * defense layers always see the same number, regardless of which seam
236
+ * resolves it first.
237
+ *
238
+ * @param {string} modelName
239
+ * @param {{ maxContextTokens?: number }} [config]
240
+ * @returns {number}
241
+ */
242
+ export function resolveContextWindow(modelName, config) {
243
+ const info = resolveModel(modelName);
244
+ if (info && Number.isFinite(info.contextWindow) && info.contextWindow > 0) {
245
+ return info.contextWindow;
246
+ }
247
+ const cfg = config && Number.isFinite(config.maxContextTokens) && config.maxContextTokens > 0
248
+ ? config.maxContextTokens : null;
249
+ if (cfg !== null) return cfg;
250
+ return DEFAULT_CONTEXT_WINDOW;
251
+ }
252
+
216
253
  /**
217
254
  * List all known models.
218
255
  *
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 ────────────────────────────────────
@@ -9,6 +9,73 @@
9
9
  * still carry a `modes` field, but the registry ignores it.
10
10
  */
11
11
 
12
+ import { formatSize } from '../archive/tool-results.js';
13
+ import { DEFAULT_CONTEXT_WINDOW } from '../models.js';
14
+
15
+ /**
16
+ * Per-tool-result hard cap.
17
+ *
18
+ * A single tool can return megabytes (a grep over a large repo, a large
19
+ * file read, a paginated web fetch). If we forward that verbatim into the
20
+ * next LLM request the model returns LLMContextError ("context_length_exceeded")
21
+ * and the user sees a hard failure mid-turn. Cap at a fraction of the
22
+ * model's context window so even a runaway tool can't kill the request.
23
+ *
24
+ * Cap = max(MIN_CAP, floor(contextWindow * RATIO))
25
+ * - RATIO = 10%: leaves 90% of context for system prompt, history, the
26
+ * model's own output, and other tools called this turn. Generous in
27
+ * absolute terms (25.6 KB on a 256 K window, ~20 K on a 200 K window),
28
+ * plenty for a real tool result; small enough that 5 such results
29
+ * still fit alongside everything else.
30
+ * - MIN_CAP = 8 KB: sanity floor for tiny test fixtures (4 K context
31
+ * models in tests would otherwise cap at 409 chars, defeating the
32
+ * test's purpose). Real production models all have ≥ 32 K context, so
33
+ * the floor never bites in practice.
34
+ *
35
+ * The truncation lands HERE (not in engine.js when pushing tool results
36
+ * into messages) so the UI's `tool_end` event, the exec log, AND the
37
+ * model all see the same truncated content. Truncating later would mean
38
+ * the user sees the full 2 MB output but the model gets a stub —
39
+ * confusing.
40
+ */
41
+ const TOOL_RESULT_CAP_RATIO = 0.10;
42
+ const TOOL_RESULT_MIN_CAP = 8 * 1024;
43
+
44
+ /**
45
+ * Truncate a tool result if it exceeds the per-result cap. Non-string
46
+ * outputs are JSON-stringified first (matching what engine.js eventually
47
+ * pushes into `content`), then capped.
48
+ *
49
+ * Edge cases handled:
50
+ * - `undefined` → `JSON.stringify(undefined)` returns `undefined`, not
51
+ * a string, so we coerce to `'undefined'` and let the cap apply.
52
+ * - circular refs / `JSON.stringify` throws → fall back to `String(...)`.
53
+ *
54
+ * @param {unknown} output
55
+ * @param {{ contextWindow?: number, toolName: string }} opts
56
+ * @returns {string}
57
+ */
58
+ export function truncateToolResultIfNeeded(output, { contextWindow, toolName }) {
59
+ let text;
60
+ if (typeof output === 'string') {
61
+ text = output;
62
+ } else {
63
+ try {
64
+ const json = JSON.stringify(output);
65
+ text = typeof json === 'string' ? json : String(output);
66
+ } catch {
67
+ text = String(output);
68
+ }
69
+ }
70
+ const ctx = Number.isFinite(contextWindow) && contextWindow > 0
71
+ ? contextWindow : DEFAULT_CONTEXT_WINDOW;
72
+ const cap = Math.max(TOOL_RESULT_MIN_CAP, Math.floor(ctx * TOOL_RESULT_CAP_RATIO));
73
+ if (text.length <= cap) return text;
74
+ const head = text.slice(0, cap);
75
+ const marker = `\n\n[truncated: ${toolName} returned ${formatSize(text.length)}, capped at ${formatSize(cap)}; the model will not see the rest of this output]`;
76
+ return head + marker;
77
+ }
78
+
12
79
  export class ToolRegistry {
13
80
  /** @type {Map<string, import('./types.js').ToolDef>} */
14
81
  #tools = new Map();
@@ -93,6 +160,12 @@ export class ToolRegistry {
93
160
 
94
161
  /**
95
162
  * Execute a tool by name.
163
+ *
164
+ * The result is passed through {@link truncateToolResultIfNeeded} so that
165
+ * a single tool can never blow the context window. The cap derives from
166
+ * `ctx.contextWindow` (the live model's window, threaded by engine.js);
167
+ * fall back to a 200K default for callers that don't supply it.
168
+ *
96
169
  * @param {string} name
97
170
  * @param {object} input
98
171
  * @param {import('./types.js').ToolContext} [ctx={}]
@@ -101,7 +174,11 @@ export class ToolRegistry {
101
174
  async execute(name, input, ctx = {}) {
102
175
  const tool = this.#tools.get(name);
103
176
  if (!tool) throw new Error(`Unknown tool: ${name}`);
104
- return tool.execute(input, ctx);
177
+ const output = await tool.execute(input, ctx);
178
+ return truncateToolResultIfNeeded(output, {
179
+ contextWindow: ctx.contextWindow,
180
+ toolName: name,
181
+ });
105
182
  }
106
183
 
107
184
  /** Number of registered tools. */
@@ -24,6 +24,9 @@
24
24
  * @property {(groupId: string) => string[]|null} [getGroupRoster]
25
25
  * — R6: resolve a group's roster (used by TaskCreate / route_forward to
26
26
  * validate `members` ⊆ roster without importing group-store directly).
27
+ * @property {number} [contextWindow] — current model's context window in
28
+ * tokens (used by ToolRegistry.execute to cap a single tool result at a
29
+ * fraction of the window so one runaway grep can't blow the wire).
27
30
  */
28
31
 
29
32
  /**
@@ -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
  })) {