memory.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. import express from 'express';
  2. import fs from 'fs';
  3. import path from 'path';
  4. import { fileURLToPath } from 'url';
  5. import {
  6. MemoryBundleValidationError,
  7. } from '../../../src/context/memory/edgeclaw-memory-core/lib/index.js';
  8. import {
  9. readPilotDeckConfigFile,
  10. writePilotDeckConfig,
  11. } from '../services/pilotdeckConfig.js';
  12. import { reloadPilotDeckConfig } from '../services/pilotdeckConfigReloader.js';
  13. import { suppressNextWatchEvent } from '../services/pilotdeckConfigWatcher.js';
  14. import {
  15. clearAllMemoryData,
  16. exportAllProjectsMemoryBundle,
  17. getMemoryServiceForRequest,
  18. getMemorySchedulerStatus,
  19. importAllProjectsMemoryBundle,
  20. rollbackLastMemoryDream,
  21. runManualMemoryDream,
  22. runManualMemoryFlush,
  23. } from '../services/memoryService.js';
  24. const router = express.Router();
  25. const __filename = fileURLToPath(import.meta.url);
  26. const __dirname = path.dirname(__filename);
  27. export const MEMORY_DASHBOARD_DIR = path.resolve(
  28. __dirname,
  29. '../../../src/context/memory/edgeclaw-memory-core/ui-source',
  30. );
  31. function parseLimit(value, fallback) {
  32. const parsed = Number.parseInt(String(value ?? ''), 10);
  33. if (!Number.isFinite(parsed)) return fallback;
  34. return Math.max(1, Math.min(200, parsed));
  35. }
  36. function parseOffset(value, fallback = 0) {
  37. const parsed = Number.parseInt(String(value ?? ''), 10);
  38. if (!Number.isFinite(parsed)) return fallback;
  39. return Math.max(0, parsed);
  40. }
  41. function parseMemoryKind(value) {
  42. return value === 'user' || value === 'feedback' || value === 'project' || value === 'general_project_meta'
  43. ? value
  44. : 'all';
  45. }
  46. function normalizeMemoryInterval(value, fallback) {
  47. const parsed = Number.parseInt(String(value ?? ''), 10);
  48. if (!Number.isFinite(parsed)) return fallback;
  49. return Math.max(0, Math.min(10_080, Math.floor(parsed)));
  50. }
  51. function getGlobalMemorySettingsFromConfig(config) {
  52. const memory = config?.memory ?? {};
  53. const reasoningMode = memory.reasoningMode === 'accuracy_first' ? 'accuracy_first' : 'answer_first';
  54. return {
  55. reasoningMode,
  56. autoIndexIntervalMinutes: normalizeMemoryInterval(memory.autoIndexIntervalMinutes, 30),
  57. autoDreamIntervalMinutes: normalizeMemoryInterval(memory.autoDreamIntervalMinutes, 60),
  58. };
  59. }
  60. function getGlobalMemorySettings() {
  61. return getGlobalMemorySettingsFromConfig(readPilotDeckConfigFile().config);
  62. }
  63. async function saveGlobalMemorySettings(partial = {}) {
  64. const { config } = readPilotDeckConfigFile();
  65. const current = getGlobalMemorySettingsFromConfig(config);
  66. const next = {
  67. reasoningMode: partial.reasoningMode === 'accuracy_first'
  68. ? 'accuracy_first'
  69. : partial.reasoningMode === 'answer_first'
  70. ? 'answer_first'
  71. : current.reasoningMode,
  72. autoIndexIntervalMinutes: normalizeMemoryInterval(
  73. partial.autoIndexIntervalMinutes,
  74. current.autoIndexIntervalMinutes,
  75. ),
  76. autoDreamIntervalMinutes: normalizeMemoryInterval(
  77. partial.autoDreamIntervalMinutes,
  78. current.autoDreamIntervalMinutes,
  79. ),
  80. };
  81. const nextConfig = {
  82. ...config,
  83. memory: {
  84. ...(config.memory ?? {}),
  85. ...next,
  86. },
  87. };
  88. suppressNextWatchEvent();
  89. const saved = await writePilotDeckConfig(nextConfig);
  90. await reloadPilotDeckConfig(saved.config);
  91. return getGlobalMemorySettingsFromConfig(saved.config);
  92. }
  93. function normalizeSearchText(value) {
  94. return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
  95. }
  96. function isExternalRecordPath(relativePath) {
  97. return typeof relativePath === 'string' && relativePath.startsWith('external:');
  98. }
  99. function summarizeEntries(entries) {
  100. const projectEntries = entries.filter((entry) => entry.type === 'project');
  101. const feedbackEntries = entries.filter((entry) => entry.type === 'feedback');
  102. const latestMemoryAt = entries
  103. .map((entry) => entry.updatedAt)
  104. .filter(Boolean)
  105. .sort()
  106. .at(-1);
  107. return {
  108. totalEntries: entries.length,
  109. projectEntries: projectEntries.length,
  110. feedbackEntries: feedbackEntries.length,
  111. ...(latestMemoryAt ? { latestMemoryAt } : {}),
  112. };
  113. }
  114. function normalizeGeneralDisplayProject(repository, project) {
  115. const localEntries = repository.listReadableProjectEntries(project.logicalProjectId, {
  116. kinds: ['project', 'feedback'],
  117. includeDeprecated: false,
  118. includeExternal: false,
  119. });
  120. const {
  121. sourceWorkspacePath,
  122. sourceProjectId,
  123. externalLogicalProjectId,
  124. localMirrorProjectId,
  125. ...rest
  126. } = project;
  127. return {
  128. ...rest,
  129. sourceType: 'general_local',
  130. readOnly: false,
  131. hasLocalMirror: false,
  132. summary: summarizeEntries(localEntries),
  133. };
  134. }
  135. function annotateWorkspaceEntries(entries) {
  136. return entries.map((entry) => ({
  137. ...entry,
  138. sourceType: 'general_local',
  139. readOnly: false,
  140. }));
  141. }
  142. function buildWorkspaceSnapshot(repository, { query = '', limit = 100, offset = 0, selectedProjectId = '' } = {}) {
  143. const store = repository.getFileMemoryStore();
  144. const workspaceMode = typeof repository.getWorkspaceMode === 'function'
  145. ? repository.getWorkspaceMode()
  146. : store.getWorkspaceMode();
  147. const manifestPath = path.join(store.getRootDir(), 'MEMORY.md');
  148. if (workspaceMode === 'general') {
  149. const generalProjects = repository
  150. .listReadableProjectCatalog()
  151. .filter((entry) => entry.sourceType !== 'workspace_external')
  152. .map((entry) => normalizeGeneralDisplayProject(repository, entry));
  153. const selectedProject = generalProjects.find((entry) => entry.logicalProjectId === selectedProjectId)
  154. || generalProjects[0]
  155. || null;
  156. const allEntries = selectedProject
  157. ? repository.listReadableProjectEntries(selectedProject.logicalProjectId, {
  158. kinds: ['project', 'feedback'],
  159. includeDeprecated: true,
  160. includeExternal: false,
  161. ...(query ? { query } : {}),
  162. })
  163. : [];
  164. const activeEntries = allEntries.filter((entry) => !entry.deprecated);
  165. const deprecatedEntries = allEntries.filter((entry) => entry.deprecated);
  166. const activePage = annotateWorkspaceEntries(
  167. activeEntries
  168. .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
  169. .slice(offset, offset + limit),
  170. );
  171. const deprecatedPage = annotateWorkspaceEntries(
  172. deprecatedEntries
  173. .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
  174. .slice(offset, offset + limit),
  175. );
  176. return {
  177. workspaceMode,
  178. generalProjects,
  179. selectedProjectId: selectedProject?.logicalProjectId ?? null,
  180. selectedProjectSource: selectedProject ? 'general_local' : null,
  181. selectedProject,
  182. projectMetaPath: selectedProject && !selectedProject.readOnly ? selectedProject.relativePath : null,
  183. projectMeta: selectedProject && !selectedProject.readOnly ? selectedProject : null,
  184. manifestPath: 'MEMORY.md',
  185. manifestContent: (() => {
  186. try {
  187. return fs.readFileSync(manifestPath, 'utf-8');
  188. } catch {
  189. return '';
  190. }
  191. })(),
  192. totalFiles: activeEntries.length,
  193. totalProjects: activeEntries.filter((record) => record.type === 'project').length,
  194. totalFeedback: activeEntries.filter((record) => record.type === 'feedback').length,
  195. projectEntries: activePage.filter((record) => record.type === 'project'),
  196. feedbackEntries: activePage.filter((record) => record.type === 'feedback'),
  197. deprecatedProjectEntries: deprecatedPage.filter((record) => record.type === 'project'),
  198. deprecatedFeedbackEntries: deprecatedPage.filter((record) => record.type === 'feedback'),
  199. };
  200. }
  201. const projectMeta = store.getProjectMeta() ?? null;
  202. const manifestEntries = repository.listMemoryEntries({
  203. scope: 'project',
  204. includeDeprecated: true,
  205. limit: 1000,
  206. });
  207. const records = repository.getMemoryRecordsByIds(
  208. manifestEntries.map((entry) => entry.relativePath),
  209. 5000,
  210. );
  211. const normalizedQuery = normalizeSearchText(query);
  212. const filtered = !normalizedQuery
  213. ? records
  214. : records.filter((record) =>
  215. normalizeSearchText(
  216. [
  217. record.name,
  218. record.description,
  219. record.relativePath,
  220. record.preview,
  221. record.sourceSessionKey ?? '',
  222. ].join(' '),
  223. ).includes(normalizedQuery),
  224. );
  225. const activeFiltered = filtered.filter((record) => !record.deprecated);
  226. const page = filtered
  227. .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
  228. .slice(offset, offset + limit);
  229. return {
  230. workspaceMode,
  231. projectMetaPath: projectMeta ? 'project.meta.md' : null,
  232. projectMeta,
  233. manifestPath: 'MEMORY.md',
  234. manifestContent: (() => {
  235. try {
  236. return fs.readFileSync(manifestPath, 'utf-8');
  237. } catch {
  238. return '';
  239. }
  240. })(),
  241. totalFiles: activeFiltered.length,
  242. totalProjects: activeFiltered.filter((record) => record.type === 'project').length,
  243. totalFeedback: activeFiltered.filter((record) => record.type === 'feedback').length,
  244. projectEntries: page.filter((record) => record.type === 'project' && !record.deprecated),
  245. feedbackEntries: page.filter((record) => record.type === 'feedback' && !record.deprecated),
  246. deprecatedProjectEntries: page.filter((record) => record.type === 'project' && record.deprecated),
  247. deprecatedFeedbackEntries: page.filter((record) => record.type === 'feedback' && record.deprecated),
  248. };
  249. }
  250. function buildDashboardSnapshot(service, repository, { query = '', selectedProjectId = '' } = {}) {
  251. return {
  252. overview: {
  253. ...service.overview(),
  254. scheduler: getMemorySchedulerStatus(),
  255. },
  256. settings: getGlobalMemorySettings(),
  257. workspace: buildWorkspaceSnapshot(repository, {
  258. query,
  259. limit: 200,
  260. offset: 0,
  261. selectedProjectId,
  262. }),
  263. userSummary: service.getUserSummary(),
  264. caseTraces: service.listCaseTraces(12),
  265. indexTraces: service.listIndexTraces(10),
  266. dreamTraces: service.listDreamTraces(10),
  267. };
  268. }
  269. function getQuery(req) {
  270. return typeof req.query.q === 'string' ? req.query.q.trim() : '';
  271. }
  272. function getSelectedProjectId(req) {
  273. return typeof req.query.selectedProjectId === 'string'
  274. ? req.query.selectedProjectId.trim()
  275. : '';
  276. }
  277. async function withMemoryService(req, res, fn) {
  278. try {
  279. const { projectPath, dataDir, service } = await getMemoryServiceForRequest(req);
  280. return await fn({ projectPath, dataDir, service, repository: service.repository });
  281. } catch (error) {
  282. const message = error instanceof Error ? error.message : String(error);
  283. return res.status(400).json({ error: message });
  284. }
  285. }
  286. function buildDownloadFileName(prefix, exportedAt) {
  287. const safe = String(exportedAt || '')
  288. .replace(/[^\dTZ-]/g, '-')
  289. .replace(/-+/g, '-');
  290. return `${prefix}-${safe || 'export'}.json`;
  291. }
  292. function sendBundleDownload(res, bundle, prefix) {
  293. res.setHeader('Content-Type', 'application/json; charset=utf-8');
  294. res.setHeader(
  295. 'Content-Disposition',
  296. `attachment; filename="${buildDownloadFileName(prefix, bundle.exportedAt)}"`,
  297. );
  298. res.send(JSON.stringify(bundle, null, 2));
  299. }
  300. router.get('/overview', async (req, res) =>
  301. withMemoryService(req, res, async ({ service }) => {
  302. res.json({
  303. ...service.overview(),
  304. scheduler: getMemorySchedulerStatus(),
  305. });
  306. }),
  307. );
  308. router.route('/settings')
  309. .get(async (req, res) =>
  310. withMemoryService(req, res, async () => {
  311. res.json(getGlobalMemorySettings());
  312. }))
  313. .post(async (req, res) =>
  314. withMemoryService(req, res, async () => {
  315. res.json(await saveGlobalMemorySettings(req.body ?? {}));
  316. }));
  317. router.post('/index/run', async (req, res) =>
  318. withMemoryService(req, res, async ({ dataDir, service, repository }) => {
  319. const result = await runManualMemoryFlush(service, dataDir, { reason: 'manual' });
  320. res.json({
  321. ...result,
  322. dashboard: buildDashboardSnapshot(service, repository, {
  323. query: getQuery(req),
  324. selectedProjectId: getSelectedProjectId(req),
  325. }),
  326. });
  327. }),
  328. );
  329. router.post('/dream/run', async (req, res) =>
  330. withMemoryService(req, res, async ({ dataDir, service, repository }) => {
  331. const result = await runManualMemoryDream(service, dataDir);
  332. res.json({
  333. ...result,
  334. dashboard: buildDashboardSnapshot(service, repository, {
  335. query: getQuery(req),
  336. selectedProjectId: getSelectedProjectId(req),
  337. }),
  338. });
  339. }),
  340. );
  341. router.post('/dream/rollback-last', async (req, res) =>
  342. withMemoryService(req, res, async ({ dataDir, service, repository }) => {
  343. const result = await rollbackLastMemoryDream(service, dataDir);
  344. res.json({
  345. ...result,
  346. dashboard: buildDashboardSnapshot(service, repository, {
  347. query: getQuery(req),
  348. selectedProjectId: getSelectedProjectId(req),
  349. }),
  350. });
  351. }),
  352. );
  353. router.get('/snapshot', async (req, res) =>
  354. withMemoryService(req, res, async ({ service }) => {
  355. res.json(service.snapshot(parseLimit(req.query.limit, 24)));
  356. }),
  357. );
  358. router.get('/memory/list', async (req, res) =>
  359. withMemoryService(req, res, async ({ service }) => {
  360. const kind = parseMemoryKind(req.query.kind);
  361. const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
  362. const limit = parseLimit(req.query.limit, 10);
  363. const offset = parseOffset(req.query.offset, 0);
  364. const items = service.list({
  365. ...(kind !== 'all' ? { kinds: [kind] } : {}),
  366. ...(query ? { query } : {}),
  367. limit,
  368. offset,
  369. });
  370. res.json(items);
  371. }),
  372. );
  373. router.get('/memory/get', async (req, res) =>
  374. withMemoryService(req, res, async ({ service }) => {
  375. const ids = String(req.query.ids || '')
  376. .split(',')
  377. .map((value) => value.trim())
  378. .filter(Boolean);
  379. if (ids.length === 0) {
  380. return res.status(400).json({ error: 'ids query parameter is required' });
  381. }
  382. res.json(service.get(ids, 5000));
  383. }),
  384. );
  385. router.post('/memory/actions', async (req, res) =>
  386. withMemoryService(req, res, async ({ service }) => {
  387. try {
  388. res.json(service.act(req.body ?? {}));
  389. } catch (error) {
  390. res.status(400).json({
  391. error: error instanceof Error ? error.message : String(error),
  392. });
  393. }
  394. }),
  395. );
  396. router.get('/memory/user-summary', async (req, res) =>
  397. withMemoryService(req, res, async ({ service }) => {
  398. res.json(service.getUserSummary());
  399. }),
  400. );
  401. router.route('/project-meta')
  402. .get(async (req, res) =>
  403. withMemoryService(req, res, async ({ service, repository }) => {
  404. const selected = getSelectedProjectId(req);
  405. if (service.getWorkspaceMode() === 'general' && selected) {
  406. const readableProject = service.getReadableProject(selected);
  407. if (!readableProject || readableProject.readOnly) {
  408. return res.json(null);
  409. }
  410. return res.json(repository.getFileMemoryStore().getProjectMeta(readableProject.projectId) ?? readableProject);
  411. }
  412. res.json(service.getProjectMeta());
  413. }))
  414. .post(async (req, res) =>
  415. withMemoryService(req, res, async ({ service }) => {
  416. try {
  417. res.json(service.updateProjectMeta(req.body ?? {}));
  418. } catch (error) {
  419. res.status(400).json({
  420. error: error instanceof Error ? error.message : String(error),
  421. });
  422. }
  423. }));
  424. router.get('/workspace', async (req, res) =>
  425. withMemoryService(req, res, async ({ repository }) => {
  426. res.json(
  427. buildWorkspaceSnapshot(repository, {
  428. query: getQuery(req),
  429. limit: parseLimit(req.query.limit, 100),
  430. offset: parseOffset(req.query.offset, 0),
  431. selectedProjectId: getSelectedProjectId(req),
  432. }),
  433. );
  434. }),
  435. );
  436. router.get('/cases', async (req, res) =>
  437. withMemoryService(req, res, async ({ service }) => {
  438. res.json(service.listCaseTraces(parseLimit(req.query.limit, 12)));
  439. }),
  440. );
  441. router.get('/cases/:caseId', async (req, res) =>
  442. withMemoryService(req, res, async ({ service }) => {
  443. const record = service.getCaseTrace(req.params.caseId);
  444. if (!record) {
  445. return res.status(404).json({ error: 'Not found' });
  446. }
  447. res.json(record);
  448. }),
  449. );
  450. router.get('/index-traces', async (req, res) =>
  451. withMemoryService(req, res, async ({ service }) => {
  452. res.json(service.listIndexTraces(parseLimit(req.query.limit, 30)));
  453. }),
  454. );
  455. router.get('/index-traces/:indexTraceId', async (req, res) =>
  456. withMemoryService(req, res, async ({ service }) => {
  457. const record = service.getIndexTrace(req.params.indexTraceId);
  458. if (!record) {
  459. return res.status(404).json({ error: 'Not found' });
  460. }
  461. res.json(record);
  462. }),
  463. );
  464. router.get('/dream-traces', async (req, res) =>
  465. withMemoryService(req, res, async ({ service }) => {
  466. res.json(service.listDreamTraces(parseLimit(req.query.limit, 30)));
  467. }),
  468. );
  469. router.get('/dream-traces/:dreamTraceId', async (req, res) =>
  470. withMemoryService(req, res, async ({ service }) => {
  471. const record = service.getDreamTrace(req.params.dreamTraceId);
  472. if (!record) {
  473. return res.status(404).json({ error: 'Not found' });
  474. }
  475. res.json(record);
  476. }),
  477. );
  478. router.get('/export/current-project', async (req, res) =>
  479. withMemoryService(req, res, async ({ service }) => {
  480. const bundle = service.exportBundle();
  481. sendBundleDownload(res, bundle, 'pilotdeck-memory-current-project');
  482. }),
  483. );
  484. router.get('/export/all-projects', async (_req, res) => {
  485. try {
  486. const bundle = await exportAllProjectsMemoryBundle();
  487. sendBundleDownload(res, bundle, 'pilotdeck-memory-all-projects');
  488. } catch (error) {
  489. res.status(500).json({
  490. error: error instanceof Error ? error.message : String(error),
  491. });
  492. }
  493. });
  494. router.post('/import/current-project', async (req, res) =>
  495. withMemoryService(req, res, async ({ service }) => {
  496. try {
  497. res.json(service.importBundle(req.body));
  498. } catch (error) {
  499. const status = error instanceof MemoryBundleValidationError ? 400 : 500;
  500. res.status(status).json({
  501. error: error instanceof Error ? error.message : String(error),
  502. });
  503. }
  504. }),
  505. );
  506. router.post('/import/all-projects', async (req, res) => {
  507. try {
  508. res.json(await importAllProjectsMemoryBundle(req.body));
  509. } catch (error) {
  510. const status = error instanceof MemoryBundleValidationError ? 400 : 500;
  511. res.status(status).json({
  512. error: error instanceof Error ? error.message : String(error),
  513. });
  514. }
  515. });
  516. router.get('/export', async (req, res) =>
  517. withMemoryService(req, res, async ({ service }) => {
  518. const bundle = service.exportBundle();
  519. sendBundleDownload(res, bundle, 'pilotdeck-memory-current-project');
  520. }),
  521. );
  522. router.post('/import', async (req, res) =>
  523. withMemoryService(req, res, async ({ service }) => {
  524. try {
  525. res.json(service.importBundle(req.body));
  526. } catch (error) {
  527. const status = error instanceof MemoryBundleValidationError ? 400 : 500;
  528. res.status(status).json({
  529. error: error instanceof Error ? error.message : String(error),
  530. });
  531. }
  532. }),
  533. );
  534. router.post('/clear', async (req, res) => {
  535. const scope = req.body?.scope === 'all_memory' ? 'all_memory' : 'current_project';
  536. if (scope === 'all_memory') {
  537. try {
  538. res.json(await clearAllMemoryData());
  539. } catch (error) {
  540. res.status(500).json({
  541. error: error instanceof Error ? error.message : String(error),
  542. });
  543. }
  544. return;
  545. }
  546. return withMemoryService(req, res, async ({ service, repository }) => {
  547. const result = service.clear(scope);
  548. res.json({
  549. ...result,
  550. dashboard: buildDashboardSnapshot(service, repository, {
  551. query: getQuery(req),
  552. selectedProjectId: getSelectedProjectId(req),
  553. }),
  554. });
  555. });
  556. });
  557. export default router;