mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:10:42 +00:00
* Revert "fix(memory/dreaming): surface blocked status when heartbeat is disabled for main (#69875)"
This reverts commit 529577e045.
Making way for the dreaming-vs-heartbeat decoupling from Josh's
josh/dreaming-isolated-cron-fix branch, which moves the managed dreaming
cron to isolated agent turns (sessionTarget: "isolated") so dreaming no
longer requires heartbeat to fire. Once the cron no longer rides the
heartbeat path, the blocked-reason observability has nothing left to
report — removing it cleanly here before the cherry-picks land.
* openclaw-3ba.1: move managed dreaming cron to isolated agent turns
* openclaw-46d: claim cron runs before embedded attempts
* openclaw-575: disable managed dreaming cron delivery
* openclaw-575: accept wrapped dreaming cron tokens
* openclaw-ccd: filter cron and wrapper transcript noise from dreaming corpus
* openclaw-cd9: filter archived, cron, and heartbeat transcript noise from dreaming corpus
* openclaw-cd9: suppress role-label reflection tags in rem dreaming
* openclaw-b49: stop narrative timeouts from blocking dreaming cron
* openclaw-b49: keep managed dreaming cron out of diary subagents
* openclaw-ff9: restore cron dream diary generation without serial waits
* openclaw-ff9: run dreaming narratives with lightweight isolated subagent lanes
* openclaw-ff9: detach cron dream diary generation from run completion
* openclaw-ff9: defer cron diary task startup until after cron completion
* doctor/cron: migrate stale managed dreaming jobs to isolated agent turns
After the dreaming cron moved off the heartbeat path to sessionTarget:
"isolated" + payload.kind: "agentTurn" (see the preceding memory-core
changes), users with existing ~/.openclaw/cron/jobs.json entries in the
old sessionTarget: "main" + payload.kind: "systemEvent" shape still
carry stale jobs until the gateway restart reconcile rewrites them.
Add a dreaming-specific cron migration to the existing
maybeRepairLegacyCronStore doctor path so "openclaw doctor" (and
"openclaw doctor --fix") rewrites those jobs without needing a gateway
restart. Match lives in a new doctor-cron-dreaming-payload-migration
helper alongside the existing legacy-delivery and store-migration files.
The matching uses the memory-core managed-job name and description tag
plus the short-term-promotion payload token. Constants are mirrored
from extensions/memory-core/src/dreaming.ts and commented so a future
rename in memory-core is a visible drift point here too.
* memory/dreaming: tighten cron-token match to known wrapper, not substring
The previous match relaxed the line check from 'trimmed line equals token'
to 'line contains token anywhere as a substring' to accept the
`[cron:<id>] <token>` wrapper that isolated-cron turns add. Substring
matching also let any user message embedding the token mid-sentence
trigger the dream-promotion hook, and was flagged by both Greptile and
Aisle on PR #70737.
Replace it with strip-the-known-prefix-then-exact-match: keep the
`[cron:<id>]` wrapper case working, reject every other variant. Add
focused unit coverage that the bare token, the wrapped token, and bare
multiline cases match while embedded / code-fenced / arbitrarily-wrapped
variants do not.
* memory/dreaming: drop assistant followup only on assistant-side signals
Per PR #70737 review (aisle-research-bot, Medium): the previous logic
suppressed the next assistant message whenever the prior user message
matched a 'generated prompt' pattern (`[cron:...]`,
`System (untrusted): ...`, heartbeat prompts, exec-completion events).
Real users can type those same patterns, which let a user exfiltrate
real assistant replies from the dreaming corpus by prefixing their own
prompt — the assistant's reply would be silently dropped.
Remove the cross-message coupling. Assistant-side machinery (silent
replies, system wrappers) is already dropped by sanitizeSessionText,
which is the right layer for that filter. Add an explicit assistant-side
HEARTBEAT_TOKEN check to keep the legitimate `HEARTBEAT_OK` ack drop
working without depending on the prior user message. Add a regression
test exercising the spoofing scenario.
* doctor/cron: assert mirrored dreaming constants stay in sync
Per PR #70737 review (greptile-apps): the doctor migration mirrors three
constants (MANAGED_DREAMING_CRON_NAME, MANAGED_DREAMING_CRON_TAG,
DREAMING_SYSTEM_EVENT_TEXT) from extensions/memory-core/src/dreaming.ts.
A future rename in either file would silently break the migration.
Add a vitest unit that reads both files and asserts the literals match.
Manually verified the assertion fires with a clear error when one side
diverges. Adds no runtime cost; sits in the regular test pipeline.
* fix(memory): stabilize dreaming CI checks
* memory/dreaming: skip eager narrative session cleanup when detached
Per PR #70737 review (chatgpt-codex-connector, P2): runDreamingSweepPhases
called deleteNarrativeSessionBestEffort synchronously right after each
phase. Once narrative generation moved to detached mode (queued via
queueMicrotask), the eager cleanup races the writer: the session is
deleted before the queued subagent run reads it, silently dropping cron
diary entries.
Skip the eager cleanup branch when params.detachNarratives is true.
generateAndAppendDreamNarrative still runs its own deleteSession in the
finally{} block, so the cleanup intent is preserved without the race.
Heartbeat-driven (non-detached) runs keep the original eager-cleanup
behavior.
* fix(plugin-sdk): restore heartbeat-summary re-export
Per PR #70737 review (chatgpt-codex-connector, P1): the revert of
PR #69875 dropped the `heartbeat-summary` re-export from
`openclaw/plugin-sdk/infra-runtime`. That subpath shipped publicly two
days earlier, so removing it is technically a breaking change to a
public SDK surface — third-party plugins importing
`isHeartbeatEnabledForAgent` / `resolveHeartbeatIntervalMs` from this
path would fail with no replacement contract introduced.
Restore the re-export. Costs nothing to keep; the helpers are already
public via `../infra/heartbeat-summary.ts`. SDK additions are by
default backwards-compatible (CLAUDE.md), so removing within days of
introduction violates that intent.
* changelog: note dreaming decoupling from heartbeat
Refs PR #70737.
---------
Co-authored-by: Josh Lehman <josh@martian.engineering>
1928 lines
67 KiB
TypeScript
1928 lines
67 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 { 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 memoryDir = path.join(workspaceDir, "memory");
|
|
|
|
const primary = await checkReadableFile(memoryFile);
|
|
if (primary.issue) {
|
|
issues.push(primary.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);
|
|
}
|
|
}
|
|
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[];
|
|
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 });
|
|
}
|
|
},
|
|
});
|
|
}
|