mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 12:30:24 +00:00
fix(memory-core): align dreaming promotion
This commit is contained in:
@@ -855,6 +855,10 @@ export async function runMemorySearch(
|
||||
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory search");
|
||||
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
||||
const agentId = resolveAgent(cfg, opts.agent);
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg),
|
||||
cfg,
|
||||
});
|
||||
await withMemoryManagerForAgent({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -881,6 +885,7 @@ export async function runMemorySearch(
|
||||
workspaceDir,
|
||||
query,
|
||||
results,
|
||||
timezone: dreaming.timezone,
|
||||
}).catch(() => {
|
||||
// Recall tracking is best-effort and must not block normal search results.
|
||||
});
|
||||
@@ -947,6 +952,10 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
let applyResult: Awaited<ReturnType<typeof applyShortTermPromotions>> | undefined;
|
||||
if (opts.apply) {
|
||||
try {
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryPluginConfig(cfg),
|
||||
cfg,
|
||||
});
|
||||
applyResult = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates,
|
||||
@@ -954,6 +963,7 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
||||
minScore: opts.minScore,
|
||||
minRecallCount: opts.minRecallCount,
|
||||
minUniqueQueries: opts.minUniqueQueries,
|
||||
timezone: dreaming.timezone,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(`Memory promote apply failed: ${formatErrorMessage(err)}`);
|
||||
|
||||
@@ -176,6 +176,16 @@ describe("memory cli", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDailyMemoryNote(
|
||||
workspaceDir: string,
|
||||
date: string,
|
||||
lines: string[],
|
||||
): Promise<void> {
|
||||
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
|
||||
await fs.mkdir(path.dirname(notePath), { recursive: true });
|
||||
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function expectCloseFailureAfterCommand(params: {
|
||||
args: string[];
|
||||
manager: Record<string, unknown>;
|
||||
@@ -873,6 +883,22 @@ describe("memory cli", () => {
|
||||
|
||||
it("applies top promote candidates into MEMORY.md", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"line 1",
|
||||
"line 2",
|
||||
"line 3",
|
||||
"line 4",
|
||||
"line 5",
|
||||
"line 6",
|
||||
"line 7",
|
||||
"line 8",
|
||||
"line 9",
|
||||
"Gateway host uses local mode and binds loopback port 18789",
|
||||
"Keep agent gateway local",
|
||||
"Expose healthcheck only on loopback",
|
||||
"Monitor restart policy",
|
||||
"Review proxy config",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "network setup",
|
||||
@@ -909,7 +935,7 @@ describe("memory cli", () => {
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
const memoryText = await fs.readFile(memoryPath, "utf-8");
|
||||
expect(memoryText).toContain("Promoted From Short-Term Memory");
|
||||
expect(memoryText).toContain("memory/2026-04-01.md:10-14");
|
||||
expect(memoryText).toContain("memory/2026-04-01.md:10-10");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Promoted 1 candidate(s) to"));
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,16 @@ function createLogger() {
|
||||
};
|
||||
}
|
||||
|
||||
async function writeDailyMemoryNote(
|
||||
workspaceDir: string,
|
||||
date: string,
|
||||
lines: string[],
|
||||
): Promise<void> {
|
||||
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
|
||||
await fs.mkdir(path.dirname(notePath), { recursive: true });
|
||||
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function createCronHarness(
|
||||
initialJobs: CronJobLike[] = [],
|
||||
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
|
||||
@@ -504,6 +514,7 @@ describe("short-term dreaming trigger", () => {
|
||||
const logger = createLogger();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-"));
|
||||
tempDirs.push(workspaceDir);
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
|
||||
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
@@ -545,6 +556,10 @@ describe("short-term dreaming trigger", () => {
|
||||
const logger = createLogger();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-strict-"));
|
||||
tempDirs.push(workspaceDir);
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
|
||||
"Move backups to S3 Glacier.",
|
||||
"Retain quarterly snapshots.",
|
||||
]);
|
||||
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
@@ -646,6 +661,10 @@ describe("short-term dreaming trigger", () => {
|
||||
const logger = createLogger();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-repair-"));
|
||||
tempDirs.push(workspaceDir);
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [
|
||||
"Move backups to S3 Glacier and sync router failover notes.",
|
||||
"Keep router recovery docs current.",
|
||||
]);
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -722,6 +741,7 @@ describe("short-term dreaming trigger", () => {
|
||||
const logger = createLogger();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-verbose-"));
|
||||
tempDirs.push(workspaceDir);
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
|
||||
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
@@ -765,4 +785,89 @@ describe("short-term dreaming trigger", () => {
|
||||
expect.stringContaining("memory-core: dreaming applied details"),
|
||||
);
|
||||
});
|
||||
|
||||
it("fans out one dreaming run across configured agent workspaces", async () => {
|
||||
const logger = createLogger();
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-multi-"));
|
||||
tempDirs.push(workspaceRoot);
|
||||
const alphaWorkspace = path.join(workspaceRoot, "alpha");
|
||||
const betaWorkspace = path.join(workspaceRoot, "beta");
|
||||
|
||||
await writeDailyMemoryNote(alphaWorkspace, "2026-04-02", ["Alpha backup note."]);
|
||||
await writeDailyMemoryNote(betaWorkspace, "2026-04-02", ["Beta router note."]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: alphaWorkspace,
|
||||
query: "alpha backup",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-02.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Alpha backup note.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: betaWorkspace,
|
||||
query: "beta router",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-02.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Beta router note.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runShortTermDreamingPromotionIfTriggered({
|
||||
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
|
||||
trigger: "heartbeat",
|
||||
workspaceDir: alphaWorkspace,
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "alpha",
|
||||
workspace: alphaWorkspace,
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
workspace: betaWorkspace,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
config: {
|
||||
enabled: true,
|
||||
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
|
||||
limit: 10,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
verboseLogging: false,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(await fs.readFile(path.join(alphaWorkspace, "MEMORY.md"), "utf-8")).toContain(
|
||||
"Alpha backup note.",
|
||||
);
|
||||
expect(await fs.readFile(path.join(betaWorkspace, "MEMORY.md"), "utf-8")).toContain(
|
||||
"Beta router note.",
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"memory-core: dreaming promotion complete (workspaces=2, candidates=2, applied=2, failed=0).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
|
||||
import {
|
||||
DEFAULT_MEMORY_DREAMING_CRON_EXPR,
|
||||
DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
|
||||
DEFAULT_MEMORY_DREAMING_MIN_SCORE,
|
||||
DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
DEFAULT_MEMORY_DREAMING_MODE,
|
||||
DEFAULT_MEMORY_DREAMING_PRESET,
|
||||
MEMORY_DREAMING_PRESET_DEFAULTS,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import {
|
||||
applyShortTermPromotions,
|
||||
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
|
||||
DEFAULT_PROMOTION_MIN_SCORE,
|
||||
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
|
||||
repairShortTermPromotionArtifacts,
|
||||
rankShortTermPromotionCandidates,
|
||||
} from "./short-term-promotion.js";
|
||||
@@ -11,49 +21,6 @@ import {
|
||||
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
|
||||
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
|
||||
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
|
||||
const DEFAULT_DREAMING_CRON_EXPR = "0 3 * * *";
|
||||
const DEFAULT_DREAMING_LIMIT = 10;
|
||||
const DEFAULT_DREAMING_MIN_SCORE = DEFAULT_PROMOTION_MIN_SCORE;
|
||||
const DEFAULT_DREAMING_MIN_RECALL_COUNT = DEFAULT_PROMOTION_MIN_RECALL_COUNT;
|
||||
const DEFAULT_DREAMING_MIN_UNIQUE_QUERIES = DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES;
|
||||
const DEFAULT_DREAMING_MODE = "off";
|
||||
const DEFAULT_DREAMING_PRESET = "core";
|
||||
|
||||
type DreamingPreset = "core" | "deep" | "rem";
|
||||
type DreamingMode = DreamingPreset | "off";
|
||||
|
||||
const DREAMING_PRESET_DEFAULTS: Record<
|
||||
DreamingPreset,
|
||||
{
|
||||
cron: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
cron: DEFAULT_DREAMING_CRON_EXPR,
|
||||
limit: DEFAULT_DREAMING_LIMIT,
|
||||
minScore: DEFAULT_DREAMING_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_DREAMING_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
},
|
||||
deep: {
|
||||
cron: "0 */12 * * *",
|
||||
limit: DEFAULT_DREAMING_LIMIT,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
cron: "0 */6 * * *",
|
||||
limit: DEFAULT_DREAMING_LIMIT,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
|
||||
|
||||
@@ -138,64 +105,6 @@ function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeDreamingMode(value: unknown): DreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "deep" ||
|
||||
normalized === "rem"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_DREAMING_MODE;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return fallback;
|
||||
}
|
||||
const floored = Math.floor(num);
|
||||
if (floored < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return floored;
|
||||
}
|
||||
|
||||
function normalizeScore(value: unknown, fallback: number): number {
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return fallback;
|
||||
}
|
||||
if (num < 0 || num > 1) {
|
||||
return fallback;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
@@ -203,12 +112,6 @@ function formatErrorMessage(err: unknown): string {
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function resolveTimezoneFallback(cfg: OpenClawConfig | undefined): string | undefined {
|
||||
const agents = asRecord(cfg?.agents);
|
||||
const defaults = asRecord(agents?.defaults);
|
||||
return normalizeTrimmedString(defaults?.userTimezone);
|
||||
}
|
||||
|
||||
function formatRepairSummary(repair: {
|
||||
rewroteStore: boolean;
|
||||
removedInvalidEntries: number;
|
||||
@@ -357,37 +260,16 @@ export function resolveShortTermPromotionDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
}): ShortTermPromotionDreamingConfig {
|
||||
const dreaming = asRecord(params.pluginConfig?.dreaming);
|
||||
const mode = normalizeDreamingMode(dreaming?.mode);
|
||||
const enabled = mode !== "off";
|
||||
const thresholdPreset: DreamingPreset = mode === "off" ? DEFAULT_DREAMING_PRESET : mode;
|
||||
const thresholdDefaults = DREAMING_PRESET_DEFAULTS[thresholdPreset];
|
||||
const cron =
|
||||
normalizeTrimmedString(dreaming?.cron) ??
|
||||
normalizeTrimmedString(dreaming?.frequency) ??
|
||||
thresholdDefaults.cron;
|
||||
const timezone =
|
||||
normalizeTrimmedString(dreaming?.timezone) ?? resolveTimezoneFallback(params.cfg);
|
||||
const limit = normalizeNonNegativeInt(dreaming?.limit, thresholdDefaults.limit);
|
||||
const minScore = normalizeScore(dreaming?.minScore, thresholdDefaults.minScore);
|
||||
const minRecallCount = normalizeNonNegativeInt(
|
||||
dreaming?.minRecallCount,
|
||||
thresholdDefaults.minRecallCount,
|
||||
);
|
||||
const minUniqueQueries = normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
thresholdDefaults.minUniqueQueries,
|
||||
);
|
||||
|
||||
const resolved = resolveMemoryDreamingConfig(params);
|
||||
return {
|
||||
enabled,
|
||||
cron,
|
||||
...(timezone ? { timezone } : {}),
|
||||
limit,
|
||||
minScore,
|
||||
minRecallCount,
|
||||
minUniqueQueries,
|
||||
verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false),
|
||||
enabled: resolved.enabled,
|
||||
cron: resolved.cron,
|
||||
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
|
||||
limit: resolved.limit,
|
||||
minScore: resolved.minScore,
|
||||
minRecallCount: resolved.minRecallCount,
|
||||
minUniqueQueries: resolved.minUniqueQueries,
|
||||
verboseLogging: resolved.verboseLogging,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,6 +345,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
cleanedBody: string;
|
||||
trigger?: string;
|
||||
workspaceDir?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
config: ShortTermPromotionDreamingConfig;
|
||||
logger: Logger;
|
||||
}): Promise<{ handled: true; reason: string } | undefined> {
|
||||
@@ -476,10 +359,24 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
return { handled: true, reason: "memory-core: short-term dreaming disabled" };
|
||||
}
|
||||
|
||||
const workspaceDir = normalizeTrimmedString(params.workspaceDir);
|
||||
if (!workspaceDir) {
|
||||
const workspaceCandidates = params.cfg
|
||||
? resolveMemoryDreamingWorkspaces(params.cfg).map((entry) => entry.workspaceDir)
|
||||
: [];
|
||||
const seenWorkspaces = new Set<string>();
|
||||
const workspaces = workspaceCandidates.filter((workspaceDir) => {
|
||||
if (seenWorkspaces.has(workspaceDir)) {
|
||||
return false;
|
||||
}
|
||||
seenWorkspaces.add(workspaceDir);
|
||||
return true;
|
||||
});
|
||||
const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir);
|
||||
if (workspaces.length === 0 && fallbackWorkspaceDir) {
|
||||
workspaces.push(fallbackWorkspaceDir);
|
||||
}
|
||||
if (workspaces.length === 0) {
|
||||
params.logger.warn(
|
||||
"memory-core: dreaming promotion skipped because workspaceDir is unavailable.",
|
||||
"memory-core: dreaming promotion skipped because no memory workspace is available.",
|
||||
);
|
||||
return { handled: true, reason: "memory-core: short-term dreaming missing workspace" };
|
||||
}
|
||||
@@ -488,64 +385,80 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
return { handled: true, reason: "memory-core: short-term dreaming disabled by limit" };
|
||||
}
|
||||
|
||||
try {
|
||||
if (params.config.verboseLogging) {
|
||||
params.logger.info(
|
||||
`memory-core: dreaming verbose enabled (cron=${params.config.cron}, limit=${params.config.limit}, minScore=${params.config.minScore.toFixed(3)}, minRecallCount=${params.config.minRecallCount}, minUniqueQueries=${params.config.minUniqueQueries}).`,
|
||||
);
|
||||
}
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
if (repair.changed) {
|
||||
params.logger.info(
|
||||
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}).`,
|
||||
);
|
||||
}
|
||||
const candidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
minRecallCount: params.config.minRecallCount,
|
||||
minUniqueQueries: params.config.minUniqueQueries,
|
||||
});
|
||||
if (params.config.verboseLogging) {
|
||||
const candidateSummary =
|
||||
candidates.length > 0
|
||||
? candidates
|
||||
.map(
|
||||
(candidate) =>
|
||||
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "none";
|
||||
params.logger.info(`memory-core: dreaming candidate details ${candidateSummary}`);
|
||||
}
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
minRecallCount: params.config.minRecallCount,
|
||||
minUniqueQueries: params.config.minUniqueQueries,
|
||||
});
|
||||
if (params.config.verboseLogging) {
|
||||
const appliedSummary =
|
||||
applied.appliedCandidates.length > 0
|
||||
? applied.appliedCandidates
|
||||
.map(
|
||||
(candidate) =>
|
||||
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "none";
|
||||
params.logger.info(`memory-core: dreaming applied details ${appliedSummary}`);
|
||||
}
|
||||
if (params.config.verboseLogging) {
|
||||
params.logger.info(
|
||||
`memory-core: dreaming promotion complete (candidates=${candidates.length}, applied=${applied.applied}).`,
|
||||
`memory-core: dreaming verbose enabled (cron=${params.config.cron}, limit=${params.config.limit}, minScore=${params.config.minScore.toFixed(3)}, minRecallCount=${params.config.minRecallCount}, minUniqueQueries=${params.config.minUniqueQueries}, workspaces=${workspaces.length}).`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.logger.error(`memory-core: dreaming promotion failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
let totalCandidates = 0;
|
||||
let totalApplied = 0;
|
||||
let failedWorkspaces = 0;
|
||||
for (const workspaceDir of workspaces) {
|
||||
try {
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
if (repair.changed) {
|
||||
params.logger.info(
|
||||
`memory-core: normalized recall artifacts before dreaming (${formatRepairSummary(repair)}) [workspace=${workspaceDir}].`,
|
||||
);
|
||||
}
|
||||
const candidates = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
minRecallCount: params.config.minRecallCount,
|
||||
minUniqueQueries: params.config.minUniqueQueries,
|
||||
});
|
||||
totalCandidates += candidates.length;
|
||||
if (params.config.verboseLogging) {
|
||||
const candidateSummary =
|
||||
candidates.length > 0
|
||||
? candidates
|
||||
.map(
|
||||
(candidate) =>
|
||||
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "none";
|
||||
params.logger.info(
|
||||
`memory-core: dreaming candidate details [workspace=${workspaceDir}] ${candidateSummary}`,
|
||||
);
|
||||
}
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates,
|
||||
limit: params.config.limit,
|
||||
minScore: params.config.minScore,
|
||||
minRecallCount: params.config.minRecallCount,
|
||||
minUniqueQueries: params.config.minUniqueQueries,
|
||||
timezone: params.config.timezone,
|
||||
});
|
||||
totalApplied += applied.applied;
|
||||
if (params.config.verboseLogging) {
|
||||
const appliedSummary =
|
||||
applied.appliedCandidates.length > 0
|
||||
? applied.appliedCandidates
|
||||
.map(
|
||||
(candidate) =>
|
||||
`${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "none";
|
||||
params.logger.info(
|
||||
`memory-core: dreaming applied details [workspace=${workspaceDir}] ${appliedSummary}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
failedWorkspaces += 1;
|
||||
params.logger.error(
|
||||
`memory-core: dreaming promotion failed for workspace ${workspaceDir}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
params.logger.info(
|
||||
`memory-core: dreaming promotion complete (workspaces=${workspaces.length}, candidates=${totalCandidates}, applied=${totalApplied}, failed=${failedWorkspaces}).`,
|
||||
);
|
||||
|
||||
return { handled: true, reason: "memory-core: short-term dreaming processed" };
|
||||
}
|
||||
|
||||
@@ -555,7 +468,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
async (event: unknown) => {
|
||||
try {
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: api.pluginConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
|
||||
cfg: api.config,
|
||||
});
|
||||
const cron = resolveCronServiceFromStartupEvent(event);
|
||||
@@ -581,13 +494,14 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
api.on("before_agent_reply", async (event, ctx) => {
|
||||
try {
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: api.pluginConfig,
|
||||
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
|
||||
cfg: api.config,
|
||||
});
|
||||
return await runShortTermDreamingPromotionIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
trigger: ctx.trigger,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
cfg: api.config,
|
||||
config,
|
||||
logger: api.logger,
|
||||
});
|
||||
@@ -607,13 +521,13 @@ export const __testing = {
|
||||
MANAGED_DREAMING_CRON_NAME,
|
||||
MANAGED_DREAMING_CRON_TAG,
|
||||
DREAMING_SYSTEM_EVENT_TEXT,
|
||||
DEFAULT_DREAMING_MODE,
|
||||
DEFAULT_DREAMING_PRESET,
|
||||
DEFAULT_DREAMING_CRON_EXPR,
|
||||
DEFAULT_DREAMING_LIMIT,
|
||||
DEFAULT_DREAMING_MIN_SCORE,
|
||||
DEFAULT_DREAMING_MIN_RECALL_COUNT,
|
||||
DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
DREAMING_PRESET_DEFAULTS,
|
||||
DEFAULT_DREAMING_MODE: DEFAULT_MEMORY_DREAMING_MODE,
|
||||
DEFAULT_DREAMING_PRESET: DEFAULT_MEMORY_DREAMING_PRESET,
|
||||
DEFAULT_DREAMING_CRON_EXPR: DEFAULT_MEMORY_DREAMING_CRON_EXPR,
|
||||
DEFAULT_DREAMING_LIMIT: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
DEFAULT_DREAMING_MIN_SCORE: DEFAULT_MEMORY_DREAMING_MIN_SCORE,
|
||||
DEFAULT_DREAMING_MIN_RECALL_COUNT: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
|
||||
DEFAULT_DREAMING_MIN_UNIQUE_QUERIES: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
DREAMING_PRESET_DEFAULTS: MEMORY_DREAMING_PRESET_DEFAULTS,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,17 @@ describe("short-term promotion", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDailyMemoryNote(
|
||||
workspaceDir: string,
|
||||
date: string,
|
||||
lines: string[],
|
||||
): Promise<string> {
|
||||
const notePath = path.join(workspaceDir, "memory", `${date}.md`);
|
||||
await fs.mkdir(path.dirname(notePath), { recursive: true });
|
||||
await fs.writeFile(notePath, `${lines.join("\n")}\n`, "utf-8");
|
||||
return notePath;
|
||||
}
|
||||
|
||||
it("detects short-term daily memory paths", () => {
|
||||
expect(isShortTermMemoryPath("memory/2026-04-03.md")).toBe(true);
|
||||
expect(isShortTermMemoryPath("2026-04-03.md")).toBe(true);
|
||||
@@ -262,6 +273,20 @@ describe("short-term promotion", () => {
|
||||
|
||||
it("applies promotion candidates to MEMORY.md and marks them promoted", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
"delta",
|
||||
"epsilon",
|
||||
"zeta",
|
||||
"eta",
|
||||
"theta",
|
||||
"iota",
|
||||
"Gateway binds loopback and port 18789",
|
||||
"Keep gateway on localhost only",
|
||||
"Document healthcheck endpoint",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "gateway host",
|
||||
@@ -294,7 +319,7 @@ describe("short-term promotion", () => {
|
||||
|
||||
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
||||
expect(memoryText).toContain("Promoted From Short-Term Memory");
|
||||
expect(memoryText).toContain("memory/2026-04-01.md:10-12");
|
||||
expect(memoryText).toContain("memory/2026-04-01.md:10-10");
|
||||
|
||||
const rankedAfter = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
@@ -318,6 +343,20 @@ describe("short-term promotion", () => {
|
||||
|
||||
it("does not re-append candidates that were promoted in a prior run", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
"delta",
|
||||
"epsilon",
|
||||
"zeta",
|
||||
"eta",
|
||||
"theta",
|
||||
"iota",
|
||||
"Gateway binds loopback and port 18789",
|
||||
"Keep gateway on localhost only",
|
||||
"Document healthcheck endpoint",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "gateway host",
|
||||
@@ -363,6 +402,229 @@ describe("short-term promotion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates moved snippets from the live daily note before promotion", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"intro",
|
||||
"summary",
|
||||
"Moved backups to S3 Glacier.",
|
||||
"Keep cold storage retention at 365 days.",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "glacier",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.94,
|
||||
snippet: "Moved backups to S3 Glacier.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ranked = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: ranked,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(1);
|
||||
expect(applied.appliedCandidates[0]?.startLine).toBe(3);
|
||||
expect(applied.appliedCandidates[0]?.endLine).toBe(3);
|
||||
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
||||
expect(memoryText).toContain("memory/2026-04-01.md:3-3");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the nearest matching snippet when the same text appears multiple times", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"header",
|
||||
"Repeat backup note.",
|
||||
"gap",
|
||||
"gap",
|
||||
"gap",
|
||||
"gap",
|
||||
"gap",
|
||||
"gap",
|
||||
"Repeat backup note.",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "backup repeat",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 8,
|
||||
endLine: 9,
|
||||
score: 0.9,
|
||||
snippet: "Repeat backup note.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ranked = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: ranked,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(1);
|
||||
expect(applied.appliedCandidates[0]?.startLine).toBe(9);
|
||||
expect(applied.appliedCandidates[0]?.endLine).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates legacy basename-only short-term paths from the memory directory", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["Legacy basename path note."]);
|
||||
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: [
|
||||
{
|
||||
key: "memory:2026-04-01.md:1:1",
|
||||
path: "2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "Legacy basename path note.",
|
||||
recallCount: 2,
|
||||
avgScore: 0.9,
|
||||
maxScore: 0.95,
|
||||
uniqueQueries: 2,
|
||||
firstRecalledAt: "2026-04-01T00:00:00.000Z",
|
||||
lastRecalledAt: "2026-04-02T00:00:00.000Z",
|
||||
ageDays: 0,
|
||||
score: 0.9,
|
||||
recallDays: ["2026-04-01", "2026-04-02"],
|
||||
conceptTags: ["legacy", "note"],
|
||||
components: {
|
||||
frequency: 0.3,
|
||||
relevance: 0.9,
|
||||
diversity: 0.4,
|
||||
recency: 1,
|
||||
consolidation: 0.5,
|
||||
conceptual: 0.3,
|
||||
},
|
||||
},
|
||||
],
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(1);
|
||||
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
||||
expect(memoryText).toContain("source=2026-04-01.md:1-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips promotion when the live daily note no longer contains the snippet", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["Different note content now."]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "glacier",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.94,
|
||||
snippet: "Moved backups to S3 Glacier.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ranked = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: ranked,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(0);
|
||||
await expect(fs.access(path.join(workspaceDir, "MEMORY.md"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses dreaming timezone for recall-day bucketing and promotion headers", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [
|
||||
"Cross-midnight router maintenance window.",
|
||||
]);
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "router window",
|
||||
nowMs: Date.parse("2026-04-01T23:30:00.000Z"),
|
||||
timezone: "America/Los_Angeles",
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Cross-midnight router maintenance window.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ranked = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
expect(ranked[0]?.recallDays).toEqual(["2026-04-01"]);
|
||||
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: ranked,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
nowMs: Date.parse("2026-04-02T06:30:00.000Z"),
|
||||
timezone: "America/Los_Angeles",
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(1);
|
||||
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
||||
expect(memoryText).toContain("Promoted From Short-Term Memory (2026-04-01)");
|
||||
});
|
||||
});
|
||||
|
||||
it("audits and repairs invalid store metadata plus stale locks", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import {
|
||||
deriveConceptTags,
|
||||
MAX_CONCEPT_TAGS,
|
||||
@@ -159,6 +160,7 @@ export type ApplyShortTermPromotionsOptions = {
|
||||
minRecallCount?: number;
|
||||
minUniqueQueries?: number;
|
||||
nowMs?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type ApplyShortTermPromotionsResult = {
|
||||
@@ -566,6 +568,7 @@ export async function recordShortTermRecalls(params: {
|
||||
query: string;
|
||||
results: MemorySearchResult[];
|
||||
nowMs?: number;
|
||||
timezone?: string;
|
||||
}): Promise<void> {
|
||||
const workspaceDir = params.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
@@ -600,7 +603,7 @@ export async function recordShortTermRecalls(params: {
|
||||
const queryHashes = mergeQueryHashes(existing?.queryHashes ?? [], queryHash);
|
||||
const recallDays = mergeRecentDistinct(
|
||||
existing?.recallDays ?? [],
|
||||
nowIso.slice(0, 10),
|
||||
formatMemoryDreamingDay(nowMs, params.timezone),
|
||||
MAX_RECALL_DAYS,
|
||||
);
|
||||
const conceptTags = deriveConceptTags({ path: normalizedPath, snippet });
|
||||
@@ -746,8 +749,164 @@ export async function rankShortTermPromotionCandidates(
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
function buildPromotionSection(candidates: PromotionCandidate[], nowMs: number): string {
|
||||
const sectionDate = new Date(nowMs).toISOString().slice(0, 10);
|
||||
function resolveShortTermSourcePathCandidates(
|
||||
workspaceDir: string,
|
||||
candidatePath: string,
|
||||
): string[] {
|
||||
const normalizedPath = normalizeMemoryPath(candidatePath);
|
||||
const basenames = [normalizedPath];
|
||||
if (!normalizedPath.startsWith("memory/")) {
|
||||
basenames.push(path.posix.join("memory", path.posix.basename(normalizedPath)));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const resolved: string[] = [];
|
||||
for (const relativePath of basenames) {
|
||||
const absolutePath = path.resolve(workspaceDir, relativePath);
|
||||
if (seen.has(absolutePath)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(absolutePath);
|
||||
resolved.push(absolutePath);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function normalizeRangeSnippet(lines: string[], startLine: number, endLine: number): string {
|
||||
const startIndex = Math.max(0, startLine - 1);
|
||||
const endIndex = Math.min(lines.length, endLine);
|
||||
if (startIndex >= endIndex) {
|
||||
return "";
|
||||
}
|
||||
return normalizeSnippet(lines.slice(startIndex, endIndex).join(" "));
|
||||
}
|
||||
|
||||
function compareCandidateWindow(
|
||||
targetSnippet: string,
|
||||
windowSnippet: string,
|
||||
): { matched: boolean; quality: number } {
|
||||
if (!targetSnippet || !windowSnippet) {
|
||||
return { matched: false, quality: 0 };
|
||||
}
|
||||
if (windowSnippet === targetSnippet) {
|
||||
return { matched: true, quality: 3 };
|
||||
}
|
||||
if (windowSnippet.includes(targetSnippet)) {
|
||||
return { matched: true, quality: 2 };
|
||||
}
|
||||
if (targetSnippet.includes(windowSnippet)) {
|
||||
return { matched: true, quality: 1 };
|
||||
}
|
||||
return { matched: false, quality: 0 };
|
||||
}
|
||||
|
||||
function relocateCandidateRange(
|
||||
lines: string[],
|
||||
candidate: PromotionCandidate,
|
||||
): { startLine: number; endLine: number; snippet: string } | null {
|
||||
const targetSnippet = normalizeSnippet(candidate.snippet);
|
||||
const preferredSpan = Math.max(1, candidate.endLine - candidate.startLine + 1);
|
||||
if (targetSnippet.length === 0) {
|
||||
const fallbackSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
|
||||
if (!fallbackSnippet) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
startLine: candidate.startLine,
|
||||
endLine: candidate.endLine,
|
||||
snippet: fallbackSnippet,
|
||||
};
|
||||
}
|
||||
|
||||
const exactSnippet = normalizeRangeSnippet(lines, candidate.startLine, candidate.endLine);
|
||||
if (exactSnippet === targetSnippet) {
|
||||
return {
|
||||
startLine: candidate.startLine,
|
||||
endLine: candidate.endLine,
|
||||
snippet: exactSnippet,
|
||||
};
|
||||
}
|
||||
|
||||
const maxSpan = Math.min(lines.length, Math.max(preferredSpan + 3, 8));
|
||||
let bestMatch:
|
||||
| { startLine: number; endLine: number; snippet: string; quality: number; distance: number }
|
||||
| undefined;
|
||||
for (let startIndex = 0; startIndex < lines.length; startIndex += 1) {
|
||||
for (let span = 1; span <= maxSpan && startIndex + span <= lines.length; span += 1) {
|
||||
const startLine = startIndex + 1;
|
||||
const endLine = startIndex + span;
|
||||
const snippet = normalizeRangeSnippet(lines, startLine, endLine);
|
||||
const comparison = compareCandidateWindow(targetSnippet, snippet);
|
||||
if (!comparison.matched) {
|
||||
continue;
|
||||
}
|
||||
const distance = Math.abs(startLine - candidate.startLine);
|
||||
if (
|
||||
!bestMatch ||
|
||||
comparison.quality > bestMatch.quality ||
|
||||
(comparison.quality === bestMatch.quality && distance < bestMatch.distance) ||
|
||||
(comparison.quality === bestMatch.quality &&
|
||||
distance === bestMatch.distance &&
|
||||
Math.abs(span - preferredSpan) <
|
||||
Math.abs(bestMatch.endLine - bestMatch.startLine + 1 - preferredSpan))
|
||||
) {
|
||||
bestMatch = {
|
||||
startLine,
|
||||
endLine,
|
||||
snippet,
|
||||
quality: comparison.quality,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
startLine: bestMatch.startLine,
|
||||
endLine: bestMatch.endLine,
|
||||
snippet: bestMatch.snippet,
|
||||
};
|
||||
}
|
||||
|
||||
async function rehydratePromotionCandidate(
|
||||
workspaceDir: string,
|
||||
candidate: PromotionCandidate,
|
||||
): Promise<PromotionCandidate | null> {
|
||||
const sourcePaths = resolveShortTermSourcePathCandidates(workspaceDir, candidate.path);
|
||||
for (const sourcePath of sourcePaths) {
|
||||
let rawSource: string;
|
||||
try {
|
||||
rawSource = await fs.readFile(sourcePath, "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lines = rawSource.split(/\r?\n/);
|
||||
const relocated = relocateCandidateRange(lines, candidate);
|
||||
if (!relocated) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
startLine: relocated.startLine,
|
||||
endLine: relocated.endLine,
|
||||
snippet: relocated.snippet,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPromotionSection(
|
||||
candidates: PromotionCandidate[],
|
||||
nowMs: number,
|
||||
timezone?: string,
|
||||
): string {
|
||||
const sectionDate = formatMemoryDreamingDay(nowMs, timezone);
|
||||
const lines = ["", `## Promoted From Short-Term Memory (${sectionDate})`, ""];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -813,7 +972,15 @@ export async function applyShortTermPromotions(
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
if (selected.length === 0) {
|
||||
const rehydratedSelected: PromotionCandidate[] = [];
|
||||
for (const candidate of selected) {
|
||||
const rehydrated = await rehydratePromotionCandidate(workspaceDir, candidate);
|
||||
if (rehydrated) {
|
||||
rehydratedSelected.push(rehydrated);
|
||||
}
|
||||
}
|
||||
|
||||
if (rehydratedSelected.length === 0) {
|
||||
return {
|
||||
memoryPath,
|
||||
applied: 0,
|
||||
@@ -829,18 +996,21 @@ export async function applyShortTermPromotions(
|
||||
});
|
||||
|
||||
const header = existingMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
|
||||
const section = buildPromotionSection(selected, nowMs);
|
||||
const section = buildPromotionSection(rehydratedSelected, nowMs, options.timezone);
|
||||
await fs.writeFile(
|
||||
memoryPath,
|
||||
`${header}${withTrailingNewline(existingMemory)}${section}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
for (const candidate of selected) {
|
||||
for (const candidate of rehydratedSelected) {
|
||||
const entry = store.entries[candidate.key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
entry.startLine = candidate.startLine;
|
||||
entry.endLine = candidate.endLine;
|
||||
entry.snippet = candidate.snippet;
|
||||
entry.promotedAt = nowIso;
|
||||
}
|
||||
store.updatedAt = nowIso;
|
||||
@@ -848,8 +1018,8 @@ export async function applyShortTermPromotions(
|
||||
|
||||
return {
|
||||
memoryPath,
|
||||
applied: selected.length,
|
||||
appliedCandidates: selected,
|
||||
applied: rehydratedSelected.length,
|
||||
appliedCandidates: rehydratedSelected,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { recordShortTermRecalls } from "./short-term-promotion.js";
|
||||
import {
|
||||
clampResultsByInjectedChars,
|
||||
@@ -51,12 +55,14 @@ function queueShortTermRecallTracking(params: {
|
||||
query: string;
|
||||
rawResults: MemorySearchResult[];
|
||||
surfacedResults: MemorySearchResult[];
|
||||
timezone?: string;
|
||||
}): void {
|
||||
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
|
||||
void recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
query: params.query,
|
||||
results: trackingResults,
|
||||
timezone: params.timezone,
|
||||
}).catch(() => {
|
||||
// Recall tracking is best-effort and must never block memory recall.
|
||||
});
|
||||
@@ -102,11 +108,16 @@ export function createMemorySearchTool(options: {
|
||||
status.backend === "qmd"
|
||||
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
|
||||
: decorated;
|
||||
const dreamingTimezone = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryCorePluginConfig(cfg),
|
||||
cfg,
|
||||
}).timezone;
|
||||
queueShortTermRecallTracking({
|
||||
workspaceDir: status.workspaceDir,
|
||||
query,
|
||||
rawResults,
|
||||
surfacedResults: results,
|
||||
timezone: dreamingTimezone,
|
||||
});
|
||||
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
|
||||
return jsonResult({
|
||||
|
||||
Reference in New Issue
Block a user