mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(memory): promote durable facts after repeated recall (#115715)
* fix(memory): calibrate promotion gate defaults * docs: refresh generated docs map
This commit is contained in:
committed by
GitHub
parent
14940edf15
commit
b2cc5f5042
@@ -1,4 +1,4 @@
|
||||
1702c4a729d952844880aa6f45dfbab1ff4a8408bc8338d5c41354a043869f7f config-baseline.json
|
||||
4dc7458ef793bc3c483e57be2af7e5118f3de824483a4b8e6ef2afcdf7a38e0a config-baseline.json
|
||||
82dbf6abbd417071aa545c3d398afbab297591b117627ff5766c1fad689b8f40 config-baseline.core.json
|
||||
b3d7fdc2662b49b2e6567573852545a36a8eab13bb2111011510aa96a80a3b8b config-baseline.channel.json
|
||||
c7c25cc8e151d9db96fb6497e9f2e69870b4106f88e6c0f6cd481100217a556d config-baseline.plugin.json
|
||||
99e8401cbeff42d79d84e08cdadb5b1b285e2b6734e41ab77ed2b42438aea991 config-baseline.plugin.json
|
||||
|
||||
+7
-9
@@ -79,14 +79,13 @@ openclaw memory promote [--agent <id>] [--limit <n>] [--min-score <n>] \
|
||||
| `--limit <n>` | | Max candidates to return/apply. |
|
||||
| `--min-score <n>` | `0.75` | Minimum weighted promotion score. |
|
||||
| `--min-recall-count <n>` | `3` | Minimum recall count required. |
|
||||
| `--min-unique-queries <n>` | `2` | Minimum distinct query count required. |
|
||||
| `--min-unique-queries <n>` | `3` | Minimum distinct query count required. |
|
||||
| `--apply` | preview only | Append selected candidates to `MEMORY.md` and mark them promoted. |
|
||||
| `--include-promoted` | | Include candidates already promoted in previous cycles. |
|
||||
| `--json` | | Print JSON. |
|
||||
|
||||
These CLI defaults differ from the scheduled dreaming sweep's deep-phase
|
||||
thresholds (see [Dreaming](#dreaming) below); pass explicit flags to match
|
||||
sweep behavior for a one-off manual run.
|
||||
The CLI and scheduled dreaming sweep share the deep-phase defaults below.
|
||||
Explicit CLI flags override them for a one-off manual run.
|
||||
|
||||
Ranking signals: recall frequency, retrieval relevance, query diversity,
|
||||
temporal recency, cross-day consolidation, and derived concept richness, drawn
|
||||
@@ -191,7 +190,7 @@ material), **REM** (reflect and surface themes), **deep** (promote durable
|
||||
facts into `MEMORY.md`). Only deep writes to `MEMORY.md`.
|
||||
|
||||
- Enable with `plugins.entries.memory-core.config.dreaming.enabled: true`
|
||||
(default `false`); `memory-core` auto-manages the sweep cron job, no manual
|
||||
(default `true`); `memory-core` auto-manages the sweep cron job, no manual
|
||||
`openclaw cron add` required.
|
||||
- Toggle from chat with `/dreaming on|off`; inspect with `/dreaming status`
|
||||
(or `/dreaming`/`/dreaming help`). `on`/`off` requires channel owner status
|
||||
@@ -202,9 +201,8 @@ facts into `MEMORY.md`). Only deep writes to `MEMORY.md`.
|
||||
standalone report to `memory/dreaming/<phase>/YYYY-MM-DD.md`; set `mode:
|
||||
"inline"` to fold reports into the daily memory file instead, or `"both"`
|
||||
for both.
|
||||
- Scheduled and manual `memory promote` runs share the same deep-phase
|
||||
ranking signals; only the default thresholds differ (see table above vs.
|
||||
scheduled defaults below).
|
||||
- Scheduled and manual `memory promote` runs share the same deep-phase ranking
|
||||
signals and default thresholds; explicit CLI flags remain one-run overrides.
|
||||
- Scheduled runs fan out across every configured agent's memory workspace.
|
||||
|
||||
Scheduled defaults (`plugins.entries.memory-core.config.dreaming`):
|
||||
@@ -212,7 +210,7 @@ Scheduled defaults (`plugins.entries.memory-core.config.dreaming`):
|
||||
| Key | Default |
|
||||
| -------------------------------------- | ----------- |
|
||||
| `frequency` | `0 3 * * *` |
|
||||
| `phases.deep.minScore` | `0.8` |
|
||||
| `phases.deep.minScore` | `0.75` |
|
||||
| `phases.deep.minRecallCount` | `3` |
|
||||
| `phases.deep.minUniqueQueries` | `3` |
|
||||
| `phases.deep.recencyHalfLifeDays` | `14` |
|
||||
|
||||
@@ -140,15 +140,18 @@
|
||||
"minScore": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
"maximum": 1,
|
||||
"default": 0.75
|
||||
},
|
||||
"minRecallCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"default": 3
|
||||
},
|
||||
"minUniqueQueries": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"default": 3
|
||||
},
|
||||
"recencyHalfLifeDays": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -11,6 +11,28 @@ const manifest = JSON.parse(
|
||||
) as { configSchema: JsonSchemaObject };
|
||||
|
||||
describe("memory-core manifest config schema", () => {
|
||||
it("publishes the canonical promotion gate defaults", () => {
|
||||
expect(manifest.configSchema).toMatchObject({
|
||||
properties: {
|
||||
dreaming: {
|
||||
properties: {
|
||||
phases: {
|
||||
properties: {
|
||||
deep: {
|
||||
properties: {
|
||||
minScore: { default: 0.75 },
|
||||
minRecallCount: { default: 3 },
|
||||
minUniqueQueries: { default: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts dreaming phase thresholds used by QA and runtime", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Memory Core tests calibrate deterministic short-term promotion scoring.
|
||||
import {
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
|
||||
import type { PromotionWeights } from "./short-term-promotion-types.js";
|
||||
import {
|
||||
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
|
||||
DEFAULT_PROMOTION_MIN_SCORE,
|
||||
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
|
||||
rankShortTermPromotionCandidates,
|
||||
type ShortTermRecallEntry,
|
||||
} from "./short-term-promotion.js";
|
||||
import { createMemoryCoreTestHarness, shortTermTestState } from "./test-helpers.js";
|
||||
|
||||
const NOW_ISO = "2026-04-03T10:01:00.000Z";
|
||||
const NOW_MS = Date.parse(NOW_ISO);
|
||||
const RECALL_DAYS = ["2026-04-01", "2026-04-02", "2026-04-03"];
|
||||
const THREE_QUERY_HASHES = ["query-a", "query-b", "query-c"];
|
||||
|
||||
const { createTempWorkspace } = createMemoryCoreTestHarness();
|
||||
|
||||
type CalibrationClass = "genuine" | "filler" | "oneoff";
|
||||
|
||||
function createRecallEntry(params: {
|
||||
key: string;
|
||||
snippet?: string;
|
||||
signalCount: number;
|
||||
avgScore: number;
|
||||
queryHashes: string[];
|
||||
recallDays: string[];
|
||||
conceptTags: string[];
|
||||
}): ShortTermRecallEntry {
|
||||
return {
|
||||
key: params.key,
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: params.snippet ?? `${params.key} durable note`,
|
||||
recallCount: 0,
|
||||
dailyCount: params.signalCount,
|
||||
groundedCount: 0,
|
||||
totalScore: params.avgScore * params.signalCount,
|
||||
maxScore: params.avgScore,
|
||||
firstRecalledAt: "2026-04-01T10:00:00.000Z",
|
||||
lastRecalledAt: NOW_ISO,
|
||||
queryHashes: params.queryHashes,
|
||||
recallDays: params.recallDays,
|
||||
conceptTags: params.conceptTags,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeCalibrationStore(workspaceDir: string): Promise<void> {
|
||||
const entries = [
|
||||
createRecallEntry({
|
||||
key: "genuine-a",
|
||||
signalCount: 3,
|
||||
avgScore: 0.58,
|
||||
queryHashes: THREE_QUERY_HASHES,
|
||||
recallDays: RECALL_DAYS,
|
||||
conceptTags: ["backup", "backups", "glacier", "s3"],
|
||||
}),
|
||||
createRecallEntry({
|
||||
key: "genuine-b",
|
||||
signalCount: 3,
|
||||
avgScore: 0.6,
|
||||
queryHashes: THREE_QUERY_HASHES,
|
||||
recallDays: RECALL_DAYS,
|
||||
conceptTags: ["backup", "backups", "glacier", "s3"],
|
||||
}),
|
||||
createRecallEntry({
|
||||
key: "genuine-c",
|
||||
signalCount: 3,
|
||||
avgScore: 0.62,
|
||||
queryHashes: THREE_QUERY_HASHES,
|
||||
recallDays: RECALL_DAYS,
|
||||
conceptTags: ["backup", "glacier", "s3"],
|
||||
}),
|
||||
...[0.2, 0.3, 0.4].map((avgScore, index) =>
|
||||
createRecallEntry({
|
||||
key: `filler-${index}`,
|
||||
snippet: "Routine heartbeat completed successfully.",
|
||||
signalCount: 3,
|
||||
avgScore,
|
||||
queryHashes: THREE_QUERY_HASHES,
|
||||
recallDays: RECALL_DAYS,
|
||||
conceptTags: [],
|
||||
}),
|
||||
),
|
||||
...[0.8, 0.9, 0.99].map((avgScore, index) =>
|
||||
createRecallEntry({
|
||||
key: `oneoff-${index}`,
|
||||
signalCount: 1,
|
||||
avgScore,
|
||||
queryHashes: ["query-a"],
|
||||
recallDays: ["2026-04-03"],
|
||||
conceptTags: ["novel", "signal", "single", "mention"].slice(0, index + 2),
|
||||
}),
|
||||
),
|
||||
];
|
||||
await shortTermTestState.writeRawRecallStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: NOW_ISO,
|
||||
entries: Object.fromEntries(entries.map((entry) => [entry.key, entry])),
|
||||
});
|
||||
await shortTermTestState.writeRawPhaseSignalStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: NOW_ISO,
|
||||
entries: Object.fromEntries(
|
||||
entries
|
||||
.filter((entry) => entry.key.startsWith("genuine-"))
|
||||
.map((entry) => [
|
||||
entry.key,
|
||||
{
|
||||
key: entry.key,
|
||||
lightHits: 3,
|
||||
remHits: 3,
|
||||
lastLightAt: NOW_ISO,
|
||||
lastRemAt: NOW_ISO,
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function scoresForClass(
|
||||
candidates: Awaited<ReturnType<typeof rankShortTermPromotionCandidates>>,
|
||||
calibrationClass: CalibrationClass,
|
||||
): number[] {
|
||||
return candidates
|
||||
.filter((candidate) => candidate.key.startsWith(`${calibrationClass}-`))
|
||||
.toSorted((left, right) => left.key.localeCompare(right.key))
|
||||
.map((candidate) => Number(candidate.score.toFixed(6)));
|
||||
}
|
||||
|
||||
describe("short-term promotion score calibration", () => {
|
||||
it("separates repeated durable facts from filler and high-signal one-offs", async () => {
|
||||
const workspaceDir = await createTempWorkspace("promotion-score-distribution-");
|
||||
await writeCalibrationStore(workspaceDir);
|
||||
|
||||
const measured = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
nowMs: NOW_MS,
|
||||
});
|
||||
const distribution = {
|
||||
genuine: scoresForClass(measured, "genuine"),
|
||||
filler: scoresForClass(measured, "filler"),
|
||||
oneoff: scoresForClass(measured, "oneoff"),
|
||||
};
|
||||
|
||||
expect(distribution).toEqual({
|
||||
genuine: [0.750014, 0.756014, 0.752014],
|
||||
filler: [0.489152, 0.519152, 0.549152],
|
||||
oneoff: [0.529376, 0.569376, 0.606376],
|
||||
});
|
||||
|
||||
const promoted = await rankShortTermPromotionCandidates({ workspaceDir, nowMs: NOW_MS });
|
||||
expect(promoted.map((candidate) => candidate.key).toSorted()).toEqual([
|
||||
"genuine-a",
|
||||
"genuine-b",
|
||||
"genuine-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps sweep and direct promotion fallback defaults aligned", () => {
|
||||
const sweep = resolveShortTermPromotionDreamingConfig({ pluginConfig: {} });
|
||||
expect({
|
||||
minScore: sweep.minScore,
|
||||
minRecallCount: sweep.minRecallCount,
|
||||
minUniqueQueries: sweep.minUniqueQueries,
|
||||
}).toEqual({
|
||||
minScore: DEFAULT_PROMOTION_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_PROMOTION_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
|
||||
});
|
||||
expect({
|
||||
minScore: DEFAULT_PROMOTION_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_PROMOTION_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
|
||||
}).toEqual({
|
||||
minScore: DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the minimum score boundary inclusive", async () => {
|
||||
const workspaceDir = await createTempWorkspace("promotion-score-boundary-");
|
||||
const boundary = createRecallEntry({
|
||||
key: "boundary",
|
||||
signalCount: 1,
|
||||
avgScore: DEFAULT_PROMOTION_MIN_SCORE,
|
||||
queryHashes: ["query-a"],
|
||||
recallDays: ["2026-04-03"],
|
||||
conceptTags: [],
|
||||
});
|
||||
await shortTermTestState.writeRawRecallStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: NOW_ISO,
|
||||
entries: { boundary },
|
||||
});
|
||||
const relevanceOnly: PromotionWeights = {
|
||||
frequency: 0,
|
||||
relevance: 1,
|
||||
diversity: 0,
|
||||
recency: 0,
|
||||
consolidation: 0,
|
||||
conceptual: 0,
|
||||
};
|
||||
|
||||
await expect(
|
||||
rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: DEFAULT_PROMOTION_MIN_SCORE,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
weights: relevanceOnly,
|
||||
nowMs: NOW_MS,
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
await expect(
|
||||
rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: DEFAULT_PROMOTION_MIN_SCORE + 0.000001,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
weights: relevanceOnly,
|
||||
nowMs: NOW_MS,
|
||||
}),
|
||||
).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import path from "node:path";
|
||||
import type { MemoryEntryProvenance } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE,
|
||||
DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import type { ConceptTagScriptCoverage } from "./concept-vocabulary.js";
|
||||
|
||||
export const DEFAULT_PROMOTION_MIN_SCORE = 0.75;
|
||||
export const DEFAULT_PROMOTION_MIN_RECALL_COUNT = 3;
|
||||
export const DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES = 2;
|
||||
export const DEFAULT_PROMOTION_MIN_SCORE = DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE;
|
||||
export const DEFAULT_PROMOTION_MIN_RECALL_COUNT = DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT;
|
||||
export const DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES = DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES;
|
||||
export const SHORT_TERM_STORE_RELATIVE_PATH = path.join(
|
||||
"memory",
|
||||
".dreams",
|
||||
|
||||
@@ -999,7 +999,7 @@ describe("short-term promotion", () => {
|
||||
snippet: 'Always use "Happy Together" calendar for flights and reservations.',
|
||||
score: 0.92,
|
||||
query: "__dreaming_grounded_backfill__:lasting-update",
|
||||
signalCount: 2,
|
||||
signalCount: 1,
|
||||
dayBucket: "2026-04-03",
|
||||
},
|
||||
{
|
||||
@@ -1012,6 +1012,16 @@ describe("short-term promotion", () => {
|
||||
signalCount: 1,
|
||||
dayBucket: "2026-04-03",
|
||||
},
|
||||
{
|
||||
path: "memory/2026-04-03.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
snippet: 'Always use "Happy Together" calendar for flights and reservations.',
|
||||
score: 0.86,
|
||||
query: "__dreaming_grounded_backfill__:durable-fact",
|
||||
signalCount: 1,
|
||||
dayBucket: "2026-04-03",
|
||||
},
|
||||
],
|
||||
dedupeByQueryPerDay: true,
|
||||
nowMs: Date.parse("2026-04-03T10:00:00.000Z"),
|
||||
@@ -1024,7 +1034,7 @@ describe("short-term promotion", () => {
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0]?.groundedCount).toBe(3);
|
||||
expect(ranked[0]?.uniqueQueries).toBe(2);
|
||||
expect(ranked[0]?.uniqueQueries).toBe(3);
|
||||
expect(ranked[0]?.avgScore).toBeGreaterThan(0.85);
|
||||
|
||||
const applied = await applyShortTermPromotions({
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("memory dreaming host helpers", () => {
|
||||
expect(resolved.timezone).toBe("America/Los_Angeles");
|
||||
expect(resolved.phases.deep.cron).toBe("0 3 * * *");
|
||||
expect(resolved.phases.deep.limit).toBe(10);
|
||||
expect(resolved.phases.deep.minScore).toBe(0.8);
|
||||
expect(resolved.phases.deep.minScore).toBe(0.75);
|
||||
expect(resolved.phases.deep.recencyHalfLifeDays).toBe(14);
|
||||
expect(resolved.phases.deep.maxAgeDays).toBe(30);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ const DEFAULT_MEMORY_LIGHT_DREAMING_LOOKBACK_DAYS = 2;
|
||||
const DEFAULT_MEMORY_LIGHT_DREAMING_LIMIT = 100;
|
||||
const DEFAULT_MEMORY_LIGHT_DREAMING_DEDUPE_SIMILARITY = 0.9;
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_LIMIT = 10;
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.8;
|
||||
// Deterministic calibration scores 3-day/3-query durable facts at 0.750-0.756,
|
||||
// versus repeated filler at 0.489-0.549 and high-relevance one-offs at 0.529-0.606.
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE = 0.75;
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT = 3;
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_MIN_UNIQUE_QUERIES = 3;
|
||||
export const DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS = 14;
|
||||
|
||||
Reference in New Issue
Block a user