@yeaft/webchat-agent 1.0.349 → 1.0.351

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 +91 -97
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/dream/apply.js +59 -30
  9. package/yeaft/dream/output-snapshot.js +8 -4
  10. package/yeaft/dream/prompts/consolidate-topics.md +31 -0
  11. package/yeaft/dream/prompts/create.md +8 -8
  12. package/yeaft/dream/prompts/index.js +4 -2
  13. package/yeaft/dream/prompts/merge-topics.md +35 -0
  14. package/yeaft/dream/prompts/triage-pass1.md +2 -2
  15. package/yeaft/dream/prompts/triage-pass2.md +4 -2
  16. package/yeaft/dream/prompts/update.md +16 -14
  17. package/yeaft/dream/runner.js +69 -13
  18. package/yeaft/dream/segment-extract.js +16 -13
  19. package/yeaft/dream/session-wiring.js +2 -2
  20. package/yeaft/dream/snapshot.js +3 -3
  21. package/yeaft/dream/topic-consolidation.js +316 -0
  22. package/yeaft/dream/triage.js +7 -2
  23. package/yeaft/engine.js +221 -236
  24. package/yeaft/memory/ams-registry.js +42 -61
  25. package/yeaft/memory/ams.js +17 -9
  26. package/yeaft/memory/budget.js +15 -18
  27. package/yeaft/memory/content-backfill.js +118 -0
  28. package/yeaft/memory/index-db.js +10 -3
  29. package/yeaft/memory/keywords.js +25 -7
  30. package/yeaft/memory/preflow.js +26 -9
  31. package/yeaft/memory/segment-store.js +44 -8
  32. package/yeaft/memory/segment-sync.js +8 -4
  33. package/yeaft/memory/segment.js +10 -3
  34. package/yeaft/memory/store.js +88 -38
  35. package/yeaft/memory/summary-store.js +3 -3
  36. package/yeaft/memory/topic-redirect.js +28 -0
  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 || [];
@@ -1698,7 +1645,7 @@ export class Engine {
1698
1645
  timeout = setTimeout(() => {
1699
1646
  maintenanceCtrl.abort('compact_summary_timeout');
1700
1647
  reject(new LLMAbortError());
1701
- }, AMS_ADJUST_TIMEOUT_MS);
1648
+ }, MAINTENANCE_CALL_TIMEOUT_MS);
1702
1649
  if (timeout && typeof timeout.unref === 'function') timeout.unref();
1703
1650
  });
1704
1651
  try {
@@ -1975,7 +1922,7 @@ export class Engine {
1975
1922
  }
1976
1923
  }
1977
1924
 
1978
- 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 } = {}) {
1979
1926
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1980
1927
  const error = new Error('prompt is required and must be a non-empty string');
1981
1928
  yield {
@@ -2061,7 +2008,7 @@ export class Engine {
2061
2008
  };
2062
2009
  try {
2063
2010
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
2064
- 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 });
2065
2012
  } finally {
2066
2013
  // Closing the async generator at a visible retry boundary means the
2067
2014
  // continuation never reached a provider. Keep it out of history and
@@ -2109,7 +2056,7 @@ export class Engine {
2109
2056
  * in a try/finally without indenting the whole loop.
2110
2057
  * @private
2111
2058
  */
2112
- 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 }) {
2113
2060
 
2114
2061
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2115
2062
  ? collabToolPolicy
@@ -2159,14 +2106,11 @@ export class Engine {
2159
2106
  };
2160
2107
 
2161
2108
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
2162
- // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
2163
- // 1. FTS5 pre-flow recall produces a list of segments;
2164
- // 2. those segments are pushed into AMS OnDemand;
2165
- // 3. AMS renders a budget-aware snapshot (Resident + Recent +
2166
- // OnDemand) that snapshot IS `memoryInjection`.
2167
- // The legacy second path (`recallResult.formatted` concatenated
2168
- // directly into `memoryInjection`) was a duplicate render of the
2169
- // 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.
2170
2114
  let memoryInjection = '';
2171
2115
  let recallEntryCount = 0;
2172
2116
 
@@ -2194,32 +2138,33 @@ export class Engine {
2194
2138
  if (recallEntryCount > 0) {
2195
2139
  yield { type: 'recall', entryCount: recallEntryCount, cached: false, threadId };
2196
2140
  }
2141
+ const selectedMemoryScopes = selectCanonicalMemoryScopes(recallResult?.entries || []);
2197
2142
  const topicScopesForResident = selectResidentTopicScopes(
2198
2143
  topicScopesForMemory,
2199
2144
  recallResult?.entries || [],
2200
- prompt,
2145
+ );
2146
+ const recalledRelatedSessionIds = selectRelatedSessionIds(
2147
+ projectSessionIds,
2148
+ recallResult?.entries || [],
2201
2149
  );
2202
2150
 
2203
- // Layer-A summaries same scopes AMS Resident will surface, loaded
2204
- // here so we can pass them into #prepareAms. (Rolling per-scope
2205
- // 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.
2206
2153
  const summaries = await this.#loadLayerASummaries({
2207
2154
  sessionId,
2208
2155
  vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
2209
2156
  ? vpPersona.vpId
2210
2157
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
2211
2158
  language: this.#config.language || 'en',
2159
+ selectedScopes: selectedMemoryScopes,
2212
2160
  topicScopes: topicScopesForResident,
2213
- relatedSessionIds: projectSessionIds,
2161
+ relatedSessionIds: recalledRelatedSessionIds,
2214
2162
  });
2215
2163
 
2216
2164
  // ─── AMS: populate + snapshot ───────────────────────────────
2217
- // Group-keyed and persisted across session deactivation. Each turn:
2218
- // (a) resident layer is rebuilt from <scope>/summary.md;
2219
- // (b) onDemand is replaced with this turn's FTS hits;
2220
- // (c) we render a budget-aware snapshot block — this is the SOLE
2221
- // Memory section in the system prompt. Adjust runs post-turn
2222
- // (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.
2223
2168
  const ownVpIdForAms = vpPersona && typeof vpPersona === 'object'
2224
2169
  && typeof vpPersona.vpId === 'string'
2225
2170
  ? vpPersona.vpId
@@ -2255,7 +2200,9 @@ export class Engine {
2255
2200
  scope: e.scope,
2256
2201
  summary: String(e.summary),
2257
2202
  truncated: false,
2258
- source: 'resident-summary',
2203
+ source: e.scope?.startsWith(activeTopicDreamPrefix || '\u0000')
2204
+ ? 'canonical-topic-content'
2205
+ : 'resident-summary',
2259
2206
  }))
2260
2207
  : [];
2261
2208
 
@@ -2263,9 +2210,8 @@ export class Engine {
2263
2210
  // Structured per-turn scope summary: session + vp + members + envelope routing
2264
2211
  // info. Long-form scope content lives in AMS — this block carries
2265
2212
  // only IDs + tiny labels. (Feature scope retired 2026-05-13.)
2266
- const activeSessionTopics = Array.isArray(sessionTopics)
2267
- ? sessionTopics
2268
- : topicScopesForMemory.slice(0, 8).map(scope => scope.replace(/^sessions\/[^/]+\/topic\//, ''));
2213
+ const activeSessionTopics = topicScopesForResident
2214
+ .map(scope => scope.replace(/^sessions\/[^/]+\/topic\//, ''));
2269
2215
  const activeScope = {
2270
2216
  sessionId: sessionId || '',
2271
2217
  sessionMember: ownVpIdForAms || '',
@@ -2327,9 +2273,9 @@ export class Engine {
2327
2273
  // a `<conversation_summary>` user/assistant pair. It MUST NEVER appear
2328
2274
  // in the system prompt — that was the bug DESIGN-PROMPT §4.3 banned.
2329
2275
  //
2330
- // Inversely: Dream V2's output (per-scope `memory.md` / `summary.md`)
2331
- // flows exclusively through `prompts.js#buildSystemPrompt`'s §6 Memory
2332
- // 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.
2333
2279
  // It MUST NEVER appear in the messages array.
2334
2280
  //
2335
2281
  // Two write roots, two scheduler triggers, two prompt slots — never
@@ -3622,30 +3568,6 @@ export class Engine {
3622
3568
  yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
3623
3569
  }
3624
3570
 
3625
- // ─── Post-turn AMS adjust ────────────────────────────────
3626
- // shouldRunAdjust gates the LLM round-trip so most turns are
3627
- // free; first turn always runs, plus on budget pressure.
3628
- if (amsContext) {
3629
- const adjustResult = await this.#runAdjustHook({
3630
- amsContext,
3631
- userMsg: prompt,
3632
- assistantReply: fullResponseText,
3633
- turnTokenUsage: cumulativeInputTokens + cumulativeOutputTokens,
3634
- });
3635
- if (adjustResult && adjustResult.ran) {
3636
- yield {
3637
- type: 'memory_adjust',
3638
- turnId: queryTurnId,
3639
- threadId,
3640
- sessionKey: amsContext.sessionKey,
3641
- added: adjustResult.added,
3642
- evicted: adjustResult.evicted,
3643
- skipped: adjustResult.skipped || 0,
3644
- reason: adjustResult.reason,
3645
- };
3646
- }
3647
- }
3648
-
3649
3571
  // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
3650
3572
  // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
3651
3573
  // (8) AND no T1 has actually rewritten the arc yet. Kicks off the
@@ -4677,33 +4599,96 @@ export class Engine {
4677
4599
  }
4678
4600
  }
4679
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
+
4680
4658
  async function readTopicSummary(scope, opts) {
4681
- const m = /^sessions\/([^/]+)\/topic\/(.+)$/.exec(String(scope || ''));
4682
- if (!m) return null;
4683
- const path = m[2].split('/').filter(Boolean);
4684
- if (path.length === 0) return null;
4685
- const summary = await readScopeSummary(
4686
- { kind: 'session-topic', sessionId: m[1], path },
4687
- opts,
4688
- ).catch(() => '');
4689
- 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.]`;
4690
4677
  }
4691
4678
 
4692
- async function collectTopicLabels(dir, prefix, labels, limit) {
4693
- if (labels.length >= limit) return;
4679
+ async function collectTopicLabels(dir, prefix, labels) {
4694
4680
  let entries;
4695
4681
  try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
4682
+ const hasContent = entries.some(entry => entry.isFile() && entry.name === 'content.md');
4696
4683
  const hasMemory = entries.some(entry => entry.isFile() && entry.name === 'memory.md');
4697
4684
  const hasSummary = entries.some(entry => entry.isFile() && entry.name === 'summary.md');
4698
- if (prefix && (hasMemory || hasSummary)) labels.push(prefix);
4699
- if (labels.length >= limit) return;
4685
+ if (prefix && (hasContent || hasMemory || hasSummary)) labels.push(prefix);
4700
4686
  const dirs = entries
4701
4687
  .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
4702
4688
  .map(entry => entry.name)
4703
4689
  .sort();
4704
4690
  for (const name of dirs) {
4705
4691
  const nextPrefix = prefix ? `${prefix}/${name}` : name;
4706
- await collectTopicLabels(join(dir, name), nextPrefix, labels, limit);
4707
- if (labels.length >= limit) return;
4692
+ await collectTopicLabels(join(dir, name), nextPrefix, labels);
4708
4693
  }
4709
4694
  }