memoryService.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import fs from 'fs/promises';
  2. import os from 'os';
  3. import path from 'path';
  4. import { DatabaseSync } from 'node:sqlite';
  5. import {
  6. ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION,
  7. EdgeClawMemoryService,
  8. MemoryBundleValidationError,
  9. hashText,
  10. } from '../../../src/context/memory/edgeclaw-memory-core/lib/index.js';
  11. import { extractProjectDirectory } from '../projects.js';
  12. import {
  13. buildMemoryDefaults,
  14. readPilotDeckConfigFile,
  15. } from './pilotdeckConfig.js';
  16. const MEMORY_ROOT_DIR = path.join(process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck'), 'memory');
  17. const MEMORY_WORKSPACES_ROOT = path.join(MEMORY_ROOT_DIR, 'workspaces');
  18. const MEMORY_GLOBAL_ROOT = path.join(MEMORY_ROOT_DIR, 'global');
  19. const MEMORY_SCHEDULER_INTERVAL_MS = 60_000;
  20. const GLOBAL_MAINTENANCE_TASK_KEY = '__edgeclaw_memory_global_maintenance__';
  21. const servicesByDataDir = new Map();
  22. const workspaceTaskChains = new Map();
  23. let schedulerTimer = null;
  24. let schedulerCyclePromise = null;
  25. function normalizePath(projectPath) {
  26. return typeof projectPath === 'string' && projectPath.trim()
  27. ? path.resolve(projectPath.trim())
  28. : '';
  29. }
  30. function resolveWorkspaceDataDir(projectPath) {
  31. return path.join(MEMORY_WORKSPACES_ROOT, hashText(path.resolve(projectPath)));
  32. }
  33. function buildServiceForDataDir(dataDir, workspaceDir = dataDir) {
  34. let memoryDefaults = {};
  35. try {
  36. memoryDefaults = buildMemoryDefaults(readPilotDeckConfigFile().config);
  37. } catch {
  38. memoryDefaults = {};
  39. }
  40. const service = new EdgeClawMemoryService({
  41. workspaceDir,
  42. rootDir: MEMORY_ROOT_DIR,
  43. dbPath: path.join(dataDir, 'control.sqlite'),
  44. memoryDir: path.join(dataDir, 'memory'),
  45. source: 'pilotdeck',
  46. ...memoryDefaults,
  47. });
  48. if (memoryDefaults.defaultIndexingSettings) {
  49. service.saveSettings(memoryDefaults.defaultIndexingSettings);
  50. }
  51. return service;
  52. }
  53. function readWorkspaceDirFromDataDir(dataDir) {
  54. const dbPath = path.join(dataDir, 'control.sqlite');
  55. try {
  56. const db = new DatabaseSync(dbPath);
  57. try {
  58. const row = db.prepare(
  59. 'SELECT state_json FROM pipeline_state WHERE state_key = ?',
  60. ).get('workspaceDir');
  61. if (!row || typeof row.state_json !== 'string') {
  62. return null;
  63. }
  64. const parsed = JSON.parse(row.state_json);
  65. return typeof parsed === 'string' && parsed.trim()
  66. ? path.resolve(parsed.trim())
  67. : null;
  68. } finally {
  69. db.close();
  70. }
  71. } catch {
  72. return null;
  73. }
  74. }
  75. function getOrCreateServiceForDataDir(dataDir, workspaceDir = dataDir) {
  76. const normalizedDataDir = path.resolve(dataDir);
  77. let service = servicesByDataDir.get(normalizedDataDir);
  78. if (!service) {
  79. const restoredWorkspaceDir = readWorkspaceDirFromDataDir(normalizedDataDir);
  80. service = buildServiceForDataDir(normalizedDataDir, restoredWorkspaceDir ?? workspaceDir);
  81. servicesByDataDir.set(normalizedDataDir, service);
  82. }
  83. return {
  84. dataDir: normalizedDataDir,
  85. service,
  86. };
  87. }
  88. function getOrCreateServiceForProjectPath(projectPath) {
  89. const normalizedProjectPath = normalizePath(projectPath);
  90. if (!normalizedProjectPath) {
  91. throw new Error('projectPath is required');
  92. }
  93. const dataDir = resolveWorkspaceDataDir(normalizedProjectPath);
  94. const existing = servicesByDataDir.get(path.resolve(dataDir));
  95. if (existing && existing.workspaceDir !== normalizedProjectPath) {
  96. try {
  97. existing.close();
  98. } catch {
  99. // ignore close failures when refreshing workspace context
  100. }
  101. servicesByDataDir.delete(path.resolve(dataDir));
  102. }
  103. return {
  104. projectPath: normalizedProjectPath,
  105. ...getOrCreateServiceForDataDir(dataDir, normalizedProjectPath),
  106. };
  107. }
  108. function enqueueTaskWithKeys(keys, task) {
  109. const normalizedKeys = Array.from(new Set(
  110. keys
  111. .map((key) => String(key || '').trim())
  112. .filter(Boolean),
  113. ));
  114. const previous = Promise.all(
  115. normalizedKeys.map((key) => (workspaceTaskChains.get(key) ?? Promise.resolve()).catch(() => undefined)),
  116. );
  117. const next = previous.then(task);
  118. const sentinel = next.then(() => undefined, () => undefined);
  119. normalizedKeys.forEach((key) => workspaceTaskChains.set(key, sentinel));
  120. sentinel.finally(() => {
  121. normalizedKeys.forEach((key) => {
  122. if (workspaceTaskChains.get(key) === sentinel) {
  123. workspaceTaskChains.delete(key);
  124. }
  125. });
  126. });
  127. return next;
  128. }
  129. function enqueueWorkspaceTask(dataDir, task) {
  130. return enqueueTaskWithKeys([path.resolve(dataDir)], task);
  131. }
  132. function enqueueMaintenanceTask(dataDir, task) {
  133. return enqueueTaskWithKeys([path.resolve(dataDir), GLOBAL_MAINTENANCE_TASK_KEY], task);
  134. }
  135. async function pathExists(targetPath) {
  136. try {
  137. await fs.access(targetPath);
  138. return true;
  139. } catch {
  140. return false;
  141. }
  142. }
  143. function normalizeSnapshotRelativePath(relativePath, label) {
  144. if (typeof relativePath !== 'string' || !relativePath.trim()) {
  145. throw new MemoryBundleValidationError(`Invalid ${label}.relativePath`);
  146. }
  147. const segments = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
  148. if (segments.length === 0 || segments.some((segment) => segment === '.' || segment === '..')) {
  149. throw new MemoryBundleValidationError(`Invalid ${label}.relativePath`);
  150. }
  151. return segments.join('/');
  152. }
  153. function normalizeSnapshotFileRecord(value, label) {
  154. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  155. throw new MemoryBundleValidationError(`Invalid ${label}`);
  156. }
  157. if (typeof value.content !== 'string') {
  158. throw new MemoryBundleValidationError(`Invalid ${label}.content`);
  159. }
  160. return {
  161. relativePath: normalizeSnapshotRelativePath(value.relativePath, label),
  162. content: value.content,
  163. };
  164. }
  165. async function listSnapshotFiles(rootDir) {
  166. if (!(await pathExists(rootDir))) {
  167. return [];
  168. }
  169. const files = [];
  170. async function walk(currentDir) {
  171. const entries = await fs.readdir(currentDir, { withFileTypes: true });
  172. entries.sort((left, right) => left.name.localeCompare(right.name));
  173. for (const entry of entries) {
  174. const absolutePath = path.join(currentDir, entry.name);
  175. if (entry.isDirectory()) {
  176. await walk(absolutePath);
  177. continue;
  178. }
  179. if (!entry.isFile()) {
  180. continue;
  181. }
  182. const relativePath = path.relative(rootDir, absolutePath).replace(/\\/g, '/');
  183. files.push({
  184. relativePath,
  185. content: await fs.readFile(absolutePath, 'utf8'),
  186. });
  187. }
  188. }
  189. await walk(rootDir);
  190. return files;
  191. }
  192. async function replaceSnapshotFiles(rootDir, files) {
  193. await fs.rm(rootDir, { recursive: true, force: true });
  194. await fs.mkdir(rootDir, { recursive: true });
  195. for (let index = 0; index < files.length; index += 1) {
  196. const record = normalizeSnapshotFileRecord(files[index], `files[${index}]`);
  197. const absolutePath = path.resolve(rootDir, record.relativePath);
  198. const relativeCheck = path.relative(rootDir, absolutePath);
  199. if (
  200. !relativeCheck
  201. || relativeCheck.startsWith('..')
  202. || path.isAbsolute(relativeCheck)
  203. ) {
  204. throw new MemoryBundleValidationError(`Invalid files[${index}].relativePath`);
  205. }
  206. await fs.mkdir(path.dirname(absolutePath), { recursive: true });
  207. await fs.writeFile(absolutePath, record.content, 'utf8');
  208. }
  209. }
  210. function createEmptyTransferCounts() {
  211. return {
  212. managedFiles: 0,
  213. memoryFiles: 0,
  214. project: 0,
  215. feedback: 0,
  216. user: 0,
  217. tmp: 0,
  218. projectMetas: 0,
  219. };
  220. }
  221. function addTransferCounts(total, partial) {
  222. total.managedFiles += Number(partial?.managedFiles ?? 0);
  223. total.memoryFiles += Number(partial?.memoryFiles ?? 0);
  224. total.project += Number(partial?.project ?? 0);
  225. total.feedback += Number(partial?.feedback ?? 0);
  226. total.user += Number(partial?.user ?? 0);
  227. total.tmp += Number(partial?.tmp ?? 0);
  228. total.projectMetas += Number(partial?.projectMetas ?? 0);
  229. }
  230. function normalizeAllProjectsBundle(value) {
  231. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  232. throw new MemoryBundleValidationError('Invalid all-projects memory bundle');
  233. }
  234. if (value.formatVersion !== ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION) {
  235. throw new MemoryBundleValidationError(
  236. `Unsupported all-projects memory bundle formatVersion. Expected ${ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION}.`,
  237. );
  238. }
  239. if (value.scope !== 'all_projects') {
  240. throw new MemoryBundleValidationError('Unsupported all-projects memory bundle scope.');
  241. }
  242. if (!Array.isArray(value.projects)) {
  243. throw new MemoryBundleValidationError('Invalid all-projects memory bundle projects.');
  244. }
  245. const globalFiles = Array.isArray(value.globalFiles)
  246. ? value.globalFiles.map((item, index) => normalizeSnapshotFileRecord(item, `globalFiles[${index}]`))
  247. : [];
  248. const seenGlobalPaths = new Set();
  249. for (const record of globalFiles) {
  250. if (seenGlobalPaths.has(record.relativePath)) {
  251. throw new MemoryBundleValidationError(`Duplicate globalFiles path: ${record.relativePath}`);
  252. }
  253. seenGlobalPaths.add(record.relativePath);
  254. }
  255. const seenProjectPaths = new Set();
  256. const projects = value.projects.map((project, index) => {
  257. if (!project || typeof project !== 'object' || Array.isArray(project)) {
  258. throw new MemoryBundleValidationError(`Invalid projects[${index}]`);
  259. }
  260. const projectPath = normalizePath(project.projectPath);
  261. if (!projectPath) {
  262. throw new MemoryBundleValidationError(`Invalid projects[${index}].projectPath`);
  263. }
  264. if (seenProjectPaths.has(projectPath)) {
  265. throw new MemoryBundleValidationError(`Duplicate projectPath in projects[${index}]`);
  266. }
  267. seenProjectPaths.add(projectPath);
  268. if (!project.bundle || typeof project.bundle !== 'object' || Array.isArray(project.bundle)) {
  269. throw new MemoryBundleValidationError(`Invalid projects[${index}].bundle`);
  270. }
  271. return {
  272. projectPath,
  273. projectName: typeof project.projectName === 'string' && project.projectName.trim()
  274. ? project.projectName.trim()
  275. : path.basename(projectPath),
  276. bundle: project.bundle,
  277. };
  278. });
  279. return {
  280. formatVersion: ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION,
  281. scope: 'all_projects',
  282. exportedAt: typeof value.exportedAt === 'string' && value.exportedAt.trim()
  283. ? value.exportedAt.trim()
  284. : new Date().toISOString(),
  285. projects,
  286. globalFiles,
  287. };
  288. }
  289. async function listWorkspaceDataDirs() {
  290. try {
  291. const entries = await fs.readdir(MEMORY_WORKSPACES_ROOT, { withFileTypes: true });
  292. const dirs = [];
  293. for (const entry of entries) {
  294. if (!entry.isDirectory()) continue;
  295. const dataDir = path.join(MEMORY_WORKSPACES_ROOT, entry.name);
  296. const hasDb = await pathExists(path.join(dataDir, 'control.sqlite'));
  297. const hasMemoryDir = await pathExists(path.join(dataDir, 'memory'));
  298. if (hasDb && hasMemoryDir) {
  299. dirs.push(dataDir);
  300. }
  301. }
  302. return dirs.sort((left, right) => left.localeCompare(right));
  303. } catch {
  304. return [];
  305. }
  306. }
  307. async function executeScheduledMaintenanceForDataDir(dataDir) {
  308. const { service } = getOrCreateServiceForDataDir(dataDir);
  309. return enqueueMaintenanceTask(dataDir, async () => service.runDueScheduledMaintenance('scheduled:server_scheduler'));
  310. }
  311. export async function resolveProjectPathFromRequest(req) {
  312. const queryProjectPath = normalizePath(req.query?.projectPath);
  313. if (queryProjectPath) {
  314. return queryProjectPath;
  315. }
  316. const bodyProjectPath = normalizePath(req.body?.projectPath);
  317. if (bodyProjectPath) {
  318. return bodyProjectPath;
  319. }
  320. const projectName = typeof req.query?.projectName === 'string'
  321. ? req.query.projectName.trim()
  322. : typeof req.params?.projectName === 'string'
  323. ? req.params.projectName.trim()
  324. : '';
  325. if (!projectName) {
  326. throw new Error('projectPath or projectName is required');
  327. }
  328. return path.resolve(await extractProjectDirectory(projectName));
  329. }
  330. export async function getMemoryServiceForRequest(req) {
  331. const projectPath = await resolveProjectPathFromRequest(req);
  332. return getOrCreateServiceForProjectPath(projectPath);
  333. }
  334. export async function runManualMemoryFlush(service, dataDir, options = {}) {
  335. return enqueueMaintenanceTask(dataDir, async () => service.flush({
  336. reason: options.reason ?? 'manual',
  337. ...(typeof options.batchSize === 'number' ? { batchSize: options.batchSize } : {}),
  338. ...(Array.isArray(options.sessionKeys) ? { sessionKeys: options.sessionKeys } : {}),
  339. }));
  340. }
  341. export async function runManualMemoryDream(service, dataDir) {
  342. return enqueueMaintenanceTask(dataDir, async () => service.dream('manual'));
  343. }
  344. export async function rollbackLastMemoryDream(service, dataDir) {
  345. return enqueueMaintenanceTask(dataDir, async () => service.rollbackLastDream());
  346. }
  347. export async function runMemorySchedulerCycle() {
  348. try {
  349. if (!readPilotDeckConfigFile().config.memory?.enabled) {
  350. return null;
  351. }
  352. } catch {
  353. // If config cannot be read, keep the scheduler's default enabled behavior.
  354. }
  355. if (schedulerCyclePromise) {
  356. return schedulerCyclePromise;
  357. }
  358. schedulerCyclePromise = (async () => {
  359. const workspaceDataDirs = await listWorkspaceDataDirs();
  360. for (const dataDir of workspaceDataDirs) {
  361. try {
  362. await executeScheduledMaintenanceForDataDir(dataDir);
  363. } catch (error) {
  364. console.error(`[memory-scheduler] scheduled maintenance failed for ${dataDir}:`, error);
  365. }
  366. }
  367. })().finally(() => {
  368. schedulerCyclePromise = null;
  369. });
  370. return schedulerCyclePromise;
  371. }
  372. export function startMemoryScheduler() {
  373. try {
  374. if (!readPilotDeckConfigFile().config.memory?.enabled) {
  375. return;
  376. }
  377. } catch {
  378. // If config cannot be read, keep the scheduler's default enabled behavior.
  379. }
  380. if (schedulerTimer) {
  381. return;
  382. }
  383. schedulerTimer = setInterval(() => {
  384. void runMemorySchedulerCycle();
  385. }, MEMORY_SCHEDULER_INTERVAL_MS);
  386. if (typeof schedulerTimer.unref === 'function') {
  387. schedulerTimer.unref();
  388. }
  389. void runMemorySchedulerCycle();
  390. }
  391. export function stopMemoryScheduler() {
  392. if (schedulerTimer) {
  393. clearInterval(schedulerTimer);
  394. schedulerTimer = null;
  395. }
  396. }
  397. export function getMemorySchedulerStatus() {
  398. let enabled = true;
  399. let configError = null;
  400. try {
  401. enabled = readPilotDeckConfigFile().config.memory?.enabled !== false;
  402. } catch (error) {
  403. configError = error instanceof Error ? error.message : String(error);
  404. }
  405. return {
  406. enabled,
  407. running: Boolean(schedulerTimer),
  408. intervalMs: MEMORY_SCHEDULER_INTERVAL_MS,
  409. ...(configError ? { configError } : {}),
  410. };
  411. }
  412. export function closeMemoryServices() {
  413. stopMemoryScheduler();
  414. for (const service of servicesByDataDir.values()) {
  415. try {
  416. service.close();
  417. } catch {
  418. // ignore close failures during shutdown
  419. }
  420. }
  421. servicesByDataDir.clear();
  422. workspaceTaskChains.clear();
  423. }
  424. export async function clearAllMemoryData() {
  425. for (const service of servicesByDataDir.values()) {
  426. try {
  427. service.close();
  428. } catch {
  429. // ignore close failures during clear
  430. }
  431. }
  432. servicesByDataDir.clear();
  433. workspaceTaskChains.clear();
  434. await fs.rm(MEMORY_ROOT_DIR, { recursive: true, force: true });
  435. await fs.mkdir(MEMORY_WORKSPACES_ROOT, { recursive: true });
  436. await fs.mkdir(MEMORY_GLOBAL_ROOT, { recursive: true });
  437. return {
  438. scope: 'all_memory',
  439. clearedAt: new Date().toISOString(),
  440. cleared: {
  441. l0Sessions: 0,
  442. pipelineState: 0,
  443. memoryFiles: 0,
  444. projectMetas: 0,
  445. },
  446. };
  447. }
  448. export async function exportAllProjectsMemoryBundle() {
  449. const workspaceDataDirs = await listWorkspaceDataDirs();
  450. const projects = [];
  451. for (const dataDir of workspaceDataDirs) {
  452. const restoredWorkspaceDir = readWorkspaceDirFromDataDir(dataDir);
  453. const { service } = getOrCreateServiceForDataDir(dataDir, restoredWorkspaceDir ?? dataDir);
  454. const projectMeta = service.getProjectMeta();
  455. projects.push({
  456. projectPath: service.workspaceDir,
  457. projectName: projectMeta?.projectName || path.basename(service.workspaceDir),
  458. bundle: service.exportBundle(),
  459. });
  460. }
  461. return {
  462. formatVersion: ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION,
  463. scope: 'all_projects',
  464. exportedAt: new Date().toISOString(),
  465. globalFiles: await listSnapshotFiles(MEMORY_GLOBAL_ROOT),
  466. projects,
  467. };
  468. }
  469. export async function importAllProjectsMemoryBundle(bundle) {
  470. const normalized = normalizeAllProjectsBundle(bundle);
  471. await clearAllMemoryData();
  472. const imported = createEmptyTransferCounts();
  473. const warnings = [];
  474. for (const project of normalized.projects) {
  475. const { service } = getOrCreateServiceForProjectPath(project.projectPath);
  476. const result = service.importBundle(project.bundle);
  477. addTransferCounts(imported, result.imported);
  478. if (Array.isArray(result.warnings) && result.warnings.length > 0) {
  479. warnings.push(...result.warnings.map((warning) => `[${project.projectName}] ${warning}`));
  480. }
  481. }
  482. await replaceSnapshotFiles(MEMORY_GLOBAL_ROOT, normalized.globalFiles);
  483. return {
  484. formatVersion: ALL_PROJECTS_MEMORY_EXPORT_FORMAT_VERSION,
  485. scope: 'all_projects',
  486. importedAt: new Date().toISOString(),
  487. projectCount: normalized.projects.length,
  488. imported,
  489. ...(warnings.length > 0 ? { warnings } : {}),
  490. };
  491. }