Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getEffectiveVectorDimensions,
} from "./src/embedder.js";
import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "./src/retriever.js";
import { AccessTracker } from "./src/access-tracker.js";
import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey } from "./src/scopes.js";
import { createMigrator } from "./src/migrate.js";
import { registerAllMemoryTools } from "./src/tools.js";
Expand Down Expand Up @@ -65,6 +66,8 @@ import { createLlmClient } from "./src/llm-client.js";
import { createDecayEngine, DEFAULT_DECAY_CONFIG } from "./src/decay-engine.js";
import { createTierManager, DEFAULT_TIER_CONFIG } from "./src/tier-manager.js";
import { createMemoryUpgrader } from "./src/memory-upgrader.js";
import { createDreamingEngine, mergeDreamingConfig } from "./src/dreaming-engine.js";
import type { DreamingConfig } from "./src/dreaming-engine.js";
import {
buildSmartMetadata,
parseSmartMetadata,
Expand Down Expand Up @@ -256,6 +259,7 @@ interface PluginConfig {
*/
categoryField?: string;
};
dreaming?: DreamingConfig;
}

type ReflectionThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
Expand All @@ -273,6 +277,11 @@ function getDefaultDbPath(): string {

function getDefaultWorkspaceDir(): string {
const home = homedir();
// Try workspace-main first (standard OpenClaw layout), fallback to workspace
const mainDir = join(home, ".openclaw", "workspace-main");
try {
if (readFileSync(join(mainDir, "AGENTS.md"))) return mainDir;
} catch {}
return join(home, ".openclaw", "workspace");
}

Expand Down Expand Up @@ -1875,6 +1884,14 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState {
{ ...DEFAULT_RETRIEVAL_CONFIG, ...config.retrieval },
{ decayEngine },
);

// Wire access tracker so recall operations update access_count on memories
const accessTracker = new AccessTracker({
store,
logger: { warn: (...args: unknown[]) => api.logger.warn(...args), info: (...args: unknown[]) => api.logger.info(...args) },
debounceMs: 5000,
});
retriever.setAccessTracker(accessTracker);
const scopeManager = createScopeManager(config.scopes);

const clawteamScopes = parseClawteamScopes(process.env.CLAWTEAM_MEMORY_SCOPE);
Expand Down Expand Up @@ -4208,6 +4225,7 @@ const memoryLanceDBProPlugin = {
// ========================================================================

let backupTimer: ReturnType<typeof setInterval> | null = null;
let dreamingTimer: ReturnType<typeof setInterval> | null = null;
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours

async function runBackup() {
Expand Down Expand Up @@ -4351,12 +4369,167 @@ const memoryLanceDBProPlugin = {
// Run initial backup after a short delay, then schedule daily
setTimeout(() => void runBackup(), 60_000); // 1 min after start
backupTimer = setInterval(() => void runBackup(), BACKUP_INTERVAL_MS);

// ========================================================================
// Dreaming Engine — Periodic memory consolidation
// ========================================================================

const dreamingUserConfig = (api.pluginConfig as Record<string, unknown>)?.dreaming as Record<string, unknown> | undefined;
const dreamingCfg = mergeDreamingConfig(dreamingUserConfig);

if (dreamingCfg.enabled) {
const { createDreamingEngine: createDreaming } = await import("./src/dreaming-engine.js");

const dreamingLog = (msg: string) => api.logger.info(`dreaming: ${msg}`);
const dreamingDebug = (msg: string) => api.logger.debug(`dreaming: ${msg}`);

const dreamingEngine = createDreaming({
store,
embedder,
decayEngine,
tierManager,
config: dreamingCfg,
log: dreamingLog,
debugLog: dreamingDebug,
workspaceDir: getDefaultWorkspaceDir(),
fallbackDimensions: embedder.dimensions,
});

// Simple cron scheduler: checks every 60s, matches minute+hour fields
function parseCron(expr: string) {
const parts = expr.trim().split(/\s+/);
if (parts.length < 2) return { minute: [0], hour: [3], dayOfMonth: undefined, month: undefined, dayOfWeek: undefined };
const parseField = (field: string, min: number, max: number): number[] | undefined => {
if (!field || field === "*") return undefined; // wildcard = match all
return field.split(",").flatMap((p) => {
const stepMatch = p.match(/^(\*|\d+)\/(\d+)$/);
if (stepMatch) {
const base = stepMatch[1] === "*" ? min : parseInt(stepMatch[1], 10);
const step = parseInt(stepMatch[2], 10);
if (step <= 0) return []; // guard: reject step=0 to prevent infinite loop
const r: number[] = [];
for (let i = base; i <= max; i += step) r.push(i);
return r;
}
const n = parseInt(p, 10);
return Number.isFinite(n) ? [n] : [];
});
};
return {
minute: parseField(parts[0], 0, 59),
hour: parseField(parts[1], 0, 23),
dayOfMonth: parts.length > 2 ? parseField(parts[2], 1, 31) : undefined,
month: parts.length > 3 ? parseField(parts[3], 1, 12) : undefined,
dayOfWeek: parts.length > 4 ? parseField(parts[4], 0, 6) : undefined,
};
}

const parsedCron = parseCron(dreamingCfg.cron);

let dreamingCycleRunning = false; // Cycle-level guard to prevent overlapping cycles

dreamingTimer = setInterval(async () => {
const now = new Date();
if (parsedCron.minute && !parsedCron.minute.includes(now.getMinutes())) return;
if (parsedCron.hour && !parsedCron.hour.includes(now.getHours())) return;
if (parsedCron.dayOfMonth && !parsedCron.dayOfMonth.includes(now.getDate())) return;
if (parsedCron.month && !parsedCron.month.includes(now.getMonth() + 1)) return;
if (parsedCron.dayOfWeek && !parsedCron.dayOfWeek.includes(now.getDay())) return;

// Cycle-level guard: skip if a previous cycle is still running
if (dreamingCycleRunning) {
dreamingLog("skipping cycle — previous cycle still in progress");
return;
}
dreamingCycleRunning = true;
try {

// Run dreaming for each scope that has memories (MR1: scope isolation)
// Include both defined scopes and dynamic agent scopes discovered from the store
const definedScopes = scopeManager.getAllScopes();
const scopes = new Set(definedScopes);
try {
// Paginate through all memories to discover scopes (avoids 500-limit blind spot)
let offset = 0;
const batchSize = 1000;
while (true) {
const batch = await store.list(undefined, undefined, batchSize, offset);
if (batch.length === 0) break;
for (const m of batch) {
if (m.scope) scopes.add(m.scope);
}
if (batch.length < batchSize) break;
offset += batchSize;
}
} catch {}
scopes.add("global");

// Run scopes sequentially to avoid write races on DREAMS.md
const dreamLines: string[] = [];
for (const scope of scopes) {
try {
const report = await dreamingEngine.run(scope);
dreamingLog(
`cycle complete [${report.scope}] — ` +
`light:${report.phases.light.scanned}/${report.phases.light.transitions.length} transitions, ` +
`deep:${report.phases.deep.candidates}/${report.phases.deep.promoted} promoted, ` +
`rem:${report.phases.rem.patterns.length} patterns/${report.phases.rem.reflectionsCreated} reflections`,
);
dreamLines.push(
`## Dream Cycle — ${new Date().toISOString().replace("T", " ").slice(0, 19)} [${report.scope}]`, ``,
`**Light Sleep:** ${report.phases.light.scanned} scanned, ${report.phases.light.transitions.length} transitions`,
`**Deep Sleep:** ${report.phases.deep.candidates} candidates, ${report.phases.deep.promoted} promoted`,
`**REM:** ${report.phases.rem.patterns.length} patterns, ${report.phases.rem.reflectionsCreated} reflections`, ``,
);
if (report.phases.rem.patterns.length > 0) {
dreamLines.push(`### Patterns`);
for (const p of report.phases.rem.patterns) dreamLines.push(`- ${p}`);
dreamLines.push("");
}
} catch (err) {
dreamingLog(`cycle error [${scope}]: ${String(err)}`);
}
}

// Write DREAMS.md once after all scopes complete
if (dreamLines.length > 0) {
const workspaceDir = getDefaultWorkspaceDir();
const dreamsPath = join(workspaceDir, "DREAMS.md");
try {
const existing = await readFile(dreamsPath, "utf-8").catch(() => "");
await writeFile(dreamsPath, dreamLines.join("\n") + "\n" + existing, "utf-8");
} catch {}
}

} finally {
dreamingCycleRunning = false;
}
}, 60_000);

api.logger.info(
`dreaming engine enabled (cron: ${dreamingCfg.cron}, verbose: ${dreamingCfg.verboseLogging})`,
);
}
},
stop: async () => {
if (backupTimer) {
clearInterval(backupTimer);
backupTimer = null;
}
if (dreamingTimer) {
clearInterval(dreamingTimer);
dreamingTimer = null;
api.logger.info("dreaming: scheduler stopped");
}
// Flush and destroy AccessTracker on plugin stop
try {
if (accessTracker) {
accessTracker.destroy();
Comment on lines +4526 to +4527
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the access tracker in service scope

When the service stops after a manual recall has scheduled a debounced access-count write, this accessTracker identifier is not in the register() closure—the tracker is created as a local inside _initPluginState() and is not returned in PluginSingletonState. The resulting ReferenceError is caught by the surrounding cleanup catch, so the tracker is never destroyed and its timer/pending writes can outlive plugin stop or re-registration.

Useful? React with 👍 / 👎.

api.logger.info("memory-lancedb-pro: AccessTracker destroyed");
}
} catch (err) {
api.logger.warn(`memory-lancedb-pro: AccessTracker cleanup failed: ${String(err)}`);
}
api.logger.info("memory-lancedb-pro: stopped");
},
});
Expand Down
123 changes: 101 additions & 22 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,17 @@
},
"autoRecallExcludeAgents": {
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "Blacklist mode for auto-recall injection. Agents in this list are skipped. Agent resolution falls back to 'main' when no explicit agentId is available. If autoRecallIncludeAgents is also set, include wins."
"items": {
"type": "string"
},
"description": "Blacklist mode for auto-recall injection. Agents in this list are skipped. Agent resolution falls back to 'main' when no explicit agentId is available. If autoRecallIncludeAgents is also set, include wins.",
"default": []
},
"autoRecallIncludeAgents": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"default": [],
"description": "Whitelist mode for auto-recall injection. Only agents in this list receive auto-recall. Agent resolution falls back to 'main' when no explicit agentId is available. If both include and exclude are set, autoRecallIncludeAgents takes precedence (whitelist wins)."
},
Expand Down Expand Up @@ -730,18 +734,6 @@
"dedupeErrorSignals": {
"type": "boolean",
"default": true
},
"serialCooldownMs": {
"type": "integer",
"minimum": 0,
"description": "Cooldown in ms between reflection triggers for the same session. Default: 120000 (2 min). Set to 0 to disable."
},
"excludeAgents": {
"type": "array",
"items": {
"type": "string"
},
"description": "Agent/session patterns excluded from reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*."
}
}
},
Expand Down Expand Up @@ -946,12 +938,99 @@
}
}
},
"autoRecallExcludeAgents": {
"type": "array",
"items": {
"type": "string"
},
"description": "Agent/session patterns excluded from auto-recall and reflection injection. Supports exact match, wildcard prefix (e.g. pi-), and temp:*."
"dreaming": {
"type": "object",
"additionalProperties": false,
"description": "Dreaming: periodic memory consolidation and promotion from short-term to long-term storage",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable dreaming memory consolidation cycles"
},
"cron": {
"type": "string",
"default": "0 3 * * *",
"description": "Cron expression for dreaming schedule (minute hour day month weekday). Uses server local timezone."
},
"verboseLogging": {
"type": "boolean",
"default": false,
"description": "Enable verbose logging for dreaming cycles"
},
"phases": {
"type": "object",
"additionalProperties": false,
"description": "Per-phase tuning parameters",
"properties": {
"light": {
"type": "object",
"additionalProperties": false,
"properties": {
"lookbackDays": {
"type": "number",
"minimum": 1,
"default": 3
},
"limit": {
"type": "number",
"minimum": 1,
"default": 100
}
}
},
"deep": {
"type": "object",
"additionalProperties": false,
"properties": {
"limit": {
"type": "number",
"minimum": 1,
"default": 50
},
"minScore": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.6
},
"minRecallCount": {
"type": "number",
"minimum": 0,
"default": 2
},
"recencyHalfLifeDays": {
"type": "number",
"minimum": 1,
"default": 30
}
}
},
"rem": {
"type": "object",
"additionalProperties": false,
"properties": {
"lookbackDays": {
"type": "number",
"minimum": 1,
"default": 7
},
"limit": {
"type": "number",
"minimum": 1,
"default": 80
},
"minPatternStrength": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 0.7
}
}
}
}
}
}
}
},
"required": [
Expand Down
Loading
Loading