@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +91 -97
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- 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 +221 -236
- 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/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 || [];
|
|
@@ -1698,7 +1645,7 @@ export class Engine {
|
|
|
1698
1645
|
timeout = setTimeout(() => {
|
|
1699
1646
|
maintenanceCtrl.abort('compact_summary_timeout');
|
|
1700
1647
|
reject(new LLMAbortError());
|
|
1701
|
-
},
|
|
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 = '',
|
|
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,
|
|
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 = '',
|
|
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
|
|
2163
|
-
// 1. FTS5
|
|
2164
|
-
// 2.
|
|
2165
|
-
// 3. AMS renders
|
|
2166
|
-
//
|
|
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
|
-
|
|
2145
|
+
);
|
|
2146
|
+
const recalledRelatedSessionIds = selectRelatedSessionIds(
|
|
2147
|
+
projectSessionIds,
|
|
2148
|
+
recallResult?.entries || [],
|
|
2201
2149
|
);
|
|
2202
2150
|
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
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:
|
|
2161
|
+
relatedSessionIds: recalledRelatedSessionIds,
|
|
2214
2162
|
});
|
|
2215
2163
|
|
|
2216
2164
|
// ─── AMS: populate + snapshot ───────────────────────────────
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
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: '
|
|
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 =
|
|
2267
|
-
|
|
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
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
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
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
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
|
|
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
|
|
4707
|
-
if (labels.length >= limit) return;
|
|
4692
|
+
await collectTopicLabels(join(dir, name), nextPrefix, labels);
|
|
4708
4693
|
}
|
|
4709
4694
|
}
|