Skip to content
Open
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
71 changes: 46 additions & 25 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ import {
stringifySmartMetadata,
toLifecycleMemory,
} from "./src/smart-metadata.js";
import {
computeTier1Patch,
isSuppressed as isTier1Suppressed,
TIER1_DEFAULT_BAD_RECALL_DECAY_MS,
TIER1_DEFAULT_SUPPRESSION_DURATION_MS,
} from "./src/auto-recall-tier1.js";
import {
filterUserMdExclusiveRecallResults,
isUserMdExclusiveMemory,
Expand Down Expand Up @@ -106,6 +112,12 @@ interface PluginConfig {
autoRecall?: boolean;
autoRecallMinLength?: number;
autoRecallMinRepeated?: number;
/** If a memory's last auto-recall injection was more than this many ms ago,
* its bad_recall_count is reset to 0 on the next injection. 0 disables decay. Default: 86400000 (24h). */
autoRecallBadRecallDecayMs?: number;
/** When bad_recall_count reaches the suppression threshold, the memory is
* suppressed from auto-recall for this many ms from now. Default: 1800000 (30min). */
autoRecallSuppressionDurationMs?: number;
autoRecallTimeoutMs?: number;
autoRecallMaxItems?: number;
autoRecallMaxChars?: number;
Expand Down Expand Up @@ -326,6 +338,22 @@ function parsePositiveInt(value: unknown): number | undefined {
return undefined;
}

// Like parsePositiveInt but allows 0. Used for fields where 0 is a meaningful
// "disabled" sentinel (e.g. autoRecallBadRecallDecayMs=0 disables decay).
function parseNonNegativeInt(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
if (typeof value === "string") {
const s = value.trim();
if (!s) return undefined;
const resolved = resolveEnvVars(s);
const n = Number(resolved);
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
}
return undefined;
}

function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.floor(value)));
Expand Down Expand Up @@ -2635,7 +2663,7 @@ const memoryLanceDBProPlugin = {
api.logger.debug(`memory-lancedb-pro: governance: filtered id=${r.entry.id} reason=layer(${meta.memory_layer}) score=${r.score?.toFixed(3)} text=${r.entry.text.slice(0, 50)}`);
return false;
}
if (meta.suppressed_until_turn > 0 && currentTurn <= meta.suppressed_until_turn) {
if (isTier1Suppressed(meta, Date.now())) {
suppressedFilteredCount++;
return false;
}
Expand Down Expand Up @@ -2759,33 +2787,22 @@ const memoryLanceDBProPlugin = {
}

const injectedAt = Date.now();
const tier1PatchOpts = {
injectedAt,
badRecallDecayMs:
config.autoRecallBadRecallDecayMs ?? TIER1_DEFAULT_BAD_RECALL_DECAY_MS,
suppressionDurationMs:
config.autoRecallSuppressionDurationMs ?? TIER1_DEFAULT_SUPPRESSION_DURATION_MS,
minRepeated,
};
await Promise.allSettled(
selected.map(async (item) => {
const meta = item.meta;
const staleInjected =
typeof meta.last_injected_at === "number" &&
meta.last_injected_at > 0 &&
(
typeof meta.last_confirmed_use_at !== "number" ||
meta.last_confirmed_use_at < meta.last_injected_at
);
const nextBadRecallCount = staleInjected
? meta.bad_recall_count + 1
: meta.bad_recall_count;
const shouldSuppress = nextBadRecallCount >= 3 && minRepeated > 0;
await store.patchMetadata(
selected.map(async (item) =>
store.patchMetadata(
item.id,
{
injected_count: meta.injected_count + 1,
last_injected_at: injectedAt,
bad_recall_count: nextBadRecallCount,
suppressed_until_turn: shouldSuppress
? Math.max(meta.suppressed_until_turn, currentTurn + minRepeated)
: meta.suppressed_until_turn,
},
computeTier1Patch(item.meta, tier1PatchOpts),
accessibleScopes,
);
}),
),
),
);

const memoryContext = selected.map((item) => item.line).join("\n");
Expand Down Expand Up @@ -4271,6 +4288,10 @@ export function parsePluginConfig(value: unknown): PluginConfig {
autoRecall: cfg.autoRecall === true,
autoRecallMinLength: parsePositiveInt(cfg.autoRecallMinLength),
autoRecallMinRepeated: parsePositiveInt(cfg.autoRecallMinRepeated) ?? 8,
// 0 is a meaningful sentinel for both Tier 1 knobs (disable decay /
// collapse suppression to a no-op), so use the non-negative parser.
autoRecallBadRecallDecayMs: parseNonNegativeInt(cfg.autoRecallBadRecallDecayMs),
autoRecallSuppressionDurationMs: parseNonNegativeInt(cfg.autoRecallSuppressionDurationMs),
autoRecallMaxItems: parsePositiveInt(cfg.autoRecallMaxItems) ?? 3,
autoRecallMaxChars: parsePositiveInt(cfg.autoRecallMaxChars) ?? 600,
autoRecallPerItemMaxChars: parsePositiveInt(cfg.autoRecallPerItemMaxChars) ?? 180,
Expand Down
14 changes: 14 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@
"default": 8,
"description": "Minimum number of turns before the same memory can be recalled again in the same session. Set to 0 to disable deduplication."
},
"autoRecallBadRecallDecayMs": {
"type": "integer",
"minimum": 0,
"default": 86400000,
"$comment": "No maximum: 0 disables decay; very large values also effectively disable. Unlike sibling autoRecall* integers, this is a time window with no natural upper bound.",
"description": "If a memory's last auto-recall injection was more than this many ms ago, its bad_recall_count is reset to 0 on the next injection. 0 disables decay. Default: 86400000 (24 hours)."
},
"autoRecallSuppressionDurationMs": {
"type": "integer",
"minimum": 0,
"default": 1800000,
"$comment": "No maximum: 0 effectively disables suppression; large values allow long quarantine windows.",
"description": "When bad_recall_count reaches the suppression threshold, the memory is suppressed from auto-recall for this many ms from now. Default: 1800000 (30 minutes)."
},
"autoRecallTimeoutMs": {
"type": "integer",
"minimum": 500,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"author": "win4r",
"license": "MIT",
"scripts": {
"test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs",
"test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node --test test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs && node --test test/command-reflection-guard.test.mjs && node --test test/tier1-counters.test.mjs",
"test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke",
"test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression",
"test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema",
Expand Down
2 changes: 2 additions & 0 deletions scripts/ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const CI_TEST_MANIFEST = [
// Issue #492 agentId validation tests
{ group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] },
// Tier 1 memory counter fix
{ group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] },
];

export function getEntriesForGroup(group) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/verify-ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const EXPECTED_BASELINE = [
// Issue #492 agentId validation tests
{ group: "core-regression", runner: "node", file: "test/agentid-validation.test.mjs", args: ["--test"] },
{ group: "core-regression", runner: "node", file: "test/command-reflection-guard.test.mjs", args: ["--test"] },
// Tier 1 memory counter fix
{ group: "core-regression", runner: "node", file: "test/tier1-counters.test.mjs", args: ["--test"] },
];

function fail(message) {
Expand Down
141 changes: 141 additions & 0 deletions src/auto-recall-tier1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { SmartMemoryMetadata } from "./smart-metadata.ts";

// Suppression fires when bad_recall_count reaches this value (and the recall
// dedup window is active). Intentionally a constant rather than public config:
// the "3 strikes" rule is a behavioral design choice that should hold across
// deployments, while the companion knobs (decay window, suppression duration)
// are operational tuning parameters that ops may legitimately tune. If a real
// use case for tuning the threshold appears, add it as an opt on
// computeTier1Patch (it already accepts `minRepeated`, so the seam exists).
export const TIER1_BAD_RECALL_SUPPRESSION_THRESHOLD = 3;

// Default values for the two plugin config fields. Kept here so the
// production code path and tests share a single source of truth.
export const TIER1_DEFAULT_BAD_RECALL_DECAY_MS = 86_400_000; // 24h
export const TIER1_DEFAULT_SUPPRESSION_DURATION_MS = 1_800_000; // 30min

// Subset of SmartMemoryMetadata that Tier 1 actually reads. Using a structural
// type lets unit tests pass partial objects without losing type safety in
// production (where the full SmartMemoryMetadata is supplied).
export interface Tier1MetaInput {
access_count?: number;
injected_count?: number;
bad_recall_count?: number;
last_injected_at?: number;
last_confirmed_use_at?: number;
suppressed_until_turn?: number;
// Presence semantics: `undefined` = never touched by Tier 1 (lazy-heal
// sentinel); `0` = touched, no active suppression; `> 0` = suppressed.
suppressed_until_ms?: number;
}

export interface ComputeTier1PatchOpts {
injectedAt: number;
badRecallDecayMs?: number;
suppressionDurationMs?: number;
// Recall-dedup window. When 0, suppression cannot fire even if the threshold
// is reached — there is no per-session repeat-injection mechanism in play.
minRepeated?: number;
}

// Patch shape produced by Tier 1 for an auto-recall injection. The keys are
// a subset of SmartMemoryMetadata so the result can be passed directly to
// store.patchMetadata().
export interface Tier1Patch {
access_count: number;
last_accessed_at: number;
injected_count: number;
last_injected_at: number;
bad_recall_count: number;
suppressed_until_ms: number;
suppressed_until_turn: 0;
}

// Tier 1 governance predicate: is this memory currently suppressed from
// auto-recall? Reads only the ms-based field; the legacy turn field is
// retired in the read path.
export function isSuppressed(meta: Tier1MetaInput, nowMs: number): boolean {
const until = meta.suppressed_until_ms ?? 0;
return until > 0 && nowMs < until;
}

// Tier 1 staleInjected judgment — whether the previous injection of this
// memory ever got confirmed by user behavior. Preserved verbatim from the
// pre-Tier-1 path: PR #597 / Proposal A owns any future change to this rule.
function isStaleInjection(meta: Tier1MetaInput): boolean {
return (
typeof meta.last_injected_at === "number" &&
meta.last_injected_at > 0 &&
(typeof meta.last_confirmed_use_at !== "number" ||
meta.last_confirmed_use_at < meta.last_injected_at)
);
}

// Compute the metadata patch to apply to a memory after Tier 1 auto-recall
// injects it. Pure function — caller persists the patch.
export function computeTier1Patch(
meta: Tier1MetaInput,
opts: ComputeTier1PatchOpts,
): Tier1Patch {
const {
injectedAt,
badRecallDecayMs = TIER1_DEFAULT_BAD_RECALL_DECAY_MS,
suppressionDurationMs = TIER1_DEFAULT_SUPPRESSION_DURATION_MS,
minRepeated = 0,
} = opts;

const accessCount = meta.access_count ?? 0;
const injectedCount = meta.injected_count ?? 0;
const rawBadRecall = meta.bad_recall_count ?? 0;
const turnLegacy = meta.suppressed_until_turn ?? 0;

// Lazy heal: a memory has never been touched by Tier 1 if
// `suppressed_until_ms` is undefined. If it still carries legacy pollution,
// reset before any new logic runs.
let baseBadRecall = rawBadRecall;
if (
meta.suppressed_until_ms === undefined &&
(rawBadRecall > 0 || turnLegacy > 0)
) {
baseBadRecall = 0;
}

// Option C decay: if the gap since the last injection exceeds the decay
// window, reset bad_recall_count — "this memory is being needed again".
// Negative gap (clock skew, e.g. NTP resync) falls through as "no decay":
// never falsely reset due to apparent time travel.
const gapSinceLastInjection =
typeof meta.last_injected_at === "number"
? injectedAt - meta.last_injected_at
: Infinity;
const decayedBadRecall =
badRecallDecayMs > 0 && gapSinceLastInjection > badRecallDecayMs
? 0
: baseBadRecall;

const staleInjected = isStaleInjection(meta);
const nextBadRecallCount = staleInjected
? decayedBadRecall + 1
: decayedBadRecall;
const shouldSuppress =
nextBadRecallCount >= TIER1_BAD_RECALL_SUPPRESSION_THRESHOLD &&
minRepeated > 0;

return {
access_count: accessCount + 1,
last_accessed_at: injectedAt,
injected_count: injectedCount + 1,
last_injected_at: injectedAt,
bad_recall_count: nextBadRecallCount,
suppressed_until_ms: shouldSuppress
? Math.max(meta.suppressed_until_ms ?? 0, injectedAt + suppressionDurationMs)
: (meta.suppressed_until_ms ?? 0),
// Always zero the legacy turn field on any Tier-1-era patch so stale
// numbers cannot leak through.
suppressed_until_turn: 0,
};
}

// Re-export the SmartMemoryMetadata type alias used here so callers don't
// need a second import for the patch input.
export type { SmartMemoryMetadata };
27 changes: 27 additions & 0 deletions src/smart-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export interface SmartMemoryMetadata {
last_confirmed_use_at?: number;
bad_recall_count: number;
suppressed_until_turn: number;
/**
* Unix ms timestamp until which auto-recall should suppress this memory.
* OPTIONAL: `undefined` means this memory has never been written by Tier 1
* code (sentinel used for lazy heal of legacy data). `0` means Tier 1 has
* touched the memory but there is no active suppression.
*/
suppressed_until_ms?: number;
canonical_id?: string;
[key: string]: unknown;
}
Expand Down Expand Up @@ -330,6 +337,17 @@ export function parseSmartMetadata(
last_confirmed_use_at: normalizeOptionalTimestamp(parsed.last_confirmed_use_at),
bad_recall_count: clampCount(parsed.bad_recall_count, 0),
suppressed_until_turn: clampCount(parsed.suppressed_until_turn, 0),
// DO NOT replace with `clampCount(parsed.suppressed_until_ms, 0)` directly —
// preserving `undefined` is load-bearing for the Tier 1 lazy-heal sentinel
// (see JSDoc on SmartMemoryMetadata.suppressed_until_ms). The `undefined`
// signal distinguishes "never touched by Tier 1 code" from "Tier 1 touched
// but no active suppression (0)". `null` is treated as missing too —
// some persistence layers serialize undefined → null on round-trip, and
// we want the sentinel to survive that.
suppressed_until_ms:
parsed.suppressed_until_ms != null
? clampCount(parsed.suppressed_until_ms, 0)
: undefined,
canonical_id: normalizeOptionalString(parsed.canonical_id),
};

Expand Down Expand Up @@ -419,6 +437,15 @@ export function buildSmartMetadata(
patch.suppressed_until_turn,
base.suppressed_until_turn,
),
// Treat null patches the same as undefined (leave base value alone),
// mirroring parseSmartMetadata. A patch caller that wants to clear
// suppression must pass 0 explicitly.
suppressed_until_ms:
patch.suppressed_until_ms == null
? base.suppressed_until_ms
: (typeof patch.suppressed_until_ms === "number" && patch.suppressed_until_ms >= 0
? Math.floor(patch.suppressed_until_ms)
: 0),
canonical_id:
patch.canonical_id === undefined
? base.canonical_id
Expand Down
Loading
Loading