@yeaft/webchat-agent 1.0.348 → 1.0.350

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.
Files changed (41) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +73 -73
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +1 -1
  5. package/package.json +1 -1
  6. package/yeaft/conversation/persist.js +4 -0
  7. package/yeaft/dream/apply.js +59 -30
  8. package/yeaft/dream/output-snapshot.js +8 -4
  9. package/yeaft/dream/prompts/consolidate-topics.md +31 -0
  10. package/yeaft/dream/prompts/create.md +8 -8
  11. package/yeaft/dream/prompts/index.js +4 -2
  12. package/yeaft/dream/prompts/merge-topics.md +35 -0
  13. package/yeaft/dream/prompts/triage-pass1.md +2 -2
  14. package/yeaft/dream/prompts/triage-pass2.md +4 -2
  15. package/yeaft/dream/prompts/update.md +16 -14
  16. package/yeaft/dream/runner.js +69 -13
  17. package/yeaft/dream/segment-extract.js +16 -13
  18. package/yeaft/dream/session-wiring.js +2 -2
  19. package/yeaft/dream/snapshot.js +3 -3
  20. package/yeaft/dream/topic-consolidation.js +316 -0
  21. package/yeaft/dream/triage.js +7 -2
  22. package/yeaft/engine.js +237 -239
  23. package/yeaft/memory/ams-registry.js +42 -61
  24. package/yeaft/memory/ams.js +17 -9
  25. package/yeaft/memory/budget.js +15 -18
  26. package/yeaft/memory/content-backfill.js +118 -0
  27. package/yeaft/memory/index-db.js +10 -3
  28. package/yeaft/memory/keywords.js +25 -7
  29. package/yeaft/memory/preflow.js +26 -9
  30. package/yeaft/memory/segment-store.js +44 -8
  31. package/yeaft/memory/segment-sync.js +8 -4
  32. package/yeaft/memory/segment.js +10 -3
  33. package/yeaft/memory/store.js +88 -38
  34. package/yeaft/memory/summary-store.js +3 -3
  35. package/yeaft/memory/topic-redirect.js +28 -0
  36. package/yeaft/router/continuity.js +12 -3
  37. package/yeaft/session.js +18 -19
  38. package/yeaft/sessions/pre-flow.js +7 -3
  39. package/yeaft/sub-agent/runner.js +10 -1
  40. package/yeaft/work-center/bridge.js +1 -0
  41. package/yeaft/work-center/runner.js +33 -4
package/yeaft/engine.js CHANGED
@@ -30,10 +30,9 @@ import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js'
30
30
  import { evaluateCompactTriggers } from './compact/triggers.js';
31
31
  import { archiveTurn } from './archive/turn-archive.js';
32
32
  import { archiveToolResults } from './archive/tool-results.js';
33
- import { readSummary as readScopeSummary } from './memory/store.js';
33
+ import { isVpForeign, readContent as readScopeContent } from './memory/store.js';
34
34
  import { ActiveMemorySet } from './memory/ams.js';
35
- import { runAdjust } from './memory/adjust.js';
36
- import { cleanMemoryPromptText, isMemoryPromptRelevant } from './memory/prompt-cleanup.js';
35
+ import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
37
36
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
38
37
  import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
39
38
  // Default thread marker for legacy / non-group flows. Group VP runtime may
@@ -80,12 +79,15 @@ import {
80
79
  const MAX_CONTINUE_TURNS = 3;
81
80
 
82
81
  /** Bound the best-effort post-turn AMS LLM call independently of the user turn. */
83
- const AMS_ADJUST_TIMEOUT_MS = 30_000;
82
+ const MAINTENANCE_CALL_TIMEOUT_MS = 30_000;
84
83
 
85
84
  /** Maximum silence while a visible turn waits for a result-producing task. */
86
85
  const DEFAULT_ASYNC_TASK_WAIT_TIMEOUT_MS = 120_000;
87
86
 
88
87
  const DEFAULT_MEMORY_RECALL_LIMIT = 8;
88
+ const MAX_PROMPT_MEMORY_ITEMS = 8;
89
+ const MAX_RELATED_SESSION_MEMORY_ITEMS = 2;
90
+ const MAX_MEMORY_ITEM_TOKENS = 1600;
89
91
 
90
92
  // ─── LLM retry policy defaults ──────────────────────────────────
91
93
  // Hard-coded floor / ceiling for retry behaviour. The engine reads the
@@ -380,7 +382,7 @@ export function shouldAllowGroupReflection({
380
382
 
381
383
  /**
382
384
  * buildResidentEntries — pure helper that builds the AMS Resident entry
383
- * list from the per-turn Layer-A summaries.
385
+ * list from per-turn query-selected canonical content.
384
386
  *
385
387
  * Encodes one non-trivial rule on top of "push if non-empty":
386
388
  *
@@ -393,29 +395,50 @@ export function shouldAllowGroupReflection({
393
395
  * PR #722. Once Dream-v2 writes a real summary for this scope it
394
396
  * lacks the marker and is surfaced normally.
395
397
  *
396
- * Other-VP entries (group collaborators) are NOT considered here — only
397
- * the local VP's summary is loaded into `summaries.vp` upstream by
398
- * `#loadLayerASummaries`. Cross-VP context flows through onDemand recall.
398
+ * Other-VP entries (Session collaborators) are NOT considered here — only
399
+ * the local VP's summary is loaded into `summaries.vp` upstream. FTS evidence
400
+ * selects canonical topic content but is never itself rendered into the prompt.
399
401
  *
400
402
  * @param {{
401
403
  * sessionId?: string|null,
402
404
  * ownVpId?: string|null,
403
- * summaries: { user?: string, session?: string, vp?: string, topics?: Array<{scope:string, summary:string}>, relatedSessions?: Array<{sessionId:string, summary:string}> }
405
+ * summaries: { user?: string, session?: string, sessionScope?: string, vp?: string, vpScope?: string, topics?: Array<{scope:string, summary:string}>, relatedSessions?: Array<{sessionId:string, summary:string}> }
404
406
  * }} args
405
407
  * @returns {Array<{scope: string, summary: string}>}
406
408
  */
407
- export function selectResidentTopicScopes(topicScopes, recallEntries, userMsg = '') {
408
- const recalledTopicScopes = new Set((recallEntries || [])
409
- .map(entry => entry?.scope)
410
- .filter(scope => typeof scope === 'string' && /^sessions\/[^/]+\/topic\//.test(scope)));
411
- return (Array.isArray(topicScopes) ? topicScopes : [])
412
- .filter(scope => recalledTopicScopes.has(scope) || isTopicScopeRelevant(scope, userMsg));
409
+ export function selectResidentTopicScopes(topicScopes, recallEntries) {
410
+ const available = new Set(Array.isArray(topicScopes) ? topicScopes : []);
411
+ const selected = [];
412
+ for (const entry of recallEntries || []) {
413
+ const scope = typeof entry?.scope === 'string' ? entry.scope : '';
414
+ if (!/^(?:sessions|session|group)\/[^/]+\/topic\//.test(scope)) continue;
415
+ if (!available.has(scope) || selected.includes(scope)) continue;
416
+ selected.push(scope);
417
+ }
418
+ return selected;
419
+ }
420
+
421
+ export function selectCanonicalMemoryScopes(recallEntries) {
422
+ const selected = new Set();
423
+ for (const entry of recallEntries || []) {
424
+ const scope = typeof entry?.scope === 'string' ? entry.scope.trim() : '';
425
+ if (scope) selected.add(scope);
426
+ }
427
+ return selected;
413
428
  }
414
429
 
415
- function isTopicScopeRelevant(scope, userMsg) {
416
- if (!userMsg || typeof scope !== 'string') return false;
417
- const label = scope.replace(/^sessions\/[^/]+\/topic\//, '').replace(/[/-]+/g, ' ');
418
- return isMemoryPromptRelevant(label, userMsg);
430
+ export function selectRelatedSessionIds(projectSessionIds, recallEntries) {
431
+ const candidates = new Set((Array.isArray(projectSessionIds) ? projectSessionIds : [])
432
+ .filter(id => typeof id === 'string' && id.trim())
433
+ .map(id => id.trim()));
434
+ const selected = [];
435
+ for (const entry of recallEntries || []) {
436
+ const id = sessionIdFromMemoryScope(entry?.scope);
437
+ if (!id || !candidates.has(id) || selected.includes(id)) continue;
438
+ selected.push(id);
439
+ if (selected.length >= MAX_RELATED_SESSION_MEMORY_ITEMS) break;
440
+ }
441
+ return selected;
419
442
  }
420
443
 
421
444
  export function buildResidentEntries(args) {
@@ -426,7 +449,10 @@ export function buildResidentEntries(args) {
426
449
  const vpSummary = cleanMemoryPromptText(summaries.vp);
427
450
  if (userSummary) out.push({ scope: 'user', summary: userSummary });
428
451
  if (args.sessionId && sessionSummary) {
429
- out.push({ scope: `sessions/${args.sessionId}`, summary: sessionSummary });
452
+ out.push({
453
+ scope: summaries.sessionScope || `sessions/${args.sessionId}`,
454
+ summary: sessionSummary,
455
+ });
430
456
  }
431
457
  if (args.sessionId && Array.isArray(summaries.topics)) {
432
458
  for (const topic of summaries.topics) {
@@ -436,7 +462,7 @@ export function buildResidentEntries(args) {
436
462
  }
437
463
  // VP per-session isolation (2026-06-09): the VP summary scope MUST be
438
464
  // session-qualified. The legacy bare `vp/<id>` scope was a structural
439
- // (see #loadLayerASummaries, kind:'group-vp'), so labelling it `vp/<id>`
465
+ // mismatch, so labelling it `vp/<id>`
440
466
  // in the Resident layer (a) collides with the ACL regex in store
441
467
  // (which only recognises `<root>/<sid>/vp/...`) and (b) makes the same
442
468
  // VP persona leak across DIFFERENT sessions whenever the AMS rehydrates
@@ -444,7 +470,10 @@ export function buildResidentEntries(args) {
444
470
  // makes the per-session boundary explicit and matches the on-disk
445
471
  // layout 1:1.
446
472
  if (args.sessionId && args.ownVpId && vpSummary && !isVpSeedBackfillStub(vpSummary)) {
447
- out.push({ scope: `sessions/${args.sessionId}/vp/${args.ownVpId}`, summary: vpSummary });
473
+ out.push({
474
+ scope: summaries.vpScope || `sessions/${args.sessionId}/vp/${args.ownVpId}`,
475
+ summary: vpSummary,
476
+ });
448
477
  }
449
478
  // Related Session experience is useful but lower-priority than every memory
450
479
  // source owned by the active Session/VP. Append it last so the resident
@@ -466,7 +495,7 @@ function isZhRuntimeLanguage(language) {
466
495
  }
467
496
 
468
497
  function sessionIdFromMemoryScope(scope) {
469
- const match = /^(?:sessions|session|group)\/([^/]+)$/.exec(String(scope || ''));
498
+ const match = /^(?:sessions|session|group)\/([^/]+)(?:\/|$)/.exec(String(scope || ''));
470
499
  return match ? match[1] : null;
471
500
  }
472
501
 
@@ -534,7 +563,7 @@ export class Engine {
534
563
  /** @type {import('./memory/index-db.js').SegmentIndex|null} — GC.1: SQLite FTS5 segment index */
535
564
  #memoryIndex;
536
565
 
537
- /** @type {import('./memory/ams-registry.js').AmsRegistry|null} — group-keyed AMS cache */
566
+ /** @type {import('./memory/ams-registry.js').AmsRegistry|null} — Session-keyed AMS cache */
538
567
  #amsRegistry;
539
568
 
540
569
  /** @type {import('./tools/registry.js').ToolRegistry|null} */
@@ -710,14 +739,6 @@ export class Engine {
710
739
  */
711
740
  #asyncTaskCoordinator = null;
712
741
 
713
- /**
714
- * Per-group "adjust has run at least once this engine lifetime" flag.
715
- * Keyed by sessionId (or 'default'). The first turn always runs adjust;
716
- * subsequent turns only run on budget pressure or new memory.
717
- * @type {Map<string, boolean>}
718
- */
719
- #adjustRanBySession = new Map();
720
-
721
742
  /** @type {string|null} */
722
743
  #abortReason = null;
723
744
 
@@ -918,62 +939,75 @@ export class Engine {
918
939
  }
919
940
 
920
941
  /**
921
- * Load Layer A scope summaries from `<memoryRoot>/<scope>/summary.md`.
922
- *
923
- * Scopes:
924
- * - user → `user/summary.md` (always attempted)
925
- * - session <sid> → `sessions/<sid>/summary.md` (if sessionId)
926
- * - session-vp → `sessions/<sid>/vp/<vpId>/summary.md` (if vpId)
942
+ * Load prompt-facing canonical scope content. Every durable memory scope is
943
+ * query-gated; summary.md remains catalog metadata for Dream triage only.
927
944
  *
928
- * Each fetch is best-effort — missing files / read errors return ''. The
929
- * dream tick (Phase 6) is what populates these; on a fresh install they
930
- * all return ''.
945
+ * Each fetch is best-effort — missing files / read errors return ''.
931
946
  *
932
- * @param {{sessionId?: string, vpId?: string, language?: string, topicScopes?: string[], relatedSessionIds?: string[]}} ctx
947
+ * @param {{sessionId?: string, vpId?: string, language?: string, selectedScopes?: Set<string>, topicScopes?: string[], relatedSessionIds?: string[]}} ctx
933
948
  * @returns {Promise<{user:string, session:string, vp:string, topics:Array<{scope:string, summary:string}>, relatedSessions:Array<{sessionId:string, summary:string}>}>}
934
949
  */
935
- async #loadLayerASummaries({ sessionId, vpId, language, topicScopes, relatedSessionIds } = {}) {
950
+ async #loadLayerASummaries({ sessionId, vpId, language, selectedScopes, topicScopes, relatedSessionIds } = {}) {
936
951
  if (!this.#yeaftDir) return { user: '', session: '', vp: '', topics: [], relatedSessions: [] };
937
952
  const memoryRoot = `${this.#yeaftDir}/memory`;
938
- const topicScopeList = Array.isArray(topicScopes) ? topicScopes.slice(0, 12) : [];
953
+ const topicScopeList = Array.isArray(topicScopes) ? topicScopes.slice(0, MAX_PROMPT_MEMORY_ITEMS) : [];
939
954
  const relatedIds = Array.from(new Set((Array.isArray(relatedSessionIds) ? relatedSessionIds : [])
940
955
  .filter(id => typeof id === 'string' && id.trim() && id.trim() !== sessionId)
941
956
  .map(id => id.trim())))
942
- .slice(0, 8);
957
+ .slice(0, MAX_RELATED_SESSION_MEMORY_ITEMS);
958
+ const selected = selectedScopes instanceof Set ? selectedScopes : new Set();
959
+ const exactSessionScope = selectExactSessionScope(selected, sessionId);
960
+ const exactVpScope = selectExactVpScope(selected, sessionId, vpId);
943
961
  const tasks = [
944
- readScopeSummary({ kind: 'user' }, { root: memoryRoot, language }).catch(() => ''),
945
- sessionId
946
- ? readScopeSummary({ kind: 'session', id: sessionId }, { root: memoryRoot, language }).catch(() => '')
962
+ selected.has('user')
963
+ ? readCanonicalScope('user', { root: memoryRoot, currentVpId: vpId }).catch(() => '')
947
964
  : Promise.resolve(''),
948
- vpId && sessionId
949
- ? readScopeSummary({ kind: 'session-vp', sessionId, id: vpId }, { root: memoryRoot, language }).catch(() => '')
965
+ exactSessionScope
966
+ ? readCanonicalScope(exactSessionScope, { root: memoryRoot, currentVpId: vpId }).catch(() => '')
950
967
  : Promise.resolve(''),
951
- Promise.all(topicScopeList.map(scope => readTopicSummary(scope, { root: memoryRoot, language }))),
952
- Promise.all(relatedIds.map(async relatedSessionId => ({
953
- sessionId: relatedSessionId,
954
- summary: await readScopeSummary(
955
- { kind: 'session', id: relatedSessionId },
956
- { root: memoryRoot, language },
957
- ).catch(() => ''),
968
+ exactVpScope
969
+ ? readCanonicalScope(exactVpScope, { root: memoryRoot, currentVpId: vpId }).catch(() => '')
970
+ : Promise.resolve(''),
971
+ Promise.all(topicScopeList.map(scope => readTopicSummary(scope, {
972
+ root: memoryRoot,
973
+ language,
974
+ currentVpId: vpId,
958
975
  }))),
976
+ Promise.all(relatedIds.map(async relatedSessionId => {
977
+ const scope = selectExactSessionScope(selected, relatedSessionId);
978
+ return {
979
+ sessionId: relatedSessionId,
980
+ summary: scope
981
+ ? await readCanonicalScope(scope, { root: memoryRoot, currentVpId: vpId }).catch(() => '')
982
+ : '',
983
+ };
984
+ })),
959
985
  ];
960
986
  const [user, session, vp, topicsRaw, relatedSessionsRaw] = await Promise.all(tasks);
961
987
  const topics = (topicsRaw || []).filter(t => t && t.summary);
962
988
  const relatedSessions = (relatedSessionsRaw || []).filter(entry => entry && entry.summary);
963
- return { user: user || '', session: session || '', vp: vp || '', topics, relatedSessions };
964
- }
965
-
966
- async #loadSessionTopicLabels(sessionId, limit = 8) {
967
- return (await this.#loadSessionTopicScopes(sessionId, limit))
968
- .map(scope => scope.replace(/^sessions\/[^/]+\/topic\//, ''));
989
+ return {
990
+ user: user || '',
991
+ session: session || '',
992
+ sessionScope: exactSessionScope || '',
993
+ vp: vp || '',
994
+ vpScope: exactVpScope || '',
995
+ topics,
996
+ relatedSessions,
997
+ };
969
998
  }
970
999
 
971
- async #loadSessionTopicScopes(sessionId, limit = 24) {
1000
+ async #loadSessionTopicScopes(sessionId) {
972
1001
  if (!this.#yeaftDir || !sessionId) return [];
973
- const topicRoot = join(this.#yeaftDir, 'memory', 'sessions', sessionId, 'topic');
974
- const labels = [];
975
- await collectTopicLabels(topicRoot, '', labels, limit).catch(() => {});
976
- return labels.map(label => `sessions/${sessionId}/topic/${label}`);
1002
+ const memoryRoot = join(this.#yeaftDir, 'memory');
1003
+ const scopes = [];
1004
+ for (const prefix of ['sessions', 'session', 'group']) {
1005
+ const labels = [];
1006
+ const topicRoot = join(memoryRoot, prefix, sessionId, 'topic');
1007
+ await collectTopicLabels(topicRoot, '', labels).catch(() => {});
1008
+ scopes.push(...labels.map(label => `${prefix}/${sessionId}/topic/${label}`));
1009
+ }
1010
+ return scopes;
977
1011
  }
978
1012
 
979
1013
  /**
@@ -1012,18 +1046,7 @@ export class Engine {
1012
1046
  budget: computeBudget(this.#config?.maxContextTokens),
1013
1047
  });
1014
1048
 
1015
- // Prime #adjustRanBySession from disk-hydrated state on first access:
1016
- // a reactivated group resumes with whatever adjustRanThisSession bit
1017
- // it had on disconnect, so we don't burn a fresh adjust on every
1018
- // reload. Once set true in this session we never clear it.
1019
- if (this.#amsRegistry
1020
- && !this.#adjustRanBySession.has(sessionKey)
1021
- && this.#amsRegistry.adjustRanThisSession(sessionKey)) {
1022
- this.#adjustRanBySession.set(sessionKey, true);
1023
- }
1024
-
1025
- // (a) Resident: rebuild from the same scope summaries the worker
1026
- // prompt is already going to see.
1049
+ // Resident: rebuild from the canonical content selected for this query.
1027
1050
  const relatedSessionIds = new Set((args.summaries?.relatedSessions || [])
1028
1051
  .map(entry => entry?.sessionId)
1029
1052
  .filter(Boolean));
@@ -1036,9 +1059,10 @@ export class Engine {
1036
1059
  : { ...entry, category: 'memory' });
1037
1060
  ams.setResident(residentEntries);
1038
1061
 
1039
- // (b) onDemand: replace with this turn's FTS hits.
1040
- const segs = Array.isArray(args.recallEntries) ? args.recallEntries : [];
1041
- ams.setOnDemand(segs);
1062
+ // Segment hits identify relevant scopes, but raw evidence bodies do not
1063
+ // enter the normal prompt. Clear both persisted Recent ids and this turn's
1064
+ // OnDemand bodies; prompt-facing text comes only from canonical content.
1065
+ ams.clearSegmentLayers();
1042
1066
 
1043
1067
  // (c) Snapshot — render the AMS layers as a single prompt block.
1044
1068
  const snapshot = ams.snapshot({ userMsg: args.userMsg || '' });
@@ -1076,8 +1100,8 @@ export class Engine {
1076
1100
  const residentMemory = snap.resident.filter(entry => entry.category !== 'experience');
1077
1101
  parts.push(zh ? '## 相关上下文' : '## Relevant Context');
1078
1102
  parts.push(zh
1079
- ? '以下内容来自持久记忆与相关 Session 的只读经验总结;只把它当作事实背景,不要把过期执行状态当成当前任务。'
1080
- : 'The following text comes from persistent memory and read-only summaries of related Sessions. Treat it as factual context, not as current execution state.');
1103
+ ? '以下内容来自与当前 query 相关的持久记忆;只把它当作事实背景,不要把过期执行状态当成当前任务。'
1104
+ : 'The following text comes from persistent memory selected for the current query. Treat it as factual context, not as current execution state.');
1081
1105
  if (experiences.length > 0) {
1082
1106
  parts.push(zh ? '### 过去 Session 的经验总结' : '### Experience From Past Sessions');
1083
1107
  for (const entry of experiences) {
@@ -1100,86 +1124,6 @@ export class Engine {
1100
1124
  return parts.join('\n');
1101
1125
  }
1102
1126
 
1103
- /**
1104
- * Post-turn AMS correction. Decides whether to run via
1105
- * `shouldRunAdjust`, then drives the LLM round-trip through
1106
- * `runAdjust`. Persists the AMS to disk if membership changed.
1107
- *
1108
- * Failure here is intentionally swallowed — adjust is a best-effort
1109
- * memory-quality step; a parse failure or LLM blip should never
1110
- * surface as a turn failure.
1111
- *
1112
- * @param {{
1113
- * amsContext: { ams: import('./memory/ams.js').ActiveMemorySet, sessionKey: string, ownVpId: string|null, scopes: string[] }|null,
1114
- * userMsg: string,
1115
- * assistantReply: string,
1116
- * turnTokenUsage: number,
1117
- * }} args
1118
- * @returns {Promise<{ ran: boolean, added: number, evicted: number, reason: string } | null>}
1119
- */
1120
- async #runAdjustHook(args) {
1121
- const ctx = args.amsContext;
1122
- if (!ctx || !this.#amsRegistry || !this.#memoryIndex) return null;
1123
- const totalBudget = ctx.ams.budget?.total || 0;
1124
- if (!totalBudget) return null;
1125
-
1126
- const adjustRanThisSession = this.#adjustRanBySession.get(ctx.sessionKey) === true;
1127
- try {
1128
- const result = await runAdjust({
1129
- trigger: {
1130
- turnTokenUsage: args.turnTokenUsage,
1131
- totalBudget,
1132
- adjustRanThisSession,
1133
- },
1134
- ams: ctx.ams,
1135
- index: this.#memoryIndex,
1136
- scopes: ctx.scopes,
1137
- ownVpId: ctx.ownVpId,
1138
- userMsg: args.userMsg,
1139
- assistantReply: args.assistantReply,
1140
- runLLM: async (prompt) => {
1141
- const maintenanceCtrl = new AbortController();
1142
- let timeout = null;
1143
- const timedOut = new Promise((_, reject) => {
1144
- timeout = setTimeout(() => {
1145
- maintenanceCtrl.abort('ams_adjust_timeout');
1146
- reject(new LLMAbortError());
1147
- }, AMS_ADJUST_TIMEOUT_MS);
1148
- if (timeout && typeof timeout.unref === 'function') timeout.unref();
1149
- });
1150
- try {
1151
- const request = this.#adapter.call({
1152
- model: this.#fastConfig.model,
1153
- system: (String(this.#config?.language || '').toLowerCase().startsWith('zh')
1154
- ? '你是记忆管理子程序。请按要求只回复一个 JSON 对象,不要输出额外说明。'
1155
- : 'You are a memory-management subroutine. Reply with a single JSON object as instructed.'),
1156
- messages: [{ role: 'user', content: prompt }],
1157
- maxTokens: 1024,
1158
- signal: maintenanceCtrl.signal,
1159
- });
1160
- const out = await Promise.race([request, timedOut]);
1161
- return out?.text || '';
1162
- } finally {
1163
- if (timeout) clearTimeout(timeout);
1164
- }
1165
- },
1166
- });
1167
- if (result?.ran) {
1168
- this.#adjustRanBySession.set(ctx.sessionKey, true);
1169
- // Always persist when we ran — even with no membership change,
1170
- // the adjustRanThisSession bit is part of the on-disk state we
1171
- // want to preserve.
1172
- this.#amsRegistry.markDirty(ctx.sessionKey);
1173
- this.#amsRegistry.persist(ctx.sessionKey, {
1174
- adjustRanThisSession: true,
1175
- });
1176
- }
1177
- return result;
1178
- } catch {
1179
- return null;
1180
- }
1181
- }
1182
-
1183
1127
  /**
1184
1128
  * Build the system prompt with the AMS-rendered Memory block, the
1185
1129
  * Active Scope block, and skill content. The legacy multi-path
@@ -1461,7 +1405,7 @@ export class Engine {
1461
1405
  * Perform memory recall for a given prompt.
1462
1406
  *
1463
1407
  * Single path (GC.1 follow-up): SQLite FTS5 pre-flow via
1464
- * `groups/pre-flow.js` → `memory/preflow.js`. When the index isn't
1408
+ * `sessions/pre-flow.js` → `memory/preflow.js`. When the index isn't
1465
1409
  * wired (e.g. read-only sessions or pre-FTS yeaft dirs) recall is
1466
1410
  * skipped and an empty memory shape is returned — engine continues
1467
1411
  * without injection.
@@ -1481,7 +1425,10 @@ export class Engine {
1481
1425
  vpId: ctx.vpId,
1482
1426
  extraScopes: ctx.extraScopes,
1483
1427
  pickLimit: resolveMemoryRecallLimit(this.#config),
1484
- fallbackOnEmpty: true,
1428
+ uniqueScopes: true,
1429
+ canonicalOnly: true,
1430
+ topK: 500,
1431
+ fallbackOnEmpty: false,
1485
1432
  });
1486
1433
  memory.profile = result.profile || '';
1487
1434
  memory.entries = result.entries || [];
@@ -1519,7 +1466,7 @@ export class Engine {
1519
1466
  return Boolean(this.#conversationStore) && !this.#config._readOnly;
1520
1467
  }
1521
1468
 
1522
- #conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null } = {}) {
1469
+ #conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null, executionOrigin = null } = {}) {
1523
1470
  const record = {
1524
1471
  role: message.role,
1525
1472
  content: typeof message.content === 'string'
@@ -1545,6 +1492,9 @@ export class Engine {
1545
1492
  record.foldedMessageIds = [...message.foldedMessageIds];
1546
1493
  }
1547
1494
  if (turnId && (message.role === 'assistant' || message.role === 'tool')) record.turnId = turnId;
1495
+ if (executionOrigin === 'route_forward' && (message.role === 'assistant' || message.role === 'tool')) {
1496
+ record.executionOrigin = executionOrigin;
1497
+ }
1548
1498
  if (this.#vpId && (message.role === 'assistant' || message.role === 'tool')) record.speakerVpId = this.#vpId;
1549
1499
  if (incomplete) record.incomplete = true;
1550
1500
  if (stopReason) record.stopReason = stopReason;
@@ -1695,7 +1645,7 @@ export class Engine {
1695
1645
  timeout = setTimeout(() => {
1696
1646
  maintenanceCtrl.abort('compact_summary_timeout');
1697
1647
  reject(new LLMAbortError());
1698
- }, AMS_ADJUST_TIMEOUT_MS);
1648
+ }, MAINTENANCE_CALL_TIMEOUT_MS);
1699
1649
  if (timeout && typeof timeout.unref === 'function') timeout.unref();
1700
1650
  });
1701
1651
  try {
@@ -1972,7 +1922,7 @@ export class Engine {
1972
1922
  }
1973
1923
  }
1974
1924
 
1975
- async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1925
+ async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1976
1926
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1977
1927
  const error = new Error('prompt is required and must be a non-empty string');
1978
1928
  yield {
@@ -2058,7 +2008,7 @@ export class Engine {
2058
2008
  };
2059
2009
  try {
2060
2010
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
2061
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2011
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2062
2012
  } finally {
2063
2013
  // Closing the async generator at a visible retry boundary means the
2064
2014
  // continuation never reached a provider. Keep it out of history and
@@ -2106,7 +2056,7 @@ export class Engine {
2106
2056
  * in a try/finally without indenting the whole loop.
2107
2057
  * @private
2108
2058
  */
2109
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
2059
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
2110
2060
 
2111
2061
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2112
2062
  ? collabToolPolicy
@@ -2117,6 +2067,9 @@ export class Engine {
2117
2067
  const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
2118
2068
  ? threadId.trim()
2119
2069
  : MAIN_THREAD_ID;
2070
+ const executionOrigin = inboundEnvelope?.msg?.meta?.injectedBy === 'route_forward'
2071
+ ? 'route_forward'
2072
+ : null;
2120
2073
  const queryTurnId = randomUUID();
2121
2074
  const queryStartedAt = Date.now();
2122
2075
  const userQuestionPreview = String(prompt || '').slice(0, 200);
@@ -2153,14 +2106,11 @@ export class Engine {
2153
2106
  };
2154
2107
 
2155
2108
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
2156
- // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
2157
- // 1. FTS5 pre-flow recall produces a list of segments;
2158
- // 2. those segments are pushed into AMS OnDemand;
2159
- // 3. AMS renders a budget-aware snapshot (Resident + Recent +
2160
- // OnDemand) that snapshot IS `memoryInjection`.
2161
- // The legacy second path (`recallResult.formatted` concatenated
2162
- // directly into `memoryInjection`) was a duplicate render of the
2163
- // same segments AMS would also surface, so it's gone.
2109
+ // Memory has one render outlet:
2110
+ // 1. FTS5 ranks canonical-content records and chooses scopes;
2111
+ // 2. Engine reloads those scopes from content.md;
2112
+ // 3. AMS renders the budget-aware Resident snapshot as memoryInjection.
2113
+ // Raw memory.md evidence and summary.md catalog text never enter the prompt.
2164
2114
  let memoryInjection = '';
2165
2115
  let recallEntryCount = 0;
2166
2116
 
@@ -2188,32 +2138,33 @@ export class Engine {
2188
2138
  if (recallEntryCount > 0) {
2189
2139
  yield { type: 'recall', entryCount: recallEntryCount, cached: false, threadId };
2190
2140
  }
2141
+ const selectedMemoryScopes = selectCanonicalMemoryScopes(recallResult?.entries || []);
2191
2142
  const topicScopesForResident = selectResidentTopicScopes(
2192
2143
  topicScopesForMemory,
2193
2144
  recallResult?.entries || [],
2194
- prompt,
2145
+ );
2146
+ const recalledRelatedSessionIds = selectRelatedSessionIds(
2147
+ projectSessionIds,
2148
+ recallResult?.entries || [],
2195
2149
  );
2196
2150
 
2197
- // Layer-A summaries same scopes AMS Resident will surface, loaded
2198
- // here so we can pass them into #prepareAms. (Rolling per-scope
2199
- // synopsis maintained by the dream tick.) Failures are non-fatal.
2151
+ // Load canonical content only for scopes selected by ranked FTS records.
2152
+ // summary.md remains catalog metadata and never enters the prompt.
2200
2153
  const summaries = await this.#loadLayerASummaries({
2201
2154
  sessionId,
2202
2155
  vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
2203
2156
  ? vpPersona.vpId
2204
2157
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
2205
2158
  language: this.#config.language || 'en',
2159
+ selectedScopes: selectedMemoryScopes,
2206
2160
  topicScopes: topicScopesForResident,
2207
- relatedSessionIds: projectSessionIds,
2161
+ relatedSessionIds: recalledRelatedSessionIds,
2208
2162
  });
2209
2163
 
2210
2164
  // ─── AMS: populate + snapshot ───────────────────────────────
2211
- // Group-keyed and persisted across session deactivation. Each turn:
2212
- // (a) resident layer is rebuilt from <scope>/summary.md;
2213
- // (b) onDemand is replaced with this turn's FTS hits;
2214
- // (c) we render a budget-aware snapshot block — this is the SOLE
2215
- // Memory section in the system prompt. Adjust runs post-turn
2216
- // (see end_turn below).
2165
+ // Session + VP keyed AMS is rebuilt each turn from selected canonical content.
2166
+ // FTS segment hits choose scopes but their bodies are not rendered. The
2167
+ // budget-aware Resident snapshot is the sole Memory prompt outlet.
2217
2168
  const ownVpIdForAms = vpPersona && typeof vpPersona === 'object'
2218
2169
  && typeof vpPersona.vpId === 'string'
2219
2170
  ? vpPersona.vpId
@@ -2249,7 +2200,9 @@ export class Engine {
2249
2200
  scope: e.scope,
2250
2201
  summary: String(e.summary),
2251
2202
  truncated: false,
2252
- source: 'resident-summary',
2203
+ source: e.scope?.startsWith(activeTopicDreamPrefix || '\u0000')
2204
+ ? 'canonical-topic-content'
2205
+ : 'resident-summary',
2253
2206
  }))
2254
2207
  : [];
2255
2208
 
@@ -2257,9 +2210,8 @@ export class Engine {
2257
2210
  // Structured per-turn scope summary: session + vp + members + envelope routing
2258
2211
  // info. Long-form scope content lives in AMS — this block carries
2259
2212
  // only IDs + tiny labels. (Feature scope retired 2026-05-13.)
2260
- const activeSessionTopics = Array.isArray(sessionTopics)
2261
- ? sessionTopics
2262
- : topicScopesForMemory.slice(0, 8).map(scope => scope.replace(/^sessions\/[^/]+\/topic\//, ''));
2213
+ const activeSessionTopics = topicScopesForResident
2214
+ .map(scope => scope.replace(/^sessions\/[^/]+\/topic\//, ''));
2263
2215
  const activeScope = {
2264
2216
  sessionId: sessionId || '',
2265
2217
  sessionMember: ownVpIdForAms || '',
@@ -2321,9 +2273,9 @@ export class Engine {
2321
2273
  // a `<conversation_summary>` user/assistant pair. It MUST NEVER appear
2322
2274
  // in the system prompt — that was the bug DESIGN-PROMPT §4.3 banned.
2323
2275
  //
2324
- // Inversely: Dream V2's output (per-scope `memory.md` / `summary.md`)
2325
- // flows exclusively through `prompts.js#buildSystemPrompt`'s §6 Memory
2326
- // section via the AMS Resident layer (see `engine.js#buildResidentEntries`).
2276
+ // Inversely: Dream's prompt-facing `content.md` flows exclusively through
2277
+ // `prompts.js#buildSystemPrompt`'s Memory section via AMS Resident (see
2278
+ // `engine.js#buildResidentEntries`). Evidence and catalog files stay out.
2327
2279
  // It MUST NEVER appear in the messages array.
2328
2280
  //
2329
2281
  // Two write roots, two scheduler triggers, two prompt slots — never
@@ -2615,6 +2567,7 @@ export class Engine {
2615
2567
  model: currentModel,
2616
2568
  incomplete: true,
2617
2569
  stopReason: reason,
2570
+ executionOrigin,
2618
2571
  });
2619
2572
  };
2620
2573
  const toolCalls = [];
@@ -3363,6 +3316,7 @@ export class Engine {
3363
3316
  sessionId: runtimeSessionId,
3364
3317
  turnId: vpTurnId || queryTurnId,
3365
3318
  model: currentModel,
3319
+ executionOrigin,
3366
3320
  });
3367
3321
  if (persistedAssistantMessage) {
3368
3322
  assistantMsg._persistedMessageId = persistedAssistantMessage.id;
@@ -3614,30 +3568,6 @@ export class Engine {
3614
3568
  yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
3615
3569
  }
3616
3570
 
3617
- // ─── Post-turn AMS adjust ────────────────────────────────
3618
- // shouldRunAdjust gates the LLM round-trip so most turns are
3619
- // free; first turn always runs, plus on budget pressure.
3620
- if (amsContext) {
3621
- const adjustResult = await this.#runAdjustHook({
3622
- amsContext,
3623
- userMsg: prompt,
3624
- assistantReply: fullResponseText,
3625
- turnTokenUsage: cumulativeInputTokens + cumulativeOutputTokens,
3626
- });
3627
- if (adjustResult && adjustResult.ran) {
3628
- yield {
3629
- type: 'memory_adjust',
3630
- turnId: queryTurnId,
3631
- threadId,
3632
- sessionKey: amsContext.sessionKey,
3633
- added: adjustResult.added,
3634
- evicted: adjustResult.evicted,
3635
- skipped: adjustResult.skipped || 0,
3636
- reason: adjustResult.reason,
3637
- };
3638
- }
3639
- }
3640
-
3641
3571
  // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
3642
3572
  // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
3643
3573
  // (8) AND no T1 has actually rewritten the arc yet. Kicks off the
@@ -3687,6 +3617,7 @@ export class Engine {
3687
3617
  count: pairs.length,
3688
3618
  originalUserMsg: prompt,
3689
3619
  originatingTurnId: queryTurnId,
3620
+ executionOrigin,
3690
3621
  ready: false,
3691
3622
  result: null,
3692
3623
  error: null,
@@ -3973,6 +3904,7 @@ export class Engine {
3973
3904
  sessionId: runtimeSessionId,
3974
3905
  turnId: vpTurnId || queryTurnId,
3975
3906
  model: currentModel,
3907
+ executionOrigin,
3976
3908
  });
3977
3909
  if (persistedToolMessage) {
3978
3910
  toolMessage._persistedMessageId = persistedToolMessage.id;
@@ -4110,7 +4042,7 @@ export class Engine {
4110
4042
  batchStart,
4111
4043
  batchEnd,
4112
4044
  reflectionMessage,
4113
- { sessionId: runtimeSessionId, model: currentModel },
4045
+ { sessionId: runtimeSessionId, model: currentModel, executionOrigin },
4114
4046
  );
4115
4047
  if (durableRowsInRange && !persistedReflection) {
4116
4048
  throw new Error('T1 reflection could not publish its durable range replacement');
@@ -4303,7 +4235,10 @@ export class Engine {
4303
4235
  startIdx,
4304
4236
  endIdx,
4305
4237
  reflectionMessage,
4306
- context,
4238
+ {
4239
+ ...context,
4240
+ executionOrigin: info.executionOrigin === 'route_forward' ? 'route_forward' : null,
4241
+ },
4307
4242
  );
4308
4243
  if (durableRowsInRange && !persistedReflection) continue;
4309
4244
  // Mutate in place so caller's reference stays valid.
@@ -4664,33 +4599,96 @@ export class Engine {
4664
4599
  }
4665
4600
  }
4666
4601
 
4602
+ function selectExactSessionScope(selected, sessionId) {
4603
+ if (!sessionId) return '';
4604
+ for (const scope of selected || []) {
4605
+ if (['sessions', 'session', 'group'].some(prefix => scope === `${prefix}/${sessionId}`)) {
4606
+ return scope;
4607
+ }
4608
+ }
4609
+ return '';
4610
+ }
4611
+
4612
+ function selectExactVpScope(selected, sessionId, vpId) {
4613
+ if (!sessionId || !vpId) return '';
4614
+ for (const scope of selected || []) {
4615
+ if (['sessions', 'session', 'group'].some(prefix => scope === `${prefix}/${sessionId}/vp/${vpId}`)) {
4616
+ return scope;
4617
+ }
4618
+ }
4619
+ return '';
4620
+ }
4621
+
4622
+ async function readCanonicalScope(scope, opts) {
4623
+ const value = String(scope || '');
4624
+ if (isVpForeign(value, opts?.currentVpId)) return '';
4625
+ const scopeObject = memoryScopeObject(value);
4626
+ if (!scopeObject || !opts?.root) return '';
4627
+ let content = await fsp.readFile(join(opts.root, value, 'content.md'), 'utf8').catch(() => '');
4628
+ // Current Session topic redirects intentionally have no content.md at the
4629
+ // old path. Let the store resolve that redirect, but never cross-fallback
4630
+ // between `group/`, `session/`, and `sessions/` aliases.
4631
+ if (!content && scopeObject.kind === 'session-topic' && value.startsWith('sessions/')) {
4632
+ content = await readScopeContent(scopeObject, opts).catch(() => '');
4633
+ }
4634
+ return truncateMemoryContent(content, MAX_MEMORY_ITEM_TOKENS);
4635
+ }
4636
+
4637
+ function memoryScopeObject(scope) {
4638
+ if (scope === 'user') return { kind: 'user' };
4639
+ let match = /^(sessions|session|group)\/([^/]+)$/.exec(scope);
4640
+ if (match) return match[1] === 'group'
4641
+ ? { kind: 'group', id: match[2] }
4642
+ : { kind: 'session', id: match[2] };
4643
+ match = /^(sessions|session|group)\/([^/]+)\/vp\/([^/]+)$/.exec(scope);
4644
+ if (match) return match[1] === 'group'
4645
+ ? { kind: 'group-vp', sessionId: match[2], id: match[3] }
4646
+ : { kind: 'session-vp', sessionId: match[2], id: match[3] };
4647
+ match = /^(sessions|session|group)\/([^/]+)\/topic\/(.+)$/.exec(scope);
4648
+ if (match) {
4649
+ const path = match[3].split('/').filter(Boolean);
4650
+ if (path.length === 0 || path.length > 2) return null;
4651
+ return match[1] === 'group'
4652
+ ? { kind: 'group-topic', sessionId: match[2], path }
4653
+ : { kind: 'session-topic', sessionId: match[2], path };
4654
+ }
4655
+ return null;
4656
+ }
4657
+
4667
4658
  async function readTopicSummary(scope, opts) {
4668
- const m = /^sessions\/([^/]+)\/topic\/(.+)$/.exec(String(scope || ''));
4669
- if (!m) return null;
4670
- const path = m[2].split('/').filter(Boolean);
4671
- if (path.length === 0) return null;
4672
- const summary = await readScopeSummary(
4673
- { kind: 'session-topic', sessionId: m[1], path },
4674
- opts,
4675
- ).catch(() => '');
4676
- return summary ? { scope, summary } : null;
4659
+ const content = await readCanonicalScope(scope, opts);
4660
+ return content ? { scope, summary: content } : null;
4661
+ }
4662
+
4663
+ function truncateMemoryContent(content, maxTokens) {
4664
+ const text = cleanMemoryPromptText(content);
4665
+ if (!text || approxTokens(text) <= maxTokens) return text;
4666
+ let lo = 0;
4667
+ let hi = text.length;
4668
+ while (lo < hi) {
4669
+ const mid = Math.ceil((lo + hi) / 2);
4670
+ if (approxTokens(text.slice(0, mid)) <= maxTokens) lo = mid;
4671
+ else hi = mid - 1;
4672
+ }
4673
+ const cut = text.slice(0, lo);
4674
+ const boundary = Math.max(cut.lastIndexOf('\n\n'), cut.lastIndexOf('\n- '));
4675
+ const body = (boundary > lo * 0.6 ? cut.slice(0, boundary) : cut).trimEnd();
4676
+ return `${body}\n\n[Additional canonical topic content omitted by prompt budget.]`;
4677
4677
  }
4678
4678
 
4679
- async function collectTopicLabels(dir, prefix, labels, limit) {
4680
- if (labels.length >= limit) return;
4679
+ async function collectTopicLabels(dir, prefix, labels) {
4681
4680
  let entries;
4682
4681
  try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
4682
+ const hasContent = entries.some(entry => entry.isFile() && entry.name === 'content.md');
4683
4683
  const hasMemory = entries.some(entry => entry.isFile() && entry.name === 'memory.md');
4684
4684
  const hasSummary = entries.some(entry => entry.isFile() && entry.name === 'summary.md');
4685
- if (prefix && (hasMemory || hasSummary)) labels.push(prefix);
4686
- if (labels.length >= limit) return;
4685
+ if (prefix && (hasContent || hasMemory || hasSummary)) labels.push(prefix);
4687
4686
  const dirs = entries
4688
4687
  .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
4689
4688
  .map(entry => entry.name)
4690
4689
  .sort();
4691
4690
  for (const name of dirs) {
4692
4691
  const nextPrefix = prefix ? `${prefix}/${name}` : name;
4693
- await collectTopicLabels(join(dir, name), nextPrefix, labels, limit);
4694
- if (labels.length >= limit) return;
4692
+ await collectTopicLabels(join(dir, name), nextPrefix, labels);
4695
4693
  }
4696
4694
  }