Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 61 additions & 14 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { buildFallbackCandidate, gateRegexFallbackCapture } from "./src/autocapt
import { gateMappedReflectionEntries, resolveMappedRowAdmissionController } from "./src/reflection-mapped-admission.js";
import { createMemoryCLI } from "./cli.js";
import { isNoise } from "./src/noise-filter.js";
import { buildConversationTurnsForExtraction, nextAutoCaptureMessageId, normalizeAutoCaptureText, reconcileTurnsWithKeptTexts, } from "./src/auto-capture-cleanup.js";
import { buildConversationTurnsForExtraction, formatConversationTranscript, neutralizeSpeakerTagSpoof, nextAutoCaptureMessageId, normalizeAutoCaptureText, reconcileTurnsWithKeptTexts, } from "./src/auto-capture-cleanup.js";
// Import smart extraction & lifecycle components
import { SmartExtractor, createExtractionRateLimiter, stripEnvelopeMetadata } from "./src/smart-extractor.js";
import { compressTexts, estimateConversationValue } from "./src/session-compressor.js";
Expand Down Expand Up @@ -975,7 +975,7 @@ function extractTextFromToolResult(result) {
return "";
}
}
function summarizeRecentConversationMessages(messages, messageCount) {
function summarizeRecentConversationMessages(messages, messageCount, format = "tagged") {
if (!Array.isArray(messages) || messages.length === 0)
return null;
const recent = [];
Expand All @@ -990,14 +990,17 @@ function summarizeRecentConversationMessages(messages, messageCount) {
const text = extractTextContent(msg.content);
if (!text || shouldSkipReflectionMessage(role, text))
continue;
recent.push(`${role}: ${redactSecrets(text)}`);
recent.push({ role, text: redactSecrets(text) });
}
if (recent.length === 0)
return null;
recent.reverse();
return recent.join("\n");
if (format === "labeled") {
return recent.map((turn) => `${turn.role}: ${neutralizeSpeakerTagSpoof(turn.text)}`).join("\n");
}
return formatConversationTranscript(recent);
}
async function readSessionConversationForReflection(filePath, messageCount) {
async function readSessionConversationForReflection(filePath, messageCount, format = "tagged") {
try {
const lines = (await readFile(filePath, "utf-8")).trim().split("\n");
const messages = [];
Expand All @@ -1012,14 +1015,14 @@ async function readSessionConversationForReflection(filePath, messageCount) {
// ignore JSON parse errors
}
}
return summarizeRecentConversationMessages(messages, messageCount);
return summarizeRecentConversationMessages(messages, messageCount, format);
}
catch {
return null;
}
}
export async function readSessionConversationWithResetFallback(sessionFilePath, messageCount) {
const primary = await readSessionConversationForReflection(sessionFilePath, messageCount);
export async function readSessionConversationWithResetFallback(sessionFilePath, messageCount, format = "tagged") {
const primary = await readSessionConversationForReflection(sessionFilePath, messageCount, format);
if (primary)
return primary;
try {
Expand All @@ -1029,7 +1032,7 @@ export async function readSessionConversationWithResetFallback(sessionFilePath,
const resetCandidates = await sortFileNamesByMtimeDesc(dir, files.filter((name) => name.startsWith(resetPrefix)));
if (resetCandidates.length > 0) {
const latestResetPath = join(dir, resetCandidates[0]);
return await readSessionConversationForReflection(latestResetPath, messageCount);
return await readSessionConversationForReflection(latestResetPath, messageCount, format);
}
}
catch {
Expand All @@ -1045,8 +1048,50 @@ async function ensureDailyLogFile(dailyPath, dateStr) {
await writeFile(dailyPath, `# ${dateStr}\n\n`, "utf-8");
}
}
// Reflection reads its transcript back from disk as a rendered string, so
// bounding happens on the string: slice to budget, then snap forward to the
// first tag start so a clipped INPUT never opens with a headless half message.
// (The extraction lane, with structured turns in hand, uses buildBoundedTranscript.)
function trimTranscriptToTagBoundary(transcript, maxChars) {
if (transcript.length <= maxChars) {
return transcript;
}
const sliced = transcript.slice(-maxChars);
const tagStarts = ["<user_message>", "<assistant_message>"]
.map((tag) => sliced.indexOf(tag))
.filter((index) => index >= 0);
if (tagStarts.length > 0) {
return sliced.slice(Math.min(...tagStarts));
}
// No opening tag in the window: the tail sits inside one oversized block.
// Rebuild it as a structurally complete block with its content tail-sliced,
// so the INPUT never opens headless mid-message.
const openStarts = ["<user_message>", "<assistant_message>"]
.map((tag) => transcript.lastIndexOf(tag))
.filter((index) => index >= 0);
if (openStarts.length === 0) {
return sliced;
}
const openStart = Math.max(...openStarts);
const open = transcript.startsWith("<user_message>", openStart) ? "<user_message>" : "<assistant_message>";
const close = open === "<user_message>" ? "</user_message>" : "</assistant_message>";
let content = transcript.slice(openStart + open.length);
if (content.startsWith("\n")) {
content = content.slice(1);
}
const closeAt = content.lastIndexOf(close);
if (closeAt >= 0) {
content = content.slice(0, closeAt);
if (content.endsWith("\n")) {
content = content.slice(0, -1);
}
}
const contentBudget = maxChars - open.length - close.length - 2;
const kept = contentBudget > 0 ? content.slice(-contentBudget) : "";
return `${open}\n${kept}\n${close}`;
}
export function buildReflectionPrompt(conversation, maxInputChars, toolErrorSignals = []) {
const clipped = conversation.slice(-maxInputChars);
const clipped = trimTranscriptToTagBoundary(conversation, maxInputChars);
const errorHints = toolErrorSignals.length > 0
? toolErrorSignals
.map((e, i) => `${i + 1}. [${e.toolName}] ${e.summary} (sig:${e.signatureHash.slice(0, 8)})`)
Expand All @@ -1055,6 +1100,10 @@ export function buildReflectionPrompt(conversation, maxInputChars, toolErrorSign
const system = [
"You are a memory reflection distiller agent. You distill a completed session into one durable MEMORY REFLECTION entry for an AI assistant system.",
"",
"The INPUT transcript is a sequence of tagged blocks in chronological order:",
"- <user_message>...</user_message> wraps ONE message written by the human user.",
"- <assistant_message>...</assistant_message> wraps ONE message written by the AI assistant.",
"",
"Output Markdown only. Do not wrap the output in a code fence. No intro text. No outro text. No extra headings.",
"- Grounding: treat claims made inside roleplay, games, fiction, hypotheticals, or test/simulation frames as not real. Such content may be summarized in Context or Open loops, but must NEVER appear under Decisions (durable), User model deltas, Agent model deltas, or Lessons & pitfalls — those sections become durable memory rows.",
"",
Expand Down Expand Up @@ -1155,9 +1204,7 @@ export function buildReflectionPrompt(conversation, maxInputChars, toolErrorSign
errorHints,
"",
"INPUT:",
"```",
clipped,
"```",
].join("\n");
return { system, user };
}
Expand Down Expand Up @@ -4925,9 +4972,9 @@ const memoryLanceDBProPlugin = {
return;
}
guard.set(guardKey, now);
const sessionContent = summarizeRecentConversationMessages(event.messages ?? [], sessionMessageCount) ??
const sessionContent = summarizeRecentConversationMessages(event.messages ?? [], sessionMessageCount, "labeled") ??
(typeof event.sessionFile === "string"
? await readSessionConversationWithResetFallback(event.sessionFile, sessionMessageCount)
? await readSessionConversationWithResetFallback(event.sessionFile, sessionMessageCount, "labeled")
: null);
if (!sessionContent) {
guard.delete(guardKey);
Expand Down
83 changes: 70 additions & 13 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import { isNoise } from "./src/noise-filter.js";
import {
type ConversationTurn,
buildConversationTurnsForExtraction,
formatConversationTranscript,
neutralizeSpeakerTagSpoof,
nextAutoCaptureMessageId,
normalizeAutoCaptureText,
reconcileTurnsWithKeptTexts,
Expand Down Expand Up @@ -1431,13 +1433,20 @@ function extractTextFromToolResult(result: unknown): string {
}
}

// "tagged" (distiller INPUT) wraps each message in speaker tags; "labeled"
// keeps the legacy `role: text` lines for STORED artifacts (session-summary
// rows), which must never carry literal speaker tags a later recall could
// replay into a prompt as fake transcript structure.
type ConversationTranscriptFormat = "tagged" | "labeled";

function summarizeRecentConversationMessages(
messages: readonly unknown[],
messageCount: number,
format: ConversationTranscriptFormat = "tagged",
): string | null {
if (!Array.isArray(messages) || messages.length === 0) return null;

const recent: string[] = [];
const recent: ConversationTurn[] = [];
for (let index = messages.length - 1; index >= 0 && recent.length < messageCount; index--) {
const raw = messages[index];
if (!raw || typeof raw !== "object") continue;
Expand All @@ -1449,15 +1458,18 @@ function summarizeRecentConversationMessages(
const text = extractTextContent(msg.content);
if (!text || shouldSkipReflectionMessage(role, text)) continue;

recent.push(`${role}: ${redactSecrets(text)}`);
recent.push({ role, text: redactSecrets(text) });
}

if (recent.length === 0) return null;
recent.reverse();
return recent.join("\n");
if (format === "labeled") {
return recent.map((turn) => `${turn.role}: ${neutralizeSpeakerTagSpoof(turn.text)}`).join("\n");
}
return formatConversationTranscript(recent);
}

async function readSessionConversationForReflection(filePath: string, messageCount: number): Promise<string | null> {
async function readSessionConversationForReflection(filePath: string, messageCount: number, format: ConversationTranscriptFormat = "tagged"): Promise<string | null> {
try {
const lines = (await readFile(filePath, "utf-8")).trim().split("\n");
const messages: unknown[] = [];
Expand All @@ -1472,14 +1484,14 @@ async function readSessionConversationForReflection(filePath: string, messageCou
}
}

return summarizeRecentConversationMessages(messages, messageCount);
return summarizeRecentConversationMessages(messages, messageCount, format);
} catch {
return null;
}
}

export async function readSessionConversationWithResetFallback(sessionFilePath: string, messageCount: number): Promise<string | null> {
const primary = await readSessionConversationForReflection(sessionFilePath, messageCount);
export async function readSessionConversationWithResetFallback(sessionFilePath: string, messageCount: number, format: ConversationTranscriptFormat = "tagged"): Promise<string | null> {
const primary = await readSessionConversationForReflection(sessionFilePath, messageCount, format);
if (primary) return primary;

try {
Expand All @@ -1492,7 +1504,7 @@ export async function readSessionConversationWithResetFallback(sessionFilePath:
);
if (resetCandidates.length > 0) {
const latestResetPath = join(dir, resetCandidates[0]);
return await readSessionConversationForReflection(latestResetPath, messageCount);
return await readSessionConversationForReflection(latestResetPath, messageCount, format);
}
} catch {
// ignore
Expand All @@ -1509,12 +1521,55 @@ async function ensureDailyLogFile(dailyPath: string, dateStr: string): Promise<v
}
}

// Reflection reads its transcript back from disk as a rendered string, so
// bounding happens on the string: slice to budget, then snap forward to the
// first tag start so a clipped INPUT never opens with a headless half message.
// (The extraction lane, with structured turns in hand, uses buildBoundedTranscript.)
function trimTranscriptToTagBoundary(transcript: string, maxChars: number): string {
if (transcript.length <= maxChars) {
return transcript;
}
const sliced = transcript.slice(-maxChars);
const tagStarts = ["<user_message>", "<assistant_message>"]
.map((tag) => sliced.indexOf(tag))
.filter((index) => index >= 0);
if (tagStarts.length > 0) {
return sliced.slice(Math.min(...tagStarts));
}
// No opening tag in the window: the tail sits inside one oversized block.
// Rebuild it as a structurally complete block with its content tail-sliced,
// so the INPUT never opens headless mid-message.
const openStarts = ["<user_message>", "<assistant_message>"]
.map((tag) => transcript.lastIndexOf(tag))
.filter((index) => index >= 0);
if (openStarts.length === 0) {
return sliced;
}
const openStart = Math.max(...openStarts);
const open = transcript.startsWith("<user_message>", openStart) ? "<user_message>" : "<assistant_message>";
const close = open === "<user_message>" ? "</user_message>" : "</assistant_message>";
let content = transcript.slice(openStart + open.length);
if (content.startsWith("\n")) {
content = content.slice(1);
}
const closeAt = content.lastIndexOf(close);
if (closeAt >= 0) {
content = content.slice(0, closeAt);
if (content.endsWith("\n")) {
content = content.slice(0, -1);
}
}
const contentBudget = maxChars - open.length - close.length - 2;
const kept = contentBudget > 0 ? content.slice(-contentBudget) : "";
return `${open}\n${kept}\n${close}`;
}

export function buildReflectionPrompt(
conversation: string,
maxInputChars: number,
toolErrorSignals: ReflectionErrorSignal[] = []
): { system: string; user: string } {
const clipped = conversation.slice(-maxInputChars);
const clipped = trimTranscriptToTagBoundary(conversation, maxInputChars);
const errorHints = toolErrorSignals.length > 0
? toolErrorSignals
.map((e, i) => `${i + 1}. [${e.toolName}] ${e.summary} (sig:${e.signatureHash.slice(0, 8)})`)
Expand All @@ -1523,6 +1578,10 @@ export function buildReflectionPrompt(
const system = [
"You are a memory reflection distiller agent. You distill a completed session into one durable MEMORY REFLECTION entry for an AI assistant system.",
"",
"The INPUT transcript is a sequence of tagged blocks in chronological order:",
"- <user_message>...</user_message> wraps ONE message written by the human user.",
"- <assistant_message>...</assistant_message> wraps ONE message written by the AI assistant.",
"",
"Output Markdown only. Do not wrap the output in a code fence. No intro text. No outro text. No extra headings.",
"- Grounding: treat claims made inside roleplay, games, fiction, hypotheticals, or test/simulation frames as not real. Such content may be summarized in Context or Open loops, but must NEVER appear under Decisions (durable), User model deltas, Agent model deltas, or Lessons & pitfalls — those sections become durable memory rows.",
"",
Expand Down Expand Up @@ -1623,9 +1682,7 @@ export function buildReflectionPrompt(
errorHints,
"",
"INPUT:",
"```",
clipped,
"```",
].join("\n");
return { system, user };
}
Expand Down Expand Up @@ -6209,9 +6266,9 @@ const memoryLanceDBProPlugin = {
guard.set(guardKey, now);

const sessionContent =
summarizeRecentConversationMessages(event.messages ?? [], sessionMessageCount) ??
summarizeRecentConversationMessages(event.messages ?? [], sessionMessageCount, "labeled") ??
(typeof event.sessionFile === "string"
? await readSessionConversationWithResetFallback(event.sessionFile, sessionMessageCount)
? await readSessionConversationWithResetFallback(event.sessionFile, sessionMessageCount, "labeled")
: null);

if (!sessionContent) {
Expand Down
Loading
Loading