Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

150 lines
5.2 KiB
TypeScript

/**
* PBL v2 — Instructor thread memory compression.
*
* Long chat threads burn context window and slow down every
* Instructor turn. When `thread.messages` exceeds the threshold,
* fold the older half into `earlierSummary` (a bounded
* system-prompt-friendly digest) and keep only the more recent half
* as live messages.
*
* The compression boundary is always aligned to a user message so a
* mid-question pair never gets split — the LLM should never see
* "the learner just said ..." without seeing what they said.
*
* Pure (no LLM call): the summary is a small learner-memory layer
* (facts, blockers, preferences, progress) plus a short recent trace.
* It is intentionally bounded so memory never crowds out the
* project/task/rules blocks in the Instructor prompt.
*/
import type { PBLAgentThread, PBLChatMessage } from '../types';
/** Compress when this many messages are in the live history. */
export const COMPRESS_THRESHOLD = 30;
/** After compression, this many messages remain live (most recent). */
const KEEP_RECENT = 16;
/** Hard cap for folded memory. Keeps prompt cost stable over long projects. */
export const MAX_EARLIER_SUMMARY_CHARS = 4500;
/**
* Compress the thread if its message list has grown past the
* threshold. Returns a (possibly new) thread; mutates nothing in
* place — the caller is expected to assign the return value.
*/
export function compressIfNeeded(thread: PBLAgentThread): PBLAgentThread {
if (thread.messages.length <= COMPRESS_THRESHOLD) return thread;
// Find a cut point that lands on the boundary "user msg ends, next
// assistant msg starts" so we don't split a turn.
const targetCutIdx = thread.messages.length - KEEP_RECENT;
let cutIdx = targetCutIdx;
while (cutIdx > 0 && thread.messages[cutIdx].roleType !== 'user') {
cutIdx--;
}
// Safety: don't cut everything; preserve at least KEEP_RECENT/2 live.
if (cutIdx < 1 || cutIdx > thread.messages.length - 4) {
cutIdx = Math.max(1, targetCutIdx);
}
const older = thread.messages.slice(0, cutIdx);
const recent = thread.messages.slice(cutIdx);
const olderSummary = digestOlderHalf(older);
const combined = compactSummary(
thread.earlierSummary ? `${thread.earlierSummary}\n\n${olderSummary}` : olderSummary,
);
return {
...thread,
earlierSummary: combined,
messages: recent,
};
}
/** Produce a structural digest from the older half of the thread. */
function digestOlderHalf(messages: PBLChatMessage[]): string {
const userTurns = messages.filter((m) => m.roleType === 'user').length;
const assistantTurns = messages.filter((m) => m.roleType === 'instructor').length;
const memory = extractLearnerMemory(messages);
const trace = messages
.slice(-8)
.map((m) => {
const role =
m.roleType === 'user' ? 'Learner' : m.roleType === 'instructor' ? 'Instructor' : m.roleType;
return `- ${role}: ${truncate(firstParagraph(m.content), 150)}`;
})
.join('\n');
return [
'## Earlier conversation digest',
`Older turns folded: learner=${userTurns}, instructor=${assistantTurns}.`,
'',
'Learner context inferred from folded turns:',
memory.length
? memory.map((m) => `- ${m}`).join('\n')
: '- No durable learner facts extracted.',
'',
'Recent folded exchange trace:',
trace || '(none)',
].join('\n');
}
function extractLearnerMemory(messages: PBLChatMessage[]): string[] {
const userMessages = messages.filter((m) => m.roleType === 'user');
const buckets: Array<{ label: string; re: RegExp; max: number }> = [
{
label: 'Environment/tools',
re: /(vscode|vs code|pycharm|cursor|ide|terminal|终端|命令行|python|node|npm|pnpm|浏览器|mac|windows|jupyter)/i,
max: 3,
},
{
label: 'Level/preference',
re: /(零基础|0基础|小白|新手|初学|最简单|不懂|不会|有基础|高级|慢一点|详细|直接|中文|英文|beginner|advanced|explain|simple)/i,
max: 4,
},
{
label: 'Blocker/confusion',
re: /(报错|错误|失败|卡住|不懂|不会|error|failed|traceback|stuck|confused|cannot|can't)/i,
max: 4,
},
{
label: 'Progress/evidence',
re: /(完成|好了|可以了|通过|成功|提交|保存|done|works|worked|passed|submitted|saved)/i,
max: 4,
},
];
const notes: string[] = [];
for (const bucket of buckets) {
const matches = userMessages
.filter((m) => bucket.re.test(m.content))
.slice(-bucket.max)
.map((m) => truncate(m.content, 140));
if (matches.length) {
notes.push(`${bucket.label}: ${matches.join(' / ')}`);
}
}
return notes.slice(0, 10);
}
function compactSummary(summary: string): string {
if (summary.length <= MAX_EARLIER_SUMMARY_CHARS) return summary;
const tail = summary.slice(summary.length - MAX_EARLIER_SUMMARY_CHARS + 120);
return [
'## Earlier conversation memory (compacted)',
'Older folded memory exceeded the budget; this keeps the newest durable learner context and recent trace.',
tail.replace(/^\s+/, ''),
].join('\n');
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max - 1) + '…';
}
function firstParagraph(s: string): string {
const idx = s.indexOf('\n\n');
return idx > 0 ? s.slice(0, idx) : s;
}