Files
JohnRiceMLandClaude Opus 4.6 40db84d69b fix: memory page shows indexed status correctly (#24)
Add --json flag to `openclaw memory status --deep` call and handle the
new array-of-agents response format. Previously the missing --json flag
caused JSON.parse to fail, always falling back to indexed: false. Now
correctly derives indexed state from status.files > 0 && !dirty across
all agents, sums chunks for totalEntries, and reads provider/vector
from the primary agent. Bumps to v0.8.9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:57:13 -05:00

303 lines
10 KiB
TypeScript

import type { MemoryFileInfo, MemoryConfig, MemoryStatus, MemoryStats } from '@/lib/types'
import { readFileSync, existsSync, statSync, readdirSync } from 'fs'
import { join, basename, dirname } from 'path'
import { execSync } from 'child_process'
import { requireEnv } from '@/lib/env'
import { extractJson } from '@/lib/cli-utils'
// ── Date pattern for daily logs ─────────────────────────────────
const DAILY_PATTERN = /^\d{4}-\d{2}-\d{2}\.md$/
function isDaily(filename: string): boolean {
return DAILY_PATTERN.test(filename)
}
function extractDate(filename: string): string {
return filename.replace('.md', '')
}
// ── Labeling ────────────────────────────────────────────────────
function humanizeFilename(filename: string): string {
const name = filename.replace(/\.(md|json)$/, '')
return name
.split(/[-_]/)
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
function labelForFile(filename: string, fullPath: string, workspacePath: string): string {
// Root MEMORY.md
if (fullPath === join(workspacePath, 'MEMORY.md')) {
return 'Long-Term Memory'
}
// Daily logs
if (isDaily(filename)) {
const dateStr = extractDate(filename)
const today = new Date().toISOString().slice(0, 10)
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
if (dateStr === today) return 'Daily Log (Today)'
if (dateStr === yesterday) return 'Daily Log (Yesterday)'
return `Daily Log (${dateStr})`
}
return humanizeFilename(filename)
}
// ── getMemoryFiles ──────────────────────────────────────────────
export async function getMemoryFiles(): Promise<MemoryFileInfo[]> {
const workspacePath = requireEnv('WORKSPACE_PATH')
const files: MemoryFileInfo[] = []
const memoryDir = join(workspacePath, 'memory')
// 1. Root MEMORY.md
const rootMemory = join(workspacePath, 'MEMORY.md')
if (existsSync(rootMemory)) {
try {
const content = readFileSync(rootMemory, 'utf-8')
const stats = statSync(rootMemory)
files.push({
label: labelForFile('MEMORY.md', rootMemory, workspacePath),
path: rootMemory,
relativePath: 'MEMORY.md',
content,
lastModified: stats.mtime.toISOString(),
sizeBytes: stats.size,
category: 'evergreen',
})
} catch { /* skip unreadable */ }
}
// 2. Scan memory/ directory
if (existsSync(memoryDir)) {
try {
const entries = readdirSync(memoryDir)
for (const entry of entries) {
const fullPath = join(memoryDir, entry)
// Only .md and .json files, skip directories
if (!/\.(md|json)$/.test(entry)) continue
try {
const stat = statSync(fullPath)
if (!stat.isFile()) continue
const content = readFileSync(fullPath, 'utf-8')
const category = isDaily(entry) ? 'daily' : 'evergreen'
files.push({
label: labelForFile(entry, fullPath, workspacePath),
path: fullPath,
relativePath: `memory/${entry}`,
content,
lastModified: stat.mtime.toISOString(),
sizeBytes: stat.size,
category,
})
} catch { /* skip unreadable */ }
}
} catch { /* skip unreadable dir */ }
}
// Sort: evergreen first, then daily by date descending
files.sort((a, b) => {
if (a.category !== b.category) {
if (a.category === 'evergreen') return -1
if (b.category === 'evergreen') return 1
}
// Within same category, sort by lastModified descending
return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
})
return files
}
// ── getMemoryConfig ─────────────────────────────────────────────
const SEARCH_DEFAULTS: MemoryConfig['memorySearch'] = {
enabled: false,
provider: null,
model: null,
hybrid: {
enabled: true,
vectorWeight: 0.7,
textWeight: 0.3,
temporalDecay: { enabled: true, halfLifeDays: 30 },
mmr: { enabled: true, lambda: 0.7 },
},
cache: { enabled: true, maxEntries: 256 },
extraPaths: [],
}
const FLUSH_DEFAULTS: MemoryConfig['memoryFlush'] = {
enabled: false,
softThresholdTokens: 80000,
}
export function getMemoryConfig(): MemoryConfig {
const workspacePath = requireEnv('WORKSPACE_PATH')
// openclaw.json is in the parent of the workspace directory
const configPath = join(dirname(workspacePath), 'openclaw.json')
if (!existsSync(configPath)) {
return { memorySearch: SEARCH_DEFAULTS, memoryFlush: FLUSH_DEFAULTS, configFound: false }
}
try {
const raw = readFileSync(configPath, 'utf-8')
const config = JSON.parse(raw)
const agentDefaults = config?.agents?.defaults ?? {}
// Memory search
const ms = agentDefaults.memorySearch ?? {}
const hybrid = ms.hybrid ?? {}
const decay = hybrid.temporalDecay ?? {}
const mmr = hybrid.mmr ?? {}
const cache = ms.cache ?? {}
const memorySearch: MemoryConfig['memorySearch'] = {
enabled: ms.enabled ?? SEARCH_DEFAULTS.enabled,
provider: ms.provider ?? SEARCH_DEFAULTS.provider,
model: ms.model ?? SEARCH_DEFAULTS.model,
hybrid: {
enabled: hybrid.enabled ?? SEARCH_DEFAULTS.hybrid.enabled,
vectorWeight: hybrid.vectorWeight ?? SEARCH_DEFAULTS.hybrid.vectorWeight,
textWeight: hybrid.textWeight ?? SEARCH_DEFAULTS.hybrid.textWeight,
temporalDecay: {
enabled: decay.enabled ?? SEARCH_DEFAULTS.hybrid.temporalDecay.enabled,
halfLifeDays: decay.halfLifeDays ?? SEARCH_DEFAULTS.hybrid.temporalDecay.halfLifeDays,
},
mmr: {
enabled: mmr.enabled ?? SEARCH_DEFAULTS.hybrid.mmr.enabled,
lambda: mmr.lambda ?? SEARCH_DEFAULTS.hybrid.mmr.lambda,
},
},
cache: {
enabled: cache.enabled ?? SEARCH_DEFAULTS.cache.enabled,
maxEntries: cache.maxEntries ?? SEARCH_DEFAULTS.cache.maxEntries,
},
extraPaths: ms.extraPaths ?? SEARCH_DEFAULTS.extraPaths,
}
// Memory flush (under compaction)
const flush = agentDefaults.compaction?.memoryFlush ?? {}
const memoryFlush: MemoryConfig['memoryFlush'] = {
enabled: flush.enabled ?? FLUSH_DEFAULTS.enabled,
softThresholdTokens: flush.softThresholdTokens ?? FLUSH_DEFAULTS.softThresholdTokens,
}
// configFound = true only if memorySearch key exists explicitly
const configFound = 'memorySearch' in agentDefaults
return { memorySearch, memoryFlush, configFound }
} catch {
return { memorySearch: SEARCH_DEFAULTS, memoryFlush: FLUSH_DEFAULTS, configFound: false }
}
}
// ── getMemoryStatus ─────────────────────────────────────────────
export function getMemoryStatus(): MemoryStatus {
const defaults: MemoryStatus = {
indexed: false,
lastIndexed: null,
totalEntries: null,
vectorAvailable: null,
embeddingProvider: null,
raw: 'Memory status unavailable',
}
let bin: string
try {
bin = requireEnv('OPENCLAW_BIN')
} catch {
return defaults
}
try {
const output = execSync(`${bin} memory status --deep --json`, {
timeout: 15000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
try {
const data = extractJson(output)
// New format: array of per-agent status objects
// [{ agentId, status: { files, chunks, dirty, provider, vector } }, ...]
if (Array.isArray(data) && data.length > 0) {
const agents = data as { agentId?: string; status?: Record<string, unknown> }[]
const primary = agents[0].status ?? {}
const totalEntries = agents.reduce(
(sum, a) => sum + (typeof a.status?.chunks === 'number' ? a.status.chunks : 0), 0,
)
const allIndexed = agents.every(
a => (typeof a.status?.files === 'number' && a.status.files > 0) && !a.status?.dirty,
)
const vec = primary.vector as Record<string, unknown> | undefined
return {
indexed: allIndexed,
lastIndexed: null,
totalEntries,
vectorAvailable: typeof vec?.available === 'boolean' ? vec.available : null,
embeddingProvider: typeof primary.provider === 'string' ? primary.provider : null,
raw: output,
}
}
// Legacy flat object format
const flat = data as Record<string, unknown>
return {
indexed: flat.indexed === true,
lastIndexed: typeof flat.lastIndexed === 'string' ? flat.lastIndexed : null,
totalEntries: typeof flat.totalEntries === 'number' ? flat.totalEntries : null,
vectorAvailable: typeof flat.vectorAvailable === 'boolean' ? flat.vectorAvailable : null,
embeddingProvider: typeof flat.embeddingProvider === 'string' ? flat.embeddingProvider : null,
raw: output,
}
} catch {
// Plain text fallback
return { ...defaults, raw: output }
}
} catch {
return defaults
}
}
// ── computeMemoryStats ──────────────────────────────────────────
export function computeMemoryStats(files: MemoryFileInfo[]): MemoryStats {
const dailyFiles = files.filter(f => f.category === 'daily')
const evergreenFiles = files.filter(f => f.category === 'evergreen')
// Extract dates from daily file names
const dailyDates = dailyFiles
.map(f => {
const match = basename(f.path).match(/^(\d{4}-\d{2}-\d{2})\.md$/)
return match ? match[1] : null
})
.filter((d): d is string => d !== null)
.sort()
// Build 30-day timeline
const timeline: MemoryStats['dailyTimeline'] = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const dateStr = d.toISOString().slice(0, 10)
const file = dailyFiles.find(f => basename(f.path) === `${dateStr}.md`)
timeline.push(file ? { date: dateStr, sizeBytes: file.sizeBytes } : null)
}
return {
totalFiles: files.length,
totalSizeBytes: files.reduce((sum, f) => sum + f.sizeBytes, 0),
dailyLogCount: dailyFiles.length,
evergreenCount: evergreenFiles.length,
oldestDaily: dailyDates[0] ?? null,
newestDaily: dailyDates[dailyDates.length - 1] ?? null,
dailyTimeline: timeline,
}
}