mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 04:40:42 +00:00
* fix(memory/dreaming): surface blocked status in memory status when heartbeat disabled for main Replace the hand-rolled heartbeat-rules logic in resolveDreamingBlockedReason with the shared resolveHeartbeatSummaryForAgent helper, promoted from core to the plugin-sdk via infra-runtime. Collapses the two disabled-reason branches into a single message that points at a new Troubleshooting section in the dreaming docs, so the silent-failure mode described in openclaw/openclaw#69843 becomes legible without the extension re-encoding heartbeat-enablement rules. Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * refactor(memory/dreaming): share resolveDreamingBlockedReason across cli and /dreaming surfaces - Move resolveDreamingBlockedReason from cli.runtime.ts into dreaming.ts as an exported helper and pin its heartbeat check to DEFAULT_AGENT_ID (now exported from plugin-sdk/routing) so the status-line check agrees with the cron's hardcoded sessionTarget even when the configured default agent is not main. - Render the blocked reason from formatStatus in dreaming-command.ts directly under the enabled line, so /dreaming status, /dreaming on, /dreaming off, and bare /dreaming all flag that the cron is blocked instead of implying dreaming is healthy. - Tighten the blocked-reason text to lead with user impact ('dreaming is enabled but will not run because heartbeat is disabled for main'), so operators immediately understand the config is toggled on but nothing is actually running. - Tighten the dreaming Troubleshooting copy to name main explicitly and mention both surfaces. - Add tests locking the new behavior across cli.test.ts (default-agent=ops still reports blocked for main) and dreaming-command.test.ts (/dreaming status ordering, /dreaming on surfacing, healthy-heartbeat omission). Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * fix(memory/dreaming): check heartbeat for the resolved default agent, not the literal 'main' sessionTarget: 'main' is a cron session-type enum variant meaning 'the default agent's main session', not an agent id (see src/cron/service/jobs.ts). buildManagedDreamingCronJob does not set agentId, and cron runtime resolves the missing agentId through resolveDefaultAgentId(cfg) before enqueuing or waking. The previous pin to DEFAULT_AGENT_ID could produce a false 'blocked' reading when a configured default agent is not 'main' and its heartbeat is fine, and could miss a real block when the default agent is not 'main' and that agent's heartbeat is actually off. Switch resolveDreamingBlockedReason to resolveDefaultAgentId(cfg) and interpolate the resolved agent id into the message so the blocked line names the agent whose heartbeat is the blocker. Introduce a narrow local CRON_SESSION_TARGET_MAIN constant for the cron session-type enum variant (used by the sessionTarget type and value) so the remaining 'main' literal is semantically distinct from any agent id. Revert the DEFAULT_AGENT_ID export addition on plugin-sdk/routing; memory-core no longer needs it. Update the Troubleshooting doc wording and the cli test that was locking the wrong behaviour. Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * fix(memory/dreaming): align blocked check with server-cron wake's defaults-only heartbeat resolveDreamingBlockedReason was using resolveHeartbeatSummaryForAgent, which merges agents.defaults.heartbeat with agents.list[].heartbeat. The managed dreaming cron leaves job.agentId and job.sessionKey unset, so server-cron's wake wrapper cannot look up a per-agent entry and calls runHeartbeatOnce with agents.defaults.heartbeat only. Using the summary helper would disagree with the actual wake when the default agent overrides heartbeat.every differently from the defaults (either direction — false blocked when the override would run, or false healthy when defaults block). Mirror the wake path explicitly: rule-1 enablement via isHeartbeatEnabledForAgent against the default agent, rule-3 interval via resolveHeartbeatIntervalMs with defaults-only heartbeat config. Comment points at server-cron so a future cleanup of that latent override-propagation gap sees the coupling. Refs openclaw/openclaw#69843.
1943 lines
68 KiB
TypeScript
1943 lines
68 KiB
TypeScript
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { resolveMemoryRemDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
|
|
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
|
import {
|
|
colorize,
|
|
defaultRuntime,
|
|
formatErrorMessage,
|
|
getMemorySearchManager,
|
|
isRich,
|
|
listMemoryFiles,
|
|
loadConfig,
|
|
normalizeExtraMemoryPaths,
|
|
resolveCommandSecretRefsViaGateway,
|
|
resolveDefaultAgentId,
|
|
resolveSessionTranscriptsDirForAgent,
|
|
resolveStateDir,
|
|
setVerbose,
|
|
shortenHomeInString,
|
|
shortenHomePath,
|
|
theme,
|
|
type OpenClawConfig,
|
|
withManager,
|
|
withProgress,
|
|
withProgressTotals,
|
|
} from "./cli.host.runtime.js";
|
|
import type {
|
|
MemoryCommandOptions,
|
|
MemoryPromoteCommandOptions,
|
|
MemoryPromoteExplainOptions,
|
|
MemoryRemBackfillOptions,
|
|
MemoryRemHarnessOptions,
|
|
MemorySearchCommandOptions,
|
|
} from "./cli.types.js";
|
|
import { removeBackfillDiaryEntries, writeBackfillDiaryEntries } from "./dreaming-narrative.js";
|
|
import { previewRemDreaming, seedHistoricalDailyMemorySignals } from "./dreaming-phases.js";
|
|
import {
|
|
auditDreamingArtifacts,
|
|
repairDreamingArtifacts,
|
|
type DreamingArtifactsAuditSummary,
|
|
type RepairDreamingArtifactsResult,
|
|
} from "./dreaming-repair.js";
|
|
import { asRecord } from "./dreaming-shared.js";
|
|
import {
|
|
resolveDreamingBlockedReason,
|
|
resolveShortTermPromotionDreamingConfig,
|
|
} from "./dreaming.js";
|
|
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
|
|
import {
|
|
applyShortTermPromotions,
|
|
auditShortTermPromotionArtifacts,
|
|
removeGroundedShortTermCandidates,
|
|
repairShortTermPromotionArtifacts,
|
|
readShortTermRecallEntries,
|
|
recordGroundedShortTermCandidates,
|
|
recordShortTermRecalls,
|
|
rankShortTermPromotionCandidates,
|
|
resolveShortTermRecallLockPath,
|
|
resolveShortTermRecallStorePath,
|
|
type RepairShortTermPromotionArtifactsResult,
|
|
type ShortTermAuditSummary,
|
|
} from "./short-term-promotion.js";
|
|
|
|
type MemoryManager = NonNullable<Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]>;
|
|
type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpose"];
|
|
|
|
type MemorySourceName = "memory" | "sessions";
|
|
|
|
type SourceScan = {
|
|
source: MemorySourceName;
|
|
totalFiles: number | null;
|
|
issues: string[];
|
|
};
|
|
|
|
type MemorySourceScan = {
|
|
sources: SourceScan[];
|
|
totalFiles: number | null;
|
|
issues: string[];
|
|
};
|
|
|
|
type LoadedMemoryCommandConfig = {
|
|
config: OpenClawConfig;
|
|
diagnostics: string[];
|
|
};
|
|
|
|
function getMemoryCommandSecretTargetIds(): Set<string> {
|
|
return new Set([
|
|
"agents.defaults.memorySearch.remote.apiKey",
|
|
"agents.list[].memorySearch.remote.apiKey",
|
|
]);
|
|
}
|
|
|
|
async function loadMemoryCommandConfig(commandName: string): Promise<LoadedMemoryCommandConfig> {
|
|
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
|
config: loadConfig(),
|
|
commandName,
|
|
targetIds: getMemoryCommandSecretTargetIds(),
|
|
});
|
|
return {
|
|
config: resolvedConfig,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
function emitMemorySecretResolveDiagnostics(
|
|
diagnostics: string[],
|
|
params?: { json?: boolean },
|
|
): void {
|
|
if (diagnostics.length === 0) {
|
|
return;
|
|
}
|
|
const toStderr = params?.json === true;
|
|
for (const entry of diagnostics) {
|
|
const message = theme.warn(`[secrets] ${entry}`);
|
|
if (toStderr) {
|
|
defaultRuntime.error(message);
|
|
} else {
|
|
defaultRuntime.log(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
|
|
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
|
|
return asRecord(entry?.config) ?? {};
|
|
}
|
|
|
|
const DAILY_MEMORY_FILE_NAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/;
|
|
|
|
async function listHistoricalDailyFiles(inputPath: string): Promise<string[]> {
|
|
const resolvedPath = path.resolve(inputPath);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(resolvedPath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
if (stat.isFile()) {
|
|
return DAILY_MEMORY_FILE_NAME_RE.test(path.basename(resolvedPath)) ? [resolvedPath] : [];
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return [];
|
|
}
|
|
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && DAILY_MEMORY_FILE_NAME_RE.test(entry.name))
|
|
.map((entry) => path.join(resolvedPath, entry.name))
|
|
.toSorted((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
}
|
|
|
|
async function createHistoricalRemHarnessWorkspace(params: {
|
|
inputPath: string;
|
|
remLimit: number;
|
|
nowMs: number;
|
|
timezone?: string;
|
|
}): Promise<{
|
|
workspaceDir: string;
|
|
sourceFiles: string[];
|
|
workspaceSourceFiles: string[];
|
|
importedFileCount: number;
|
|
importedSignalCount: number;
|
|
skippedPaths: string[];
|
|
}> {
|
|
const sourceFiles = await listHistoricalDailyFiles(params.inputPath);
|
|
const workspaceDir = await fs.mkdtemp(
|
|
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-harness-"),
|
|
);
|
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
await fs.mkdir(memoryDir, { recursive: true });
|
|
for (const filePath of sourceFiles) {
|
|
await fs.copyFile(filePath, path.join(memoryDir, path.basename(filePath)));
|
|
}
|
|
const workspaceSourceFiles = sourceFiles.map((entry) =>
|
|
path.join(memoryDir, path.basename(entry)),
|
|
);
|
|
const seeded = await seedHistoricalDailyMemorySignals({
|
|
workspaceDir,
|
|
filePaths: workspaceSourceFiles,
|
|
limit: params.remLimit,
|
|
nowMs: params.nowMs,
|
|
timezone: params.timezone,
|
|
});
|
|
return {
|
|
workspaceDir,
|
|
sourceFiles,
|
|
workspaceSourceFiles,
|
|
importedFileCount: seeded.importedFileCount,
|
|
importedSignalCount: seeded.importedSignalCount,
|
|
skippedPaths: seeded.skippedPaths,
|
|
};
|
|
}
|
|
|
|
async function listWorkspaceDailyFiles(workspaceDir: string, limit: number): Promise<string[]> {
|
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
try {
|
|
const files = await listHistoricalDailyFiles(memoryDir);
|
|
if (!Number.isFinite(limit) || limit <= 0 || files.length <= limit) {
|
|
return files;
|
|
}
|
|
return files.slice(-limit);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function formatDreamingSummary(cfg: OpenClawConfig): string {
|
|
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
|
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
|
|
if (!dreaming.enabled) {
|
|
return "off";
|
|
}
|
|
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
|
|
return `${dreaming.cron}${timezone} · limit=${dreaming.limit} · minScore=${dreaming.minScore} · minRecallCount=${dreaming.minRecallCount} · minUniqueQueries=${dreaming.minUniqueQueries} · recencyHalfLifeDays=${dreaming.recencyHalfLifeDays} · maxAgeDays=${dreaming.maxAgeDays ?? "none"}`;
|
|
}
|
|
|
|
function formatAuditCounts(audit: ShortTermAuditSummary): string {
|
|
const scriptCoverage = audit.conceptTagScripts
|
|
? [
|
|
audit.conceptTagScripts.latinEntryCount > 0
|
|
? `${audit.conceptTagScripts.latinEntryCount} latin`
|
|
: null,
|
|
audit.conceptTagScripts.cjkEntryCount > 0
|
|
? `${audit.conceptTagScripts.cjkEntryCount} cjk`
|
|
: null,
|
|
audit.conceptTagScripts.mixedEntryCount > 0
|
|
? `${audit.conceptTagScripts.mixedEntryCount} mixed`
|
|
: null,
|
|
audit.conceptTagScripts.otherEntryCount > 0
|
|
? `${audit.conceptTagScripts.otherEntryCount} other`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(", ")
|
|
: "";
|
|
const suffix = scriptCoverage ? ` · scripts=${scriptCoverage}` : "";
|
|
return `${audit.entryCount} entries · ${audit.promotedCount} promoted · ${audit.conceptTaggedEntryCount} concept-tagged · ${audit.spacedEntryCount} spaced${suffix}`;
|
|
}
|
|
|
|
function formatRepairSummary(repair: RepairShortTermPromotionArtifactsResult): string {
|
|
const actions: string[] = [];
|
|
if (repair.rewroteStore) {
|
|
actions.push(
|
|
`rewrote store${repair.removedInvalidEntries > 0 ? ` (-${repair.removedInvalidEntries} invalid)` : ""}`,
|
|
);
|
|
}
|
|
if (repair.removedStaleLock) {
|
|
actions.push("removed stale lock");
|
|
}
|
|
return actions.length > 0 ? actions.join(" · ") : "no changes";
|
|
}
|
|
|
|
function formatDreamingAuditSummary(audit: DreamingArtifactsAuditSummary): string {
|
|
const bits = [
|
|
audit.dreamsPath ? "diary present" : "diary absent",
|
|
`${audit.sessionCorpusFileCount} corpus files`,
|
|
audit.sessionIngestionExists ? "ingestion state present" : "ingestion state absent",
|
|
audit.suspiciousSessionCorpusLineCount > 0
|
|
? `${audit.suspiciousSessionCorpusLineCount} suspicious lines`
|
|
: null,
|
|
].filter(Boolean);
|
|
return bits.join(" · ");
|
|
}
|
|
|
|
function formatDreamingRepairSummary(repair: RepairDreamingArtifactsResult): string {
|
|
const actions: string[] = [];
|
|
if (repair.archivedSessionCorpus) {
|
|
actions.push("archived session corpus");
|
|
}
|
|
if (repair.archivedSessionIngestion) {
|
|
actions.push("archived ingestion state");
|
|
}
|
|
if (repair.archivedDreamsDiary) {
|
|
actions.push("archived diary");
|
|
}
|
|
if (repair.warnings.length > 0) {
|
|
actions.push(`${repair.warnings.length} warning${repair.warnings.length === 1 ? "" : "s"}`);
|
|
}
|
|
return actions.length > 0 ? actions.join(" · ") : "no changes";
|
|
}
|
|
|
|
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
|
if (source === "memory") {
|
|
return shortenHomeInString(
|
|
`memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`,
|
|
);
|
|
}
|
|
if (source === "sessions") {
|
|
const stateDir = resolveStateDir(process.env, os.homedir);
|
|
return shortenHomeInString(
|
|
`sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`,
|
|
);
|
|
}
|
|
return source;
|
|
}
|
|
|
|
function resolveAgent(cfg: OpenClawConfig, agent?: string) {
|
|
const trimmed = agent?.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
return resolveDefaultAgentId(cfg);
|
|
}
|
|
|
|
function buildCliMemorySearchSessionKey(agentId: string): string {
|
|
return buildAgentSessionKey({
|
|
agentId,
|
|
channel: "cli",
|
|
peer: { kind: "direct", id: "memory-search" },
|
|
dmScope: "per-channel-peer",
|
|
});
|
|
}
|
|
|
|
function resolveAgentIds(cfg: OpenClawConfig, agent?: string): string[] {
|
|
const trimmed = agent?.trim();
|
|
if (trimmed) {
|
|
return [trimmed];
|
|
}
|
|
const list = cfg.agents?.list ?? [];
|
|
if (list.length > 0) {
|
|
return list.map((entry) => entry.id).filter(Boolean);
|
|
}
|
|
return [resolveDefaultAgentId(cfg)];
|
|
}
|
|
|
|
function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] {
|
|
return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry));
|
|
}
|
|
|
|
function extractIsoDayFromPath(filePath: string): string | null {
|
|
const match = path.basename(filePath).match(DAILY_MEMORY_FILE_NAME_RE);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function groundedMarkdownToDiaryLines(markdown: string): string[] {
|
|
return markdown
|
|
.split(/\r?\n/)
|
|
.map((line) => line.replace(/^##\s+/, "").trimEnd())
|
|
.filter((line, index, lines) => !(line.length === 0 && lines[index - 1]?.length === 0));
|
|
}
|
|
|
|
function parseGroundedRef(
|
|
fallbackPath: string,
|
|
ref: string,
|
|
): { path: string; startLine: number; endLine: number } | null {
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const match = trimmed.match(/^(.*?):(\d+)(?:-(\d+))?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return {
|
|
path: (match[1] ?? fallbackPath).replaceAll("\\", "/").replace(/^\.\//, ""),
|
|
startLine: Math.max(1, Number(match[2])),
|
|
endLine: Math.max(1, Number(match[3] ?? match[2])),
|
|
};
|
|
}
|
|
|
|
function collectGroundedShortTermSeedItems(
|
|
previews: Awaited<ReturnType<typeof previewGroundedRemMarkdown>>["files"],
|
|
): Array<{
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
snippet: string;
|
|
score: number;
|
|
query: string;
|
|
signalCount: number;
|
|
dayBucket?: string;
|
|
}> {
|
|
const items: Array<{
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
snippet: string;
|
|
score: number;
|
|
query: string;
|
|
signalCount: number;
|
|
dayBucket?: string;
|
|
}> = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const file of previews) {
|
|
const dayBucket = extractIsoDayFromPath(file.path) ?? undefined;
|
|
const signals = [
|
|
...file.memoryImplications.map((item) => ({
|
|
text: item.text,
|
|
refs: item.refs,
|
|
score: 0.92,
|
|
query: "__dreaming_grounded_backfill__:lasting-update",
|
|
signalCount: 2,
|
|
})),
|
|
...file.candidates
|
|
.filter((candidate) => candidate.lean === "likely_durable")
|
|
.map((candidate) => ({
|
|
text: candidate.text,
|
|
refs: candidate.refs,
|
|
score: 0.82,
|
|
query: "__dreaming_grounded_backfill__:candidate",
|
|
signalCount: 1,
|
|
})),
|
|
];
|
|
|
|
for (const signal of signals) {
|
|
if (!signal.text.trim()) {
|
|
continue;
|
|
}
|
|
const firstRef = signal.refs.find((ref) => ref.trim().length > 0);
|
|
const parsedRef = firstRef ? parseGroundedRef(file.path, firstRef) : null;
|
|
if (!parsedRef) {
|
|
continue;
|
|
}
|
|
const key = `${parsedRef.path}:${parsedRef.startLine}:${parsedRef.endLine}:${signal.query}:${signal.text.toLowerCase()}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
items.push({
|
|
path: parsedRef.path,
|
|
startLine: parsedRef.startLine,
|
|
endLine: parsedRef.endLine,
|
|
snippet: signal.text,
|
|
score: signal.score,
|
|
query: signal.query,
|
|
signalCount: signal.signalCount,
|
|
...(dayBucket ? { dayBucket } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function matchesPromotionSelector(
|
|
candidate: {
|
|
key: string;
|
|
path: string;
|
|
snippet: string;
|
|
},
|
|
selector: string,
|
|
): boolean {
|
|
const trimmed = selector.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
return (
|
|
candidate.key.toLowerCase() === trimmed ||
|
|
candidate.key.toLowerCase().includes(trimmed) ||
|
|
candidate.path.toLowerCase().includes(trimmed) ||
|
|
candidate.snippet.toLowerCase().includes(trimmed)
|
|
);
|
|
}
|
|
|
|
async function withMemoryManagerForAgent(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
purpose?: MemoryManagerPurpose;
|
|
run: (manager: MemoryManager) => Promise<void>;
|
|
}): Promise<void> {
|
|
const managerParams: Parameters<typeof getMemorySearchManager>[0] = {
|
|
cfg: params.cfg,
|
|
agentId: params.agentId,
|
|
};
|
|
if (params.purpose) {
|
|
managerParams.purpose = params.purpose;
|
|
}
|
|
await withManager<MemoryManager>({
|
|
getManager: () => getMemorySearchManager(managerParams),
|
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
|
onCloseError: (err) =>
|
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
|
close: async (manager) => {
|
|
await manager.close?.();
|
|
},
|
|
run: params.run,
|
|
});
|
|
}
|
|
|
|
async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> {
|
|
try {
|
|
await fs.access(pathname, fsSync.constants.R_OK);
|
|
return { exists: true };
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
return { exists: false };
|
|
}
|
|
return {
|
|
exists: true,
|
|
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function scanSessionFiles(agentId: string): Promise<SourceScan> {
|
|
const issues: string[] = [];
|
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
|
|
try {
|
|
const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
|
|
const totalFiles = entries.filter(
|
|
(entry) => entry.isFile() && entry.name.endsWith(".jsonl"),
|
|
).length;
|
|
return { source: "sessions", totalFiles, issues };
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`);
|
|
return { source: "sessions", totalFiles: 0, issues };
|
|
}
|
|
issues.push(
|
|
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
|
|
);
|
|
return { source: "sessions", totalFiles: null, issues };
|
|
}
|
|
}
|
|
|
|
async function scanMemoryFiles(
|
|
workspaceDir: string,
|
|
extraPaths: string[] = [],
|
|
): Promise<SourceScan> {
|
|
const issues: string[] = [];
|
|
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
|
const altMemoryFile = path.join(workspaceDir, "memory.md");
|
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
|
|
const primary = await checkReadableFile(memoryFile);
|
|
const alt = await checkReadableFile(altMemoryFile);
|
|
if (primary.issue) {
|
|
issues.push(primary.issue);
|
|
}
|
|
if (alt.issue) {
|
|
issues.push(alt.issue);
|
|
}
|
|
|
|
const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
|
|
for (const extraPath of resolvedExtraPaths) {
|
|
try {
|
|
const stat = await fs.lstat(extraPath);
|
|
if (stat.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
const extraCheck = await checkReadableFile(extraPath);
|
|
if (extraCheck.issue) {
|
|
issues.push(extraCheck.issue);
|
|
}
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`);
|
|
} else {
|
|
issues.push(
|
|
`additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let dirReadable: boolean | null = null;
|
|
try {
|
|
await fs.access(memoryDir, fsSync.constants.R_OK);
|
|
dirReadable = true;
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`);
|
|
dirReadable = false;
|
|
} else {
|
|
issues.push(
|
|
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
|
);
|
|
dirReadable = null;
|
|
}
|
|
}
|
|
|
|
let listed: string[] = [];
|
|
let listedOk = false;
|
|
try {
|
|
listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths);
|
|
listedOk = true;
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (dirReadable !== null) {
|
|
issues.push(
|
|
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
|
);
|
|
dirReadable = null;
|
|
}
|
|
}
|
|
|
|
let totalFiles: number | null = 0;
|
|
if (dirReadable === null) {
|
|
totalFiles = null;
|
|
} else {
|
|
const files = new Set<string>(listedOk ? listed : []);
|
|
if (!listedOk) {
|
|
if (primary.exists) {
|
|
files.add(memoryFile);
|
|
}
|
|
if (alt.exists) {
|
|
files.add(altMemoryFile);
|
|
}
|
|
}
|
|
totalFiles = files.size;
|
|
}
|
|
|
|
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
|
|
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`);
|
|
}
|
|
|
|
return { source: "memory", totalFiles, issues };
|
|
}
|
|
|
|
async function summarizeQmdIndexArtifact(manager: MemoryManager): Promise<string | null> {
|
|
const status = manager.status?.();
|
|
if (!status || status.backend !== "qmd") {
|
|
return null;
|
|
}
|
|
const dbPath = status.dbPath?.trim();
|
|
if (!dbPath) {
|
|
return null;
|
|
}
|
|
let stat: fsSync.Stats;
|
|
try {
|
|
stat = await fs.stat(dbPath);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
throw new Error(`QMD index file not found: ${shortenHomePath(dbPath)}`, { cause: err });
|
|
}
|
|
throw new Error(
|
|
`QMD index file check failed: ${shortenHomePath(dbPath)} (${code ?? "error"})`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
if (!stat.isFile() || stat.size <= 0) {
|
|
throw new Error(`QMD index file is empty: ${shortenHomePath(dbPath)}`);
|
|
}
|
|
return `QMD index: ${shortenHomePath(dbPath)} (${stat.size} bytes)`;
|
|
}
|
|
|
|
async function scanMemorySources(params: {
|
|
workspaceDir: string;
|
|
agentId: string;
|
|
sources: MemorySourceName[];
|
|
extraPaths?: string[];
|
|
}): Promise<MemorySourceScan> {
|
|
const scans: SourceScan[] = [];
|
|
const extraPaths = params.extraPaths ?? [];
|
|
for (const source of params.sources) {
|
|
if (source === "memory") {
|
|
scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths));
|
|
}
|
|
if (source === "sessions") {
|
|
scans.push(await scanSessionFiles(params.agentId));
|
|
}
|
|
}
|
|
const issues = scans.flatMap((scan) => scan.issues);
|
|
const totals = scans.map((scan) => scan.totalFiles);
|
|
const numericTotals = totals.filter((total): total is number => total !== null);
|
|
const totalFiles = totals.some((total) => total === null)
|
|
? null
|
|
: numericTotals.reduce((sum, total) => sum + total, 0);
|
|
return { sources: scans, totalFiles, issues };
|
|
}
|
|
|
|
export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|
setVerbose(Boolean(opts.verbose));
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory status");
|
|
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
|
const agentIds = resolveAgentIds(cfg, opts.agent);
|
|
const allResults: Array<{
|
|
agentId: string;
|
|
status: ReturnType<MemoryManager["status"]>;
|
|
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
|
|
indexError?: string;
|
|
scan?: MemorySourceScan;
|
|
audit?: ShortTermAuditSummary;
|
|
repair?: RepairShortTermPromotionArtifactsResult;
|
|
dreamingAudit?: DreamingArtifactsAuditSummary;
|
|
dreamingRepair?: RepairDreamingArtifactsResult;
|
|
}> = [];
|
|
|
|
for (const agentId of agentIds) {
|
|
const managerPurpose = opts.index ? "default" : "status";
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
purpose: managerPurpose,
|
|
run: async (manager) => {
|
|
const deep = Boolean(opts.deep || opts.index);
|
|
let embeddingProbe:
|
|
| Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>
|
|
| undefined;
|
|
let indexError: string | undefined;
|
|
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
|
|
if (deep) {
|
|
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
|
|
progress.setLabel("Probing vector…");
|
|
await manager.probeVectorAvailability();
|
|
progress.tick();
|
|
progress.setLabel("Probing embeddings…");
|
|
embeddingProbe = await manager.probeEmbeddingAvailability();
|
|
progress.tick();
|
|
});
|
|
if (opts.index && syncFn) {
|
|
await withProgressTotals(
|
|
{
|
|
label: "Indexing memory…",
|
|
total: 0,
|
|
fallback: opts.verbose ? "line" : undefined,
|
|
},
|
|
async (update, progress) => {
|
|
try {
|
|
await syncFn({
|
|
reason: "cli",
|
|
force: Boolean(opts.force),
|
|
progress: (syncUpdate) => {
|
|
update({
|
|
completed: syncUpdate.completed,
|
|
total: syncUpdate.total,
|
|
label: syncUpdate.label,
|
|
});
|
|
if (syncUpdate.label) {
|
|
progress.setLabel(syncUpdate.label);
|
|
}
|
|
},
|
|
});
|
|
} catch (err) {
|
|
indexError = formatErrorMessage(err);
|
|
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
|
process.exitCode = 1;
|
|
}
|
|
},
|
|
);
|
|
} else if (opts.index && !syncFn) {
|
|
defaultRuntime.log("Memory backend does not support manual reindex.");
|
|
}
|
|
} else {
|
|
await manager.probeVectorAvailability();
|
|
}
|
|
const status = manager.status();
|
|
const sources = (
|
|
status.sources?.length ? status.sources : ["memory"]
|
|
) as MemorySourceName[];
|
|
const workspaceDir = status.workspaceDir;
|
|
const scan = workspaceDir
|
|
? await scanMemorySources({
|
|
workspaceDir,
|
|
agentId,
|
|
sources,
|
|
extraPaths: status.extraPaths,
|
|
})
|
|
: undefined;
|
|
let audit: ShortTermAuditSummary | undefined;
|
|
let repair: RepairShortTermPromotionArtifactsResult | undefined;
|
|
let dreamingAudit: DreamingArtifactsAuditSummary | undefined;
|
|
let dreamingRepair: RepairDreamingArtifactsResult | undefined;
|
|
if (workspaceDir) {
|
|
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
|
if (opts.fix && dreamingAudit.issues.some((issue) => issue.fixable)) {
|
|
dreamingRepair = await repairDreamingArtifacts({ workspaceDir });
|
|
dreamingAudit = await auditDreamingArtifacts({ workspaceDir });
|
|
}
|
|
if (opts.fix) {
|
|
repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
|
}
|
|
const customQmd = asRecord(asRecord(status.custom)?.qmd);
|
|
audit = await auditShortTermPromotionArtifacts({
|
|
workspaceDir,
|
|
qmd:
|
|
status.backend === "qmd"
|
|
? {
|
|
dbPath: status.dbPath,
|
|
collections:
|
|
typeof customQmd?.collections === "number"
|
|
? customQmd.collections
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
allResults.push({
|
|
agentId,
|
|
status,
|
|
embeddingProbe,
|
|
indexError,
|
|
scan,
|
|
audit,
|
|
repair,
|
|
dreamingAudit,
|
|
dreamingRepair,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson(allResults);
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
const heading = (text: string) => colorize(rich, theme.heading, text);
|
|
const muted = (text: string) => colorize(rich, theme.muted, text);
|
|
const info = (text: string) => colorize(rich, theme.info, text);
|
|
const success = (text: string) => colorize(rich, theme.success, text);
|
|
const warn = (text: string) => colorize(rich, theme.warn, text);
|
|
const accent = (text: string) => colorize(rich, theme.accent, text);
|
|
const label = (text: string) => muted(`${text}:`);
|
|
|
|
for (const result of allResults) {
|
|
const {
|
|
agentId,
|
|
status,
|
|
embeddingProbe,
|
|
indexError,
|
|
scan,
|
|
audit,
|
|
repair,
|
|
dreamingAudit,
|
|
dreamingRepair,
|
|
} = result;
|
|
const filesIndexed = status.files ?? 0;
|
|
const chunksIndexed = status.chunks ?? 0;
|
|
const totalFiles = scan?.totalFiles ?? null;
|
|
const indexedLabel =
|
|
totalFiles === null
|
|
? `${filesIndexed}/? files · ${chunksIndexed} chunks`
|
|
: `${filesIndexed}/${totalFiles} files · ${chunksIndexed} chunks`;
|
|
if (opts.index) {
|
|
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
|
defaultRuntime.log(line);
|
|
}
|
|
const requestedProvider = status.requestedProvider ?? status.provider;
|
|
const modelLabel = status.model ?? status.provider;
|
|
const storePath = status.dbPath ? shortenHomePath(status.dbPath) : "<unknown>";
|
|
const workspacePath = status.workspaceDir ? shortenHomePath(status.workspaceDir) : "<unknown>";
|
|
const sourceList = status.sources?.length ? status.sources.join(", ") : null;
|
|
const extraPaths = status.workspaceDir
|
|
? formatExtraPaths(status.workspaceDir, status.extraPaths ?? [])
|
|
: [];
|
|
const lines = [
|
|
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
|
`${label("Provider")} ${info(status.provider)} ${muted(`(requested: ${requestedProvider})`)}`,
|
|
`${label("Model")} ${info(modelLabel)}`,
|
|
sourceList ? `${label("Sources")} ${info(sourceList)}` : null,
|
|
extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null,
|
|
`${label("Indexed")} ${success(indexedLabel)}`,
|
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
|
`${label("Store")} ${info(storePath)}`,
|
|
`${label("Workspace")} ${info(workspacePath)}`,
|
|
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
|
|
].filter(Boolean) as string[];
|
|
const dreamingBlockedReason = resolveDreamingBlockedReason(cfg);
|
|
if (dreamingBlockedReason) {
|
|
lines.push(`${label("Dreaming status")} ${warn(`blocked - ${dreamingBlockedReason}`)}`);
|
|
}
|
|
if (embeddingProbe) {
|
|
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
|
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
|
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
|
if (embeddingProbe.error) {
|
|
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
|
}
|
|
}
|
|
if (status.sourceCounts?.length) {
|
|
lines.push(label("By source"));
|
|
for (const entry of status.sourceCounts) {
|
|
const total = scan?.sources?.find(
|
|
(scanEntry) => scanEntry.source === entry.source,
|
|
)?.totalFiles;
|
|
const counts =
|
|
total === null
|
|
? `${entry.files}/? files · ${entry.chunks} chunks`
|
|
: `${entry.files}/${total} files · ${entry.chunks} chunks`;
|
|
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
|
}
|
|
}
|
|
if (status.fallback) {
|
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
|
}
|
|
if (status.vector) {
|
|
const vectorState = status.vector.enabled
|
|
? status.vector.available === undefined
|
|
? "unknown"
|
|
: status.vector.available
|
|
? "ready"
|
|
: "unavailable"
|
|
: "disabled";
|
|
const vectorColor =
|
|
vectorState === "ready"
|
|
? theme.success
|
|
: vectorState === "unavailable"
|
|
? theme.warn
|
|
: theme.muted;
|
|
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
|
|
if (status.vector.dims) {
|
|
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
|
}
|
|
if (status.vector.extensionPath) {
|
|
lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`);
|
|
}
|
|
if (status.vector.loadError) {
|
|
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
|
}
|
|
}
|
|
if (status.fts) {
|
|
const ftsState = status.fts.enabled
|
|
? status.fts.available
|
|
? "ready"
|
|
: "unavailable"
|
|
: "disabled";
|
|
const ftsColor =
|
|
ftsState === "ready"
|
|
? theme.success
|
|
: ftsState === "unavailable"
|
|
? theme.warn
|
|
: theme.muted;
|
|
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
|
|
if (status.fts.error) {
|
|
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
|
|
}
|
|
}
|
|
if (status.cache) {
|
|
const cacheState = status.cache.enabled ? "enabled" : "disabled";
|
|
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
|
|
const suffix =
|
|
status.cache.enabled && typeof status.cache.entries === "number"
|
|
? ` (${status.cache.entries} entries)`
|
|
: "";
|
|
lines.push(`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`);
|
|
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
|
|
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
|
|
}
|
|
}
|
|
if (status.batch) {
|
|
const batchState = status.batch.enabled ? "enabled" : "disabled";
|
|
const batchColor = status.batch.enabled ? theme.success : theme.warn;
|
|
const batchSuffix = ` (failures ${status.batch.failures}/${status.batch.limit})`;
|
|
lines.push(
|
|
`${label("Batch")} ${colorize(rich, batchColor, batchState)}${muted(batchSuffix)}`,
|
|
);
|
|
if (status.batch.lastError) {
|
|
lines.push(`${label("Batch error")} ${warn(status.batch.lastError)}`);
|
|
}
|
|
}
|
|
if (audit) {
|
|
lines.push(`${label("Recall store")} ${info(formatAuditCounts(audit))}`);
|
|
lines.push(`${label("Recall path")} ${info(shortenHomePath(audit.storePath))}`);
|
|
if (audit.updatedAt) {
|
|
lines.push(`${label("Recall updated")} ${info(audit.updatedAt)}`);
|
|
}
|
|
if (status.backend === "qmd" && audit.qmd) {
|
|
const qmdBits = [
|
|
audit.qmd.dbPath ? shortenHomePath(audit.qmd.dbPath) : "<unknown>",
|
|
typeof audit.qmd.dbBytes === "number" ? `${audit.qmd.dbBytes} bytes` : null,
|
|
typeof audit.qmd.collections === "number" ? `${audit.qmd.collections} collections` : null,
|
|
].filter(Boolean);
|
|
lines.push(`${label("QMD audit")} ${info(qmdBits.join(" · "))}`);
|
|
}
|
|
}
|
|
if (dreamingAudit) {
|
|
lines.push(
|
|
`${label("Dreaming artifacts")} ${info(formatDreamingAuditSummary(dreamingAudit))}`,
|
|
);
|
|
lines.push(
|
|
`${label("Dream corpus")} ${info(shortenHomePath(dreamingAudit.sessionCorpusDir))}`,
|
|
);
|
|
lines.push(
|
|
`${label("Dream ingestion")} ${info(shortenHomePath(dreamingAudit.sessionIngestionPath))}`,
|
|
);
|
|
if (dreamingAudit.dreamsPath) {
|
|
lines.push(`${label("Dream diary")} ${info(shortenHomePath(dreamingAudit.dreamsPath))}`);
|
|
}
|
|
}
|
|
if (repair) {
|
|
lines.push(`${label("Repair")} ${info(formatRepairSummary(repair))}`);
|
|
}
|
|
if (dreamingRepair) {
|
|
lines.push(`${label("Dream repair")} ${info(formatDreamingRepairSummary(dreamingRepair))}`);
|
|
if (dreamingRepair.archiveDir) {
|
|
lines.push(`${label("Dream archive")} ${info(shortenHomePath(dreamingRepair.archiveDir))}`);
|
|
}
|
|
}
|
|
if (status.fallback?.reason) {
|
|
lines.push(muted(status.fallback.reason));
|
|
}
|
|
if (indexError) {
|
|
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
|
}
|
|
if (scan?.issues.length) {
|
|
lines.push(label("Issues"));
|
|
for (const issue of scan.issues) {
|
|
lines.push(` ${warn(issue)}`);
|
|
}
|
|
}
|
|
if (audit?.issues.length) {
|
|
if (!scan?.issues.length) {
|
|
lines.push(label("Issues"));
|
|
}
|
|
for (const issue of audit.issues) {
|
|
lines.push(` ${issue.severity === "error" ? warn(issue.message) : muted(issue.message)}`);
|
|
}
|
|
if (!opts.fix) {
|
|
lines.push(` ${muted(`Fix: openclaw memory status --fix --agent ${agentId}`)}`);
|
|
}
|
|
}
|
|
if (dreamingAudit?.issues.length) {
|
|
if (!scan?.issues.length && !audit?.issues.length) {
|
|
lines.push(label("Issues"));
|
|
}
|
|
for (const issue of dreamingAudit.issues) {
|
|
lines.push(` ${issue.severity === "error" ? warn(issue.message) : muted(issue.message)}`);
|
|
}
|
|
if (!opts.fix) {
|
|
lines.push(` ${muted(`Fix: openclaw memory status --fix --agent ${agentId}`)}`);
|
|
}
|
|
}
|
|
defaultRuntime.log(lines.join("\n"));
|
|
defaultRuntime.log("");
|
|
}
|
|
}
|
|
|
|
export async function runMemoryIndex(opts: MemoryCommandOptions) {
|
|
setVerbose(Boolean(opts.verbose));
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory index");
|
|
emitMemorySecretResolveDiagnostics(diagnostics);
|
|
const agentIds = resolveAgentIds(cfg, opts.agent);
|
|
for (const agentId of agentIds) {
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
run: async (manager) => {
|
|
try {
|
|
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
|
|
if (opts.verbose) {
|
|
const status = manager.status();
|
|
const rich = isRich();
|
|
const heading = (text: string) => colorize(rich, theme.heading, text);
|
|
const muted = (text: string) => colorize(rich, theme.muted, text);
|
|
const info = (text: string) => colorize(rich, theme.info, text);
|
|
const warn = (text: string) => colorize(rich, theme.warn, text);
|
|
const label = (text: string) => muted(`${text}:`);
|
|
const sourceLabels = (status.sources ?? []).map((source) =>
|
|
formatSourceLabel(source, status.workspaceDir ?? "", agentId),
|
|
);
|
|
const extraPaths = status.workspaceDir
|
|
? formatExtraPaths(status.workspaceDir, status.extraPaths ?? [])
|
|
: [];
|
|
const requestedProvider = status.requestedProvider ?? status.provider;
|
|
const modelLabel = status.model ?? status.provider;
|
|
const lines = [
|
|
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
|
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
|
`(requested: ${requestedProvider})`,
|
|
)}`,
|
|
`${label("Model")} ${info(modelLabel)}`,
|
|
sourceLabels.length ? `${label("Sources")} ${info(sourceLabels.join(", "))}` : null,
|
|
extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null,
|
|
].filter(Boolean) as string[];
|
|
if (status.fallback) {
|
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
|
}
|
|
defaultRuntime.log(lines.join("\n"));
|
|
defaultRuntime.log("");
|
|
}
|
|
const startedAt = Date.now();
|
|
let lastLabel = "Indexing memory…";
|
|
let lastCompleted = 0;
|
|
let lastTotal = 0;
|
|
const formatElapsed = () => {
|
|
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
|
const seconds = Math.floor(elapsedMs / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
|
};
|
|
const formatEta = () => {
|
|
if (lastTotal <= 0 || lastCompleted <= 0) {
|
|
return null;
|
|
}
|
|
const elapsedMs = Math.max(1, Date.now() - startedAt);
|
|
const rate = lastCompleted / elapsedMs;
|
|
if (!Number.isFinite(rate) || rate <= 0) {
|
|
return null;
|
|
}
|
|
const remainingMs = Math.max(0, (lastTotal - lastCompleted) / rate);
|
|
const seconds = Math.floor(remainingMs / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
|
};
|
|
const buildLabel = () => {
|
|
const elapsed = formatElapsed();
|
|
const eta = formatEta();
|
|
return eta
|
|
? `${lastLabel} · elapsed ${elapsed} · eta ${eta}`
|
|
: `${lastLabel} · elapsed ${elapsed}`;
|
|
};
|
|
if (!syncFn) {
|
|
defaultRuntime.log("Memory backend does not support manual reindex.");
|
|
return;
|
|
}
|
|
await withProgressTotals(
|
|
{
|
|
label: "Indexing memory…",
|
|
total: 0,
|
|
fallback: opts.verbose ? "line" : undefined,
|
|
},
|
|
async (update, progress) => {
|
|
const interval = setInterval(() => {
|
|
progress.setLabel(buildLabel());
|
|
}, 1000);
|
|
try {
|
|
await syncFn({
|
|
reason: "cli",
|
|
force: Boolean(opts.force),
|
|
progress: (syncUpdate) => {
|
|
if (syncUpdate.label) {
|
|
lastLabel = syncUpdate.label;
|
|
}
|
|
lastCompleted = syncUpdate.completed;
|
|
lastTotal = syncUpdate.total;
|
|
update({
|
|
completed: syncUpdate.completed,
|
|
total: syncUpdate.total,
|
|
label: buildLabel(),
|
|
});
|
|
progress.setLabel(buildLabel());
|
|
},
|
|
});
|
|
} finally {
|
|
clearInterval(interval);
|
|
}
|
|
},
|
|
);
|
|
const qmdIndexSummary = await summarizeQmdIndexArtifact(manager);
|
|
if (qmdIndexSummary) {
|
|
defaultRuntime.log(qmdIndexSummary);
|
|
}
|
|
const postIndexStatus = manager.status();
|
|
const vectorEnabled = postIndexStatus.vector?.enabled ?? false;
|
|
const vectorAvailable = postIndexStatus.vector?.available;
|
|
const vectorLoadErr = postIndexStatus.vector?.loadError;
|
|
if (vectorEnabled && vectorAvailable === false) {
|
|
const errDetail = vectorLoadErr ? `: ${vectorLoadErr}` : "";
|
|
// Indexing still persisted chunks/FTS state; keep the command successful but
|
|
// emit a stderr warning so operators and scripts can detect degraded recall.
|
|
defaultRuntime.error(
|
|
`Memory index WARNING (${agentId}): chunks_vec not updated — sqlite-vec unavailable${errDetail}. Vector recall degraded.`,
|
|
);
|
|
} else {
|
|
defaultRuntime.log(`Memory index updated (${agentId}).`);
|
|
}
|
|
} catch (err) {
|
|
const message = formatErrorMessage(err);
|
|
defaultRuntime.error(`Memory index failed (${agentId}): ${message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function runMemorySearch(
|
|
queryArg: string | undefined,
|
|
opts: MemorySearchCommandOptions,
|
|
) {
|
|
const query = opts.query ?? queryArg;
|
|
if (!query) {
|
|
defaultRuntime.error("Missing search query. Provide a positional query or use --query <text>.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
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,
|
|
run: async (manager) => {
|
|
const sessionKey = buildCliMemorySearchSessionKey(agentId);
|
|
let results: Awaited<ReturnType<typeof manager.search>>;
|
|
try {
|
|
results = await manager.search(query, {
|
|
maxResults: opts.maxResults,
|
|
minScore: opts.minScore,
|
|
sessionKey,
|
|
});
|
|
} catch (err) {
|
|
const message = formatErrorMessage(err);
|
|
defaultRuntime.error(`Memory search failed: ${message}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const workspaceDir =
|
|
typeof (manager as { status?: () => { workspaceDir?: string } }).status === "function"
|
|
? manager.status().workspaceDir
|
|
: undefined;
|
|
void recordShortTermRecalls({
|
|
workspaceDir,
|
|
query,
|
|
results,
|
|
timezone: dreaming.timezone,
|
|
}).catch(() => {
|
|
// Recall tracking is best-effort and must not block normal search results.
|
|
});
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({ results });
|
|
return;
|
|
}
|
|
if (results.length === 0) {
|
|
defaultRuntime.log("No matches.");
|
|
return;
|
|
}
|
|
const rich = isRich();
|
|
const lines: string[] = [];
|
|
for (const result of results) {
|
|
lines.push(
|
|
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
|
|
rich,
|
|
theme.accent,
|
|
`${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`,
|
|
)}`,
|
|
);
|
|
lines.push(colorize(rich, theme.muted, result.snippet));
|
|
lines.push("");
|
|
}
|
|
defaultRuntime.log(lines.join("\n").trim());
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory promote");
|
|
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
|
const agentId = resolveAgent(cfg, opts.agent);
|
|
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
purpose: "status",
|
|
run: async (manager) => {
|
|
const status = manager.status();
|
|
const workspaceDir = status.workspaceDir?.trim();
|
|
const dreaming = resolveShortTermPromotionDreamingConfig({
|
|
pluginConfig: resolveMemoryPluginConfig(cfg),
|
|
cfg,
|
|
});
|
|
if (!workspaceDir) {
|
|
defaultRuntime.error("Memory promote requires a resolvable workspace directory.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let candidates: Awaited<ReturnType<typeof rankShortTermPromotionCandidates>>;
|
|
try {
|
|
candidates = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
limit: opts.limit,
|
|
minScore: opts.minScore ?? dreaming.minScore,
|
|
minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount,
|
|
minUniqueQueries: opts.minUniqueQueries ?? dreaming.minUniqueQueries,
|
|
recencyHalfLifeDays: dreaming.recencyHalfLifeDays,
|
|
maxAgeDays: dreaming.maxAgeDays,
|
|
includePromoted: Boolean(opts.includePromoted),
|
|
});
|
|
} catch (err) {
|
|
defaultRuntime.error(`Memory promote ranking failed: ${formatErrorMessage(err)}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let applyResult: Awaited<ReturnType<typeof applyShortTermPromotions>> | undefined;
|
|
if (opts.apply) {
|
|
try {
|
|
applyResult = await applyShortTermPromotions({
|
|
workspaceDir,
|
|
candidates,
|
|
limit: opts.limit,
|
|
minScore: opts.minScore ?? dreaming.minScore,
|
|
minRecallCount: opts.minRecallCount ?? dreaming.minRecallCount,
|
|
minUniqueQueries: opts.minUniqueQueries ?? dreaming.minUniqueQueries,
|
|
maxAgeDays: dreaming.maxAgeDays,
|
|
timezone: dreaming.timezone,
|
|
});
|
|
} catch (err) {
|
|
defaultRuntime.error(`Memory promote apply failed: ${formatErrorMessage(err)}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
|
|
const customQmd = asRecord(asRecord(status.custom)?.qmd);
|
|
const audit = await auditShortTermPromotionArtifacts({
|
|
workspaceDir,
|
|
qmd:
|
|
status.backend === "qmd"
|
|
? {
|
|
dbPath: status.dbPath,
|
|
collections:
|
|
typeof customQmd?.collections === "number" ? customQmd.collections : undefined,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({
|
|
workspaceDir,
|
|
storePath,
|
|
lockPath,
|
|
audit,
|
|
candidates,
|
|
apply: applyResult
|
|
? {
|
|
applied: applyResult.applied,
|
|
appended: applyResult.appended,
|
|
reconciledExisting: applyResult.reconciledExisting,
|
|
memoryPath: applyResult.memoryPath,
|
|
appliedCandidates: applyResult.appliedCandidates,
|
|
}
|
|
: undefined,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
defaultRuntime.log("No short-term recall candidates.");
|
|
defaultRuntime.log(`Recall store: ${shortenHomePath(storePath)}`);
|
|
if (audit.issues.length > 0) {
|
|
for (const issue of audit.issues) {
|
|
defaultRuntime.log(issue.message);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
const lines: string[] = [];
|
|
lines.push(
|
|
`${colorize(rich, theme.heading, "Short-Term Promotion Candidates")} ${colorize(
|
|
rich,
|
|
theme.muted,
|
|
`(${agentId})`,
|
|
)}`,
|
|
);
|
|
lines.push(`${colorize(rich, theme.muted, "Recall store:")} ${shortenHomePath(storePath)}`);
|
|
lines.push(colorize(rich, theme.muted, `Store health: ${formatAuditCounts(audit)}`));
|
|
for (const candidate of candidates) {
|
|
lines.push(
|
|
`${colorize(rich, theme.success, candidate.score.toFixed(3))} ${colorize(
|
|
rich,
|
|
theme.accent,
|
|
`${shortenHomePath(candidate.path)}:${candidate.startLine}-${candidate.endLine}`,
|
|
)}`,
|
|
);
|
|
lines.push(
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} queries=${candidate.uniqueQueries} age=${candidate.ageDays.toFixed(1)}d consolidate=${candidate.components.consolidation.toFixed(2)} conceptual=${candidate.components.conceptual.toFixed(2)}`,
|
|
),
|
|
);
|
|
if (candidate.conceptTags.length > 0) {
|
|
lines.push(colorize(rich, theme.muted, `concepts=${candidate.conceptTags.join(", ")}`));
|
|
}
|
|
if (candidate.snippet) {
|
|
lines.push(colorize(rich, theme.muted, candidate.snippet));
|
|
}
|
|
lines.push("");
|
|
}
|
|
if (audit.issues.length > 0) {
|
|
lines.push(colorize(rich, theme.warn, "Audit issues:"));
|
|
for (const issue of audit.issues) {
|
|
lines.push(
|
|
colorize(rich, issue.severity === "error" ? theme.warn : theme.muted, issue.message),
|
|
);
|
|
}
|
|
lines.push("");
|
|
}
|
|
if (applyResult) {
|
|
if (applyResult.applied > 0) {
|
|
lines.push(
|
|
colorize(
|
|
rich,
|
|
theme.success,
|
|
`Processed ${applyResult.applied} candidate(s) for ${shortenHomePath(applyResult.memoryPath)}.`,
|
|
),
|
|
);
|
|
lines.push(
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`appended=${applyResult.appended} reconciledExisting=${applyResult.reconciledExisting}`,
|
|
),
|
|
);
|
|
} else {
|
|
lines.push(colorize(rich, theme.warn, "No candidates met apply criteria."));
|
|
}
|
|
}
|
|
defaultRuntime.log(lines.join("\n").trim());
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function runMemoryPromoteExplain(
|
|
selectorArg: string | undefined,
|
|
opts: MemoryPromoteExplainOptions,
|
|
) {
|
|
const selector = selectorArg?.trim();
|
|
if (!selector) {
|
|
defaultRuntime.error("Memory promote-explain requires a non-empty selector.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory promote-explain");
|
|
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
|
const agentId = resolveAgent(cfg, opts.agent);
|
|
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
purpose: "status",
|
|
run: async (manager) => {
|
|
const status = manager.status();
|
|
const workspaceDir = status.workspaceDir?.trim();
|
|
const dreaming = resolveShortTermPromotionDreamingConfig({
|
|
pluginConfig: resolveMemoryPluginConfig(cfg),
|
|
cfg,
|
|
});
|
|
if (!workspaceDir) {
|
|
defaultRuntime.error("Memory promote-explain requires a resolvable workspace directory.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let candidates: Awaited<ReturnType<typeof rankShortTermPromotionCandidates>>;
|
|
try {
|
|
candidates = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
includePromoted: Boolean(opts.includePromoted),
|
|
recencyHalfLifeDays: dreaming.recencyHalfLifeDays,
|
|
maxAgeDays: dreaming.maxAgeDays,
|
|
});
|
|
} catch (err) {
|
|
defaultRuntime.error(`Memory promote-explain failed: ${formatErrorMessage(err)}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const candidate = candidates.find((entry) => matchesPromotionSelector(entry, selector));
|
|
if (!candidate) {
|
|
defaultRuntime.error(`No promotion candidate matched "${selector}".`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const thresholds = {
|
|
minScore: dreaming.minScore,
|
|
minRecallCount: dreaming.minRecallCount,
|
|
minUniqueQueries: dreaming.minUniqueQueries,
|
|
maxAgeDays: dreaming.maxAgeDays ?? null,
|
|
};
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({
|
|
workspaceDir,
|
|
thresholds,
|
|
candidate,
|
|
passes: {
|
|
score: candidate.score >= thresholds.minScore,
|
|
recallCount: candidate.recallCount >= thresholds.minRecallCount,
|
|
uniqueQueries: candidate.uniqueQueries >= thresholds.minUniqueQueries,
|
|
maxAge:
|
|
thresholds.maxAgeDays === null ? true : candidate.ageDays <= thresholds.maxAgeDays,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
const lines = [
|
|
`${colorize(rich, theme.heading, "Promotion Explain")} ${colorize(
|
|
rich,
|
|
theme.muted,
|
|
"(" + agentId + ")",
|
|
)}`,
|
|
colorize(rich, theme.accent, candidate.key),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`${shortenHomePath(candidate.path)}:${String(candidate.startLine)}-${String(candidate.endLine)}`,
|
|
),
|
|
candidate.snippet,
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`score=${candidate.score.toFixed(3)} recallCount=${candidate.recallCount} uniqueQueries=${candidate.uniqueQueries} ageDays=${candidate.ageDays.toFixed(1)}`,
|
|
),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`components: frequency=${candidate.components.frequency.toFixed(2)} relevance=${candidate.components.relevance.toFixed(2)} diversity=${candidate.components.diversity.toFixed(2)} recency=${candidate.components.recency.toFixed(2)} consolidation=${candidate.components.consolidation.toFixed(2)} conceptual=${candidate.components.conceptual.toFixed(2)}`,
|
|
),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`thresholds: minScore=${thresholds.minScore} minRecallCount=${thresholds.minRecallCount} minUniqueQueries=${thresholds.minUniqueQueries} maxAgeDays=${thresholds.maxAgeDays ?? "none"}`,
|
|
),
|
|
];
|
|
if (candidate.conceptTags.length > 0) {
|
|
lines.push(colorize(rich, theme.muted, `concepts=${candidate.conceptTags.join(", ")}`));
|
|
}
|
|
defaultRuntime.log(lines.join("\n"));
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function runMemoryRemHarness(opts: MemoryRemHarnessOptions) {
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory rem-harness");
|
|
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
|
const agentId = resolveAgent(cfg, opts.agent);
|
|
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
purpose: "status",
|
|
run: async (manager) => {
|
|
const status = manager.status();
|
|
const managerWorkspaceDir = status.workspaceDir?.trim();
|
|
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
|
const deep = resolveShortTermPromotionDreamingConfig({
|
|
pluginConfig,
|
|
cfg,
|
|
});
|
|
if (!managerWorkspaceDir && !opts.path) {
|
|
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const remConfig = resolveMemoryRemDreamingConfig({
|
|
pluginConfig,
|
|
cfg,
|
|
});
|
|
const nowMs = Date.now();
|
|
let workspaceDir = managerWorkspaceDir ?? "";
|
|
let sourceFiles: string[] = [];
|
|
let groundedInputPaths: string[] = [];
|
|
let importedFileCount = 0;
|
|
let importedSignalCount = 0;
|
|
let skippedPaths: string[] = [];
|
|
let cleanupWorkspaceDir: string | null = null;
|
|
if (opts.path) {
|
|
const historical = await createHistoricalRemHarnessWorkspace({
|
|
inputPath: opts.path,
|
|
remLimit: remConfig.limit,
|
|
nowMs,
|
|
timezone: remConfig.timezone,
|
|
});
|
|
workspaceDir = historical.workspaceDir;
|
|
cleanupWorkspaceDir = historical.workspaceDir;
|
|
sourceFiles = historical.sourceFiles;
|
|
groundedInputPaths = historical.workspaceSourceFiles;
|
|
importedFileCount = historical.importedFileCount;
|
|
importedSignalCount = historical.importedSignalCount;
|
|
skippedPaths = historical.skippedPaths;
|
|
if (sourceFiles.length === 0) {
|
|
await fs.rm(historical.workspaceDir, { recursive: true, force: true });
|
|
defaultRuntime.error(
|
|
`Memory rem-harness found no YYYY-MM-DD.md files at ${shortenHomePath(path.resolve(opts.path))}.`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
if (!workspaceDir) {
|
|
defaultRuntime.error("Memory rem-harness requires a resolvable workspace directory.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
try {
|
|
if (groundedInputPaths.length === 0 && opts.grounded) {
|
|
groundedInputPaths = await listWorkspaceDailyFiles(workspaceDir, remConfig.limit);
|
|
}
|
|
const cutoffMs = nowMs - Math.max(0, remConfig.lookbackDays) * 24 * 60 * 60 * 1000;
|
|
const recallEntries = (await readShortTermRecallEntries({ workspaceDir, nowMs })).filter(
|
|
(entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs,
|
|
);
|
|
const remPreview = previewRemDreaming({
|
|
entries: recallEntries,
|
|
limit: remConfig.limit,
|
|
minPatternStrength: remConfig.minPatternStrength,
|
|
});
|
|
const groundedPreview =
|
|
opts.grounded && groundedInputPaths.length > 0
|
|
? await previewGroundedRemMarkdown({
|
|
workspaceDir,
|
|
inputPaths: groundedInputPaths,
|
|
})
|
|
: null;
|
|
const deepCandidates = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
includePromoted: Boolean(opts.includePromoted),
|
|
recencyHalfLifeDays: deep.recencyHalfLifeDays,
|
|
maxAgeDays: deep.maxAgeDays,
|
|
});
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({
|
|
workspaceDir,
|
|
sourcePath: opts.path ? path.resolve(opts.path) : null,
|
|
sourceFiles,
|
|
historicalImport: opts.path
|
|
? {
|
|
importedFileCount,
|
|
importedSignalCount,
|
|
skippedPaths,
|
|
}
|
|
: null,
|
|
remConfig,
|
|
deepConfig: {
|
|
minScore: deep.minScore,
|
|
minRecallCount: deep.minRecallCount,
|
|
minUniqueQueries: deep.minUniqueQueries,
|
|
recencyHalfLifeDays: deep.recencyHalfLifeDays,
|
|
maxAgeDays: deep.maxAgeDays ?? null,
|
|
},
|
|
rem: remPreview,
|
|
grounded: groundedPreview,
|
|
deep: {
|
|
candidateCount: deepCandidates.length,
|
|
candidates: deepCandidates,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
const lines = [
|
|
`${colorize(rich, theme.heading, "REM Harness")} ${colorize(rich, theme.muted, `(${agentId})`)}`,
|
|
colorize(rich, theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
|
|
...(opts.path
|
|
? [
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`sourcePath=${shortenHomePath(path.resolve(opts.path))}`,
|
|
),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`historicalFiles=${sourceFiles.length} importedFiles=${importedFileCount} importedSignals=${importedSignalCount}`,
|
|
),
|
|
...(skippedPaths.length > 0
|
|
? [
|
|
colorize(
|
|
rich,
|
|
theme.warn,
|
|
`skipped=${skippedPaths.map((entry) => shortenHomePath(entry)).join(", ")}`,
|
|
),
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
...(opts.grounded
|
|
? [
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`groundedInputs=${groundedInputPaths.length > 0 ? groundedInputPaths.map((entry) => shortenHomePath(entry)).join(", ") : "none"}`,
|
|
),
|
|
]
|
|
: []),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`recentRecallEntries=${recallEntries.length} deepCandidates=${deepCandidates.length}`,
|
|
),
|
|
"",
|
|
colorize(rich, theme.heading, "REM Preview"),
|
|
...remPreview.bodyLines,
|
|
...(groundedPreview
|
|
? [
|
|
"",
|
|
colorize(rich, theme.heading, "Grounded REM"),
|
|
...groundedPreview.files.flatMap((file) => [
|
|
colorize(rich, theme.muted, file.path),
|
|
file.renderedMarkdown,
|
|
"",
|
|
]),
|
|
]
|
|
: []),
|
|
"",
|
|
colorize(rich, theme.heading, "Deep Candidates"),
|
|
...(deepCandidates.length > 0
|
|
? deepCandidates
|
|
.slice(0, 10)
|
|
.map(
|
|
(candidate) =>
|
|
`${candidate.score.toFixed(3)} ${candidate.snippet} [${shortenHomePath(candidate.path)}:${candidate.startLine}-${candidate.endLine}]`,
|
|
)
|
|
: ["- No deep candidates."]),
|
|
];
|
|
defaultRuntime.log(lines.join("\n"));
|
|
} finally {
|
|
if (cleanupWorkspaceDir) {
|
|
await fs.rm(cleanupWorkspaceDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
|
|
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory rem-backfill");
|
|
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
|
|
const agentId = resolveAgent(cfg, opts.agent);
|
|
|
|
await withMemoryManagerForAgent({
|
|
cfg,
|
|
agentId,
|
|
purpose: "status",
|
|
run: async (manager) => {
|
|
const status = manager.status();
|
|
const workspaceDir = status.workspaceDir?.trim();
|
|
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
|
const remConfig = resolveMemoryRemDreamingConfig({
|
|
pluginConfig,
|
|
cfg,
|
|
});
|
|
if (!workspaceDir) {
|
|
defaultRuntime.error("Memory rem-backfill requires a resolvable workspace directory.");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (opts.rollback || opts.rollbackShortTerm) {
|
|
const diaryRollback = opts.rollback
|
|
? await removeBackfillDiaryEntries({ workspaceDir })
|
|
: null;
|
|
const shortTermRollback = opts.rollbackShortTerm
|
|
? await removeGroundedShortTermCandidates({ workspaceDir })
|
|
: null;
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({
|
|
workspaceDir,
|
|
rollback: Boolean(opts.rollback),
|
|
rollbackShortTerm: Boolean(opts.rollbackShortTerm),
|
|
...(diaryRollback
|
|
? {
|
|
dreamsPath: diaryRollback.dreamsPath,
|
|
removedEntries: diaryRollback.removed,
|
|
}
|
|
: {}),
|
|
...(shortTermRollback
|
|
? {
|
|
shortTermStorePath: shortTermRollback.storePath,
|
|
removedShortTermEntries: shortTermRollback.removed,
|
|
}
|
|
: {}),
|
|
});
|
|
return;
|
|
}
|
|
defaultRuntime.log(
|
|
[
|
|
`${colorize(isRich(), theme.heading, "REM Backfill")} ${colorize(isRich(), theme.muted, "(rollback)")}`,
|
|
colorize(isRich(), theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
|
|
...(diaryRollback
|
|
? [
|
|
colorize(
|
|
isRich(),
|
|
theme.muted,
|
|
`dreamsPath=${shortenHomePath(diaryRollback.dreamsPath)}`,
|
|
),
|
|
colorize(isRich(), theme.muted, `removedEntries=${diaryRollback.removed}`),
|
|
]
|
|
: []),
|
|
...(shortTermRollback
|
|
? [
|
|
colorize(
|
|
isRich(),
|
|
theme.muted,
|
|
`shortTermStorePath=${shortenHomePath(shortTermRollback.storePath)}`,
|
|
),
|
|
colorize(
|
|
isRich(),
|
|
theme.muted,
|
|
`removedShortTermEntries=${shortTermRollback.removed}`,
|
|
),
|
|
]
|
|
: []),
|
|
].join("\n"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!opts.path) {
|
|
defaultRuntime.error(
|
|
"Memory rem-backfill requires --path <file-or-dir> unless using --rollback.",
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const scratchDir = await fs.mkdtemp(
|
|
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-backfill-"),
|
|
);
|
|
try {
|
|
const sourceFiles = await listHistoricalDailyFiles(opts.path);
|
|
if (sourceFiles.length === 0) {
|
|
defaultRuntime.error(
|
|
`Memory rem-backfill found no YYYY-MM-DD.md files at ${shortenHomePath(path.resolve(opts.path))}.`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const scratchMemoryDir = path.join(scratchDir, "memory");
|
|
await fs.mkdir(scratchMemoryDir, { recursive: true });
|
|
const workspaceSourceFiles: string[] = [];
|
|
for (const filePath of sourceFiles) {
|
|
const dst = path.join(scratchMemoryDir, path.basename(filePath));
|
|
await fs.copyFile(filePath, dst);
|
|
workspaceSourceFiles.push(dst);
|
|
}
|
|
const grounded = await previewGroundedRemMarkdown({
|
|
workspaceDir: scratchDir,
|
|
inputPaths: workspaceSourceFiles,
|
|
});
|
|
const sourcePathByDay = new Map(
|
|
sourceFiles
|
|
.map((sourcePath) => [extractIsoDayFromPath(sourcePath), sourcePath] as const)
|
|
.filter((entry): entry is [string, string] => Boolean(entry[0])),
|
|
);
|
|
const entries = grounded.files
|
|
.map((file) => {
|
|
const isoDay = extractIsoDayFromPath(file.path);
|
|
if (!isoDay) {
|
|
return null;
|
|
}
|
|
return {
|
|
isoDay,
|
|
sourcePath: sourcePathByDay.get(isoDay) ?? file.path,
|
|
bodyLines: groundedMarkdownToDiaryLines(file.renderedMarkdown),
|
|
};
|
|
})
|
|
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
|
|
const written = await writeBackfillDiaryEntries({
|
|
workspaceDir,
|
|
entries,
|
|
timezone: remConfig.timezone,
|
|
});
|
|
let stagedShortTermEntries = 0;
|
|
let replacedShortTermEntries = 0;
|
|
if (opts.stageShortTerm) {
|
|
const cleared = await removeGroundedShortTermCandidates({ workspaceDir });
|
|
replacedShortTermEntries = cleared.removed;
|
|
const shortTermSeedItems = collectGroundedShortTermSeedItems(grounded.files);
|
|
if (shortTermSeedItems.length > 0) {
|
|
await recordGroundedShortTermCandidates({
|
|
workspaceDir,
|
|
query: "__dreaming_grounded_backfill__",
|
|
items: shortTermSeedItems,
|
|
dedupeByQueryPerDay: true,
|
|
nowMs: Date.now(),
|
|
timezone: remConfig.timezone,
|
|
});
|
|
}
|
|
stagedShortTermEntries = shortTermSeedItems.length;
|
|
}
|
|
|
|
if (opts.json) {
|
|
defaultRuntime.writeJson({
|
|
workspaceDir,
|
|
sourcePath: path.resolve(opts.path),
|
|
sourceFiles,
|
|
groundedFiles: grounded.scannedFiles,
|
|
writtenEntries: written.written,
|
|
replacedEntries: written.replaced,
|
|
dreamsPath: written.dreamsPath,
|
|
...(opts.stageShortTerm
|
|
? {
|
|
stagedShortTermEntries,
|
|
replacedShortTermEntries,
|
|
}
|
|
: {}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const rich = isRich();
|
|
defaultRuntime.log(
|
|
[
|
|
`${colorize(rich, theme.heading, "REM Backfill")} ${colorize(rich, theme.muted, `(${agentId})`)}`,
|
|
colorize(rich, theme.muted, `workspace=${shortenHomePath(workspaceDir)}`),
|
|
colorize(rich, theme.muted, `sourcePath=${shortenHomePath(path.resolve(opts.path))}`),
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`historicalFiles=${sourceFiles.length} writtenEntries=${written.written} replacedEntries=${written.replaced}`,
|
|
),
|
|
...(opts.stageShortTerm
|
|
? [
|
|
colorize(
|
|
rich,
|
|
theme.muted,
|
|
`stagedShortTermEntries=${stagedShortTermEntries} replacedShortTermEntries=${replacedShortTermEntries}`,
|
|
),
|
|
]
|
|
: []),
|
|
colorize(rich, theme.muted, `dreamsPath=${shortenHomePath(written.dreamsPath)}`),
|
|
].join("\n"),
|
|
);
|
|
} finally {
|
|
await fs.rm(scratchDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
});
|
|
}
|