@yeaft/webchat-agent 1.0.289 → 1.0.292

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": "1.0.289",
3
+ "version": "1.0.292",
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",
@@ -0,0 +1,213 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { Engine } from './engine.js';
4
+ import { createRouter } from './routing/router.js';
5
+ import { createCoordinator } from './sessions/coordinator.js';
6
+ import { sessionsRoot } from './sessions/session-crud.js';
7
+ import { openSession, loadSessionMeta } from './sessions/session-store.js';
8
+ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
9
+ import { readVp } from './vp/vp-crud.js';
10
+ import { COLLAB_TOOL_POLICY } from './tools/registry.js';
11
+
12
+ function buildVpPersona(vpId, loaded) {
13
+ const vp = readVp(vpId, { libDir: join(loaded.yeaftDir, 'virtual-persons') });
14
+ if (!vp) return null;
15
+ return {
16
+ vpId,
17
+ displayName: vp.displayName || vpId,
18
+ displayNameZh: vp.displayNameZh || '',
19
+ role: vp.role || '',
20
+ roleZh: vp.roleZh || '',
21
+ persona: vp.persona || '',
22
+ planInstruction: vp.planInstruction || '',
23
+ };
24
+ }
25
+
26
+ function createEngine(loaded, sessionId, vpId) {
27
+ const effectiveConfig = resolveSessionConfig(
28
+ loaded.config,
29
+ loadSessionConfig(loaded.yeaftDir, sessionId),
30
+ );
31
+ return new Engine({
32
+ adapter: loaded.adapter,
33
+ trace: loaded.trace,
34
+ config: effectiveConfig,
35
+ conversationStore: loaded.conversationStore,
36
+ memoryIndex: loaded.memoryIndex || null,
37
+ amsRegistry: loaded.amsRegistry || null,
38
+ toolRegistry: loaded.toolRegistry,
39
+ skillManager: loaded.skillManager,
40
+ mcpManager: loaded.mcpManager,
41
+ yeaftDir: loaded.yeaftDir,
42
+ toolStats: loaded.toolStats || null,
43
+ taskManager: loaded.taskManager || null,
44
+ sessionId,
45
+ vpId,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Open a transport-neutral multi-VP runtime for an existing Yeaft Session.
51
+ * Different VPs run concurrently; each VP owns one serial promise tail and
52
+ * one Engine, matching the WebSocket runtime's state-isolation rule.
53
+ *
54
+ * Returns null when session metadata does not exist so legacy one-engine CLI
55
+ * invocations can keep their existing behavior.
56
+ */
57
+ export function createCliSessionRunner({
58
+ loaded,
59
+ sessionId,
60
+ workDir = process.cwd(),
61
+ engineFactory = createEngine,
62
+ personaFactory = buildVpPersona,
63
+ } = {}) {
64
+ if (!loaded || !sessionId) return null;
65
+ const sessionDir = join(sessionsRoot(loaded.yeaftDir), sessionId);
66
+ if (!loadSessionMeta(sessionDir)) return null;
67
+
68
+ const handle = openSession(sessionsRoot(loaded.yeaftDir), sessionId);
69
+ const engines = new Map();
70
+ const tails = new Map();
71
+ const pending = new Set();
72
+ let closed = false;
73
+
74
+ const engineFor = (vpId) => {
75
+ let engine = engines.get(vpId);
76
+ if (!engine) {
77
+ engine = engineFactory(loaded, sessionId, vpId);
78
+ engines.set(vpId, engine);
79
+ }
80
+ return engine;
81
+ };
82
+
83
+ let coordinator;
84
+
85
+ const runEnvelope = async (vpId, envelope, options) => {
86
+ const meta = handle.getMeta();
87
+ const engine = engineFor(vpId);
88
+ const promptText = envelope?.msg?.text || '';
89
+ const prompt = `@vp-${vpId} ${promptText}`;
90
+ const messages = loaded.conversationStore.loadSessionHistoryForVp(sessionId, vpId);
91
+ const todos = [];
92
+ let resultText = '';
93
+ let failed = null;
94
+ const scopedCoordinator = {
95
+ group: coordinator.group,
96
+ ingest(input, opts) {
97
+ return coordinator.ingest({ ...input, _cliTurnContext: envelope._cliTurnContext }, opts);
98
+ },
99
+ };
100
+ const queryOptions = {
101
+ prompt,
102
+ messages,
103
+ sessionId,
104
+ workDir: meta.workDir || workDir,
105
+ senderVpId: vpId,
106
+ sessionMembers: meta.roster.slice(),
107
+ sessionAnnouncement: meta.announcement || '',
108
+ vpPersona: personaFactory(vpId, loaded),
109
+ router: createRouter({ coordinator: scopedCoordinator }),
110
+ inboundEnvelope: envelope,
111
+ userAlreadyPersisted: true,
112
+ threadId: 'main',
113
+ vpTurnId: envelope?.msg?.id || randomUUID(),
114
+ collabToolPolicy: meta.roster.length > 1
115
+ ? COLLAB_TOOL_POLICY.MULTI_VP
116
+ : COLLAB_TOOL_POLICY.SINGLE_VP,
117
+ getCurrentTodos: () => todos.slice(),
118
+ setCurrentTodos: (next) => {
119
+ todos.splice(0, todos.length, ...(Array.isArray(next) ? next : []));
120
+ },
121
+ askUser: options.askUser
122
+ ? request => options.askUser(request, vpId, queryOptions.vpTurnId)
123
+ : null,
124
+ userEffort: options.modelEffort || null,
125
+ };
126
+
127
+ try {
128
+ for await (const event of engine.query(queryOptions)) {
129
+ if (event.type === 'text_delta') resultText += event.text || '';
130
+ await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
131
+ }
132
+ } catch (error) {
133
+ failed = error;
134
+ await options.onEvent?.({
135
+ vpId,
136
+ sessionId,
137
+ turnId: queryOptions.vpTurnId,
138
+ event: { type: 'error', error, retryable: false },
139
+ });
140
+ }
141
+ return { vpId, result: resultText, error: failed };
142
+ };
143
+
144
+ const enqueue = (vpId, envelope) => {
145
+ if (closed) throw new Error('CLI Session runner is closed');
146
+ const turnContext = envelope?._cliTurnContext;
147
+ if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
148
+ const previous = tails.get(vpId) || Promise.resolve();
149
+ const task = previous.catch(() => {}).then(() => runEnvelope(vpId, envelope, turnContext.options));
150
+ tails.set(vpId, task);
151
+ pending.add(task);
152
+ turnContext.pending.add(task);
153
+ task.finally(() => {
154
+ pending.delete(task);
155
+ turnContext.pending.delete(task);
156
+ if (tails.get(vpId) === task) tails.delete(vpId);
157
+ }).catch(() => {});
158
+ return task;
159
+ };
160
+
161
+ coordinator = createCoordinator(handle, { deliver: enqueue });
162
+
163
+ async function drain(tasks = pending) {
164
+ const results = [];
165
+ while (tasks.size > 0) {
166
+ const batch = Array.from(tasks);
167
+ results.push(...await Promise.all(batch));
168
+ await Promise.resolve();
169
+ }
170
+ return results;
171
+ }
172
+
173
+ return {
174
+ sessionId,
175
+ get meta() { return handle.getMeta(); },
176
+ async run(prompt, options = {}) {
177
+ if (closed) throw new Error('CLI Session runner is closed');
178
+ const turnContext = Object.freeze({ options: Object.freeze({ ...options }), pending: new Set() });
179
+ const messageId = randomUUID();
180
+ // The shared user row is the durability boundary. Every VP Engine skips
181
+ // its own user append, preventing @all from duplicating the prompt N times.
182
+ loaded.conversationStore.append({
183
+ role: 'user',
184
+ content: prompt,
185
+ sessionId,
186
+ threadId: 'main',
187
+ clientMessageId: messageId,
188
+ userAuthored: true,
189
+ });
190
+ const report = coordinator.ingest({
191
+ id: messageId,
192
+ from: 'user',
193
+ role: 'user',
194
+ text: prompt,
195
+ _cliTurnContext: turnContext,
196
+ });
197
+ const results = await drain(turnContext.pending);
198
+ return { report, results };
199
+ },
200
+ abort(reason = 'user') {
201
+ let count = 0;
202
+ for (const engine of engines.values()) {
203
+ if (engine.abort?.(reason)) count += 1;
204
+ }
205
+ return count;
206
+ },
207
+ async close() {
208
+ closed = true;
209
+ await drain();
210
+ handle.close();
211
+ },
212
+ };
213
+ }
package/yeaft/cli.js CHANGED
@@ -38,7 +38,8 @@ import { ConversationStore } from './conversation/persist.js';
38
38
  import { snapshotSessions } from './sessions/session-crud.js';
39
39
  import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
40
40
  import { validateSessionId } from './sessions/ids.js';
41
- import { createJsonlWriter, JsonlInput, runStreamTurn } from './stdio-protocol.js';
41
+ import { createJsonlWriter, JsonlInput, runStreamTurn, runStreamSessionTurn } from './stdio-protocol.js';
42
+ import { createCliSessionRunner } from './cli-session-runner.js';
42
43
 
43
44
  // ─── Argument parsing ──────────────────────────────────────────
44
45
 
@@ -333,16 +334,28 @@ function handleDeleteGroup(config, sessionId) {
333
334
  // ─── REPL ──────────────────────────────────────────────────────
334
335
 
335
336
  async function runREPL(config, args) {
336
- // Use loadSession() to wire all subsystems
337
+ const workDir = resolve(args.workDir || process.cwd());
338
+ const persisted = args.sessionId ? loadSessionConfig(config.dir, args.sessionId) : {};
339
+ const effectiveConfig = resolveSessionConfig(config, persisted);
337
340
  const session = await loadSession({
338
- model: args.model || config.model,
339
- language: args.language || config.language,
340
- debug: args.debug || config.debug,
341
+ dir: config.dir,
342
+ workDir,
343
+ model: args.model || effectiveConfig.model,
344
+ language: args.language || effectiveConfig.language,
345
+ debug: args.debug || effectiveConfig.debug,
341
346
  skipMCP: args.skipMCP,
342
347
  skipSkills: args.skipSkills,
348
+ configOverrides: effectiveConfig,
343
349
  });
344
350
 
345
351
  const { engine, conversationStore, trace, skillManager, mcpManager, toolRegistry } = session;
352
+ const sessionRunner = args.sessionId
353
+ ? createCliSessionRunner({ loaded: session, sessionId: args.sessionId, workDir })
354
+ : null;
355
+ if (args.sessionId && !sessionRunner) {
356
+ await session.shutdown();
357
+ throw new Error(`Session not found: ${args.sessionId}`);
358
+ }
346
359
 
347
360
  // Load persisted conversation as initial messages. `loadRecent` is now
348
361
  // turn-based (one user round-trip = one turn; multi-VP fan-out collapses
@@ -655,9 +668,28 @@ async function runREPL(config, args) {
655
668
  return;
656
669
  }
657
670
 
658
- // Regular input → engine.query
671
+ // Regular input → Session fan-out or legacy single Engine.
659
672
  try {
660
673
  let responseText = '';
674
+ if (sessionRunner) {
675
+ const outcome = await sessionRunner.run(input, {
676
+ modelEffort: args.modelEffort || session.config.modelEffort || null,
677
+ onEvent: ({ vpId, event }) => {
678
+ if (event.type === 'text_delta') {
679
+ responseText += event.text || '';
680
+ process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
681
+ } else if (event.type === 'tool_start') {
682
+ process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
683
+ } else if (event.type === 'error') {
684
+ process.stderr.write(`\n[${vpId}] Error: ${event.error?.message || event.error}\n`);
685
+ }
686
+ },
687
+ });
688
+ console.log();
689
+ if (outcome.results.some(result => result.error)) process.exitCode = 1;
690
+ rl.prompt();
691
+ return;
692
+ }
661
693
 
662
694
  for await (const event of engine.query({
663
695
  prompt: input,
@@ -721,6 +753,7 @@ async function runREPL(config, args) {
721
753
  });
722
754
 
723
755
  rl.on('close', async () => {
756
+ await sessionRunner?.close();
724
757
  await session.shutdown();
725
758
  console.log('\nBye!');
726
759
  process.exit(0);
@@ -766,6 +799,7 @@ async function runStreamJson(config, args) {
766
799
  },
767
800
  });
768
801
  const { engine, conversationStore, skillManager, toolRegistry } = loaded;
802
+ const sessionRunner = createCliSessionRunner({ loaded, sessionId, workDir });
769
803
  const todoState = { value: [] };
770
804
 
771
805
  write({
@@ -788,8 +822,7 @@ async function runStreamJson(config, args) {
788
822
  ...(message.toolCallId && { toolCallId: message.toolCallId }),
789
823
  ...(message.toolCalls && { toolCalls: message.toolCalls }),
790
824
  }));
791
- return runStreamTurn({
792
- engine,
825
+ const turnOptions = {
793
826
  prompt,
794
827
  messages: priorMessages,
795
828
  sessionId,
@@ -800,7 +833,10 @@ async function runStreamJson(config, args) {
800
833
  write,
801
834
  getCurrentTodos: () => todoState.value.slice(),
802
835
  setCurrentTodos: todos => { todoState.value = Array.isArray(todos) ? todos.slice() : []; },
803
- });
836
+ };
837
+ return sessionRunner
838
+ ? runStreamSessionTurn({ runner: sessionRunner, ...turnOptions })
839
+ : runStreamTurn({ engine, ...turnOptions });
804
840
  };
805
841
 
806
842
  let lastResult = null;
@@ -820,6 +856,7 @@ async function runStreamJson(config, args) {
820
856
  }
821
857
  } finally {
822
858
  input?.close();
859
+ await sessionRunner?.close();
823
860
  await loaded.shutdown();
824
861
  console.log = originalConsole.log;
825
862
  console.info = originalConsole.info;
@@ -836,18 +873,42 @@ async function runOnce(config, args) {
836
873
  return;
837
874
  }
838
875
 
839
- // Use loadSession() to wire all subsystems
876
+ const workDir = resolve(args.workDir || process.cwd());
877
+ const persisted = args.sessionId ? loadSessionConfig(config.dir, args.sessionId) : {};
878
+ const effectiveConfig = resolveSessionConfig(config, persisted);
840
879
  const session = await loadSession({
841
- model: args.model || config.model,
842
- language: args.language || config.language,
843
- debug: args.debug || config.debug,
880
+ dir: config.dir,
881
+ workDir,
882
+ model: args.model || effectiveConfig.model,
883
+ language: args.language || effectiveConfig.language,
884
+ debug: args.debug || effectiveConfig.debug,
844
885
  skipMCP: args.skipMCP,
845
886
  skipSkills: args.skipSkills,
887
+ configOverrides: effectiveConfig,
846
888
  });
847
889
 
848
890
  const { engine, conversationStore } = session;
891
+ const sessionRunner = args.sessionId
892
+ ? createCliSessionRunner({ loaded: session, sessionId: args.sessionId, workDir })
893
+ : null;
849
894
 
850
895
  try {
896
+ if (args.sessionId && !sessionRunner) {
897
+ throw new Error(`Session not found: ${args.sessionId}`);
898
+ }
899
+ if (sessionRunner) {
900
+ const outcome = await sessionRunner.run(args.prompt, {
901
+ modelEffort: args.modelEffort || session.config.modelEffort || null,
902
+ onEvent: ({ vpId, event }) => {
903
+ if (event.type === 'text_delta') process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
904
+ else if (event.type === 'tool_start') process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
905
+ else if (event.type === 'error') process.stderr.write(`\n[${vpId}] Error: ${event.error?.message || event.error}\n`);
906
+ },
907
+ });
908
+ console.log();
909
+ if (outcome.results.some(result => result.error)) process.exitCode = 1;
910
+ return;
911
+ }
851
912
  // Load recent conversation as context
852
913
  const priorMessages = conversationStore.loadRecent(20).map(m => ({
853
914
  role: m.role,
@@ -903,6 +964,7 @@ async function runOnce(config, args) {
903
964
  // Final newline after streaming text
904
965
  console.log();
905
966
  } finally {
967
+ await sessionRunner?.close();
906
968
  await session.shutdown();
907
969
  }
908
970
  }
package/yeaft/engine.js CHANGED
@@ -84,6 +84,8 @@ const AMS_ADJUST_TIMEOUT_MS = 30_000;
84
84
  /** Maximum silence while a visible turn waits for a result-producing task. */
85
85
  const DEFAULT_ASYNC_TASK_WAIT_TIMEOUT_MS = 120_000;
86
86
 
87
+ const DEFAULT_MEMORY_RECALL_LIMIT = 8;
88
+
87
89
  // ─── LLM retry policy defaults ──────────────────────────────────
88
90
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
89
91
  // effective policy from `config.llmRetry` so users can dial these via
@@ -442,6 +444,47 @@ function isZhRuntimeLanguage(language) {
442
444
  return String(language || '').toLowerCase().startsWith('zh');
443
445
  }
444
446
 
447
+ function resolveMemoryRecallLimit(config) {
448
+ const raw = config?.memoryRecallLimit ?? config?.dreamMemoryRecallLimit;
449
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_MEMORY_RECALL_LIMIT;
450
+ return Math.max(1, Math.floor(raw));
451
+ }
452
+
453
+ function loadedMemoryDebugEntries(snapshot) {
454
+ const snap = snapshot || {};
455
+ return [
456
+ ...loadedResidentDebugEntries(snap.resident || []),
457
+ ...loadedSegmentDebugEntries(snap.recent || [], 'recent'),
458
+ ...loadedSegmentDebugEntries(snap.onDemand || [], 'onDemand'),
459
+ ];
460
+ }
461
+
462
+ function loadedResidentDebugEntries(entries) {
463
+ return (entries || []).map((entry, index) => ({
464
+ id: `resident:${entry.scope || index}`,
465
+ layer: 'resident',
466
+ scope: entry.scope || null,
467
+ label: memoryScopeLabel(entry.scope || ''),
468
+ kind: 'summary',
469
+ score: null,
470
+ tags: [],
471
+ body: entry.summary || '',
472
+ })).filter(entry => entry.body);
473
+ }
474
+
475
+ function loadedSegmentDebugEntries(segments, layer) {
476
+ return (segments || []).map((seg, index) => ({
477
+ id: seg.id || `${layer}:${index}`,
478
+ layer,
479
+ scope: seg.scope || null,
480
+ label: memoryScopeLabel(seg.scope || ''),
481
+ kind: seg.kind || null,
482
+ score: typeof seg.score === 'number' ? seg.score : null,
483
+ tags: Array.isArray(seg.tags) ? seg.tags : [],
484
+ body: seg.body || '',
485
+ })).filter(entry => entry.body);
486
+ }
487
+
445
488
  export class Engine {
446
489
  /** @type {import('./llm/adapter.js').LLMAdapter} */
447
490
  #adapter;
@@ -906,6 +949,7 @@ export class Engine {
906
949
  * ownVpId: string|null,
907
950
  * scopes: string[],
908
951
  * snapshotBlock: string,
952
+ * snapshot: import('./memory/ams.js').AmsSnapshot,
909
953
  * residentEntries: Array<{scope:string, summary:string}>,
910
954
  * } | null}
911
955
  */
@@ -938,14 +982,15 @@ export class Engine {
938
982
  ams.setOnDemand(segs);
939
983
 
940
984
  // (c) Snapshot — render the AMS layers as a single prompt block.
941
- const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en', args.userMsg || '');
985
+ const snapshot = ams.snapshot({ userMsg: args.userMsg || '' });
986
+ const snapshotBlock = this.#renderAmsSnapshot(snapshot, this.#config.language || 'en');
942
987
 
943
988
  const scopes = buildRelevantScopes({
944
989
  sessionId: args.sessionId,
945
990
  vpId: ownVpId,
946
991
  });
947
992
 
948
- return { ams, sessionKey, ownVpId, scopes, snapshotBlock, residentEntries };
993
+ return { ams, sessionKey, ownVpId, scopes, snapshotBlock, snapshot, residentEntries };
949
994
  }
950
995
 
951
996
  /**
@@ -953,13 +998,11 @@ export class Engine {
953
998
  * injection. Mirrors the heading style of the existing memory blocks
954
999
  * so the LLM sees a consistent layout.
955
1000
  *
956
- * @param {import('./memory/ams.js').ActiveMemorySet} ams
1001
+ * @param {import('./memory/ams.js').AmsSnapshot} snap
957
1002
  * @param {string} [language]
958
- * @param {string} [userMsg]
959
1003
  * @returns {string}
960
1004
  */
961
- #renderAmsSnapshot(ams, language = 'en', userMsg = '') {
962
- const snap = ams.snapshot({ userMsg });
1005
+ #renderAmsSnapshot(snap, language = 'en') {
963
1006
  if (!snap) return '';
964
1007
  const parts = [];
965
1008
  if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
@@ -1342,7 +1385,7 @@ export class Engine {
1342
1385
  * @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
1343
1386
  */
1344
1387
  async #recallMemory(prompt, ctx = {}) {
1345
- const memory = { profile: '', entries: [], formatted: '' };
1388
+ const memory = { profile: '', entries: [], formatted: '', meta: {} };
1346
1389
  if (!this.#memoryIndex) return memory;
1347
1390
  try {
1348
1391
  const result = runMemoryPreflow(this.#memoryIndex, {
@@ -1351,11 +1394,13 @@ export class Engine {
1351
1394
  chatId: ctx.chatId || this.#chatId,
1352
1395
  vpId: ctx.vpId,
1353
1396
  extraScopes: ctx.extraScopes,
1397
+ pickLimit: resolveMemoryRecallLimit(this.#config),
1354
1398
  fallbackOnEmpty: true,
1355
1399
  });
1356
1400
  memory.profile = result.profile || '';
1357
1401
  memory.entries = result.entries || [];
1358
1402
  memory.formatted = result.formatted || '';
1403
+ memory.meta = result.meta || {};
1359
1404
  } catch {
1360
1405
  // Fail soft — empty injection.
1361
1406
  }
@@ -2029,6 +2074,7 @@ export class Engine {
2029
2074
  if (amsContext && amsContext.snapshotBlock) {
2030
2075
  memoryInjection = amsContext.snapshotBlock;
2031
2076
  }
2077
+ const loadedMemoryForDebug = loadedMemoryDebugEntries(amsContext?.snapshot);
2032
2078
 
2033
2079
  // Diagnostic payload for the Dream debug panel. The full AMS Resident
2034
2080
  // layer can include user and per-VP summaries, but the browser-facing
@@ -2282,19 +2328,20 @@ export class Engine {
2282
2328
  yield { type: 'skill_error', turnId: queryTurnId, skillName: explicitSkillName, message: skillResolutionError };
2283
2329
  }
2284
2330
 
2285
- // Surface memory recall to the debug panel right after turn_open.
2286
- // recallResult was loaded above; emit a structured `memory_used`
2287
- // event so the UI can show "loaded N segments" without parsing
2288
- // the legacy `recall` event (which only carried entryCount).
2289
- if (recallResult && Array.isArray(recallResult.entries) && recallResult.entries.length > 0) {
2331
+ // Surface the exact memory that entered the prompt. This must be based on
2332
+ // the AMS snapshot, not raw FTS candidates, otherwise debug can claim memory
2333
+ // was loaded even when prompt cleanup, dedupe, or token budget dropped it.
2334
+ if (loadedMemoryForDebug.length > 0) {
2290
2335
  yield {
2291
2336
  type: 'memory_used',
2292
2337
  turnId: queryTurnId,
2293
- loaded: recallResult.entries.map(e => ({
2294
- id: e && e.id || null,
2295
- score: e && typeof e.score === 'number' ? e.score : null,
2296
- kind: e && e.kind || null,
2297
- })),
2338
+ loaded: loadedMemoryForDebug,
2339
+ meta: {
2340
+ recallLimit: resolveMemoryRecallLimit(this.#config),
2341
+ recallCandidates: Number.isFinite(recallResult?.meta?.hitCount)
2342
+ ? recallResult.meta.hitCount
2343
+ : (recallResult && Array.isArray(recallResult.entries) ? recallResult.entries.length : 0),
2344
+ },
2298
2345
  };
2299
2346
  }
2300
2347
 
@@ -19,6 +19,8 @@ import { extractKeywords } from './keywords.js';
19
19
  import { approxTokens } from './budget.js';
20
20
  import { isVpForeign } from './store.js';
21
21
 
22
+ export const DEFAULT_PICK_LIMIT = 8;
23
+
22
24
  /**
23
25
  * @typedef {object} PreflowOptions
24
26
  * @property {string} userMsg
@@ -27,6 +29,7 @@ import { isVpForeign } from './store.js';
27
29
  * @property {string[]} [currentTags] tags from the current group/feature context
28
30
  * @property {number} [topK] max FTS rows to fetch (default 50)
29
31
  * @property {number} [budgetTokens] onDemand budget (caller-supplied)
32
+ * @property {number} [pickLimit] max picked segments (default 8)
30
33
  */
31
34
 
32
35
  /**
@@ -54,6 +57,8 @@ export function runPreflow(index, opts) {
54
57
  const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK : 50;
55
58
  const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
56
59
  ? opts.budgetTokens : Infinity;
60
+ const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0
61
+ ? Math.floor(opts.pickLimit) : DEFAULT_PICK_LIMIT;
57
62
 
58
63
  const keywords = extractKeywords(userMsg);
59
64
  if (keywords.length === 0) {
@@ -80,7 +85,7 @@ export function runPreflow(index, opts) {
80
85
  let dropped = 0;
81
86
  for (const h of reranked) {
82
87
  const tk = approxTokens(h.body);
83
- if (cost + tk <= budgetTokens) {
88
+ if (picked.length < pickLimit && cost + tk <= budgetTokens) {
84
89
  picked.push(toSegment(h));
85
90
  cost += tk;
86
91
  } else {
@@ -152,7 +157,7 @@ export function rerank(hits, ctx) {
152
157
  return { ...h, _score: score };
153
158
  })
154
159
  .sort((a, b) => a._score - b._score)
155
- .map(({ _score, ...rest }) => rest);
160
+ .map(({ _score, ...rest }) => ({ ...rest, score: _score }));
156
161
  }
157
162
 
158
163
  function toSegment(h) {
@@ -163,6 +168,7 @@ function toSegment(h) {
163
168
  tags: h.tags,
164
169
  sourceMessages: h.sourceMessages,
165
170
  body: h.body,
171
+ score: typeof h.score === 'number' ? h.score : (typeof h.rank === 'number' ? h.rank : undefined),
166
172
  createdAt: h.createdAt,
167
173
  updatedAt: h.updatedAt,
168
174
  };
@@ -259,6 +259,7 @@ export function formatPickedForInjection(picked) {
259
259
  * @property {string[]} [currentTags] Contextual tags for rerank
260
260
  * @property {number} [topK] Max FTS rows fetched (default 50)
261
261
  * @property {number} [budgetTokens] Token budget for picked segments
262
+ * @property {number} [pickLimit] Max picked segments (default 8)
262
263
  * @property {boolean} [fallbackOnEmpty] Include bounded recent scoped segments when FTS has no hits
263
264
  * @property {number} [fallbackPerScope] Max fallback segments per scope
264
265
  */
@@ -353,6 +354,7 @@ export function runMemoryPreflow(index, opts) {
353
354
  currentTags: opts.currentTags || [],
354
355
  topK: opts.topK,
355
356
  budgetTokens: opts.budgetTokens,
357
+ pickLimit: opts.pickLimit,
356
358
  });
357
359
 
358
360
  let fallbackUsed = false;
@@ -362,6 +364,7 @@ export function runMemoryPreflow(index, opts) {
362
364
  ownVpId: opts.vpId || null,
363
365
  budgetTokens: opts.budgetTokens,
364
366
  perScope: opts.fallbackPerScope,
367
+ pickLimit: opts.pickLimit,
365
368
  });
366
369
  if (fallback.length > 0) {
367
370
  fallbackUsed = true;
@@ -400,6 +403,7 @@ function fallbackScopedSegments(index, opts) {
400
403
  const scopes = prioritizeFallbackScopes(filterScopes(opts.relevantScopes || [], opts.ownVpId || null));
401
404
  const perScope = Number.isFinite(opts.perScope) && opts.perScope > 0 ? Math.floor(opts.perScope) : 2;
402
405
  const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0 ? opts.budgetTokens : 1200;
406
+ const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0 ? Math.floor(opts.pickLimit) : 8;
403
407
  const buckets = [];
404
408
  for (const scope of scopes) {
405
409
  let segs = [];
@@ -419,6 +423,7 @@ function fallbackScopedSegments(index, opts) {
419
423
  if (!seg) continue;
420
424
  const tk = approxTokens(seg.body || '');
421
425
  if (tk <= 0 || cost + tk > budgetTokens) continue;
426
+ if (out.length >= pickLimit) return out;
422
427
  out.push(seg);
423
428
  cost += tk;
424
429
  }