Skip to content
Closed
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
37 changes: 37 additions & 0 deletions src/adaptive-retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,40 @@ export function shouldSkipRetrieval(query: string, minLength?: number): boolean
// Default: do retrieve
return false;
}

/**
* Relaxed skip check for the first turn of a fresh session.
*
* On the very first user message after a session restart, the user often sends
* a low-signal continuation (emoji, "?", "继续", etc.). Normal skip logic would
* suppress retrieval, but this is exactly when continuity recovery matters most.
*
* This function only skips retrieval for:
* - Empty / whitespace-only messages
* - Slash commands (explicit bot commands)
* - Heartbeat / system messages
*
* Everything else — including short messages, pure emoji, and affirmations —
* is allowed through so that autoRecall can attempt continuity recovery.
*
* @param query The raw prompt text
*/
export function shouldSkipRetrievalFirstTurn(query: string): boolean {
const trimmed = normalizeQuery(query);

// Force retrieve if query has memory-related intent
if (FORCE_RETRIEVE_PATTERNS.some(p => p.test(trimmed))) return false;

// Empty after normalization — nothing to retrieve for
if (trimmed.length === 0) return true;

// Slash commands — explicit bot commands, not continuations
if (/^\//.test(trimmed)) return true;

// Heartbeat / system messages — never user-facing continuity
if (/HEARTBEAT/i.test(trimmed)) return true;
if (/^\[System/i.test(trimmed)) return true;

// Everything else: allow retrieval on first turn
return false;
}
23 changes: 20 additions & 3 deletions src/recall-engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { shouldSkipRetrieval } from "./adaptive-retrieval.js";
import { shouldSkipRetrieval, shouldSkipRetrievalFirstTurn } from "./adaptive-retrieval.js";

export interface DynamicRecallSessionState {
historyBySession: Map<string, Map<string, number>>;
Expand Down Expand Up @@ -81,14 +81,31 @@ export function clearDynamicRecallSessionState(state: DynamicRecallSessionState,
export async function orchestrateDynamicRecall<T extends DynamicRecallCandidate>(
params: OrchestrateDynamicRecallParams<T>
): Promise<DynamicRecallResult | undefined> {
if (!params.prompt || shouldSkipRetrieval(params.prompt, params.minPromptLength)) return undefined;
if (!params.prompt) return undefined;

const topK = Number.isFinite(params.topK) ? Math.max(1, Math.floor(params.topK)) : 1;
const sessionId = params.sessionId || "default";
touchDynamicRecallSessionState(params.state, sessionId);
const currentTurn = (params.state.turnCounterBySession.get(sessionId) || 0) + 1;
params.state.turnCounterBySession.set(sessionId, currentTurn);

// On the first turn of a fresh session, use relaxed skip logic to preserve
// continuity recovery. On subsequent turns, use standard adaptive retrieval.
const isFirstTurn = currentTurn === 1;
const shouldSkip = isFirstTurn
? shouldSkipRetrievalFirstTurn(params.prompt)
: shouldSkipRetrieval(params.prompt, params.minPromptLength);

if (shouldSkip) {
if (isFirstTurn) {
params.logger?.debug?.(
`memory-lancedb-pro: ${params.channelName} first-turn retrieval skipped (system/command message, session=${sessionId})`
);
}
return undefined;
}

const topK = Number.isFinite(params.topK) ? Math.max(1, Math.floor(params.topK)) : 1;

const loaded = await params.loadCandidates();
if (loaded.length === 0) return undefined;

Expand Down
142 changes: 142 additions & 0 deletions test/adaptive-retrieval.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import jitiFactory from "jiti";

const jiti = jitiFactory(import.meta.url, { interopDefault: true });

const {
shouldSkipRetrieval,
shouldSkipRetrievalFirstTurn,
} = jiti("../src/adaptive-retrieval.ts");

// ============================================================================
// shouldSkipRetrieval — existing behavior (sanity checks)
// ============================================================================

describe("shouldSkipRetrieval", () => {
it("skips pure emoji", () => {
assert.equal(shouldSkipRetrieval("🤔"), true);
assert.equal(shouldSkipRetrieval("😂👍"), true);
});

it("skips very short messages", () => {
assert.equal(shouldSkipRetrieval("hi"), true);
assert.equal(shouldSkipRetrieval("?"), true);
assert.equal(shouldSkipRetrieval("ok"), true);
});

it("skips greetings", () => {
assert.equal(shouldSkipRetrieval("hello"), true);
assert.equal(shouldSkipRetrieval("good morning"), true);
});

it("skips continuation prompts", () => {
assert.equal(shouldSkipRetrieval("继续"), true);
assert.equal(shouldSkipRetrieval("go ahead"), true);
assert.equal(shouldSkipRetrieval("好的"), true);
});

it("skips slash commands", () => {
assert.equal(shouldSkipRetrieval("/status"), true);
assert.equal(shouldSkipRetrieval("/new"), true);
});

it("does not skip memory-related queries", () => {
assert.equal(shouldSkipRetrieval("你记得吗"), false);
assert.equal(shouldSkipRetrieval("do you remember my name"), false);
assert.equal(shouldSkipRetrieval("what did I say last time"), false);
});

it("does not skip meaningful questions", () => {
assert.equal(shouldSkipRetrieval("what is the capital of France?"), false);
assert.equal(shouldSkipRetrieval("帮我查一下明天的天气怎么样"), false);
});

it("strips OpenClaw metadata before evaluating", () => {
const wrapped = `Conversation info (untrusted metadata):\n\`\`\`json\n{"message_id":"123"}\n\`\`\`\n\n🤔`;
assert.equal(shouldSkipRetrieval(wrapped), true);
});
});

// ============================================================================
// shouldSkipRetrievalFirstTurn — relaxed first-turn logic
// ============================================================================

describe("shouldSkipRetrievalFirstTurn", () => {
it("allows pure emoji through on first turn", () => {
assert.equal(shouldSkipRetrievalFirstTurn("🤔"), false);
assert.equal(shouldSkipRetrievalFirstTurn("😂"), false);
assert.equal(shouldSkipRetrievalFirstTurn("👍"), false);
});

it("allows short continuation messages on first turn", () => {
assert.equal(shouldSkipRetrievalFirstTurn("继续"), false);
assert.equal(shouldSkipRetrievalFirstTurn("嗯"), false);
assert.equal(shouldSkipRetrievalFirstTurn("?"), false);
assert.equal(shouldSkipRetrievalFirstTurn("ok"), false);
assert.equal(shouldSkipRetrievalFirstTurn("go ahead"), false);
});

it("allows greetings on first turn (may carry continuity)", () => {
assert.equal(shouldSkipRetrievalFirstTurn("hi"), false);
assert.equal(shouldSkipRetrievalFirstTurn("hello"), false);
});

it("still skips empty messages", () => {
assert.equal(shouldSkipRetrievalFirstTurn(""), true);
assert.equal(shouldSkipRetrievalFirstTurn(" "), true);
});

it("still skips slash commands", () => {
assert.equal(shouldSkipRetrievalFirstTurn("/status"), true);
assert.equal(shouldSkipRetrievalFirstTurn("/new"), true);
assert.equal(shouldSkipRetrievalFirstTurn("/reset"), true);
});

it("still skips heartbeat messages", () => {
assert.equal(shouldSkipRetrievalFirstTurn("HEARTBEAT_OK"), true);
assert.equal(shouldSkipRetrievalFirstTurn("Read HEARTBEAT.md if it exists"), true);
});

it("still skips system messages", () => {
assert.equal(shouldSkipRetrievalFirstTurn("[System] session started"), true);
});

it("allows memory-related queries", () => {
assert.equal(shouldSkipRetrievalFirstTurn("你记得吗"), false);
assert.equal(shouldSkipRetrievalFirstTurn("remember"), false);
});

it("allows meaningful questions", () => {
assert.equal(shouldSkipRetrievalFirstTurn("what were we talking about?"), false);
});

it("strips OpenClaw metadata before evaluating", () => {
const wrapped = `Conversation info (untrusted metadata):\n\`\`\`json\n{"message_id":"123"}\n\`\`\`\n\n🤔`;
// After stripping metadata, only 🤔 remains — first turn should allow it
assert.equal(shouldSkipRetrievalFirstTurn(wrapped), false);
});
});

// ============================================================================
// Contrast: same inputs, different results between standard vs first-turn
// ============================================================================

describe("standard vs first-turn skip comparison", () => {
const weakSignals = ["🤔", "?", "继续", "ok", "嗯", "hi", "👍"];

for (const msg of weakSignals) {
it(`"${msg}" is skipped by standard but allowed by first-turn`, () => {
assert.equal(shouldSkipRetrieval(msg), true, `shouldSkipRetrieval("${msg}") should be true`);
assert.equal(shouldSkipRetrievalFirstTurn(msg), false, `shouldSkipRetrievalFirstTurn("${msg}") should be false`);
});
}

const alwaysSkipped = ["/status", ""];
for (const msg of alwaysSkipped) {
it(`"${msg}" is skipped by both standard and first-turn`, () => {
assert.equal(shouldSkipRetrieval(msg), true);
assert.equal(shouldSkipRetrievalFirstTurn(msg), true);
});
}
});