@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +73 -73
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +4 -0
- package/yeaft/dream/apply.js +59 -30
- package/yeaft/dream/output-snapshot.js +8 -4
- package/yeaft/dream/prompts/consolidate-topics.md +31 -0
- package/yeaft/dream/prompts/create.md +8 -8
- package/yeaft/dream/prompts/index.js +4 -2
- package/yeaft/dream/prompts/merge-topics.md +35 -0
- package/yeaft/dream/prompts/triage-pass1.md +2 -2
- package/yeaft/dream/prompts/triage-pass2.md +4 -2
- package/yeaft/dream/prompts/update.md +16 -14
- package/yeaft/dream/runner.js +69 -13
- package/yeaft/dream/segment-extract.js +16 -13
- package/yeaft/dream/session-wiring.js +2 -2
- package/yeaft/dream/snapshot.js +3 -3
- package/yeaft/dream/topic-consolidation.js +316 -0
- package/yeaft/dream/triage.js +7 -2
- package/yeaft/engine.js +237 -239
- package/yeaft/memory/ams-registry.js +42 -61
- package/yeaft/memory/ams.js +17 -9
- package/yeaft/memory/budget.js +15 -18
- package/yeaft/memory/content-backfill.js +118 -0
- package/yeaft/memory/index-db.js +10 -3
- package/yeaft/memory/keywords.js +25 -7
- package/yeaft/memory/preflow.js +26 -9
- package/yeaft/memory/segment-store.js +44 -8
- package/yeaft/memory/segment-sync.js +8 -4
- package/yeaft/memory/segment.js +10 -3
- package/yeaft/memory/store.js +88 -38
- package/yeaft/memory/summary-store.js +3 -3
- package/yeaft/memory/topic-redirect.js +28 -0
- package/yeaft/router/continuity.js +12 -3
- package/yeaft/session.js +18 -19
- package/yeaft/sessions/pre-flow.js +7 -3
- package/yeaft/sub-agent/runner.js +10 -1
- package/yeaft/work-center/bridge.js +1 -0
- 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 {
|
|
33
|
+
import { isVpForeign, readContent as readScopeContent } from './memory/store.js';
|
|
34
34
|
import { ActiveMemorySet } from './memory/ams.js';
|
|
35
|
-
import {
|
|
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
|
|
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
|
|
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 (
|
|
397
|
-
* the local VP's summary is loaded into `summaries.vp` upstream
|
|
398
|
-
*
|
|
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
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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)\/([^/]+)
|
|
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} —
|
|
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
|
|
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 ''.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
949
|
-
?
|
|
965
|
+
exactSessionScope
|
|
966
|
+
? readCanonicalScope(exactSessionScope, { root: memoryRoot, currentVpId: vpId }).catch(() => '')
|
|
950
967
|
: Promise.resolve(''),
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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 {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
1000
|
+
async #loadSessionTopicScopes(sessionId) {
|
|
972
1001
|
if (!this.#yeaftDir || !sessionId) return [];
|
|
973
|
-
const
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
? '
|
|
1080
|
-
: 'The following text comes from persistent memory
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
-
},
|
|
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 = '',
|
|
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,
|
|
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 = '',
|
|
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
|
|
2157
|
-
// 1. FTS5
|
|
2158
|
-
// 2.
|
|
2159
|
-
// 3. AMS renders
|
|
2160
|
-
//
|
|
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
|
-
|
|
2145
|
+
);
|
|
2146
|
+
const recalledRelatedSessionIds = selectRelatedSessionIds(
|
|
2147
|
+
projectSessionIds,
|
|
2148
|
+
recallResult?.entries || [],
|
|
2195
2149
|
);
|
|
2196
2150
|
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
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:
|
|
2161
|
+
relatedSessionIds: recalledRelatedSessionIds,
|
|
2208
2162
|
});
|
|
2209
2163
|
|
|
2210
2164
|
// ─── AMS: populate + snapshot ───────────────────────────────
|
|
2211
|
-
//
|
|
2212
|
-
//
|
|
2213
|
-
//
|
|
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: '
|
|
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 =
|
|
2261
|
-
|
|
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
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
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
|
-
|
|
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
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
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
|
|
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
|
|
4694
|
-
if (labels.length >= limit) return;
|
|
4692
|
+
await collectTopicLabels(join(dir, name), nextPrefix, labels);
|
|
4695
4693
|
}
|
|
4696
4694
|
}
|