Files
openclaw/extensions/active-memory/prompt.ts
Jesse Merhi d7627d6f4c refactor(prompt): use plain inbound context labels and drop system-tag sanitizer (#112000)
* refactor(prompt): plain inbound context labels with a provenance marker

Replaces trust-worded inbound context labels ("(untrusted metadata)",
"(untrusted, for context)") with plain labels plus a fixed provenance
marker suffix appended to every OpenClaw-injected context header.

Detection keys on the marker, not label text, so strippers stay correct
across UI, TUI, replay, /trace segmentation, memory recall, and the Swift
chat preprocessor. Drops sanitizeInboundSystemTags in favor of the marker
boundary plus trusted system-prompt narration.

Renames the untrusted-named plugin SDK context identifiers to
channel-provenance names, keeping deprecated aliases registered for
removal after 2026-09-08.

Adds `openclaw doctor --fix` migrations that rewrite legacy inbound
labels in stored SQLite transcripts and purge legacy envelope-
contaminated LanceDB recall rows.

* fix(ci): resolve gate failures for plain inbound context labels

- doctor sqlite readers: open read-only connections via openNodeSqliteDatabase
  so the Kysely connection-boundary guardrail holds; unexport the now-internal
  transcript snapshot type (Knip unused-export gate).
- compat registry: split the record table into registry-records.ts and
  plugin-sdk-subpath-records.ts. The new compat record pushed registry.ts past
  the 700-line oxlint cap; suppressions are disallowed, so follow the existing
  sibling record-module pattern. Public exports and PluginCompatCode literals
  unchanged.
- acp-runtime test: assert current finalization behavior (newline normalization
  only). The bracket de-fang and System: rewrite it expected were removed with
  sanitizeInboundSystemTags; forged system lines are neutralized at the
  system-event queue, the single chokepoint feeding the System:-per-line render.
- regenerate docs_map and the plugin SDK API baseline manifest.

* fix(prompt): harden inbound context label migration and drop in-band sanitizer

Review follow-ups on the plain-label + provenance-marker change:

- Remove src/security/system-tags.ts. Rewriting inbound text to neutralize
  look-alike `System:`/`[System]` markers corrupted legitimate user text and is
  not a real injection boundary; role separation plus external-content wrapping
  is. Explicit product decision, recorded at the system-event queue.
- Narrow the LanceDB legacy-row purge so it cannot delete benign memories. It
  now requires a complete known legacy sentinel line, a legacy label followed by
  a fenced JSON body, or the complete legacy external-content header. The prior
  predicates matched ordinary prose such as `Notes (untrusted metadata):`, and
  deletion is irreversible.
- Make explicit-empty canonical ChannelStructuredContext win over the deprecated
  alias via a present/absent result instead of collapsing `[]` to undefined.
- Keep `\r?` in the active-memory doctor rule. It is the only rule spanning the
  header's line break, migrated assistant rows skip newline normalization, and
  without it the marked-header replace wins and the body strips to empty. Added
  a CRLF regression test.
- Fix stale comments that described removed behavior, and cover the Swift
  prose-block strip path.

Claude-Session: https://claude.ai/code/session_01WNzsPddQmxy9Y7jKD4wAxH
2026-07-27 11:27:53 +10:00

322 lines
13 KiB
TypeScript

import {
asOptionalRecord as asRecord,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { extractTextContentParts } from "./query.js";
import {
ACTIVE_MEMORY_PLUGIN_TAG,
ACTIVE_MEMORY_CONTEXT_HEADER,
NO_RECALL_VALUES,
STRUCTURED_MEMORY_EMPTY_STATUSES,
STRUCTURED_MEMORY_FAILURE_STATUSES,
TIMEOUT_BOILERPLATE_PATTERNS,
type ActiveMemoryPromptStyle,
type ResolvedActiveRecallPluginConfig,
} from "./types.js";
function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] {
switch (style) {
case "strict":
return [
"Treat the latest user message as the only primary query.",
"Use any additional context only for narrow disambiguation.",
"Do not return memory just because it matches the broader conversation topic.",
"Return memory only if it clearly helps with the latest user message itself.",
"If the latest user message does not strongly call for memory, reply with NONE.",
"If the connection is weak, indirect, or speculative, reply with NONE.",
];
case "contextual":
return [
"Treat the latest user message as the primary query.",
"Use recent conversation to understand continuity and intent, but do not let older context override the latest user message.",
"When the latest message shifts domains, prefer memory that matches the new domain.",
"Return memory when it materially helps the other model answer the latest user message or maintain clear conversational continuity.",
];
case "recall-heavy":
return [
"Treat the latest user message as the primary query, but be willing to surface memory on softer plausible matches when it would add useful continuity or personalization.",
"If there is a credible recurring preference, habit, or user-context match, lean toward returning memory instead of NONE.",
"Still prefer the memory domain that best matches the latest user message.",
];
case "precision-heavy":
return [
"Treat the latest user message as the primary query.",
"Use recent conversation only for narrow disambiguation.",
"Aggressively prefer NONE unless the memory clearly and directly helps with the latest user message.",
"Do not return memory for soft, speculative, or loosely adjacent matches.",
];
case "preference-only":
return [
"Treat the latest user message as the primary query.",
"Optimize for favorites, preferences, habits, routines, taste, and recurring personal facts.",
"If relevant memory is mostly a stable user preference or recurring habit, lean toward returning it.",
"If the strongest match is only a one-off historical fact and not a recurring preference or habit, prefer NONE unless the latest user message clearly asks for that fact.",
];
default:
return [
"Treat the latest user message as the primary query.",
"Use recent conversation only to disambiguate what the latest user message means.",
"Do not return memory just because it matched the broader recent topic; return memory only if it clearly helps with the latest user message itself.",
"If recent context and the latest user message point to different memory domains, prefer the domain that best matches the latest user message.",
];
}
}
function buildRecallPrompt(params: {
config: ResolvedActiveRecallPluginConfig;
query: string;
searchQuery: string;
}): string {
const defaultInstructions = [
"You are a memory search agent.",
"Another model is preparing the final user-facing answer.",
"Your job is to search memory and return only the most relevant memory context for that model.",
"You receive a bounded search query plus conversation context, including the user's latest message.",
"Use only the available memory tools.",
"Use the bounded search query with the configured memory tools.",
`Configured memory tools: ${params.config.toolsAllow.join(", ")}.`,
"Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.",
"If the available memory tools find nothing useful, reply with NONE.",
"When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.",
"Do not answer the user directly.",
`Prompt style: ${params.config.promptStyle}.`,
...buildPromptStyleLines(params.config.promptStyle),
"If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.",
"Questions like 'what is my favorite food', 'do you remember my flight preferences', or 'what do i usually get' should normally return memory when relevant results exist.",
"If the provided conversation context already contains recalled-memory summaries, debug output, or prior memory/tool traces, ignore that surfaced text unless the latest user message clearly requires re-checking it.",
"Return memory only when it would materially help the other model answer the user's latest message.",
"Mutable operational facts (cron/job health, automation status, deployments, incidents, service availability) go stale quickly: include the source timestamp when available, say when the memory may be stale, and tell the answering model to verify live before relying on it.",
"Do not summarize mutable operational facts as simply current/running/healthy unless the memory result itself contains a current source timestamp and matching health state.",
"If the connection is weak, broad, or only vaguely related, reply with NONE.",
"If nothing clearly useful is found, reply with NONE.",
"Return exactly one of these two forms:",
"1. NONE",
"2. one compact plain-text summary",
`If something is useful, reply with one compact plain-text summary under ${params.config.maxSummaryChars} characters total.`,
"Write the summary as a memory note about the user, not as a reply to the user.",
"Do not explain your reasoning.",
"Do not return bullets, numbering, labels, XML, JSON, or markdown list formatting.",
"Do not prefix the summary with 'Memory:' or any other label.",
"",
"Good examples:",
"User message: What is my favorite food?",
"Return: User's favorite food is ramen; tacos also come up often.",
"User message: Do you remember my flight preferences?",
"Return: User prefers aisle seats and extra buffer over tight connections.",
"Recent context: user was discussing flights and airport planning.",
"Latest user message: I might see a movie while I wait for the flight.",
"Return: User's favorite movie snack is buttery popcorn with extra salt.",
"User message: Explain DNS over HTTPS.",
"Return: NONE",
"",
"Bad examples:",
"Return: - Favorite food is ramen",
"Return: 1. Favorite food is ramen",
"Return: Memory: Favorite food is ramen",
'Return: {"memory":"Favorite food is ramen"}',
"Return: <memory>Favorite food is ramen</memory>",
"Return: Ramen seems to be your favorite food.",
"Return: You like aisle seats and extra buffer.",
"Return: I prefer aisle seats and extra buffer.",
"Recent context: user was discussing flights and airport planning. Latest user message: I might see a movie while I wait for the flight. Return: User prefers aisle seats and extra buffer over tight connections.",
].join("\n");
const instructionBlock = [
params.config.promptOverride ?? defaultInstructions,
params.config.promptAppend
? `Additional operator instructions:\n${params.config.promptAppend}`
: "",
]
.filter((section) => section.length > 0)
.join("\n\n");
return [
instructionBlock,
`Bounded memory search query:\n${params.searchQuery}`,
`Conversation context:\n${params.query}`,
].join("\n\n");
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function normalizeNoRecallValue(value: string): boolean {
return NO_RECALL_VALUES.has(value.trim().toLowerCase());
}
function readExplicitMemoryEvidence(source: Record<string, unknown>): boolean | undefined {
const status = normalizeOptionalString(source.status)
?.toLowerCase()
.replace(/[\s-]+/g, "_");
if (status !== undefined && STRUCTURED_MEMORY_EMPTY_STATUSES.has(status)) {
return false;
}
const resultCollections = [source.results, source.memories, source.items];
if (resultCollections.some((entry) => Array.isArray(entry))) {
return resultCollections.some((entry) => Array.isArray(entry) && entry.length > 0);
}
const resultCounts = [
source.count,
source.matches,
source.memoryCount,
source.resultCount,
source.totalMatches,
];
if (resultCounts.some((entry) => typeof entry === "number" && Number.isFinite(entry))) {
return resultCounts.some(
(entry) => typeof entry === "number" && Number.isFinite(entry) && entry > 0,
);
}
if (typeof source.found === "boolean" || typeof source.hasResults === "boolean") {
return source.found === true || source.hasResults === true;
}
return undefined;
}
function readStructuredMemoryFailure(source: unknown): boolean | undefined {
const record = asRecord(source);
if (!record) {
return undefined;
}
const status = normalizeOptionalString(record.status)
?.toLowerCase()
.replace(/[\s-]+/g, "_");
const hasFailureStatus = status !== undefined && STRUCTURED_MEMORY_FAILURE_STATUSES.has(status);
const hasFailureFields =
hasFailureStatus ||
["disabled", "unavailable", "success", "error"].some((key) => key in record);
if (!hasFailureFields) {
return undefined;
}
return (
hasFailureStatus ||
record.disabled === true ||
record.unavailable === true ||
record.success === false ||
Boolean(record.error)
);
}
function readStructuredMemoryEvidence(source: unknown): boolean | undefined {
if (Array.isArray(source)) {
return source.length > 0;
}
const record = asRecord(source);
return record ? readExplicitMemoryEvidence(record) : undefined;
}
function readStructuredContentState(
content: unknown,
readState: (source: unknown) => boolean | undefined,
decisiveState: boolean,
): boolean | undefined {
const parts = extractTextContentParts(content);
let sawOtherState = false;
for (const part of parts) {
try {
const state = readState(JSON.parse(part));
if (state === decisiveState) {
return decisiveState;
}
sawOtherState ||= state === !decisiveState;
} catch {}
}
try {
const state = readState(JSON.parse(parts.join(" ").trim()));
if (state !== undefined) {
return state;
}
} catch {}
return sawOtherState ? !decisiveState : undefined;
}
function readStructuredMemoryFailureFromContent(content: unknown): boolean | undefined {
return readStructuredContentState(content, readStructuredMemoryFailure, true);
}
function readStructuredMemoryEvidenceFromContent(content: unknown): boolean | undefined {
return readStructuredContentState(content, readStructuredMemoryEvidence, false);
}
function isTimeoutBoilerplateSummary(value: string): boolean {
return TIMEOUT_BOILERPLATE_PATTERNS.some((pattern) => pattern.test(value));
}
function normalizeActiveSummary(rawReply: string): string | null {
const trimmed = rawReply.trim();
if (normalizeNoRecallValue(trimmed)) {
return null;
}
const singleLine = trimmed.replace(/\s+/g, " ").trim();
if (
!singleLine ||
normalizeNoRecallValue(singleLine) ||
isTimeoutBoilerplateSummary(singleLine)
) {
return null;
}
return singleLine;
}
function truncateSummary(summary: string, maxSummaryChars: number): string {
const trimmed = summary.trim();
if (trimmed.length <= maxSummaryChars) {
return trimmed;
}
const ellipsis = "…";
if (maxSummaryChars <= ellipsis.length) {
return ellipsis.slice(0, Math.max(0, maxSummaryChars));
}
const contentMaxChars = maxSummaryChars - ellipsis.length;
const rawBounded = trimmed.slice(0, contentMaxChars).trimEnd();
const bounded = truncateUtf16Safe(trimmed, contentMaxChars).trimEnd();
const nextChar = trimmed.charAt(contentMaxChars);
if (!nextChar || /\s/.test(nextChar)) {
return `${bounded}${ellipsis}`;
}
const lastBoundary = rawBounded.search(/\s\S*$/);
if (lastBoundary > 0) {
return `${truncateUtf16Safe(trimmed, lastBoundary).trimEnd()}${ellipsis}`;
}
return `${bounded}${ellipsis}`;
}
function buildMetadata(summary: string | null): string | undefined {
if (!summary) {
return undefined;
}
return [
`<${ACTIVE_MEMORY_PLUGIN_TAG}>`,
escapeXml(summary),
`</${ACTIVE_MEMORY_PLUGIN_TAG}>`,
].join("\n");
}
function buildPromptPrefix(summary: string | null): string | undefined {
const metadata = buildMetadata(summary);
if (!metadata) {
return undefined;
}
return [ACTIVE_MEMORY_CONTEXT_HEADER, metadata].join("\n");
}
export {
buildMetadata,
buildPromptPrefix,
buildRecallPrompt,
normalizeActiveSummary,
readExplicitMemoryEvidence,
readStructuredMemoryEvidenceFromContent,
readStructuredMemoryFailure,
readStructuredMemoryFailureFromContent,
truncateSummary,
};