mirror of https://github.com/openclaw/openclaw.git
fix(memory-core): align dreaming promotion
This commit is contained in:
parent
40ffada812
commit
f7670bde7e
|
|
@ -99,8 +99,10 @@ Dreaming is the overnight reflection pass for memory. It is called "dreaming" be
|
|||
- You can toggle modes from chat with `/dreaming off|core|rem|deep`. Run `/dreaming` (or `/dreaming options`) to see what each mode does.
|
||||
- When enabled, `memory-core` automatically creates and maintains a managed cron job.
|
||||
- Set `dreaming.limit` to `0` if you want dreaming enabled but automatic promotion effectively paused.
|
||||
- Ranking uses weighted signals: recall frequency, retrieval relevance, query diversity, and temporal recency (recent recalls decay over time).
|
||||
- Ranking uses weighted signals: recall frequency, retrieval relevance, query diversity, temporal recency, cross-day consolidation, and derived concept richness.
|
||||
- Promotion into `MEMORY.md` only happens when quality thresholds are met, so long-term memory stays high signal instead of collecting one-off details.
|
||||
- Automatic runs fan out across configured memory workspaces, so one agent's sessions consolidate into that agent's memory workspace instead of only the default session.
|
||||
- Promotion re-reads the live daily note before writing to `MEMORY.md`, so edited or deleted short-term snippets do not get promoted from stale recall-store snapshots.
|
||||
|
||||
Default mode presets:
|
||||
|
||||
|
|
@ -132,5 +134,5 @@ Notes:
|
|||
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
|
||||
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
|
||||
- Dreaming cadence defaults to each mode's preset schedule. Override cadence with `plugins.entries.memory-core.config.dreaming.frequency` as a cron expression (for example `0 3 * * *`) and fine-tune with `timezone`, `limit`, `minScore`, `minRecallCount`, and `minUniqueQueries`.
|
||||
- Dreaming cadence defaults to each mode's preset schedule. Override cadence with `plugins.entries.memory-core.config.dreaming.cron` as a cron expression (for example `0 3 * * *`) and fine-tune with `timezone`, `limit`, `minScore`, `minRecallCount`, and `minUniqueQueries`.
|
||||
- Set `plugins.entries.memory-core.config.dreaming.verboseLogging` to `true` to emit per-run candidate and apply details into the normal gateway logs while tuning the feature.
|
||||
|
|
|
|||
|
|
@ -28,34 +28,43 @@ one-off details.
|
|||
|
||||
## Promotion signals
|
||||
|
||||
Dreaming combines four signals:
|
||||
Dreaming combines six signals:
|
||||
|
||||
- **Frequency**: how often the same candidate was recalled.
|
||||
- **Relevance**: how strong recall scores were when it was retrieved.
|
||||
- **Query diversity**: how many distinct query intents surfaced it.
|
||||
- **Recency**: temporal weighting over recent recalls.
|
||||
- **Consolidation**: whether recalls repeated across distinct days instead of one burst.
|
||||
- **Conceptual richness**: derived concept tags from the note path and snippet text.
|
||||
|
||||
Promotion requires all configured threshold gates to pass, not just one signal.
|
||||
|
||||
### Signal weights
|
||||
|
||||
| Signal | Weight | Description |
|
||||
| --------- | ------ | ------------------------------------------------ |
|
||||
| Frequency | 0.35 | How often the same entry was recalled |
|
||||
| Relevance | 0.35 | Average recall scores when retrieved |
|
||||
| Diversity | 0.15 | Count of distinct query intents that surfaced it |
|
||||
| Recency | 0.15 | Temporal decay (14-day half-life) |
|
||||
| Signal | Weight | Description |
|
||||
| ------------------- | ------ | ------------------------------------------------ |
|
||||
| Frequency | 0.24 | How often the same entry was recalled |
|
||||
| Relevance | 0.30 | Average recall scores when retrieved |
|
||||
| Query diversity | 0.15 | Count of distinct query intents that surfaced it |
|
||||
| Recency | 0.15 | Temporal decay (14-day half-life) |
|
||||
| Consolidation | 0.10 | Reward recalls repeated across multiple days |
|
||||
| Conceptual richness | 0.06 | Reward entries with richer derived concept tags |
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Recall tracking** -- Every `memory_search` hit is recorded to
|
||||
`memory/.dreams/short-term-recall.json` with recall count, scores, and query
|
||||
hash.
|
||||
`memory/.dreams/short-term-recall.json` with recall count, scores, query
|
||||
hash, recall days, and concept tags.
|
||||
2. **Scheduled scoring** -- On the configured cadence, candidates are ranked
|
||||
using weighted signals. All threshold gates must pass simultaneously.
|
||||
3. **Promotion** -- Qualifying entries are appended to `MEMORY.md` with a
|
||||
promoted timestamp.
|
||||
4. **Cleanup** -- Already-promoted entries are filtered from future cycles. A
|
||||
3. **Workspace fan-out** -- Each dreaming cycle runs once per configured memory
|
||||
workspace, so one agent's sessions consolidate into that agent's memory
|
||||
workspace.
|
||||
4. **Promotion** -- Before appending anything, dreaming re-reads the current
|
||||
daily note and skips candidates whose source snippet no longer exists.
|
||||
Qualifying live entries are appended to `MEMORY.md` with a promoted
|
||||
timestamp.
|
||||
5. **Cleanup** -- Already-promoted entries are filtered from future cycles. A
|
||||
file lock prevents concurrent runs.
|
||||
|
||||
## Modes
|
||||
|
|
@ -76,7 +85,7 @@ automatically. You do not need to manually create a cron job for this feature.
|
|||
|
||||
You can still tune behavior with explicit overrides such as:
|
||||
|
||||
- `dreaming.frequency` (cron expression)
|
||||
- `dreaming.cron` (cron expression)
|
||||
- `dreaming.timezone`
|
||||
- `dreaming.limit`
|
||||
- `dreaming.minScore`
|
||||
|
|
@ -142,7 +151,8 @@ See [memory CLI](/cli/memory) for the full flag reference.
|
|||
|
||||
When dreaming is enabled, the Gateway sidebar shows a **Dreams** tab with
|
||||
memory stats (short-term count, long-term count, promoted count) and the next
|
||||
scheduled cycle time.
|
||||
scheduled cycle time. Daily counters honor `dreaming.timezone` when set and
|
||||
otherwise fall back to the configured user timezone.
|
||||
|
||||
## Further reading
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||
|
||||
const loadConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig));
|
||||
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() =>
|
||||
vi.fn((_cfg: OpenClawConfig, _agentId: string) => "/tmp/openclaw"),
|
||||
);
|
||||
const resolveMemorySearchConfig = vi.hoisted(() =>
|
||||
vi.fn((_cfg: OpenClawConfig, _agentId: string) => ({ enabled: true })),
|
||||
);
|
||||
const getMemorySearchManager = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
|
|
@ -14,6 +20,11 @@ vi.mock("../../config/config.js", () => ({
|
|||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId,
|
||||
resolveAgentWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/memory-search.js", () => ({
|
||||
resolveMemorySearchConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/memory-runtime.js", () => ({
|
||||
|
|
@ -63,6 +74,8 @@ describe("doctor.memory.status", () => {
|
|||
beforeEach(() => {
|
||||
loadConfig.mockClear();
|
||||
resolveDefaultAgentId.mockClear();
|
||||
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
|
||||
resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true });
|
||||
getMemorySearchManager.mockReset();
|
||||
});
|
||||
|
||||
|
|
@ -134,18 +147,34 @@ describe("doctor.memory.status", () => {
|
|||
});
|
||||
|
||||
it("includes dreaming counts and managed cron status when workspace data is available", async () => {
|
||||
const now = Date.now();
|
||||
const todayIso = new Date(now).toISOString();
|
||||
const earlierIso = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
const now = Date.parse("2026-04-05T00:30:00.000Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
const recentIso = "2026-04-04T23:45:00.000Z";
|
||||
const olderIso = "2026-04-02T10:00:00.000Z";
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
|
||||
const mainWorkspaceDir = path.join(workspaceRoot, "main");
|
||||
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
|
||||
const mainStorePath = path.join(
|
||||
mainWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
const alphaStorePath = path.join(
|
||||
alphaWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
mainStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: todayIso,
|
||||
updatedAt: recentIso,
|
||||
entries: {
|
||||
"memory:memory/2026-04-03.md:1:2": {
|
||||
path: "memory/2026-04-03.md",
|
||||
|
|
@ -155,16 +184,31 @@ describe("doctor.memory.status", () => {
|
|||
"memory:memory/2026-04-02.md:1:2": {
|
||||
path: "memory/2026-04-02.md",
|
||||
source: "memory",
|
||||
promotedAt: todayIso,
|
||||
promotedAt: recentIso,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
alphaStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: recentIso,
|
||||
entries: {
|
||||
"memory:memory/2026-04-01.md:1:2": {
|
||||
path: "memory/2026-04-01.md",
|
||||
source: "memory",
|
||||
promotedAt: earlierIso,
|
||||
promotedAt: olderIso,
|
||||
},
|
||||
"memory:MEMORY.md:1:2": {
|
||||
path: "MEMORY.md",
|
||||
"memory:memory/2026-04-04.md:1:2": {
|
||||
path: "memory/2026-04-04.md",
|
||||
source: "memory",
|
||||
promotedAt: recentIso,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -175,24 +219,42 @@ describe("doctor.memory.status", () => {
|
|||
);
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Los_Angeles",
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspaceDir },
|
||||
{ id: "alpha", workspace: alphaWorkspaceDir },
|
||||
],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "rem",
|
||||
frequency: "0 */4 * * *",
|
||||
cron: "0 */4 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
resolveAgentWorkspaceDir.mockImplementation((cfg: OpenClawConfig, agentId: string) => {
|
||||
if (agentId === "alpha") {
|
||||
return alphaWorkspaceDir;
|
||||
}
|
||||
return mainWorkspaceDir;
|
||||
});
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir }),
|
||||
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
|
|
@ -224,9 +286,10 @@ describe("doctor.memory.status", () => {
|
|||
mode: "rem",
|
||||
enabled: true,
|
||||
frequency: "0 */4 * * *",
|
||||
timezone: "America/Los_Angeles",
|
||||
shortTermCount: 1,
|
||||
promotedTotal: 2,
|
||||
promotedToday: 1,
|
||||
promotedTotal: 3,
|
||||
promotedToday: 2,
|
||||
managedCronPresent: true,
|
||||
nextRunAtMs: now + 60_000,
|
||||
}),
|
||||
|
|
@ -234,8 +297,180 @@ describe("doctor.memory.status", () => {
|
|||
undefined,
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the manager workspace when no configured dreaming workspaces resolve", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-fallback-"));
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
"memory:memory/2026-04-03.md:1:2": {
|
||||
path: "memory/2026-04-03.md",
|
||||
source: "memory",
|
||||
promotedAt: "2026-04-04T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
resolveMemorySearchConfig.mockReturnValue(null);
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "core",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
const respond = vi.fn();
|
||||
|
||||
try {
|
||||
await invokeDoctorMemoryStatus(respond);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
dreaming: expect.objectContaining({
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 1,
|
||||
managedCronPresent: false,
|
||||
storePath,
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges workspace store errors when multiple workspace stores are unreadable", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-error-"));
|
||||
const mainWorkspaceDir = path.join(workspaceRoot, "main");
|
||||
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
|
||||
const alphaStorePath = path.join(
|
||||
alphaWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
alphaStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.mkdir(path.join(mainWorkspaceDir, "memory", ".dreams"), { recursive: true });
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspaceDir },
|
||||
{ id: "alpha", workspace: alphaWorkspaceDir },
|
||||
],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "core",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) =>
|
||||
agentId === "alpha" ? alphaWorkspaceDir : mainWorkspaceDir,
|
||||
);
|
||||
|
||||
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => {
|
||||
const targetPath =
|
||||
typeof target === "string"
|
||||
? target
|
||||
: Buffer.isBuffer(target)
|
||||
? target.toString("utf-8")
|
||||
: target instanceof URL
|
||||
? target.pathname
|
||||
: "";
|
||||
if (
|
||||
targetPath === path.join(mainWorkspaceDir, "memory", ".dreams", "short-term-recall.json") ||
|
||||
targetPath === alphaStorePath
|
||||
) {
|
||||
const error = Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
throw error;
|
||||
}
|
||||
return await vi
|
||||
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
|
||||
.then((actual) => actual.readFile(target, options as never));
|
||||
});
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
const respond = vi.fn();
|
||||
|
||||
try {
|
||||
await invokeDoctorMemoryStatus(respond);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
dreaming: expect.objectContaining({
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
storeError: "2 dreaming stores had read errors.",
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
readFileSpy.mockRestore();
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,55 +2,25 @@ import fs from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
isSameMemoryDreamingDay,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
type MemoryDreamingMode,
|
||||
} from "../../memory-host-sdk/dreaming.js";
|
||||
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
|
||||
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
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__";
|
||||
|
||||
type DreamingMode = "off" | "core" | "rem" | "deep";
|
||||
type DreamingPreset = Exclude<DreamingMode, "off">;
|
||||
|
||||
const DREAMING_PRESET_DEFAULTS: Record<
|
||||
DreamingPreset,
|
||||
{
|
||||
frequency: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
frequency: "0 3 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.75,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 2,
|
||||
},
|
||||
deep: {
|
||||
frequency: "0 */12 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
frequency: "0 */6 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamingPayload = {
|
||||
mode: DreamingMode;
|
||||
mode: MemoryDreamingMode;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
timezone?: string;
|
||||
|
|
@ -93,39 +63,8 @@ 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 === "rem" ||
|
||||
normalized === "deep"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const floored = Math.floor(value);
|
||||
return floored < 0 ? fallback : floored;
|
||||
}
|
||||
|
||||
function normalizeScore(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
if (value < 0 || value > 1) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveDreamingConfig(
|
||||
cfg: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
): Omit<
|
||||
DoctorMemoryDreamingPayload,
|
||||
| "shortTermCount"
|
||||
|
|
@ -137,27 +76,19 @@ function resolveDreamingConfig(
|
|||
| "managedCronPresent"
|
||||
| "storeError"
|
||||
> {
|
||||
const plugins = asRecord(cfg.plugins);
|
||||
const entries = asRecord(plugins?.entries);
|
||||
const memoryCore = asRecord(entries?.["memory-core"]);
|
||||
const pluginConfig = asRecord(memoryCore?.config);
|
||||
const dreaming = asRecord(pluginConfig?.dreaming);
|
||||
const mode = normalizeDreamingMode(dreaming?.mode);
|
||||
const preset: DreamingPreset = mode === "off" ? "core" : mode;
|
||||
const defaults = DREAMING_PRESET_DEFAULTS[preset];
|
||||
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryCorePluginConfig(cfg),
|
||||
cfg,
|
||||
});
|
||||
return {
|
||||
mode,
|
||||
enabled: mode !== "off",
|
||||
frequency: normalizeTrimmedString(dreaming?.frequency) ?? defaults.frequency,
|
||||
timezone: normalizeTrimmedString(dreaming?.timezone),
|
||||
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
|
||||
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
|
||||
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
|
||||
minUniqueQueries: normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
defaults.minUniqueQueries,
|
||||
),
|
||||
mode: resolved.mode,
|
||||
enabled: resolved.enabled,
|
||||
frequency: resolved.cron,
|
||||
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
|
||||
limit: resolved.limit,
|
||||
minScore: resolved.minScore,
|
||||
minRecallCount: resolved.minRecallCount,
|
||||
minUniqueQueries: resolved.minUniqueQueries,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -167,20 +98,10 @@ function normalizeMemoryPath(rawPath: string): string {
|
|||
|
||||
function isShortTermMemoryPath(filePath: string): boolean {
|
||||
const normalized = normalizeMemoryPath(filePath);
|
||||
if (SHORT_TERM_PATH_RE.test(normalized)) {
|
||||
if (/(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return SHORT_TERM_BASENAME_RE.test(normalized);
|
||||
}
|
||||
|
||||
function isSameLocalDay(firstEpochMs: number, secondEpochMs: number): boolean {
|
||||
const first = new Date(firstEpochMs);
|
||||
const second = new Date(secondEpochMs);
|
||||
return (
|
||||
first.getFullYear() === second.getFullYear() &&
|
||||
first.getMonth() === second.getMonth() &&
|
||||
first.getDate() === second.getDate()
|
||||
);
|
||||
return /^(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized);
|
||||
}
|
||||
|
||||
type DreamingStoreStats = Pick<
|
||||
|
|
@ -196,6 +117,7 @@ type DreamingStoreStats = Pick<
|
|||
async function loadDreamingStoreStats(
|
||||
workspaceDir: string,
|
||||
nowMs: number,
|
||||
timezone?: string,
|
||||
): Promise<DreamingStoreStats> {
|
||||
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
|
||||
try {
|
||||
|
|
@ -226,7 +148,7 @@ async function loadDreamingStoreStats(
|
|||
}
|
||||
promotedTotal += 1;
|
||||
const promotedAtMs = Date.parse(promotedAt);
|
||||
if (Number.isFinite(promotedAtMs) && isSameLocalDay(promotedAtMs, nowMs)) {
|
||||
if (Number.isFinite(promotedAtMs) && isSameMemoryDreamingDay(promotedAtMs, nowMs, timezone)) {
|
||||
promotedToday += 1;
|
||||
}
|
||||
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
|
||||
|
|
@ -262,6 +184,46 @@ async function loadDreamingStoreStats(
|
|||
}
|
||||
}
|
||||
|
||||
function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStats {
|
||||
let shortTermCount = 0;
|
||||
let promotedTotal = 0;
|
||||
let promotedToday = 0;
|
||||
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
|
||||
let lastPromotedAt: string | undefined;
|
||||
const storePaths = new Set<string>();
|
||||
const storeErrors: string[] = [];
|
||||
|
||||
for (const stat of stats) {
|
||||
shortTermCount += stat.shortTermCount;
|
||||
promotedTotal += stat.promotedTotal;
|
||||
promotedToday += stat.promotedToday;
|
||||
if (stat.storePath) {
|
||||
storePaths.add(stat.storePath);
|
||||
}
|
||||
if (stat.storeError) {
|
||||
storeErrors.push(stat.storeError);
|
||||
}
|
||||
const promotedAtMs = stat.lastPromotedAt ? Date.parse(stat.lastPromotedAt) : Number.NaN;
|
||||
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
|
||||
latestPromotedAtMs = promotedAtMs;
|
||||
lastPromotedAt = stat.lastPromotedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shortTermCount,
|
||||
promotedTotal,
|
||||
promotedToday,
|
||||
...(storePaths.size === 1 ? { storePath: [...storePaths][0] } : {}),
|
||||
...(lastPromotedAt ? { lastPromotedAt } : {}),
|
||||
...(storeErrors.length === 1
|
||||
? { storeError: storeErrors[0] }
|
||||
: storeErrors.length > 1
|
||||
? { storeError: `${storeErrors.length} dreaming stores had read errors.` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
type ManagedDreamingCronStatus = {
|
||||
managedCronPresent: boolean;
|
||||
nextRunAtMs?: number;
|
||||
|
|
@ -351,15 +313,27 @@ export const doctorHandlers: GatewayRequestHandlers = {
|
|||
embedding = { ok: false, error: "memory embeddings unavailable" };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const dreamingConfig = resolveDreamingConfig(cfg as Record<string, unknown>);
|
||||
const dreamingConfig = resolveDreamingConfig(cfg);
|
||||
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
|
||||
const storeStats = workspaceDir
|
||||
? await loadDreamingStoreStats(workspaceDir, nowMs)
|
||||
: {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
};
|
||||
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map(
|
||||
(entry) => entry.workspaceDir,
|
||||
);
|
||||
const allWorkspaces =
|
||||
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
|
||||
const storeStats =
|
||||
allWorkspaces.length > 0
|
||||
? mergeDreamingStoreStats(
|
||||
await Promise.all(
|
||||
allWorkspaces.map((entry) =>
|
||||
loadDreamingStoreStats(entry, nowMs, dreamingConfig.timezone),
|
||||
),
|
||||
),
|
||||
)
|
||||
: {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
};
|
||||
const cronStatus = await resolveManagedDreamingCronStatus(context);
|
||||
const payload: DoctorMemoryStatusPayload = {
|
||||
agentId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export const DEFAULT_MEMORY_DREAMING_CRON_EXPR = "0 3 * * *";
|
||||
export const DEFAULT_MEMORY_DREAMING_LIMIT = 10;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_SCORE = 0.75;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT = 3;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES = 2;
|
||||
export const DEFAULT_MEMORY_DREAMING_MODE = "off";
|
||||
export const DEFAULT_MEMORY_DREAMING_PRESET = "core";
|
||||
|
||||
export type MemoryDreamingPreset = "core" | "deep" | "rem";
|
||||
export type MemoryDreamingMode = MemoryDreamingPreset | "off";
|
||||
|
||||
export type MemoryDreamingConfig = {
|
||||
mode: MemoryDreamingMode;
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
verboseLogging: boolean;
|
||||
};
|
||||
|
||||
export type MemoryDreamingWorkspace = {
|
||||
workspaceDir: string;
|
||||
agentIds: string[];
|
||||
};
|
||||
|
||||
export const MEMORY_DREAMING_PRESET_DEFAULTS: Record<
|
||||
MemoryDreamingPreset,
|
||||
{
|
||||
cron: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
cron: DEFAULT_MEMORY_DREAMING_CRON_EXPR,
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: DEFAULT_MEMORY_DREAMING_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
},
|
||||
deep: {
|
||||
cron: "0 */12 * * *",
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
cron: "0 */6 * * *",
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
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) || 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 normalizePathForComparison(input: string): string {
|
||||
const normalized = path.resolve(input);
|
||||
if (process.platform === "win32") {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatLocalIsoDay(epochMs: number): string {
|
||||
const date = new Date(epochMs);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function normalizeMemoryDreamingMode(value: unknown): MemoryDreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "deep" ||
|
||||
normalized === "rem"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_MEMORY_DREAMING_MODE;
|
||||
}
|
||||
|
||||
export function resolveMemoryCorePluginConfig(
|
||||
cfg: OpenClawConfig | Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const root = asRecord(cfg);
|
||||
const plugins = asRecord(root?.plugins);
|
||||
const entries = asRecord(plugins?.entries);
|
||||
const memoryCore = asRecord(entries?.["memory-core"]);
|
||||
return asRecord(memoryCore?.config) ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveMemoryDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
}): MemoryDreamingConfig {
|
||||
const dreaming = asRecord(params.pluginConfig?.dreaming);
|
||||
const mode = normalizeMemoryDreamingMode(dreaming?.mode);
|
||||
const enabled = mode !== "off";
|
||||
const preset: MemoryDreamingPreset = mode === "off" ? DEFAULT_MEMORY_DREAMING_PRESET : mode;
|
||||
const defaults = MEMORY_DREAMING_PRESET_DEFAULTS[preset];
|
||||
const timezone =
|
||||
normalizeTrimmedString(dreaming?.timezone) ??
|
||||
normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone);
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
cron:
|
||||
normalizeTrimmedString(dreaming?.cron) ??
|
||||
normalizeTrimmedString(dreaming?.frequency) ??
|
||||
defaults.cron,
|
||||
...(timezone ? { timezone } : {}),
|
||||
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
|
||||
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
|
||||
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
|
||||
minUniqueQueries: normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
defaults.minUniqueQueries,
|
||||
),
|
||||
verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMemoryDreamingDay(epochMs: number, timezone?: string): string {
|
||||
if (!timezone) {
|
||||
return formatLocalIsoDay(epochMs);
|
||||
}
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date(epochMs));
|
||||
const values = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const year = values.get("year");
|
||||
const month = values.get("month");
|
||||
const day = values.get("day");
|
||||
if (year && month && day) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to host-local day for invalid or unsupported timezones.
|
||||
}
|
||||
return formatLocalIsoDay(epochMs);
|
||||
}
|
||||
|
||||
export function isSameMemoryDreamingDay(
|
||||
firstEpochMs: number,
|
||||
secondEpochMs: number,
|
||||
timezone?: string,
|
||||
): boolean {
|
||||
return (
|
||||
formatMemoryDreamingDay(firstEpochMs, timezone) ===
|
||||
formatMemoryDreamingDay(secondEpochMs, timezone)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] {
|
||||
const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const agentIds: string[] = [];
|
||||
const seenAgents = new Set<string>();
|
||||
for (const entry of configured) {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const id = entry.id.trim().toLowerCase();
|
||||
if (!id || seenAgents.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seenAgents.add(id);
|
||||
agentIds.push(id);
|
||||
}
|
||||
if (agentIds.length === 0) {
|
||||
agentIds.push(resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
const byWorkspace = new Map<string, MemoryDreamingWorkspace>();
|
||||
for (const agentId of agentIds) {
|
||||
if (!resolveMemorySearchConfig(cfg, agentId)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim();
|
||||
if (!workspaceDir) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizePathForComparison(workspaceDir);
|
||||
const existing = byWorkspace.get(key);
|
||||
if (existing) {
|
||||
existing.agentIds.push(agentId);
|
||||
continue;
|
||||
}
|
||||
byWorkspace.set(key, {
|
||||
workspaceDir,
|
||||
agentIds: [agentId],
|
||||
});
|
||||
}
|
||||
return [...byWorkspace.values()];
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "../memory-host-sdk/status.js";
|
||||
export * from "../memory-host-sdk/dreaming.js";
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ export {
|
|||
withProgress,
|
||||
withProgressTotals,
|
||||
} from "./memory-core-host-runtime-cli.js";
|
||||
export {
|
||||
formatMemoryDreamingDay,
|
||||
isSameMemoryDreamingDay,
|
||||
MEMORY_DREAMING_PRESET_DEFAULTS,
|
||||
normalizeMemoryDreamingMode,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
} from "./memory-core-host-status.js";
|
||||
export {
|
||||
listMemoryFiles,
|
||||
normalizeExtraMemoryPaths,
|
||||
|
|
|
|||
Loading…
Reference in New Issue