@yeaft/webchat-agent 1.0.441 → 1.0.443

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/index.js CHANGED
@@ -24,6 +24,7 @@ import { connect } from './connection.js';
24
24
  import { loadMcpServers } from './mcp.js';
25
25
  import { SAFE_REMOTE_UPGRADE_CAPABILITY } from './upgrade-command.js';
26
26
  import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
27
+ import { initYeaftDir } from './yeaft/init.js';
27
28
  import { updateBrowserRuntimeSettings } from './yeaft/config-api.js';
28
29
  import { bootBrowserRuntime, shutdownBrowserRuntime } from './browser-runtime/index.js';
29
30
  import {
@@ -106,12 +107,15 @@ const { agentName: AGENT_NAME, instanceId: INSTANCE_ID } = resolveRuntimeIdentit
106
107
  // WebSocket connection goes live, so downstream code can assume a real path.
107
108
  const YEAFT_DIR = process.env.YEAFT_DIR || fileConfig.yeaftDir || getDefaultYeaftDir(INSTANCE_ID);
108
109
  try {
109
- if (!existsSync(YEAFT_DIR)) {
110
- mkdirSync(YEAFT_DIR, { recursive: true, mode: 0o700 });
111
- console.log(`[Agent] Created yeaft dir: ${YEAFT_DIR}`);
110
+ const initResult = initYeaftDir(YEAFT_DIR);
111
+ for (const warning of initResult.warnings || []) {
112
+ console.warn(`[Agent] ${warning}`);
113
+ }
114
+ if (initResult.created?.length > 0) {
115
+ console.log(`[Agent] Initialized yeaft dir: ${YEAFT_DIR}`);
112
116
  }
113
117
  } catch (err) {
114
- console.warn(`[Agent] Could not ensure yeaft dir ${YEAFT_DIR}: ${err?.message || err}`);
118
+ console.warn(`[Agent] Could not initialize yeaft dir ${YEAFT_DIR}: ${err?.message || err}`);
115
119
  }
116
120
 
117
121
  const agentSecret = process.env.AGENT_SECRET_FILE
@@ -1 +1 @@
1
- {"version":"1.0.441"}
1
+ {"version":"1.0.443"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.441",
3
+ "version": "1.0.443",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -196,7 +196,7 @@ export function updateLlmConfig(update, dir) {
196
196
  * stable shape — `normaliseYeaftSection` guarantees that.
197
197
  *
198
198
  * @param {string} [dir] — Yeaft data directory
199
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number } | { error: string }}
199
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object } | { error: string }}
200
200
  */
201
201
  export function getYeaftSettings(dir) {
202
202
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
@@ -215,14 +215,15 @@ export function getYeaftSettings(dir) {
215
215
  * Update the Yeaft-section of config.json. Merges into existing config
216
216
  * (LLM provider / model fields are untouched) and validates each field:
217
217
  * `maxConcurrentThreads` must be 1..50, `autoArchiveIdleDays` must be
218
- * 1..3650, `recentTurnsLimit` must be 1..500. Invalid values are rejected
218
+ * 1..3650, `recentTurnsLimit` must be 1..500. Dream limits are read-only
219
+ * runtime defaults here; invalid values are rejected
219
220
  * outright so the UI sees an error rather than silently reverting — a
220
221
  * silent revert would make "I set it to 100 and nothing happened"
221
222
  * impossible to debug.
222
223
  *
223
224
  * @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number, recentTurnsLimit?: number }} update
224
225
  * @param {string} [dir]
225
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number } | { error: string }}
226
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object } | { error: string }}
226
227
  */
227
228
  export function updateYeaftSettings(update, dir) {
228
229
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
@@ -266,6 +267,9 @@ export function updateYeaftSettings(update, dir) {
266
267
  ? Math.floor(Number(update.recentTurnsLimit))
267
268
  : prev.recentTurnsLimit,
268
269
  };
270
+ if (existing.yeaft?.dream && typeof existing.yeaft.dream === 'object') {
271
+ merged.dream = existing.yeaft.dream;
272
+ }
269
273
  existing.yeaft = merged;
270
274
  return merged;
271
275
  });
package/yeaft/config.js CHANGED
@@ -29,6 +29,7 @@ import { normalizeKnownProviderForRuntime } from './llm/known-providers.js';
29
29
  import { createDenyAllPluginConfig, normalizePluginConfig } from './plugins.js';
30
30
  import { normaliseBrowserRuntimeSection } from '../browser-runtime/config.js';
31
31
  import { readWorkspaceFile } from './workspace-file.js';
32
+ import { DEFAULT_LIMITS } from './dream/limits.js';
32
33
 
33
34
  /** Default configuration values. */
34
35
  const DEFAULTS = {
@@ -254,13 +255,14 @@ function isTruthy(val) {
254
255
  * bounds via `clampYeaftField`.
255
256
  *
256
257
  * @param {any} raw — jsonConfig.yeaft (may be undefined / malformed)
257
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number }}
258
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object }}
258
259
  */
259
260
  export function normaliseYeaftSection(raw) {
260
261
  const out = {
261
262
  maxConcurrentThreads: DEFAULTS.yeaftMaxConcurrentThreads,
262
263
  autoArchiveIdleDays: DEFAULTS.yeaftAutoArchiveIdleDays,
263
264
  recentTurnsLimit: DEFAULTS.yeaftRecentTurnsLimit,
265
+ dream: { ...DEFAULT_LIMITS },
264
266
  };
265
267
  if (!raw || typeof raw !== 'object') return out;
266
268
  const mc = clampYeaftField(raw.maxConcurrentThreads, 'maxConcurrentThreads');
@@ -269,6 +271,13 @@ export function normaliseYeaftSection(raw) {
269
271
  if (ad !== null) out.autoArchiveIdleDays = ad;
270
272
  const rt = clampYeaftField(raw.recentTurnsLimit, 'recentTurnsLimit');
271
273
  if (rt !== null) out.recentTurnsLimit = rt;
274
+ const dream = raw.dream;
275
+ if (dream && typeof dream === 'object' && !Array.isArray(dream)) {
276
+ for (const key of Object.keys(DEFAULT_LIMITS)) {
277
+ const value = Number(dream[key]);
278
+ if (Number.isFinite(value) && value > 0) out.dream[key] = Math.floor(value);
279
+ }
280
+ }
272
281
  return out;
273
282
  }
274
283
 
@@ -26,10 +26,16 @@ import { inspect } from 'util';
26
26
  import { writeContent, writeSummary, readContent, readMemory, readSummary } from '../memory/store.js';
27
27
  import { parseSegments } from '../memory/segment.js';
28
28
  import { syncScope } from '../memory/segment-sync.js';
29
- import { batchSourcesForApply, needsBatchedApply, truncateMessage } from './segment.js';
29
+ import {
30
+ batchSourcesForApply,
31
+ boundDreamPrompt,
32
+ needsBatchedApply,
33
+ truncateMessage,
34
+ } from './segment.js';
30
35
  import { snapshotScope } from './snapshot.js';
31
36
  import { parseJsonSafe } from './triage.js';
32
37
  import { render } from './prompts/index.js';
38
+ import { MAX_DREAM_PROMPT_CHARS } from './limits.js';
33
39
 
34
40
  function malformedJsonError(message, raw) {
35
41
  const err = new Error(message);
@@ -239,7 +245,7 @@ export function targetToScope(target) {
239
245
  * root: string,
240
246
  * ts: string, // shared timestamp folder
241
247
  * llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
242
- * limits?: { MAX_APPLY_TOKENS?: number },
248
+ * limits?: { MAX_APPLY_TOKENS?: number, MAX_DREAM_PROMPT_CHARS?: number },
243
249
  * snapshot?: typeof snapshotScope,
244
250
  * nowIso?: () => string,
245
251
  * onProgress?: (event: object) => void,
@@ -272,55 +278,81 @@ export async function applyMergedTarget(merged, opts) {
272
278
  }
273
279
 
274
280
  let batchesUsed = 0;
275
- const maxApply = (opts.limits && opts.limits.MAX_APPLY_TOKENS) || undefined;
281
+ const maxApply = (opts.limits && opts.limits.MAX_APPLY_TOKENS) || 80000;
282
+ // Leave room for the prompt template and JSON framing. The bounded context
283
+ // and source chunks are prompt projections; canonical files remain complete.
284
+ const maxPromptChars = (opts.limits && opts.limits.MAX_DREAM_PROMPT_CHARS) || MAX_DREAM_PROMPT_CHARS;
285
+ const providerPromptBudget = Math.floor(maxPromptChars / 4) - 2048;
286
+ const promptBudget = Math.max(1024, Math.min(Math.floor(maxApply) - 2048, providerPromptBudget));
287
+ const initialContext = boundApplyContext(contentMd, summaryMd, promptBudget);
276
288
 
277
289
  if (merged.kind === 'create') {
278
290
  const siblings = opts.siblingTopicsFor ? await opts.siblingTopicsFor(merged.target) : [];
279
- const prompt = buildCreatePrompt({
280
- target: merged.target,
281
- sources: merged.sources,
282
- siblingTopics: siblings,
283
- language: opts.language,
284
- });
285
- if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: 1, of: 1 });
286
- const raw = await opts.llm({ pass: 'create', prompt, system: applySystem(opts.language) });
287
- const parsed = parseJsonSafe(raw);
288
- const parsedContent = parsed && typeof parsed.content_md === 'string'
289
- ? parsed.content_md
290
- : (parsed && typeof parsed.memory_md === 'string' ? parsed.memory_md : null);
291
- if (parsedContent === null) {
292
- throw malformedJsonError(`apply: CREATE returned malformed JSON for ${merged.target}`, raw);
291
+ const sourceBatches = needsBatchedApply(
292
+ { memoryMd: '', summaryMd: '', sources: merged.sources },
293
+ promptBudget,
294
+ )
295
+ ? batchSourcesForApply({ memoryMd: '', summaryMd: '', sources: merged.sources }, promptBudget)
296
+ : [merged.sources];
297
+
298
+ let i = 0;
299
+ for (const batch of sourceBatches) {
300
+ i += 1;
301
+ const prompt = boundDreamPrompt(i === 1
302
+ ? buildCreatePrompt({
303
+ target: merged.target,
304
+ sources: batch,
305
+ siblingTopics: siblings,
306
+ language: opts.language,
307
+ })
308
+ : buildUpdatePrompt({
309
+ target: merged.target,
310
+ contentMd: boundApplyContext(contentMd, summaryMd, promptBudget).contentMd,
311
+ summaryMd: boundApplyContext(contentMd, summaryMd, promptBudget).summaryMd,
312
+ sources: batch,
313
+ batchInfo: { index: i, total: sourceBatches.length },
314
+ language: opts.language,
315
+ }), maxPromptChars);
316
+ if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: i, of: sourceBatches.length });
317
+ const raw = await opts.llm({ pass: i === 1 ? 'create' : 'update', prompt, system: applySystem(opts.language) });
318
+ const parsed = parseJsonSafe(raw);
319
+ const parsedContent = parsed && typeof parsed.content_md === 'string'
320
+ ? parsed.content_md
321
+ : (parsed && typeof parsed.memory_md === 'string' ? parsed.memory_md : null);
322
+ if (parsedContent === null) {
323
+ throw malformedJsonError(`apply: ${i === 1 ? 'CREATE' : 'UPDATE'} batch ${i} returned malformed JSON for ${merged.target}`, raw);
324
+ }
325
+ const ensured = ensurePrimarySessionOutput({
326
+ target: merged.target,
327
+ memoryMd: parsedContent,
328
+ summaryMd: typeof parsed.summary_md === 'string' ? parsed.summary_md : summaryMd,
329
+ sources: batch,
330
+ language: opts.language,
331
+ });
332
+ contentMd = ensured.memoryMd;
333
+ summaryMd = ensured.summaryMd;
293
334
  }
294
- const ensured = ensurePrimarySessionOutput({
295
- target: merged.target,
296
- memoryMd: parsedContent,
297
- summaryMd: typeof parsed.summary_md === 'string' ? parsed.summary_md : '',
298
- sources: merged.sources,
299
- language: opts.language,
300
- });
301
- contentMd = ensured.memoryMd;
302
- summaryMd = ensured.summaryMd;
303
- batchesUsed = 1;
335
+ batchesUsed = sourceBatches.length;
304
336
  } else {
305
- // UPDATE — possibly batched.
306
- const batches = needsBatchedApply(
307
- { memoryMd: contentMd, summaryMd, sources: merged.sources },
308
- maxApply,
309
- )
310
- ? batchSourcesForApply({ memoryMd: contentMd, summaryMd, sources: merged.sources }, maxApply)
337
+ // UPDATE — possibly batched. A single Session source may be split into
338
+ // many bounded batches; old overlap is never present in merged.sources.
339
+ const input = { ...initialContext, sources: merged.sources };
340
+ const batches = needsBatchedApply(input, promptBudget)
341
+ ? batchSourcesForApply(input, promptBudget)
311
342
  : [merged.sources];
312
343
 
313
344
  let i = 0;
314
345
  for (const batch of batches) {
315
346
  i += 1;
316
- const prompt = buildUpdatePrompt({
347
+ const context = boundApplyContext(contentMd, summaryMd, promptBudget);
348
+ const prompt = boundDreamPrompt(buildUpdatePrompt({
317
349
  target: merged.target,
318
- contentMd,
319
- summaryMd,
350
+ contentMd: context.contentMd,
351
+ summaryMd: context.summaryMd,
320
352
  sources: batch,
321
353
  batchInfo: { index: i, total: batches.length },
322
354
  language: opts.language,
323
- });
355
+ }), maxPromptChars);
324
356
  if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: i, of: batches.length });
325
357
  const raw = await opts.llm({ pass: 'update', prompt, system: applySystem(opts.language) });
326
358
  const parsed = parseJsonSafe(raw);
@@ -392,6 +424,27 @@ function scopeRelDir(scope) {
392
424
 
393
425
  function oneLine(s) { return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 200); }
394
426
 
427
+ function boundApplyContext(contentMd, summaryMd, budget) {
428
+ const maxChars = Math.max(4096, Math.floor(Math.max(1, budget) * 4));
429
+ const summary = truncatePromptText(summaryMd, Math.min(maxChars, 8_000));
430
+ const contentBudget = Math.max(0, maxChars - summary.length);
431
+ return {
432
+ contentMd: truncatePromptText(contentMd, contentBudget),
433
+ summaryMd: summary,
434
+ };
435
+ }
436
+
437
+ function truncatePromptText(value, maxChars) {
438
+ const text = String(value || '');
439
+ if (text.length <= maxChars) return text;
440
+ const marker = '\n\n[canonical memory clipped for Dream apply; durable source remains on disk]\n\n';
441
+ if (maxChars <= marker.length) return text.slice(0, Math.max(0, maxChars));
442
+ const room = maxChars - marker.length;
443
+ const head = Math.ceil(room * 0.62);
444
+ const tail = room - head;
445
+ return text.slice(0, head) + marker + (tail > 0 ? text.slice(-tail) : '');
446
+ }
447
+
395
448
  function isLegacyCanonicalMemory(memoryMd, scope) {
396
449
  const raw = String(memoryMd || '').trim();
397
450
  if (!raw) return false;
@@ -14,6 +14,10 @@ export const DREAM_INTERVAL_HOURS = 1;
14
14
  export const DREAM_OVERLAP = 3;
15
15
  export const MIN_NEW_PER_GROUP = 20;
16
16
  export const MAX_SINGLE_MESSAGE_CHARS = 8000;
17
+ // Provider tokenizers differ substantially for CJK and serialized tool text.
18
+ // Keep an independent character ceiling so the approximate token budget cannot
19
+ // turn into a multi-million-token wire request.
20
+ export const MAX_DREAM_PROMPT_CHARS = 96000;
17
21
  export const MAX_DIFF_TOKENS_PER_TRIAGE = 60000;
18
22
  export const MAX_APPLY_TOKENS = 80000;
19
23
  export const DREAM_BACKUP_KEEP = 7;
@@ -28,6 +32,7 @@ export const DEFAULT_LIMITS = Object.freeze({
28
32
  DREAM_OVERLAP,
29
33
  MIN_NEW_PER_GROUP,
30
34
  MAX_SINGLE_MESSAGE_CHARS,
35
+ MAX_DREAM_PROMPT_CHARS,
31
36
  MAX_DIFF_TOKENS_PER_TRIAGE,
32
37
  MAX_APPLY_TOKENS,
33
38
  DREAM_BACKUP_KEEP,
@@ -8,8 +8,7 @@
8
8
  * enumerateSessions() via opts.listSessions()
9
9
  * ↓
10
10
  * for each session with newCount ≥ MIN_NEW_PER_GROUP (auto)
11
- * or > 0 (manual)
12
- * or prior messages in a scoped manual session rerun:
11
+ * or > 0 (manual):
13
12
  * loadDiff() via opts.loadSessionDiff(sessionId, sinceId)
14
13
  * applyOverlap() via opts.loadOverlapPreamble(...)
15
14
  * segment() segmentDiff(...)
@@ -46,7 +45,11 @@ import {
46
45
  DEFAULT_LIMITS,
47
46
  } from './limits.js';
48
47
  import { clearDreamError, readSessionState, writeSessionState, writeDreamError } from './state.js';
49
- import { segmentDiff, truncateMessage, estimateMessagesTokens } from './segment.js';
48
+ import {
49
+ segmentDiff,
50
+ compressDreamMessages,
51
+ selectDreamNewMessages,
52
+ } from './segment.js';
50
53
  import { triageGroupSegments } from './triage.js';
51
54
  import { mergeByTarget } from './merge.js';
52
55
  import { applyMergedTarget } from './apply.js';
@@ -58,7 +61,7 @@ import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
58
61
  * @typedef {Object} RunDreamOpts
59
62
  * @property {string} root — memory root, e.g. ~/.yeaft/memory
60
63
  * @property {boolean} [manual=false] — manual trigger overrides newCount<20 skip
61
- * @property {string[]} [scopeFilter] — optional: only dream these targets; scoped manual session triggers rerun the current session when there are prior messages but no new cursor delta ('*' allowed)
64
+ * @property {string[]} [scopeFilter] — optional: only dream these targets ('*' allowed)
62
65
  * @property {(req: {pass:string, prompt:string, system:string}) => Promise<string>} llm
63
66
  * @property {() => Promise<Array<string>>} listSessions — return all session ids (incl. '_no-session')
64
67
  * @property {(sessionId: string) => Promise<number>} countMessages — total message count for a session
@@ -109,11 +112,9 @@ export async function runDream(opts) {
109
112
  };
110
113
 
111
114
  for (const sessionId of sessionIds) {
112
- // Current-session manual dream passes are the one case where scopeFilter
113
- // must constrain enumeration too: clicking the conversation header means
114
- // "dream this session now", not "triage every session and then only apply
115
- // sessions/<id>". Pure target filters such as ['user'] still triage every
116
- // session so their hard-rule actions can contribute to the requested scope.
115
+ // A Session filter constrains enumeration; a pure target filter such as
116
+ // ['user'] still triages every Session so hard-rule actions can contribute
117
+ // to the requested scope.
117
118
  if (sessionFilter && !sessionFilter.has(sessionId)) {
118
119
  sessionsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'scope-filtered' });
119
120
  continue;
@@ -122,13 +123,7 @@ export async function runDream(opts) {
122
123
  const beforeCount = await safeCall(() => opts.countMessages(sessionId), 0);
123
124
  const newCount = Math.max(0, beforeCount - (state.messageCount || 0));
124
125
 
125
- const rerunScopedManual = !!opts.manual
126
- && sessionFilter
127
- && sessionFilter.has(sessionId)
128
- && newCount === 0
129
- && beforeCount > 0;
130
-
131
- if (newCount === 0 && !rerunScopedManual) {
126
+ if (newCount === 0) {
132
127
  const topics = await resolveTopicSummaries(sessionId);
133
128
  if (opts.manual && topics.length >= 2) {
134
129
  try {
@@ -140,6 +135,7 @@ export async function runDream(opts) {
140
135
  language: opts.language,
141
136
  ts,
142
137
  segmentIndex: opts.segmentIndex || null,
138
+ maxPromptChars: limits.MAX_DREAM_PROMPT_CHARS,
143
139
  });
144
140
  sessionsReport.push({ sessionId, new: 0, status: 'consolidated', ...result });
145
141
  } catch (err) {
@@ -156,14 +152,14 @@ export async function runDream(opts) {
156
152
  }
157
153
 
158
154
  onProgress({ phase: 'load-diff', sessionId });
159
- const diffCursor = rerunScopedManual ? null : state.lastDreamMessageId;
155
+ const diffCursor = state.lastDreamMessageId;
160
156
  const loadDiff = opts.loadSessionDiff || opts.loadGroupDiff;
161
157
  const diffNew = await safeCall(() => loadDiff(sessionId, diffCursor), []);
162
158
  if (!diffNew || diffNew.length === 0) {
163
159
  sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'empty-diff' });
164
160
  continue;
165
161
  }
166
- const overlapMessages = state.lastDreamMessageId && !rerunScopedManual
162
+ const overlapMessages = state.lastDreamMessageId
167
163
  ? await safeCall(
168
164
  () => opts.loadOverlapPreamble
169
165
  ? opts.loadOverlapPreamble(sessionId, state.lastDreamMessageId, limits.DREAM_OVERLAP)
@@ -171,11 +167,33 @@ export async function runDream(opts) {
171
167
  [],
172
168
  )
173
169
  : [];
174
- const taggedOverlap = overlapMessages.map(m => ({ ...m, kind: 'overlap', body: truncateMessage(m.body || '') }));
175
- const taggedNew = diffNew.map(m => ({ ...m, kind: 'new', body: truncateMessage(m.body || '') }));
170
+ const taggedOverlap = compressDreamMessages(overlapMessages)
171
+ .map(m => ({ ...m, kind: 'overlap' }));
172
+ const taggedNew = compressDreamMessages(diffNew)
173
+ .map(m => ({ ...m, kind: 'new' }));
176
174
  const fullDiff = [...taggedOverlap, ...taggedNew];
175
+ if (taggedNew.length === 0) {
176
+ // Tool-only traffic is intentionally not sent to Dream, but it still
177
+ // belongs to the processed transcript range. Advance the cursor so a
178
+ // failed/empty pass cannot replay the same execution history forever.
179
+ const tailId = lastMessageId(diffNew);
180
+ if (tailId) {
181
+ await writeSessionState(opts.root, sessionId, {
182
+ lastDreamMessageId: tailId,
183
+ lastDreamAt: nowIso,
184
+ messageCount: beforeCount,
185
+ });
186
+ }
187
+ sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'no-durable-new-messages' });
188
+ continue;
189
+ }
177
190
 
178
- const segments = segmentDiff(fullDiff, limits.MAX_DIFF_TOKENS_PER_TRIAGE, limits.DREAM_OVERLAP);
191
+ const segments = segmentDiff(
192
+ fullDiff,
193
+ limits.MAX_DIFF_TOKENS_PER_TRIAGE,
194
+ limits.DREAM_OVERLAP,
195
+ limits.MAX_DREAM_PROMPT_CHARS,
196
+ );
179
197
  onProgress({ phase: 'triage', sessionId, status: 'running', segments: segments.length });
180
198
 
181
199
  let actions;
@@ -186,6 +204,7 @@ export async function runDream(opts) {
186
204
  sessionId,
187
205
  segments,
188
206
  topicSummaries,
207
+ maxPromptChars: limits.MAX_DREAM_PROMPT_CHARS,
189
208
  llm: dreamLlmForSession(opts.llm, sessionId),
190
209
  onProgress,
191
210
  language: opts.language,
@@ -206,11 +225,15 @@ export async function runDream(opts) {
206
225
  }
207
226
 
208
227
  onProgress({ phase: 'triage', sessionId, status: 'done', actions: actions.length });
209
- sessionTriages.push({ sessionId, diff: fullDiff, actions });
228
+ sessionTriages.push({
229
+ sessionId,
230
+ diff: selectDreamNewMessages(taggedNew),
231
+ actions,
232
+ });
210
233
 
211
234
  const tailId = lastMessageId(diffNew);
212
235
  processedSessions.push({ sessionId, tailId, beforeCount, newCount, segments: segments.length, actions: actions.length });
213
- sessionsReport.push({ sessionId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged', rerun: rerunScopedManual || undefined });
236
+ sessionsReport.push({ sessionId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged' });
214
237
  }
215
238
 
216
239
  // 3. merge
@@ -277,6 +300,7 @@ export async function runDream(opts) {
277
300
  targets,
278
301
  llm: dreamLlmForSession(opts.llm, triage.sessionId),
279
302
  language: opts.language,
303
+ limits,
280
304
  nowIso: opts.nowIso || (() => nowIso),
281
305
  segmentIndex: opts.segmentIndex || null,
282
306
  });
@@ -310,6 +334,7 @@ export async function runDream(opts) {
310
334
  language: opts.language,
311
335
  ts,
312
336
  segmentIndex: opts.segmentIndex || null,
337
+ maxPromptChars: limits.MAX_DREAM_PROMPT_CHARS,
313
338
  });
314
339
  topicConsolidation.push({ sessionId, status: 'done', ...result });
315
340
  onProgress({ phase: 'topic-consolidation', sessionId, status: 'done', ...result });
@@ -324,17 +349,20 @@ export async function runDream(opts) {
324
349
  }
325
350
  }
326
351
 
327
- // 7. bookkeep — only when at least one apply for this session's actions
328
- // succeeded. We use a permissive policy: if ANY merged-target apply
329
- // succeeded for a session's contributed actions, advance that session's
330
- // cursor. (If everything errored, we keep the cursor so next run
331
- // retries.)
332
- const successfulTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
352
+ // 7. bookkeep — advance a Session cursor only after every Apply target
353
+ // selected for that Session in this run completed successfully. Apply keeps
354
+ // all batch output in memory until the target's final canonical write, so a
355
+ // failed target has no durable progress that can safely move the cursor.
356
+ // Keeping the cursor on any target error lets the next run retry the failed
357
+ // target together with the same source diff.
358
+ const targetStatus = new Map(targetsReport.map(report => [report.target, report.status]));
333
359
  for (const pg of processedSessions) {
334
- const contributed = (sessionTriages.find(g => g.sessionId === pg.sessionId) || { actions: [] })
335
- .actions.map(a => a.scope);
336
- const anySuccess = contributed.some(t => successfulTargets.has(t));
337
- if (!anySuccess) continue;
360
+ const sessionTargets = targetsToApply
361
+ .filter(target => target.sources.some(source => source.sessionId === pg.sessionId))
362
+ .map(target => target.target);
363
+ if (sessionTargets.length === 0 || !sessionTargets.every(target => targetStatus.get(target) === 'done')) {
364
+ continue;
365
+ }
338
366
  if (pg.tailId) {
339
367
  await writeSessionState(opts.root, pg.sessionId, {
340
368
  lastDreamMessageId: pg.tailId,
@@ -10,6 +10,7 @@ import { readScope, writeScope } from '../memory/segment-store.js';
10
10
  import { syncScope } from '../memory/segment-sync.js';
11
11
  import { makeSegment } from '../memory/segment.js';
12
12
  import { render, extractTemplateForScope } from './prompts/index.js';
13
+ import { boundDreamPrompt } from './segment.js';
13
14
  import { parseJsonSafe } from './triage.js';
14
15
 
15
16
  const MAX_MESSAGES = 80;
@@ -40,6 +41,7 @@ const VALID_KINDS = new Set([
40
41
  * language?: string,
41
42
  * nowIso?: Function,
42
43
  * segmentIndex?: import('../memory/index-db.js').SegmentIndex|null,
44
+ * limits?: { MAX_DREAM_PROMPT_CHARS?: number },
43
45
  * }} opts
44
46
  */
45
47
  export async function extractAndWriteMemorySegments(opts) {
@@ -68,6 +70,7 @@ export async function extractAndWriteMemorySegments(opts) {
68
70
  language: opts.language,
69
71
  now,
70
72
  allowedSourceIds,
73
+ maxPromptChars: opts.limits?.MAX_DREAM_PROMPT_CHARS,
71
74
  });
72
75
  } catch (err) {
73
76
  errors.push({ scope, error: err.message, rawSnippet: err.rawSnippet || '' });
@@ -92,10 +95,13 @@ export async function extractAndWriteMemorySegments(opts) {
92
95
  return { scopes: scopeCount, segments: segmentCount, errors };
93
96
  }
94
97
 
95
- async function extractScopeSegments({ scope, sessionId, messages, llm, language, now, allowedSourceIds }) {
98
+ async function extractScopeSegments({ scope, sessionId, messages, llm, language, now, allowedSourceIds, maxPromptChars }) {
96
99
  const template = extractTemplateForScope(scope);
97
100
  const base = render(template, templateVarsForScope(scope, sessionId), { language });
98
- const prompt = `${base}\n\nTarget scope: ${scope}\n\nConversation diff, oldest first:\n${renderMessages(messages)}\n\nReturn only the JSON array. Do not wrap it in Markdown.`;
101
+ const prompt = boundDreamPrompt(
102
+ `${base}\n\nTarget scope: ${scope}\n\nConversation diff, oldest first:\n${renderMessages(messages)}\n\nReturn only the JSON array. Do not wrap it in Markdown.`,
103
+ maxPromptChars,
104
+ );
99
105
  const firstRaw = await llm({ pass: 'extract-segments', prompt, system: extractSystem(language) });
100
106
  const firstParsed = parseJsonSafe(firstRaw);
101
107
  if (Array.isArray(firstParsed)) {
@@ -104,7 +110,7 @@ async function extractScopeSegments({ scope, sessionId, messages, llm, language,
104
110
  .filter(Boolean);
105
111
  }
106
112
 
107
- const retryPrompt = `${prompt}\n\nYour previous output was malformed JSON. Previous output snippet:\n${rawSnippet(firstRaw)}\n\nRetry now. Return only a strict JSON array.`;
113
+ const retryPrompt = boundDreamPrompt(`${prompt}\n\nYour previous output was malformed JSON. Previous output snippet:\n${rawSnippet(firstRaw)}\n\nRetry now. Return only a strict JSON array.`, maxPromptChars);
108
114
  const retryRaw = await llm({ pass: 'extract-segments-retry', prompt: retryPrompt, system: extractSystem(language) });
109
115
  const retryParsed = parseJsonSafe(retryRaw);
110
116
  if (!Array.isArray(retryParsed)) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dream/segment.js.
3
3
  *
4
- * Three independent length-control concerns, kept pure so they can be
4
+ * Four independent length-control concerns, kept pure so they can be
5
5
  * unit-tested without touching disk or any LLM:
6
6
  *
7
7
  * 1. truncateMessage — clamp a single message body to
@@ -21,15 +21,21 @@
21
21
  *
22
22
  * 4. needsBatchedApply / batchSourcesForApply — when an Apply target's
23
23
  * memory + summary + sources cumulatively exceed MAX_APPLY_TOKENS,
24
- * split the sources (one source = one group's contribution) into
25
- * batches; the LLM is then called once per batch, threading the
26
- * written-back memory.md as input to the next batch. (§17.2)
24
+ * split source messages into bounded batches; the LLM is then called
25
+ * once per batch, threading the written-back memory.md as input to the
26
+ * next batch. A single Session source may therefore produce multiple
27
+ * ordered batches.
28
+ *
29
+ * Dream only needs durable user/assistant prose. Tool results are execution
30
+ * history and can be enormous; `compressDreamMessages()` drops them before
31
+ * triage/apply while preserving the original conversation on disk.
27
32
  *
28
33
  * No side-effects. All functions are deterministic given their inputs.
29
34
  */
30
35
 
31
36
  import {
32
37
  MAX_SINGLE_MESSAGE_CHARS,
38
+ MAX_DREAM_PROMPT_CHARS,
33
39
  MAX_DIFF_TOKENS_PER_TRIAGE,
34
40
  MAX_APPLY_TOKENS,
35
41
  DREAM_OVERLAP,
@@ -80,6 +86,67 @@ export function estimateMessagesTokens(msgs) {
80
86
  return n;
81
87
  }
82
88
 
89
+ /**
90
+ * Remove execution-only messages from the Dream input. The transcript remains
91
+ * the source of truth; this is only a prompt projection. Overlap messages are
92
+ * retained for triage context but never become new durable evidence.
93
+ *
94
+ * @param {Array<object>} messages
95
+ * @returns {Array<object>}
96
+ */
97
+ export function compressDreamMessages(messages) {
98
+ if (!Array.isArray(messages)) return [];
99
+ return messages
100
+ .filter(message => message && typeof message === 'object')
101
+ .filter(message => ['user', 'assistant'].includes(String(message.role || '').toLowerCase()))
102
+ .map(message => ({
103
+ ...message,
104
+ body: truncateMessage(message.body || message.content || ''),
105
+ }))
106
+ .filter(message => String(message.body || '').trim());
107
+ }
108
+
109
+ /**
110
+ * Keep only newly observed messages when applying/extracting. Triage may use
111
+ * overlap context, but re-feeding it to Apply causes old Dreamed content to be
112
+ * processed again on every pass.
113
+ *
114
+ * @param {Array<object>} messages
115
+ * @returns {Array<object>}
116
+ */
117
+ export function selectDreamNewMessages(messages) {
118
+ return (Array.isArray(messages) ? messages : [])
119
+ .filter(message => message && message.kind !== 'overlap');
120
+ }
121
+
122
+ /**
123
+ * Hard cap the final Dream prompt at the provider boundary. Keep both the
124
+ * prompt contract (head) and the JSON/output instruction (tail); discard only
125
+ * the middle source transcript. The durable transcript and canonical memory
126
+ * remain on disk.
127
+ *
128
+ * @param {string} prompt
129
+ * @param {number} [maxChars=MAX_DREAM_PROMPT_CHARS]
130
+ * @returns {string}
131
+ */
132
+ export function boundDreamPrompt(prompt, maxChars = MAX_DREAM_PROMPT_CHARS) {
133
+ const text = String(prompt || '');
134
+ const cap = Number.isFinite(maxChars) && maxChars > 0
135
+ ? Math.floor(maxChars)
136
+ : MAX_DREAM_PROMPT_CHARS;
137
+ if (text.length <= cap) return text;
138
+ const marker = '\n\n[Dream prompt compressed: middle transcript omitted; durable source remains on disk]\n\n';
139
+ if (cap <= marker.length) {
140
+ const head = Math.ceil(cap / 2);
141
+ const tail = cap - head;
142
+ return text.slice(0, head) + (tail > 0 ? text.slice(-tail) : '');
143
+ }
144
+ const room = cap - marker.length;
145
+ const head = Math.ceil(room * 0.62);
146
+ const tail = room - head;
147
+ return text.slice(0, head) + marker + (tail > 0 ? text.slice(-tail) : '');
148
+ }
149
+
83
150
  /**
84
151
  * Split a contiguous group diff into ≤MAX-token segments, with a
85
152
  * DREAM_OVERLAP-message tail/head overlap between consecutive segments.
@@ -99,14 +166,19 @@ export function estimateMessagesTokens(msgs) {
99
166
  * @param {Array<{id?: string, role?: string, body?: string}>} diff
100
167
  * @param {number} [maxTokens=MAX_DIFF_TOKENS_PER_TRIAGE]
101
168
  * @param {number} [overlap=DREAM_OVERLAP]
169
+ * @param {number} [maxPromptChars=MAX_DREAM_PROMPT_CHARS]
102
170
  * @returns {Array<{ messages: Array<object>, overlapCount: number, newCount: number }>}
103
171
  */
104
- export function segmentDiff(diff, maxTokens = MAX_DIFF_TOKENS_PER_TRIAGE, overlap = DREAM_OVERLAP) {
172
+ export function segmentDiff(diff, maxTokens = MAX_DIFF_TOKENS_PER_TRIAGE, overlap = DREAM_OVERLAP, maxPromptChars = MAX_DREAM_PROMPT_CHARS) {
105
173
  const msgs = Array.isArray(diff) ? diff : [];
174
+ const boundedPromptChars = Number.isFinite(maxPromptChars) && maxPromptChars > 0
175
+ ? maxPromptChars
176
+ : MAX_DREAM_PROMPT_CHARS;
177
+ const boundedMaxTokens = Math.min(maxTokens, Math.max(1, Math.floor(boundedPromptChars / 4) - 2048));
106
178
  if (msgs.length === 0) return [];
107
179
 
108
180
  // Fast path: whole diff fits in one segment.
109
- if (estimateMessagesTokens(msgs) <= maxTokens) {
181
+ if (estimateMessagesTokens(msgs) <= boundedMaxTokens) {
110
182
  return [{ messages: msgs, overlapCount: 0, newCount: msgs.length }];
111
183
  }
112
184
 
@@ -120,7 +192,7 @@ export function segmentDiff(diff, maxTokens = MAX_DIFF_TOKENS_PER_TRIAGE, overla
120
192
  let end = cursor;
121
193
  while (end < msgs.length) {
122
194
  const cost = estimateTokens(msgs[end].body || '') + estimateTokens(msgs[end].role || '') + 2;
123
- if (used + cost > maxTokens && end > cursor) break;
195
+ if (used + cost > boundedMaxTokens && end > cursor) break;
124
196
  used += cost;
125
197
  end += 1;
126
198
  }
@@ -161,9 +233,9 @@ function totalApplyTokens(merged) {
161
233
  * previous-batch output replaces memoryMd, so we account for the same
162
234
  * baseline cost in each batch.
163
235
  *
164
- * If a single source (one group's diff) alone would overflow, it still
165
- * goes into its own batch — we never split a source diff here (segment
166
- * happens earlier, in triage).
236
+ * Sources are split into ordered message chunks when one Session's diff is
237
+ * larger than the apply budget. This is required because a single Session
238
+ * can contribute thousands of messages after a long gap between Dream runs.
167
239
  *
168
240
  * @param {{ memoryMd?: string, summaryMd?: string, sources: Array<{ sessionId: string, diff: any }> }} merged
169
241
  * @param {number} [maxTokens=MAX_APPLY_TOKENS]
@@ -173,10 +245,11 @@ export function batchSourcesForApply(merged, maxTokens = MAX_APPLY_TOKENS) {
173
245
  const sources = Array.isArray(merged.sources) ? merged.sources : [];
174
246
  if (sources.length === 0) return [];
175
247
  const baseline = estimateTokens(merged.memoryMd || '') + estimateTokens(merged.summaryMd || '');
248
+ const sourceChunks = sources.flatMap(source => splitApplySource(source, Math.max(1, maxTokens - baseline)));
176
249
  const batches = [];
177
250
  let cur = [];
178
251
  let used = baseline;
179
- for (const src of sources) {
252
+ for (const src of sourceChunks) {
180
253
  const cost = estimateMessagesTokens(src.diff || []);
181
254
  if (cur.length > 0 && used + cost > maxTokens) {
182
255
  batches.push(cur);
@@ -189,3 +262,23 @@ export function batchSourcesForApply(merged, maxTokens = MAX_APPLY_TOKENS) {
189
262
  if (cur.length > 0) batches.push(cur);
190
263
  return batches;
191
264
  }
265
+
266
+ function splitApplySource(source, budget) {
267
+ const messages = Array.isArray(source?.diff) ? source.diff : [];
268
+ if (messages.length === 0) return [{ ...source, diff: [] }];
269
+ const chunks = [];
270
+ let current = [];
271
+ let used = 0;
272
+ for (const message of messages) {
273
+ const cost = estimateMessagesTokens([message]);
274
+ if (current.length > 0 && used + cost > budget) {
275
+ chunks.push({ ...source, diff: current });
276
+ current = [];
277
+ used = 0;
278
+ }
279
+ current.push(message);
280
+ used += cost;
281
+ }
282
+ if (current.length > 0) chunks.push({ ...source, diff: current });
283
+ return chunks;
284
+ }
@@ -46,7 +46,8 @@ import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
46
46
  import { loadSessionConfig, resolveSessionConfig } from '../sessions/session-config.js';
47
47
  import { listSessions as listSessionMetas } from '../sessions/session-store.js';
48
48
  import { readSessionState } from './state.js';
49
- import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
49
+ import { boundDreamPrompt } from './segment.js';
50
+ import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS, loadLimitsFromConfig } from './limits.js';
50
51
 
51
52
  /**
52
53
  * Build the per-call options for runDream. Pure: takes a session and returns
@@ -83,6 +84,7 @@ export function buildRunDreamOpts(session, onProgress) {
83
84
  root: memoryRoot,
84
85
  language: session.config?.language || 'en',
85
86
  segmentIndex: session.memoryIndex || null,
87
+ limits: loadLimitsFromConfig(session.config),
86
88
  llm: makeLlm(session),
87
89
  listSessions: async () => {
88
90
  try { return listRegisteredSessions([sessionConversationsRoot, legacySessionConversationsRoot]); }
@@ -355,7 +357,11 @@ function makeLlm(session) {
355
357
  return async ({ pass, prompt, system, sessionId }) => {
356
358
  const adapter = session.adapter;
357
359
  const effectiveConfig = resolveDreamSessionConfig(session, sessionId);
358
- const model = effectiveConfig?.model || effectiveConfig?.primaryModel;
360
+ // Session model overrides may be stored provider-qualified while the
361
+ // resolved config also exposes a provider-local `model` id. Dream must
362
+ // route through the exact Session selection first; otherwise a short id
363
+ // can resolve to another provider or fail as unsupported.
364
+ const model = effectiveConfig?.primaryModel || effectiveConfig?.model;
359
365
  if (!model) {
360
366
  throw new Error(`dream: no session model configured (pass=${pass}, sessionId=${sessionId || 'unknown'})`);
361
367
  }
@@ -371,11 +377,13 @@ function makeLlm(session) {
371
377
  const effectiveSystem = system || (String(effectiveConfig?.language || '').toLowerCase().startsWith('zh')
372
378
  ? `你是梦境流水线 — pass: ${pass}。请用中文生成自然语言内容;JSON key 保持英文。`
373
379
  : `You are the dream pipeline — pass: ${pass}.`);
380
+ const dreamLimits = loadLimitsFromConfig(effectiveConfig);
381
+ const boundedPrompt = boundDreamPrompt(prompt, dreamLimits.MAX_DREAM_PROMPT_CHARS);
374
382
 
375
383
  const r = await adapter.call({
376
384
  model,
377
385
  system: effectiveSystem,
378
- messages: [{ role: 'user', content: prompt }],
386
+ messages: [{ role: 'user', content: boundedPrompt }],
379
387
  maxTokens: 2048,
380
388
  modelEffort: effectiveConfig?.modelEffort || undefined,
381
389
  });
@@ -402,7 +410,8 @@ function makeLlm(session) {
402
410
  model: model || 'unknown',
403
411
  sessionId: sessionId || null,
404
412
  systemPrompt: effectiveSystem,
405
- messages: [{ role: 'user', content: prompt }],
413
+ messages: [{ role: 'user', content: boundedPrompt }],
414
+ promptChars: boundedPrompt.length,
406
415
  response: typeof r?.text === 'string' ? r.text : '',
407
416
  toolCalls: [],
408
417
  usage,
@@ -18,6 +18,7 @@ import {
18
18
  TOPIC_REDIRECT_FILE,
19
19
  } from '../memory/topic-redirect.js';
20
20
  import { render } from './prompts/index.js';
21
+ import { boundDreamPrompt } from './segment.js';
21
22
  import { parseJsonSafe } from './triage.js';
22
23
  import { snapshotScope } from './snapshot.js';
23
24
 
@@ -54,9 +55,9 @@ export async function consolidateSessionTopics(opts) {
54
55
  const parsed = parseJsonSafe(await opts.llm({
55
56
  pass: 'topic-consolidation',
56
57
  system: topicConsolidationSystem(opts.language),
57
- prompt: render('consolidateTopics', {
58
+ prompt: boundDreamPrompt(render('consolidateTopics', {
58
59
  topics: batch.map(topic => `- ${topic.path}: ${topic.summary}`).join('\n'),
59
- }, { language: opts.language }),
60
+ }, { language: opts.language }), opts.maxPromptChars),
60
61
  }));
61
62
  if (!Array.isArray(parsed?.groups)) continue;
62
63
  groups.push(...validateGroups(parsed.groups, topics));
@@ -128,13 +129,13 @@ async function applyConsolidationGroup(group, opts) {
128
129
  const raw = await opts.llm({
129
130
  pass: 'topic-merge',
130
131
  system: topicConsolidationSystem(opts.language),
131
- prompt: render('mergeTopics', {
132
+ prompt: boundDreamPrompt(render('mergeTopics', {
132
133
  canonical: canonical.path,
133
134
  topicContents: available.map(record => [
134
135
  `## ${record.path}`,
135
136
  record.content || record.memory || record.summary,
136
137
  ].join('\n')).join('\n\n'),
137
- }, { language: opts.language }),
138
+ }, { language: opts.language }), opts.maxPromptChars),
138
139
  });
139
140
  const parsed = parseJsonSafe(raw);
140
141
  const mergedContent = String(parsed?.content_md || '').trim();
@@ -36,7 +36,7 @@ import { isValidTopic } from '../memory/store.js';
36
36
  * below.)
37
37
  */
38
38
 
39
- import { truncateMessage } from './segment.js';
39
+ import { boundDreamPrompt, truncateMessage } from './segment.js';
40
40
  import { render } from './prompts/index.js';
41
41
  import { resolveTopicRedirect } from '../memory/topic-redirect.js';
42
42
 
@@ -106,7 +106,7 @@ export function applyHardRules({ sessionId, chatId, messages }) {
106
106
  /**
107
107
  * Build the prompt used for Pass-1.
108
108
  *
109
- * @param {{ sessionId: string, messages: Array<object>, topicSummaries: Array<{ path: string, summary: string }> }} ctx
109
+ * @param {{ sessionId: string, messages: Array<object>, topicSummaries: Array<{ path: string, summary: string }>, maxPromptChars?: number }} ctx
110
110
  */
111
111
  export function buildPass1Prompt(ctx) {
112
112
  const topicSummaries = (!ctx.topicSummaries || ctx.topicSummaries.length === 0)
@@ -119,11 +119,11 @@ export function buildPass1Prompt(ctx) {
119
119
  conv.push(truncateMessage(m.body || ''));
120
120
  conv.push('');
121
121
  }
122
- return render('triagePass1', {
122
+ return boundDreamPrompt(render('triagePass1', {
123
123
  sessionId: ctx.sessionId,
124
124
  topicSummaries,
125
125
  conversation: conv.join('\n').trimEnd(),
126
- }, { language: ctx.language });
126
+ }, { language: ctx.language }), ctx.maxPromptChars);
127
127
  }
128
128
 
129
129
  /**
@@ -135,10 +135,10 @@ export function buildPass2Prompt(ctx) {
135
135
  const existingTopics = (!ctx.existingTopics || ctx.existingTopics.length === 0)
136
136
  ? (String(ctx.language || '').toLowerCase().startsWith('zh') ? ' (无)' : ' (none)')
137
137
  : ctx.existingTopics.map(t => ` - ${t.path} — ${oneLine(t.summary)}`).join('\n');
138
- return render('triagePass2', {
138
+ return boundDreamPrompt(render('triagePass2', {
139
139
  description: ctx.description,
140
140
  existingTopics,
141
- }, { language: ctx.language });
141
+ }, { language: ctx.language }), ctx.maxPromptChars);
142
142
  }
143
143
 
144
144
  /**
@@ -150,12 +150,13 @@ export function buildPass2Prompt(ctx) {
150
150
  * messages: Array<object>,
151
151
  * topicSummaries: Array<{ path: string, summary: string }>,
152
152
  * llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
153
+ * maxPromptChars?: number,
153
154
  * }} args
154
155
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
155
156
  */
156
- export async function classifySoft({ root, sessionId, messages, topicSummaries, llm, language }) {
157
+ export async function classifySoft({ root, sessionId, messages, topicSummaries, llm, language, maxPromptChars }) {
157
158
  if (!llm) throw new Error('triage.classifySoft: llm callable required');
158
- const pass1Prompt = buildPass1Prompt({ sessionId, messages, topicSummaries, language });
159
+ const pass1Prompt = buildPass1Prompt({ sessionId, messages, topicSummaries, language, maxPromptChars });
159
160
  const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: triageSystem(language) });
160
161
  const pass1 = parseJsonSafe(pass1Raw);
161
162
  const out = [];
@@ -174,6 +175,7 @@ export async function classifySoft({ root, sessionId, messages, topicSummaries,
174
175
  description: description.trim(),
175
176
  existingTopics: topicSummaries || [],
176
177
  language,
178
+ maxPromptChars,
177
179
  });
178
180
  const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: triageSystem(language) });
179
181
  const pass2 = parseJsonSafe(pass2Raw);
@@ -224,11 +226,12 @@ export async function triageOneSegment(args) {
224
226
  * segments: Array<{ messages: Array<object> }>,
225
227
  * topicSummaries: Array<{ path: string, summary: string }>,
226
228
  * llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
229
+ * maxPromptChars?: number,
227
230
  * onProgress?: (event: object) => void,
228
231
  * }} args
229
232
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
230
233
  */
231
- export async function triageGroupSegments({ root, sessionId, segments, topicSummaries, llm, onProgress, language }) {
234
+ export async function triageGroupSegments({ root, sessionId, segments, topicSummaries, llm, onProgress, language, maxPromptChars }) {
232
235
  let acc = [];
233
236
  let i = 0;
234
237
  for (const seg of (segments || [])) {
@@ -241,6 +244,7 @@ export async function triageGroupSegments({ root, sessionId, segments, topicSumm
241
244
  topicSummaries,
242
245
  llm,
243
246
  language,
247
+ maxPromptChars,
244
248
  });
245
249
  acc = dedupeActions([...acc, ...segActions]);
246
250
  }
package/yeaft/engine.js CHANGED
@@ -691,7 +691,7 @@ export class Engine {
691
691
  /** @type {import('./stats/tool-usage.js').ToolUsageStats|null} — per-tool call/latency counters */
692
692
  #toolStats = null;
693
693
 
694
- /** @type {object|null} — Config override for internal tasks (recall, consolidation, dream) using fastModel */
694
+ /** @type {object|null} — Config override for internal compact/recall tasks using fastModel; Dream uses the Session primary model */
695
695
  #fastConfig;
696
696
 
697
697
  /** @type {((agentId: string, evt: object) => void) | null} */
@@ -5437,7 +5437,7 @@ export class Engine {
5437
5437
  /** @returns {string|null} */
5438
5438
  get yeaftDir() { return this.#yeaftDir; }
5439
5439
 
5440
- /** @returns {object} — Config with fastModel as model (for internal tasks) */
5440
+ /** @returns {object} — Config with fastModel as model (for compact and other non-Dream internal tasks) */
5441
5441
  get fastConfig() { return this.#fastConfig; }
5442
5442
 
5443
5443
  /**