import express from 'express'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { MemoryBundleValidationError, } from '../../../src/context/memory/edgeclaw-memory-core/lib/index.js'; import { readPilotDeckConfigFile, writePilotDeckConfig, } from '../services/pilotdeckConfig.js'; import { reloadPilotDeckConfig } from '../services/pilotdeckConfigReloader.js'; import { suppressNextWatchEvent } from '../services/pilotdeckConfigWatcher.js'; import { clearAllMemoryData, exportAllProjectsMemoryBundle, getMemoryServiceForRequest, getMemorySchedulerStatus, importAllProjectsMemoryBundle, rollbackLastMemoryDream, runManualMemoryDream, runManualMemoryFlush, } from '../services/memoryService.js'; const router = express.Router(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export const MEMORY_DASHBOARD_DIR = path.resolve( __dirname, '../../../src/context/memory/edgeclaw-memory-core/ui-source', ); function parseLimit(value, fallback) { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed)) return fallback; return Math.max(1, Math.min(200, parsed)); } function parseOffset(value, fallback = 0) { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed)) return fallback; return Math.max(0, parsed); } function parseMemoryKind(value) { return value === 'user' || value === 'feedback' || value === 'project' || value === 'general_project_meta' ? value : 'all'; } function normalizeMemoryInterval(value, fallback) { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed)) return fallback; return Math.max(0, Math.min(10_080, Math.floor(parsed))); } function getGlobalMemorySettingsFromConfig(config) { const memory = config?.memory ?? {}; const reasoningMode = memory.reasoningMode === 'accuracy_first' ? 'accuracy_first' : 'answer_first'; return { reasoningMode, autoIndexIntervalMinutes: normalizeMemoryInterval(memory.autoIndexIntervalMinutes, 30), autoDreamIntervalMinutes: normalizeMemoryInterval(memory.autoDreamIntervalMinutes, 60), }; } function getGlobalMemorySettings() { return getGlobalMemorySettingsFromConfig(readPilotDeckConfigFile().config); } async function saveGlobalMemorySettings(partial = {}) { const { config } = readPilotDeckConfigFile(); const current = getGlobalMemorySettingsFromConfig(config); const next = { reasoningMode: partial.reasoningMode === 'accuracy_first' ? 'accuracy_first' : partial.reasoningMode === 'answer_first' ? 'answer_first' : current.reasoningMode, autoIndexIntervalMinutes: normalizeMemoryInterval( partial.autoIndexIntervalMinutes, current.autoIndexIntervalMinutes, ), autoDreamIntervalMinutes: normalizeMemoryInterval( partial.autoDreamIntervalMinutes, current.autoDreamIntervalMinutes, ), }; const nextConfig = { ...config, memory: { ...(config.memory ?? {}), ...next, }, }; suppressNextWatchEvent(); const saved = await writePilotDeckConfig(nextConfig); await reloadPilotDeckConfig(saved.config); return getGlobalMemorySettingsFromConfig(saved.config); } function normalizeSearchText(value) { return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim(); } function isExternalRecordPath(relativePath) { return typeof relativePath === 'string' && relativePath.startsWith('external:'); } function summarizeEntries(entries) { const projectEntries = entries.filter((entry) => entry.type === 'project'); const feedbackEntries = entries.filter((entry) => entry.type === 'feedback'); const latestMemoryAt = entries .map((entry) => entry.updatedAt) .filter(Boolean) .sort() .at(-1); return { totalEntries: entries.length, projectEntries: projectEntries.length, feedbackEntries: feedbackEntries.length, ...(latestMemoryAt ? { latestMemoryAt } : {}), }; } function normalizeGeneralDisplayProject(repository, project) { const localEntries = repository.listReadableProjectEntries(project.logicalProjectId, { kinds: ['project', 'feedback'], includeDeprecated: false, includeExternal: false, }); const { sourceWorkspacePath, sourceProjectId, externalLogicalProjectId, localMirrorProjectId, ...rest } = project; return { ...rest, sourceType: 'general_local', readOnly: false, hasLocalMirror: false, summary: summarizeEntries(localEntries), }; } function annotateWorkspaceEntries(entries) { return entries.map((entry) => ({ ...entry, sourceType: 'general_local', readOnly: false, })); } function buildWorkspaceSnapshot(repository, { query = '', limit = 100, offset = 0, selectedProjectId = '' } = {}) { const store = repository.getFileMemoryStore(); const workspaceMode = typeof repository.getWorkspaceMode === 'function' ? repository.getWorkspaceMode() : store.getWorkspaceMode(); const manifestPath = path.join(store.getRootDir(), 'MEMORY.md'); if (workspaceMode === 'general') { const generalProjects = repository .listReadableProjectCatalog() .filter((entry) => entry.sourceType !== 'workspace_external') .map((entry) => normalizeGeneralDisplayProject(repository, entry)); const selectedProject = generalProjects.find((entry) => entry.logicalProjectId === selectedProjectId) || generalProjects[0] || null; const allEntries = selectedProject ? repository.listReadableProjectEntries(selectedProject.logicalProjectId, { kinds: ['project', 'feedback'], includeDeprecated: true, includeExternal: false, ...(query ? { query } : {}), }) : []; const activeEntries = allEntries.filter((entry) => !entry.deprecated); const deprecatedEntries = allEntries.filter((entry) => entry.deprecated); const activePage = annotateWorkspaceEntries( activeEntries .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(offset, offset + limit), ); const deprecatedPage = annotateWorkspaceEntries( deprecatedEntries .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(offset, offset + limit), ); return { workspaceMode, generalProjects, selectedProjectId: selectedProject?.logicalProjectId ?? null, selectedProjectSource: selectedProject ? 'general_local' : null, selectedProject, projectMetaPath: selectedProject && !selectedProject.readOnly ? selectedProject.relativePath : null, projectMeta: selectedProject && !selectedProject.readOnly ? selectedProject : null, manifestPath: 'MEMORY.md', manifestContent: (() => { try { return fs.readFileSync(manifestPath, 'utf-8'); } catch { return ''; } })(), totalFiles: activeEntries.length, totalProjects: activeEntries.filter((record) => record.type === 'project').length, totalFeedback: activeEntries.filter((record) => record.type === 'feedback').length, projectEntries: activePage.filter((record) => record.type === 'project'), feedbackEntries: activePage.filter((record) => record.type === 'feedback'), deprecatedProjectEntries: deprecatedPage.filter((record) => record.type === 'project'), deprecatedFeedbackEntries: deprecatedPage.filter((record) => record.type === 'feedback'), }; } const projectMeta = store.getProjectMeta() ?? null; const manifestEntries = repository.listMemoryEntries({ scope: 'project', includeDeprecated: true, limit: 1000, }); const records = repository.getMemoryRecordsByIds( manifestEntries.map((entry) => entry.relativePath), 5000, ); const normalizedQuery = normalizeSearchText(query); const filtered = !normalizedQuery ? records : records.filter((record) => normalizeSearchText( [ record.name, record.description, record.relativePath, record.preview, record.sourceSessionKey ?? '', ].join(' '), ).includes(normalizedQuery), ); const activeFiltered = filtered.filter((record) => !record.deprecated); const page = filtered .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(offset, offset + limit); return { workspaceMode, projectMetaPath: projectMeta ? 'project.meta.md' : null, projectMeta, manifestPath: 'MEMORY.md', manifestContent: (() => { try { return fs.readFileSync(manifestPath, 'utf-8'); } catch { return ''; } })(), totalFiles: activeFiltered.length, totalProjects: activeFiltered.filter((record) => record.type === 'project').length, totalFeedback: activeFiltered.filter((record) => record.type === 'feedback').length, projectEntries: page.filter((record) => record.type === 'project' && !record.deprecated), feedbackEntries: page.filter((record) => record.type === 'feedback' && !record.deprecated), deprecatedProjectEntries: page.filter((record) => record.type === 'project' && record.deprecated), deprecatedFeedbackEntries: page.filter((record) => record.type === 'feedback' && record.deprecated), }; } function buildDashboardSnapshot(service, repository, { query = '', selectedProjectId = '' } = {}) { return { overview: { ...service.overview(), scheduler: getMemorySchedulerStatus(), }, settings: getGlobalMemorySettings(), workspace: buildWorkspaceSnapshot(repository, { query, limit: 200, offset: 0, selectedProjectId, }), userSummary: service.getUserSummary(), caseTraces: service.listCaseTraces(12), indexTraces: service.listIndexTraces(10), dreamTraces: service.listDreamTraces(10), }; } function getQuery(req) { return typeof req.query.q === 'string' ? req.query.q.trim() : ''; } function getSelectedProjectId(req) { return typeof req.query.selectedProjectId === 'string' ? req.query.selectedProjectId.trim() : ''; } async function withMemoryService(req, res, fn) { try { const { projectPath, dataDir, service } = await getMemoryServiceForRequest(req); return await fn({ projectPath, dataDir, service, repository: service.repository }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return res.status(400).json({ error: message }); } } function buildDownloadFileName(prefix, exportedAt) { const safe = String(exportedAt || '') .replace(/[^\dTZ-]/g, '-') .replace(/-+/g, '-'); return `${prefix}-${safe || 'export'}.json`; } function sendBundleDownload(res, bundle, prefix) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader( 'Content-Disposition', `attachment; filename="${buildDownloadFileName(prefix, bundle.exportedAt)}"`, ); res.send(JSON.stringify(bundle, null, 2)); } router.get('/overview', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json({ ...service.overview(), scheduler: getMemorySchedulerStatus(), }); }), ); router.route('/settings') .get(async (req, res) => withMemoryService(req, res, async () => { res.json(getGlobalMemorySettings()); })) .post(async (req, res) => withMemoryService(req, res, async () => { res.json(await saveGlobalMemorySettings(req.body ?? {})); })); router.post('/index/run', async (req, res) => withMemoryService(req, res, async ({ dataDir, service, repository }) => { const result = await runManualMemoryFlush(service, dataDir, { reason: 'manual' }); res.json({ ...result, dashboard: buildDashboardSnapshot(service, repository, { query: getQuery(req), selectedProjectId: getSelectedProjectId(req), }), }); }), ); router.post('/dream/run', async (req, res) => withMemoryService(req, res, async ({ dataDir, service, repository }) => { const result = await runManualMemoryDream(service, dataDir); res.json({ ...result, dashboard: buildDashboardSnapshot(service, repository, { query: getQuery(req), selectedProjectId: getSelectedProjectId(req), }), }); }), ); router.post('/dream/rollback-last', async (req, res) => withMemoryService(req, res, async ({ dataDir, service, repository }) => { const result = await rollbackLastMemoryDream(service, dataDir); res.json({ ...result, dashboard: buildDashboardSnapshot(service, repository, { query: getQuery(req), selectedProjectId: getSelectedProjectId(req), }), }); }), ); router.get('/snapshot', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json(service.snapshot(parseLimit(req.query.limit, 24))); }), ); router.get('/memory/list', async (req, res) => withMemoryService(req, res, async ({ service }) => { const kind = parseMemoryKind(req.query.kind); const query = typeof req.query.query === 'string' ? req.query.query.trim() : ''; const limit = parseLimit(req.query.limit, 10); const offset = parseOffset(req.query.offset, 0); const items = service.list({ ...(kind !== 'all' ? { kinds: [kind] } : {}), ...(query ? { query } : {}), limit, offset, }); res.json(items); }), ); router.get('/memory/get', async (req, res) => withMemoryService(req, res, async ({ service }) => { const ids = String(req.query.ids || '') .split(',') .map((value) => value.trim()) .filter(Boolean); if (ids.length === 0) { return res.status(400).json({ error: 'ids query parameter is required' }); } res.json(service.get(ids, 5000)); }), ); router.post('/memory/actions', async (req, res) => withMemoryService(req, res, async ({ service }) => { try { res.json(service.act(req.body ?? {})); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), }); } }), ); router.get('/memory/user-summary', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json(service.getUserSummary()); }), ); router.route('/project-meta') .get(async (req, res) => withMemoryService(req, res, async ({ service, repository }) => { const selected = getSelectedProjectId(req); if (service.getWorkspaceMode() === 'general' && selected) { const readableProject = service.getReadableProject(selected); if (!readableProject || readableProject.readOnly) { return res.json(null); } return res.json(repository.getFileMemoryStore().getProjectMeta(readableProject.projectId) ?? readableProject); } res.json(service.getProjectMeta()); })) .post(async (req, res) => withMemoryService(req, res, async ({ service }) => { try { res.json(service.updateProjectMeta(req.body ?? {})); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), }); } })); router.get('/workspace', async (req, res) => withMemoryService(req, res, async ({ repository }) => { res.json( buildWorkspaceSnapshot(repository, { query: getQuery(req), limit: parseLimit(req.query.limit, 100), offset: parseOffset(req.query.offset, 0), selectedProjectId: getSelectedProjectId(req), }), ); }), ); router.get('/cases', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json(service.listCaseTraces(parseLimit(req.query.limit, 12))); }), ); router.get('/cases/:caseId', async (req, res) => withMemoryService(req, res, async ({ service }) => { const record = service.getCaseTrace(req.params.caseId); if (!record) { return res.status(404).json({ error: 'Not found' }); } res.json(record); }), ); router.get('/index-traces', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json(service.listIndexTraces(parseLimit(req.query.limit, 30))); }), ); router.get('/index-traces/:indexTraceId', async (req, res) => withMemoryService(req, res, async ({ service }) => { const record = service.getIndexTrace(req.params.indexTraceId); if (!record) { return res.status(404).json({ error: 'Not found' }); } res.json(record); }), ); router.get('/dream-traces', async (req, res) => withMemoryService(req, res, async ({ service }) => { res.json(service.listDreamTraces(parseLimit(req.query.limit, 30))); }), ); router.get('/dream-traces/:dreamTraceId', async (req, res) => withMemoryService(req, res, async ({ service }) => { const record = service.getDreamTrace(req.params.dreamTraceId); if (!record) { return res.status(404).json({ error: 'Not found' }); } res.json(record); }), ); router.get('/export/current-project', async (req, res) => withMemoryService(req, res, async ({ service }) => { const bundle = service.exportBundle(); sendBundleDownload(res, bundle, 'pilotdeck-memory-current-project'); }), ); router.get('/export/all-projects', async (_req, res) => { try { const bundle = await exportAllProjectsMemoryBundle(); sendBundleDownload(res, bundle, 'pilotdeck-memory-all-projects'); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error), }); } }); router.post('/import/current-project', async (req, res) => withMemoryService(req, res, async ({ service }) => { try { res.json(service.importBundle(req.body)); } catch (error) { const status = error instanceof MemoryBundleValidationError ? 400 : 500; res.status(status).json({ error: error instanceof Error ? error.message : String(error), }); } }), ); router.post('/import/all-projects', async (req, res) => { try { res.json(await importAllProjectsMemoryBundle(req.body)); } catch (error) { const status = error instanceof MemoryBundleValidationError ? 400 : 500; res.status(status).json({ error: error instanceof Error ? error.message : String(error), }); } }); router.get('/export', async (req, res) => withMemoryService(req, res, async ({ service }) => { const bundle = service.exportBundle(); sendBundleDownload(res, bundle, 'pilotdeck-memory-current-project'); }), ); router.post('/import', async (req, res) => withMemoryService(req, res, async ({ service }) => { try { res.json(service.importBundle(req.body)); } catch (error) { const status = error instanceof MemoryBundleValidationError ? 400 : 500; res.status(status).json({ error: error instanceof Error ? error.message : String(error), }); } }), ); router.post('/clear', async (req, res) => { const scope = req.body?.scope === 'all_memory' ? 'all_memory' : 'current_project'; if (scope === 'all_memory') { try { res.json(await clearAllMemoryData()); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error), }); } return; } return withMemoryService(req, res, async ({ service, repository }) => { const result = service.clear(scope); res.json({ ...result, dashboard: buildDashboardSnapshot(service, repository, { query: getQuery(req), selectedProjectId: getSelectedProjectId(req), }), }); }); }); export default router;