memax-cli 0.1.0-alpha.26 → 0.1.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/commands/mcp.d.ts.map +1 -1
  2. package/dist/commands/mcp.js +8 -2
  3. package/dist/commands/mcp.js.map +1 -1
  4. package/dist/commands/recall.d.ts +5 -0
  5. package/dist/commands/recall.d.ts.map +1 -1
  6. package/dist/commands/recall.js +89 -6
  7. package/dist/commands/recall.js.map +1 -1
  8. package/dist/commands/recall.test.d.ts +2 -0
  9. package/dist/commands/recall.test.d.ts.map +1 -0
  10. package/dist/commands/recall.test.js +23 -0
  11. package/dist/commands/recall.test.js.map +1 -0
  12. package/dist/commands/setup-mcp.d.ts.map +1 -1
  13. package/dist/commands/setup-mcp.js +3 -3
  14. package/dist/commands/setup-mcp.js.map +1 -1
  15. package/dist/commands/sync.d.ts +26 -0
  16. package/dist/commands/sync.d.ts.map +1 -1
  17. package/dist/commands/sync.js +508 -79
  18. package/dist/commands/sync.js.map +1 -1
  19. package/dist/commands/sync.test.d.ts +2 -0
  20. package/dist/commands/sync.test.d.ts.map +1 -0
  21. package/dist/commands/sync.test.js +130 -0
  22. package/dist/commands/sync.test.js.map +1 -0
  23. package/dist/index.js +9 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/lib/config.d.ts +33 -0
  26. package/dist/lib/config.d.ts.map +1 -1
  27. package/dist/lib/config.js +63 -0
  28. package/dist/lib/config.js.map +1 -1
  29. package/dist/lib/project-context.d.ts +14 -4
  30. package/dist/lib/project-context.d.ts.map +1 -1
  31. package/dist/lib/project-context.js +98 -54
  32. package/dist/lib/project-context.js.map +1 -1
  33. package/dist/lib/project-context.test.d.ts +2 -0
  34. package/dist/lib/project-context.test.d.ts.map +1 -0
  35. package/dist/lib/project-context.test.js +75 -0
  36. package/dist/lib/project-context.test.js.map +1 -0
  37. package/package.json +2 -2
@@ -1,10 +1,11 @@
1
1
  import chalk from "chalk";
2
2
  import { createHash } from "node:crypto";
3
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
3
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, watch, existsSync, } from "node:fs";
4
4
  import { join, relative, extname, resolve, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { getClient } from "../lib/client.js";
7
- import { getProjectScope, resolveClaudeProjectFolder, normalizeFilePath, } from "../lib/project-context.js";
7
+ import { getProjectScope, resolveProjectScope, resolveClaudeProjectFolder, normalizeFilePath, readMemaxYmlConfig, detectProjectContext, } from "../lib/project-context.js";
8
+ import { getOrCreateDeviceID, listSyncSources, updateSyncSourceRun, } from "../lib/config.js";
8
9
  import { confirm, ask, confirmDefault } from "../lib/prompt.js";
9
10
  const DEFAULT_IGNORE = new Set([
10
11
  "node_modules",
@@ -83,6 +84,154 @@ export async function listAgentConfigsCommand() {
83
84
  }
84
85
  console.log(chalk.gray(` ${configs.length} config${configs.length > 1 ? "s" : ""} synced to cloud.\n`));
85
86
  }
87
+ export function classifyAgentConfigPlacement(agent, filePath, scope, options = {}) {
88
+ const key = `${agent}|${normalizeFilePath(filePath)}|${scope}`;
89
+ const existing = options.localByKey?.get(key);
90
+ if (existing) {
91
+ return {
92
+ kind: "present",
93
+ path: existing.path,
94
+ reason: "present locally",
95
+ };
96
+ }
97
+ const cwd = options.cwd ?? process.cwd();
98
+ const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
99
+ if (scope.startsWith("project:") &&
100
+ scope !== "project" &&
101
+ scope !== currentProjectScope) {
102
+ return {
103
+ kind: "different_project",
104
+ reason: `belongs to ${scope.replace(/^project:/, "")}`,
105
+ };
106
+ }
107
+ const path = resolveAgentConfigWritePath(agent, filePath, scope, options);
108
+ if (path) {
109
+ return {
110
+ kind: "restorable",
111
+ path,
112
+ reason: "safe restore path available",
113
+ };
114
+ }
115
+ return {
116
+ kind: "unresolved",
117
+ reason: "no safe restore path on this machine",
118
+ };
119
+ }
120
+ export async function doctorAgentConfigsCommand() {
121
+ const cwd = process.cwd();
122
+ const home = homedir();
123
+ const deviceID = getOrCreateDeviceID();
124
+ const project = resolveProjectScope(cwd);
125
+ const memaxYml = readMemaxYmlConfig(cwd);
126
+ const locations = discoverAgentConfigs();
127
+ const localConfigs = locations.filter((loc) => {
128
+ if (!existsSync(loc.path))
129
+ return false;
130
+ try {
131
+ const stat = statSync(loc.path);
132
+ return stat.isFile() && stat.size > 0;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ });
138
+ const localByKey = new Map();
139
+ for (const loc of localConfigs) {
140
+ localByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
141
+ }
142
+ let cloudConfigs = null;
143
+ try {
144
+ cloudConfigs = await getClient().configs.list();
145
+ }
146
+ catch (err) {
147
+ console.error(chalk.red(` Failed to fetch cloud configs: ${err.message}\n`));
148
+ }
149
+ const scopeSource = project.source === "memax_yml"
150
+ ? ".memax.yml project_id"
151
+ : project.source === "git_remote"
152
+ ? "git origin"
153
+ : "no canonical project identity";
154
+ console.log(chalk.bold("\n Memax Agent Config Doctor\n"));
155
+ console.log(chalk.white(" Device"));
156
+ console.log(` ID ${chalk.bold(deviceID)}`);
157
+ console.log(` Home ${chalk.gray(home)}`);
158
+ console.log();
159
+ console.log(chalk.white(" Project"));
160
+ console.log(` CWD ${chalk.gray(cwd)}`);
161
+ console.log(` Scope ${chalk.bold(project.scope)}`);
162
+ console.log(` Source ${chalk.gray(scopeSource)}`);
163
+ if (memaxYml?.hub) {
164
+ console.log(` Hub ${chalk.gray(memaxYml.hub)}`);
165
+ }
166
+ if (memaxYml?.project_id) {
167
+ console.log(` project_id ${chalk.gray(memaxYml.project_id)}`);
168
+ }
169
+ if (project.warning) {
170
+ console.log(` Warning ${chalk.yellow(project.warning)}`);
171
+ }
172
+ if (project.scope === "project") {
173
+ console.log(` Note ${chalk.yellow("project-scoped cross-device restore is disabled until git origin or .memax.yml project_id is available")}`);
174
+ }
175
+ console.log();
176
+ const byAgent = new Map();
177
+ for (const loc of localConfigs) {
178
+ const group = byAgent.get(loc.agent) ?? [];
179
+ group.push(loc);
180
+ byAgent.set(loc.agent, group);
181
+ }
182
+ console.log(chalk.white(" Local Discovery"));
183
+ if (localConfigs.length === 0) {
184
+ console.log(` ${chalk.gray("No local agent configs discovered.")}`);
185
+ }
186
+ else {
187
+ for (const [agent, group] of byAgent) {
188
+ console.log(` ${chalk.cyan(formatAgentName(agent))}`);
189
+ for (const loc of group) {
190
+ const scopeLabel = loc.scope === "global"
191
+ ? "global"
192
+ : loc.scope.replace(/^project:/, "");
193
+ console.log(` • ${loc.filePath} ${chalk.gray(scopeLabel)} ${chalk.gray(loc.path)}`);
194
+ }
195
+ }
196
+ }
197
+ console.log();
198
+ if (!cloudConfigs) {
199
+ console.log(chalk.gray(" Cloud inspection skipped because cloud config fetch failed.\n"));
200
+ return;
201
+ }
202
+ const placements = cloudConfigs.configs.map((config) => ({
203
+ config,
204
+ placement: classifyAgentConfigPlacement(config.agent, config.file_path, config.scope, {
205
+ cwd,
206
+ home,
207
+ currentProjectScope: project.scope,
208
+ localByKey,
209
+ findClaudeProjectDir,
210
+ }),
211
+ }));
212
+ const present = placements.filter((p) => p.placement.kind === "present");
213
+ const restorable = placements.filter((p) => p.placement.kind === "restorable");
214
+ const differentProject = placements.filter((p) => p.placement.kind === "different_project");
215
+ const unresolved = placements.filter((p) => p.placement.kind === "unresolved");
216
+ console.log(chalk.white(" Cloud Coverage"));
217
+ console.log(` Present locally ${chalk.bold(String(present.length))}`);
218
+ console.log(` Restorable here ${chalk.bold(String(restorable.length))}`);
219
+ console.log(` Other project ${chalk.bold(String(differentProject.length))}`);
220
+ console.log(` Unresolved here ${chalk.bold(String(unresolved.length))}`);
221
+ console.log();
222
+ printPlacementSection(" Restorable Here", chalk.cyan, restorable, (item) => [
223
+ item.config.agent,
224
+ item.config.file_path,
225
+ item.placement.path ?? "",
226
+ ]);
227
+ printPlacementSection(" Different Project", chalk.yellow, differentProject, (item) => [item.config.agent, item.config.file_path, item.placement.reason]);
228
+ printPlacementSection(" Unresolved", chalk.magenta, unresolved, (item) => [
229
+ item.config.agent,
230
+ item.config.file_path,
231
+ item.placement.reason,
232
+ ]);
233
+ console.log(chalk.gray(" Use this command to verify what sync can restore safely on this machine.\n"));
234
+ }
86
235
  export async function deleteAgentConfigsCommand() {
87
236
  let configs;
88
237
  try {
@@ -112,6 +261,7 @@ export async function deleteAgentConfigsCommand() {
112
261
  agent: c.agent,
113
262
  filePath: c.file_path,
114
263
  scope: c.scope,
264
+ version: c.version,
115
265
  });
116
266
  const idx = chalk.dim(`${items.length}.`);
117
267
  const scopeTag = c.scope === "global"
@@ -133,30 +283,89 @@ export async function deleteAgentConfigsCommand() {
133
283
  console.log(chalk.gray(" No valid selections.\n"));
134
284
  return;
135
285
  }
136
- // Confirm
286
+ const modeAnswer = await ask(" Delete from [l] this device only, [e] everywhere, or [s] skip? ");
287
+ const mode = modeAnswer.trim().toLowerCase();
288
+ if (mode !== "l" && mode !== "e") {
289
+ console.log(chalk.gray(" Cancelled.\n"));
290
+ return;
291
+ }
137
292
  console.log();
138
293
  for (const i of indices) {
139
294
  const item = items[i - 1];
140
295
  console.log(chalk.yellow(` ${item.agent}/${item.filePath}`));
141
296
  }
142
- const ok = await confirm(`\n Delete ${indices.length} config${indices.length > 1 ? "s" : ""}? (y/N) `);
297
+ const ok = await confirm(mode === "e"
298
+ ? `\n Delete ${indices.length} config${indices.length > 1 ? "s" : ""} everywhere? (y/N) `
299
+ : `\n Remove ${indices.length} config${indices.length > 1 ? "s" : ""} from this device only? (y/N) `);
143
300
  if (!ok) {
144
301
  console.log(chalk.gray(" Cancelled.\n"));
145
302
  return;
146
303
  }
304
+ const deviceID = getOrCreateDeviceID();
305
+ const cwd = process.cwd();
306
+ const currentProjectScope = getProjectScope(cwd);
307
+ const locations = discoverAgentConfigs();
308
+ const locByKey = new Map();
309
+ for (const loc of locations) {
310
+ locByKey.set(`${loc.agent}|${loc.filePath}|${loc.scope}`, loc);
311
+ }
312
+ const resolveDeletePath = (agent, filePath, scope) => {
313
+ const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
314
+ if (loc)
315
+ return loc.path;
316
+ return resolveAgentConfigWritePath(agent, filePath, scope, {
317
+ cwd,
318
+ home: homedir(),
319
+ currentProjectScope,
320
+ });
321
+ };
147
322
  let deleted = 0;
148
323
  for (const i of indices) {
149
324
  const item = items[i - 1];
150
325
  try {
151
- await getClient().configs.delete(item.id);
152
- console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("deleted"));
326
+ const localPath = resolveDeletePath(item.agent, item.filePath, item.scope);
327
+ if (mode === "l") {
328
+ await getClient().configs.localDelete({
329
+ device_id: deviceID,
330
+ agent: item.agent,
331
+ file_path: item.filePath,
332
+ scope: item.scope,
333
+ local_path: localPath ?? undefined,
334
+ });
335
+ if (localPath && existsSync(localPath)) {
336
+ rmSync(localPath);
337
+ }
338
+ console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("removed from this device"));
339
+ }
340
+ else {
341
+ await getClient().configs.delete(item.id);
342
+ if (localPath && existsSync(localPath)) {
343
+ rmSync(localPath);
344
+ }
345
+ await getClient().configs.ack({
346
+ device_id: deviceID,
347
+ configs: [
348
+ {
349
+ agent: item.agent,
350
+ file_path: item.filePath,
351
+ scope: item.scope,
352
+ version: item.version + 1,
353
+ local_path: localPath ?? undefined,
354
+ deleted: true,
355
+ },
356
+ ],
357
+ });
358
+ console.log(chalk.green(` \u2713 ${item.agent}/${item.filePath}`), chalk.gray("deleted everywhere"));
359
+ }
153
360
  deleted++;
154
361
  }
155
362
  catch (err) {
156
363
  console.log(chalk.red(` \u2717 ${item.agent}/${item.filePath}`), chalk.gray(err.message));
157
364
  }
158
365
  }
159
- console.log(chalk.gray(`\n ${deleted} config${deleted > 1 ? "s" : ""} deleted.\n`));
366
+ console.log(chalk.gray(mode === "e"
367
+ ? `\n ${deleted} config${deleted > 1 ? "s" : ""} deleted everywhere.\n`
368
+ : `\n ${deleted} config${deleted > 1 ? "s" : ""} removed from this device.\n`));
160
369
  }
161
370
  function formatAge(dateStr) {
162
371
  const ms = Date.now() - new Date(dateStr).getTime();
@@ -171,14 +380,27 @@ function formatAge(dateStr) {
171
380
  }
172
381
  export async function syncCommand(directory, options) {
173
382
  const dir = directory ?? ".";
383
+ const syncRoot = resolve(dir);
174
384
  const customIgnore = options.ignore
175
385
  ? new Set(options.ignore.split(",").map((s) => s.trim()))
176
386
  : new Set();
177
387
  const ignoreSet = new Set([...DEFAULT_IGNORE, ...customIgnore]);
388
+ const ignorePatterns = [...customIgnore].sort();
389
+ const projectContext = detectProjectContext(syncRoot);
178
390
  console.log(chalk.blue("Scanning"), dir);
179
- const files = walkDir(dir, ignoreSet);
391
+ const files = walkDir(syncRoot, ignoreSet);
180
392
  if (files.length === 0) {
181
393
  console.log(chalk.yellow("No supported files found."));
394
+ updateSyncSourceRun(syncRoot, {
395
+ ignorePatterns,
396
+ defaultCategory: options.category,
397
+ defaultBoundary: options.boundary,
398
+ mode: options.watch ? "watch" : "manual",
399
+ scanCount: 0,
400
+ pushed: 0,
401
+ skipped: 0,
402
+ errors: 0,
403
+ });
182
404
  return;
183
405
  }
184
406
  console.log(chalk.gray(`Found ${files.length} files to sync`));
@@ -193,25 +415,38 @@ export async function syncCommand(directory, options) {
193
415
  }
194
416
  console.log();
195
417
  let pushed = 0;
418
+ let skipped = 0;
196
419
  let errors = 0;
197
420
  for (const file of files) {
198
- const result = await pushFile(file, options);
421
+ const result = await pushFile(file, syncRoot, projectContext, options);
199
422
  if (result === "pushed")
200
423
  pushed++;
424
+ else if (result === "skipped")
425
+ skipped++;
201
426
  else if (result === "error")
202
427
  errors++;
203
428
  }
429
+ updateSyncSourceRun(syncRoot, {
430
+ ignorePatterns,
431
+ defaultCategory: options.category,
432
+ defaultBoundary: options.boundary,
433
+ mode: options.watch ? "watch" : "manual",
434
+ scanCount: files.length,
435
+ pushed,
436
+ skipped,
437
+ errors,
438
+ });
204
439
  console.log();
205
- console.log(chalk.blue(`Synced ${pushed} files`), errors > 0 ? chalk.red(`(${errors} errors)`) : "");
440
+ console.log(chalk.blue(`Synced ${pushed} files`), skipped > 0 ? chalk.gray(`(${skipped} skipped)`) : "", errors > 0 ? chalk.red(`(${errors} errors)`) : "");
441
+ console.log(chalk.gray(" Missing local files are retained in Memax until removed explicitly."));
206
442
  if (options.watch) {
207
- const resolvedDir = resolve(dir);
208
443
  console.log(chalk.cyan(`\nWatching ${dir} for changes... (Ctrl+C to stop)`));
209
444
  let debounceTimer = null;
210
445
  const pendingChanges = new Set();
211
- watch(resolvedDir, { recursive: true }, (_eventType, filename) => {
446
+ watch(syncRoot, { recursive: true }, (_eventType, filename) => {
212
447
  if (!filename)
213
448
  return;
214
- const fullPath = join(resolvedDir, filename);
449
+ const fullPath = join(syncRoot, filename);
215
450
  if (!isSupportedFile(filename) || isIgnored(filename, ignoreSet))
216
451
  return;
217
452
  pendingChanges.add(fullPath);
@@ -220,22 +455,22 @@ export async function syncCommand(directory, options) {
220
455
  debounceTimer = setTimeout(async () => {
221
456
  for (const file of pendingChanges) {
222
457
  if (!existsSync(file)) {
223
- console.log(chalk.gray(" ~"), relative(process.cwd(), file), chalk.gray("[deleted, skipped]"));
458
+ console.log(chalk.gray(" ~"), buildSyncSourcePath(syncRoot, file), chalk.gray("[deleted locally, retained in Memax]"));
224
459
  continue;
225
460
  }
226
- await pushFile(file, options);
461
+ await pushFile(file, syncRoot, projectContext, options);
227
462
  }
228
463
  pendingChanges.clear();
229
464
  }, 500);
230
465
  });
231
466
  }
232
467
  }
233
- async function pushFile(file, options) {
468
+ async function pushFile(file, syncRoot, projectContext, options) {
234
469
  try {
235
470
  const content = readFileSync(file, "utf-8");
236
471
  if (!content.trim())
237
472
  return "skipped";
238
- const relPath = relative(process.cwd(), file);
473
+ const relPath = buildSyncSourcePath(syncRoot, file);
239
474
  const ext = extname(file);
240
475
  const contentType = ext === ".md"
241
476
  ? "markdown"
@@ -248,6 +483,7 @@ async function pushFile(file, options) {
248
483
  source: "sync",
249
484
  sourcePath: relPath,
250
485
  contentType,
486
+ projectContext,
251
487
  });
252
488
  console.log(chalk.green(" +"), relPath, chalk.gray(`[${memory.category}]`));
253
489
  return "pushed";
@@ -257,6 +493,46 @@ async function pushFile(file, options) {
257
493
  return "error";
258
494
  }
259
495
  }
496
+ export function buildSyncSourcePath(syncRoot, file) {
497
+ const relativePath = relative(syncRoot, file);
498
+ if (!relativePath ||
499
+ relativePath.startsWith("..") ||
500
+ relativePath === "." ||
501
+ resolve(syncRoot, relativePath) !== resolve(file)) {
502
+ throw new Error("file is outside sync root");
503
+ }
504
+ return normalizeFilePath(relativePath);
505
+ }
506
+ export function syncStatusCommand() {
507
+ const sources = listSyncSources();
508
+ console.log(chalk.bold("\n Memax Sync Status\n"));
509
+ if (sources.length === 0) {
510
+ console.log(chalk.gray(" No sync sources recorded yet."));
511
+ console.log(chalk.gray(" Run: memax sync <dir>\n"));
512
+ return;
513
+ }
514
+ for (const source of sources) {
515
+ console.log(` ${chalk.cyan(source.root_path)}`);
516
+ console.log(` kind ${chalk.gray(source.kind)}`);
517
+ console.log(` deletion policy ${chalk.gray("retain missing files in Memax")}`);
518
+ if (source.default_category) {
519
+ console.log(` category ${chalk.gray(source.default_category)}`);
520
+ }
521
+ if (source.default_boundary) {
522
+ console.log(` boundary ${chalk.gray(source.default_boundary)}`);
523
+ }
524
+ if (source.ignore_patterns.length > 0) {
525
+ console.log(` ignore ${chalk.gray(source.ignore_patterns.join(", "))}`);
526
+ }
527
+ if (source.last_sync_at) {
528
+ console.log(` last sync ${chalk.gray(formatAge(source.last_sync_at))} (${chalk.gray(source.last_mode ?? "manual")})`);
529
+ }
530
+ if (source.last_scan_count !== undefined) {
531
+ console.log(` last run ${chalk.gray(`${source.last_scan_count} scanned, ${source.last_pushed ?? 0} pushed, ${source.last_skipped ?? 0} skipped, ${source.last_errors ?? 0} errors`)}`);
532
+ }
533
+ console.log();
534
+ }
535
+ }
260
536
  function isSupportedFile(filename) {
261
537
  return SUPPORTED_EXTENSIONS.has(extname(filename));
262
538
  }
@@ -312,6 +588,126 @@ function guessCategory(path) {
312
588
  return "reference/config";
313
589
  return "daily/note";
314
590
  }
591
+ function findClaudeProjectDir(scope) {
592
+ const home = homedir();
593
+ const claudeProjectsDir = join(home, ".claude", "projects");
594
+ if (!existsSync(claudeProjectsDir))
595
+ return null;
596
+ try {
597
+ for (const project of readdirSync(claudeProjectsDir)) {
598
+ const repoUrl = resolveClaudeProjectFolder(project);
599
+ if (repoUrl && scope === `project:${repoUrl}`) {
600
+ return join(claudeProjectsDir, project);
601
+ }
602
+ }
603
+ }
604
+ catch {
605
+ // Permission denied — skip
606
+ }
607
+ return null;
608
+ }
609
+ function printPlacementSection(title, color, items, format) {
610
+ if (items.length === 0)
611
+ return;
612
+ console.log(chalk.white(title));
613
+ for (const item of items) {
614
+ const [agent, filePath, detail] = format(item);
615
+ console.log(` ${color(formatAgentName(agent))} ${filePath}`);
616
+ console.log(` ${chalk.gray(detail)}`);
617
+ }
618
+ console.log();
619
+ }
620
+ export function resolveAgentConfigWritePath(agent, filePath, scope, options = {}) {
621
+ const cwd = options.cwd ?? process.cwd();
622
+ const home = options.home ?? homedir();
623
+ const currentProjectScope = options.currentProjectScope ?? getProjectScope(cwd);
624
+ const normalizedFilePath = normalizeFilePath(filePath);
625
+ if (scope === "global") {
626
+ switch (agent) {
627
+ case "claude-code":
628
+ return join(home, ".claude", normalizedFilePath);
629
+ case "codex":
630
+ return join(home, ".codex", normalizedFilePath);
631
+ case "gemini":
632
+ return join(home, ".gemini", normalizedFilePath);
633
+ case "openclaw":
634
+ return join(home, ".openclaw", normalizedFilePath);
635
+ case "opencode":
636
+ return join(home, ".opencode", normalizedFilePath);
637
+ default:
638
+ return null;
639
+ }
640
+ }
641
+ if (!scope.startsWith("project:") || scope !== currentProjectScope) {
642
+ return null;
643
+ }
644
+ switch (agent) {
645
+ case "claude-code":
646
+ if (normalizedFilePath === "CLAUDE.md" ||
647
+ normalizedFilePath === "MEMORY.md") {
648
+ return join(cwd, ".claude", normalizedFilePath);
649
+ }
650
+ if (normalizedFilePath.startsWith(".claude/")) {
651
+ return join(cwd, normalizedFilePath);
652
+ }
653
+ if (normalizedFilePath.startsWith("memory/")) {
654
+ const projectDir = options.findClaudeProjectDir?.(scope);
655
+ if (projectDir) {
656
+ return join(projectDir, normalizedFilePath);
657
+ }
658
+ const mangledCwd = cwd.replace(/\//g, "-");
659
+ return join(home, ".claude", "projects", mangledCwd, normalizedFilePath);
660
+ }
661
+ return null;
662
+ case "cursor":
663
+ if (normalizedFilePath === ".cursorrules" ||
664
+ normalizedFilePath.startsWith(".cursor/")) {
665
+ return join(cwd, normalizedFilePath);
666
+ }
667
+ return null;
668
+ case "codex":
669
+ if (normalizedFilePath === "instructions.md") {
670
+ return join(cwd, ".codex", "instructions.md");
671
+ }
672
+ if (normalizedFilePath.startsWith(".codex/")) {
673
+ return join(cwd, normalizedFilePath);
674
+ }
675
+ return null;
676
+ case "gemini":
677
+ if (normalizedFilePath === "GEMINI.md") {
678
+ return join(cwd, "GEMINI.md");
679
+ }
680
+ return null;
681
+ case "copilot":
682
+ if (normalizedFilePath === "copilot-instructions.md") {
683
+ return join(cwd, ".github", "copilot-instructions.md");
684
+ }
685
+ if (normalizedFilePath.startsWith(".github/")) {
686
+ return join(cwd, normalizedFilePath);
687
+ }
688
+ return null;
689
+ case "windsurf":
690
+ if (normalizedFilePath === ".windsurfrules" ||
691
+ normalizedFilePath.startsWith(".windsurf/")) {
692
+ return join(cwd, normalizedFilePath);
693
+ }
694
+ return null;
695
+ case "opencode":
696
+ if (normalizedFilePath.startsWith(".opencode/")) {
697
+ return join(cwd, normalizedFilePath);
698
+ }
699
+ return join(cwd, ".opencode", normalizedFilePath);
700
+ case "generic":
701
+ if (normalizedFilePath === "AGENTS.md" ||
702
+ normalizedFilePath === "CLAUDE.md" ||
703
+ normalizedFilePath === "GEMINI.md") {
704
+ return join(cwd, normalizedFilePath);
705
+ }
706
+ return null;
707
+ default:
708
+ return null;
709
+ }
710
+ }
315
711
  function discoverAgentConfigs() {
316
712
  const home = homedir();
317
713
  const cwd = process.cwd();
@@ -448,6 +844,11 @@ function discoverAgentConfigs() {
448
844
  }
449
845
  async function syncAgentMemory(options = {}) {
450
846
  console.log(chalk.bold("\n Memax Config Sync\n"));
847
+ const projectScopeResolution = resolveProjectScope();
848
+ if (projectScopeResolution.warning) {
849
+ console.log(chalk.yellow(` Warning: ${projectScopeResolution.warning}`));
850
+ console.log(chalk.gray(" Using .memax.yml project_id as the canonical project identity.\n"));
851
+ }
451
852
  // Discover local config files
452
853
  const locations = discoverAgentConfigs();
453
854
  const localConfigs = [];
@@ -474,6 +875,7 @@ async function syncAgentMemory(options = {}) {
474
875
  }
475
876
  }
476
877
  const isBootstrap = localConfigs.length === 0;
878
+ const deviceID = getOrCreateDeviceID();
477
879
  if (isBootstrap) {
478
880
  console.log(chalk.gray(" No local agent configs found. Checking cloud for backups...\n"));
479
881
  }
@@ -487,10 +889,14 @@ async function syncAgentMemory(options = {}) {
487
889
  scope: c.loc.scope,
488
890
  content_hash: c.hash,
489
891
  updated_at: c.updatedAt,
892
+ local_path: c.loc.path,
490
893
  }));
491
894
  let actions;
492
895
  try {
493
- const plan = await getClient().configs.sync(manifest);
896
+ const plan = await getClient().configs.sync({
897
+ device_id: deviceID,
898
+ configs: manifest,
899
+ });
494
900
  actions = plan.actions;
495
901
  }
496
902
  catch (err) {
@@ -512,7 +918,7 @@ async function syncAgentMemory(options = {}) {
512
918
  // current project. Without this, running `memax sync agents` from ~/
513
919
  // would dump project configs (like .cursorrules from repo X) into the
514
920
  // home directory.
515
- const currentProjectScope = getProjectScope();
921
+ const currentProjectScope = projectScopeResolution.scope;
516
922
  actions = actions.filter((a) => {
517
923
  if (!a.scope.startsWith("project:"))
518
924
  return true; // global → always sync
@@ -540,76 +946,24 @@ async function syncAgentMemory(options = {}) {
540
946
  // Resolve a local write path for any config — even ones not discovered locally.
541
947
  // This enables pulling configs to a brand-new device where agent dirs don't exist yet.
542
948
  const resolveWritePath = (agent, filePath, scope) => {
543
- // First check if we have a known location from local discovery
544
949
  const loc = locByKey.get(`${agent}|${filePath}|${scope}`);
545
950
  if (loc)
546
951
  return loc.path;
547
- const home = homedir();
548
- // Global configs: reconstruct from agent dir + filePath
549
- if (scope === "global") {
550
- const agentDirs = {
551
- "claude-code": join(home, ".claude"),
552
- cursor: join(home, ".cursor"),
553
- codex: join(home, ".codex"),
554
- gemini: join(home, ".gemini"),
555
- copilot: join(home, ".copilot"),
556
- windsurf: join(home, ".windsurf"),
557
- openclaw: join(home, ".openclaw"),
558
- opencode: join(home, ".opencode"),
559
- };
560
- const dir = agentDirs[agent];
561
- if (dir)
562
- return join(dir, filePath);
563
- }
564
- // Project-scoped configs — ONLY write if we're in the matching project.
565
- // This is the safety net: never write project files to the wrong directory.
566
- if (scope.startsWith("project")) {
567
- // Verify this scope matches the current project
568
- if (scope !== "project" && scope !== currentProjectScope) {
569
- return null; // Wrong project — refuse to write
570
- }
571
- // Claude per-project memories: filePath like "memory/feedback.md"
572
- if (agent === "claude-code" && filePath.startsWith("memory/")) {
573
- const projectDir = findClaudeProjectDir(scope);
574
- if (projectDir)
575
- return join(projectDir, filePath);
576
- // Fallback: use mangled cwd path
577
- const mangledCwd = process.cwd().replace(/\//g, "-");
578
- return join(home, ".claude", "projects", mangledCwd, filePath);
579
- }
580
- // Regular project configs: write relative to cwd
581
- return join(process.cwd(), filePath);
582
- }
583
- return null;
952
+ return resolveAgentConfigWritePath(agent, filePath, scope, {
953
+ cwd: process.cwd(),
954
+ home: homedir(),
955
+ currentProjectScope,
956
+ findClaudeProjectDir,
957
+ });
584
958
  };
585
- /**
586
- * Find the local ~/.claude/projects/<mangled> directory that corresponds
587
- * to a given project scope (e.g., "project:github.com/memaxlabs/memax").
588
- */
589
- function findClaudeProjectDir(scope) {
590
- const home = homedir();
591
- const claudeProjectsDir = join(home, ".claude", "projects");
592
- if (!existsSync(claudeProjectsDir))
593
- return null;
594
- try {
595
- for (const project of readdirSync(claudeProjectsDir)) {
596
- const repoUrl = resolveClaudeProjectFolder(project);
597
- if (repoUrl && scope === `project:${repoUrl}`) {
598
- return join(claudeProjectsDir, project);
599
- }
600
- }
601
- }
602
- catch {
603
- // Permission denied — skip
604
- }
605
- return null;
606
- }
607
959
  // Execute sync plan
608
960
  let pushed = 0;
609
961
  let pulled = 0;
962
+ let deletedLocal = 0;
610
963
  let unchangedCount = 0;
611
964
  let skipped = 0;
612
965
  let errors = 0;
966
+ const ackConfigs = [];
613
967
  // Group actions by agent for display
614
968
  const byAgent = new Map();
615
969
  for (const action of actions) {
@@ -622,7 +976,18 @@ async function syncAgentMemory(options = {}) {
622
976
  for (const action of agentActions) {
623
977
  const key = `${action.agent}|${action.file_path}|${action.scope}`;
624
978
  if (action.action === "unchanged") {
979
+ const local = localByKey.get(key);
625
980
  console.log(chalk.gray(` = ${action.file_path}`), chalk.gray("unchanged"));
981
+ if (local && action.version) {
982
+ ackConfigs.push({
983
+ agent: action.agent,
984
+ file_path: action.file_path,
985
+ scope: action.scope,
986
+ content_hash: local.hash,
987
+ version: action.version,
988
+ local_path: local.loc.path,
989
+ });
990
+ }
626
991
  unchangedCount++;
627
992
  continue;
628
993
  }
@@ -639,6 +1004,8 @@ async function syncAgentMemory(options = {}) {
639
1004
  file_path: action.file_path,
640
1005
  scope: action.scope,
641
1006
  content: local.content,
1007
+ device_id: deviceID,
1008
+ local_path: local.loc.path,
642
1009
  });
643
1010
  console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray(action.reason === "local_only"
644
1011
  ? "pushing (new)"
@@ -682,6 +1049,16 @@ async function syncAgentMemory(options = {}) {
682
1049
  const config = await getClient().configs.get(action.config_id);
683
1050
  mkdirSync(dirname(writePath), { recursive: true });
684
1051
  writeFileSync(writePath, config.content);
1052
+ if (action.version) {
1053
+ ackConfigs.push({
1054
+ agent: action.agent,
1055
+ file_path: action.file_path,
1056
+ scope: action.scope,
1057
+ content_hash: config.content_hash,
1058
+ version: action.version,
1059
+ local_path: writePath,
1060
+ });
1061
+ }
685
1062
  console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray(isNewLocally ? "restored" : "pulling (cloud newer)"));
686
1063
  pulled++;
687
1064
  }
@@ -691,6 +1068,31 @@ async function syncAgentMemory(options = {}) {
691
1068
  }
692
1069
  continue;
693
1070
  }
1071
+ if (action.action === "delete_local") {
1072
+ try {
1073
+ const writePath = resolveWritePath(action.agent, action.file_path, action.scope);
1074
+ if (writePath && existsSync(writePath)) {
1075
+ rmSync(writePath);
1076
+ }
1077
+ if (action.version) {
1078
+ ackConfigs.push({
1079
+ agent: action.agent,
1080
+ file_path: action.file_path,
1081
+ scope: action.scope,
1082
+ version: action.version,
1083
+ local_path: writePath ?? undefined,
1084
+ deleted: true,
1085
+ });
1086
+ }
1087
+ console.log(chalk.yellow(` - ${action.file_path}`), chalk.gray("deleted locally (cloud removed everywhere)"));
1088
+ deletedLocal++;
1089
+ }
1090
+ catch (err) {
1091
+ console.log(chalk.red(` \u2717 ${action.file_path}`), chalk.gray(err.message));
1092
+ errors++;
1093
+ }
1094
+ continue;
1095
+ }
694
1096
  if (action.action === "conflict") {
695
1097
  const resolution = await promptConflict(agent, action.file_path);
696
1098
  if (resolution === "local") {
@@ -702,6 +1104,8 @@ async function syncAgentMemory(options = {}) {
702
1104
  file_path: action.file_path,
703
1105
  scope: action.scope,
704
1106
  content: local.content,
1107
+ device_id: deviceID,
1108
+ local_path: local.loc.path,
705
1109
  });
706
1110
  console.log(chalk.green(` \u2191 ${action.file_path}`), chalk.gray("kept local"));
707
1111
  pushed++;
@@ -719,6 +1123,16 @@ async function syncAgentMemory(options = {}) {
719
1123
  const config = await getClient().configs.get(action.config_id);
720
1124
  mkdirSync(dirname(writePath), { recursive: true });
721
1125
  writeFileSync(writePath, config.content);
1126
+ if (action.version) {
1127
+ ackConfigs.push({
1128
+ agent: action.agent,
1129
+ file_path: action.file_path,
1130
+ scope: action.scope,
1131
+ content_hash: config.content_hash,
1132
+ version: action.version,
1133
+ local_path: writePath,
1134
+ });
1135
+ }
722
1136
  console.log(chalk.cyan(` \u2193 ${action.file_path}`), chalk.gray("used cloud"));
723
1137
  pulled++;
724
1138
  }
@@ -748,6 +1162,8 @@ async function syncAgentMemory(options = {}) {
748
1162
  file_path: action.file_path,
749
1163
  scope: action.scope,
750
1164
  content: merged,
1165
+ device_id: deviceID,
1166
+ local_path: writePath ?? undefined,
751
1167
  });
752
1168
  console.log(chalk.magenta(` \u2194 ${action.file_path}`), chalk.gray("merged (LLM)"));
753
1169
  pushed++;
@@ -769,6 +1185,17 @@ async function syncAgentMemory(options = {}) {
769
1185
  }
770
1186
  }
771
1187
  }
1188
+ if (ackConfigs.length > 0) {
1189
+ try {
1190
+ await getClient().configs.ack({
1191
+ device_id: deviceID,
1192
+ configs: ackConfigs,
1193
+ });
1194
+ }
1195
+ catch (err) {
1196
+ console.log(chalk.yellow("\n Warning: failed to persist sync state"), chalk.gray(err.message));
1197
+ }
1198
+ }
772
1199
  // Summary
773
1200
  if (pushed === 0 &&
774
1201
  pulled === 0 &&
@@ -783,6 +1210,8 @@ async function syncAgentMemory(options = {}) {
783
1210
  parts.push(`${pushed} pushed`);
784
1211
  if (pulled > 0)
785
1212
  parts.push(`${pulled} restored`);
1213
+ if (deletedLocal > 0)
1214
+ parts.push(`${deletedLocal} deleted locally`);
786
1215
  if (unchangedCount > 0)
787
1216
  parts.push(`${unchangedCount} unchanged`);
788
1217
  if (skipped > 0)