@memnexus-ai/cli 1.7.214 → 1.7.216

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.
@@ -36,7 +36,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.BULK_DELETE_LIST_PAGE_SIZE = exports.BULK_DELETE_MAX_RETRIES = exports.BULK_DELETE_PAGE_PAUSE_MS = exports.BULK_DELETE_MAX_IDS = exports.BULK_DELETE_GUARD_WINDOW_MS = exports.BULK_DELETE_GUARD_THRESHOLD = void 0;
40
+ exports.getBulkDeleteGuardPath = getBulkDeleteGuardPath;
41
+ exports.readDeleteGuardTimestamps = readDeleteGuardTimestamps;
42
+ exports.recordDeletion = recordDeletion;
43
+ exports.checkBulkDeleteGuard = checkBulkDeleteGuard;
39
44
  exports.parseCodeContextFlag = parseCodeContextFlag;
45
+ exports.parseBatchIds = parseBatchIds;
46
+ exports.isValidIsoDate = isValidIsoDate;
47
+ exports.chunkIds = chunkIds;
48
+ exports.isRateLimitError = isRateLimitError;
49
+ exports.getRetryAfterMs = getRetryAfterMs;
50
+ exports.sleep = sleep;
51
+ exports.bulkDeleteWithRetry = bulkDeleteWithRetry;
52
+ exports.runBulkDeletePaged = runBulkDeletePaged;
53
+ exports.collectAllMemoryIds = collectAllMemoryIds;
40
54
  exports.registerMemoriesCommands = registerMemoriesCommands;
41
55
  exports.registerMemoryRelationshipCommands = registerMemoryRelationshipCommands;
42
56
  exports.registerMemoryTimelineCommand = registerMemoryTimelineCommand;
@@ -55,6 +69,80 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
55
69
  const spinner_1 = require("../lib/spinner");
56
70
  const date_utils_1 = require("../lib/date-utils");
57
71
  const session_reader_1 = require("../lib/commit-context/session-reader");
72
+ const fs = __importStar(require("fs"));
73
+ const os = __importStar(require("os"));
74
+ const path = __importStar(require("path"));
75
+ // ── Cross-process bulk-delete guard (P0 #4144 fix-forward) ─────────────────
76
+ // When an agent pipes `mx memories search | xargs mx memories delete`, each
77
+ // delete is a separate process — a module-level counter resets every time.
78
+ // This guard uses a temp file to track deletion timestamps across invocations.
79
+ // If >BULK_DELETE_GUARD_THRESHOLD deletions happen within BULK_DELETE_GUARD_WINDOW_MS,
80
+ // the CLI refuses to continue unless --yes is passed.
81
+ //
82
+ // The guard file lives in os.tmpdir() scoped to the current user, so concurrent
83
+ // users on a shared machine don't interfere.
84
+ /** Number of deletions within the window that triggers the guard. */
85
+ exports.BULK_DELETE_GUARD_THRESHOLD = 5;
86
+ /** Rolling window in milliseconds (60 seconds). */
87
+ exports.BULK_DELETE_GUARD_WINDOW_MS = 60_000;
88
+ /** Path to the guard file for the current user. */
89
+ function getBulkDeleteGuardPath() {
90
+ return path.join(os.tmpdir(), `.mx-delete-guard-${process.getuid?.() ?? 'default'}`);
91
+ }
92
+ /**
93
+ * Read recent deletion timestamps from the guard file, pruning entries
94
+ * older than the window. Returns the count of recent deletions.
95
+ *
96
+ * The file format is one timestamp (ms since epoch) per line. Malformed
97
+ * lines are silently dropped — the file is advisory, not critical.
98
+ */
99
+ function readDeleteGuardTimestamps(guardPath, now) {
100
+ try {
101
+ const raw = fs.readFileSync(guardPath, 'utf8');
102
+ const cutoff = now - exports.BULK_DELETE_GUARD_WINDOW_MS;
103
+ return raw
104
+ .split('\n')
105
+ .map((line) => parseInt(line.trim(), 10))
106
+ .filter((ts) => Number.isFinite(ts) && ts > cutoff);
107
+ }
108
+ catch {
109
+ // File doesn't exist or can't be read — no recent deletions.
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * Record a deletion timestamp in the guard file. Prunes old entries
115
+ * on write to keep the file small.
116
+ */
117
+ function recordDeletion(guardPath, now) {
118
+ const recent = readDeleteGuardTimestamps(guardPath, now);
119
+ recent.push(now);
120
+ try {
121
+ fs.writeFileSync(guardPath, recent.join('\n') + '\n', { mode: 0o600 });
122
+ }
123
+ catch {
124
+ // Best-effort — if we can't write the guard file, the deletion still
125
+ // proceeds (the guard is a safety net, not a hard gate).
126
+ }
127
+ }
128
+ /**
129
+ * Check whether the bulk-delete guard should block. Returns true if the
130
+ * caller should proceed, false if it should abort.
131
+ *
132
+ * When the guard fires, it prints a warning to stderr explaining what
133
+ * happened and how to override.
134
+ */
135
+ function checkBulkDeleteGuard(guardPath, now) {
136
+ const recent = readDeleteGuardTimestamps(guardPath, now);
137
+ if (recent.length >= exports.BULK_DELETE_GUARD_THRESHOLD) {
138
+ console.error(chalk_1.default.red(`\n⚠️ Rapid bulk deletion detected: ${recent.length} memories deleted in the last 60s.`));
139
+ console.error(chalk_1.default.yellow('This guard prevents accidental mass-deletion when piping search results into delete.'));
140
+ console.error(chalk_1.default.yellow('If this is intentional, use batch mode: mx memories delete --batch <id1> <id2> ... --yes'));
141
+ console.error(chalk_1.default.yellow('Or pipe via stdin: mx memories search --id-only | mx memories delete --stdin --yes'));
142
+ return false;
143
+ }
144
+ return true;
145
+ }
58
146
  // ── Client-side recall/digest ceiling (KI-77 client half) ────────────────────
59
147
  // Digest is LLM-powered and can take 60-120s on cold calls, so we raise the SDK's
60
148
  // default 30s timeout to a 180s ceiling. The SDK enforces this via an AbortController
@@ -154,7 +242,11 @@ async function readIdsFromStdin() {
154
242
  * Resolve short ID prefixes to full UUIDs
155
243
  * Returns array of resolved IDs (in same order as input)
156
244
  */
157
- async function resolveShortIds(ids) {
245
+ async function resolveShortIds(ids, opts = {}) {
246
+ // Default warner preserves the existing `get --stdin` behavior (warnings on
247
+ // stdout). Batch delete injects a stderr warner so short-id resolution
248
+ // warnings never pollute the pure-JSON stdout stream under --format json.
249
+ const warn = opts.warn ?? ((msg) => console.log(chalk_1.default.yellow(msg)));
158
250
  const MIN_PREFIX_LENGTH = 6;
159
251
  const resolvedIds = [];
160
252
  const resolvedMemories = new Map();
@@ -164,7 +256,7 @@ async function resolveShortIds(ids) {
164
256
  resolvedIds.push(id);
165
257
  }
166
258
  else if (id.length < MIN_PREFIX_LENGTH) {
167
- console.log(chalk_1.default.yellow(`Warning: Skipping ID prefix "${id}" (too short, min ${MIN_PREFIX_LENGTH} chars)`));
259
+ warn(`Warning: Skipping ID prefix "${id}" (too short, min ${MIN_PREFIX_LENGTH} chars)`);
168
260
  }
169
261
  else {
170
262
  // Resolve short ID
@@ -175,10 +267,10 @@ async function resolveShortIds(ids) {
175
267
  });
176
268
  const matches = listResponse.data?.data || [];
177
269
  if (matches.length === 0) {
178
- console.log(chalk_1.default.yellow(`Warning: No memory found with prefix "${id}"`));
270
+ warn(`Warning: No memory found with prefix "${id}"`);
179
271
  }
180
272
  else if (matches.length > 1) {
181
- console.log(chalk_1.default.yellow(`Warning: Ambiguous prefix "${id}" matches ${matches.length} memories, skipping`));
273
+ warn(`Warning: Ambiguous prefix "${id}" matches ${matches.length} memories, skipping`);
182
274
  }
183
275
  else {
184
276
  resolvedIds.push(matches[0].id);
@@ -349,6 +441,344 @@ async function handleBatchMemoryGet(ids, options) {
349
441
  throw error;
350
442
  }
351
443
  }
444
+ // ── Batch delete (PO CLI-latency directive, iteration 1) ─────────────────────
445
+ // Scripted single-deletes hit core-api's deleteRateLimit (20/min for CLI
446
+ // clients): a 53-memory loop issues 53 requests and fails partway. The server's
447
+ // DELETE /api/memories/bulk endpoint counts a whole bulk request as ONE against
448
+ // that limiter (max 100 ids/request, requires confirmDeletion: true), so 500
449
+ // ids = 5 requests — well under 20/min. These helpers implement paging, the
450
+ // 429/Retry-After-honoring retry, and the confirmation gate.
451
+ /** Max ids the server accepts per bulk-delete request (bulk-operations.schema.ts). */
452
+ exports.BULK_DELETE_MAX_IDS = 100;
453
+ /** Pause between sequential 100-id pages, keeps request cadence gentle. */
454
+ exports.BULK_DELETE_PAGE_PAUSE_MS = 250;
455
+ /** Max retries per page on 429 before giving up. */
456
+ exports.BULK_DELETE_MAX_RETRIES = 3;
457
+ /** Page size when listing memories for --all. */
458
+ exports.BULK_DELETE_LIST_PAGE_SIZE = 100;
459
+ /**
460
+ * Parse `--batch <ids...>` values into a flat, de-duplicated id list.
461
+ * Commander collects the variadic option into a string[]; each element may
462
+ * itself be comma- or space-separated, so we split again and trim.
463
+ */
464
+ function parseBatchIds(values) {
465
+ if (!values || values.length === 0)
466
+ return [];
467
+ const out = [];
468
+ for (const value of values) {
469
+ for (const part of value.split(/[,\s]+/)) {
470
+ const trimmed = part.trim();
471
+ if (trimmed.length > 0)
472
+ out.push(trimmed);
473
+ }
474
+ }
475
+ return out;
476
+ }
477
+ /**
478
+ * True when `value` is a parseable ISO 8601 date. Used to validate the
479
+ * `--after`/`--before` filters on `--all` before they count as filters — a
480
+ * malformed date must be rejected, not silently treated as a real filter that
481
+ * bypasses the delete-everything gate. Requires a date-shaped prefix
482
+ * (YYYY-MM-DD…) so lenient locale strings like "notadate" or "July" are refused.
483
+ */
484
+ function isValidIsoDate(value) {
485
+ if (typeof value !== 'string')
486
+ return false;
487
+ if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(value.trim()))
488
+ return false;
489
+ return !Number.isNaN(Date.parse(value));
490
+ }
491
+ /** Split ids into pages of at most `size` (default 100). */
492
+ function chunkIds(ids, size = exports.BULK_DELETE_MAX_IDS) {
493
+ const chunks = [];
494
+ for (let i = 0; i < ids.length; i += size) {
495
+ chunks.push(ids.slice(i, i + size));
496
+ }
497
+ return chunks;
498
+ }
499
+ /** True when an error is an HTTP 429 (rate limited). */
500
+ function isRateLimitError(error) {
501
+ const status = error?.status ?? error?.statusCode ?? error?.response?.status;
502
+ return status === 429;
503
+ }
504
+ /**
505
+ * Extract the server's Retry-After hint in milliseconds, or null if absent.
506
+ * The API surfaces it as a body field (`retryAfter`, seconds) on 429s
507
+ * (see lib/errors.ts) and may also send a Retry-After header; we honor both.
508
+ */
509
+ function getRetryAfterMs(error) {
510
+ const data = error?.data ?? error?.response?.data ?? {};
511
+ const secs = data?.retryAfter ?? data?.retry_after;
512
+ if (typeof secs === 'number' && Number.isFinite(secs) && secs >= 0) {
513
+ return Math.round(secs * 1000);
514
+ }
515
+ const headers = error?.headers;
516
+ let headerVal;
517
+ if (headers) {
518
+ if (typeof headers.get === 'function') {
519
+ headerVal = headers.get('retry-after') ?? headers.get('Retry-After');
520
+ }
521
+ else {
522
+ headerVal = headers['retry-after'] ?? headers['Retry-After'];
523
+ }
524
+ }
525
+ if (headerVal) {
526
+ const n = parseInt(String(headerVal), 10);
527
+ if (Number.isFinite(n) && n >= 0)
528
+ return n * 1000;
529
+ }
530
+ return null;
531
+ }
532
+ /** Promise-based sleep. Extracted so tests can inject a no-op/spy. */
533
+ function sleep(ms) {
534
+ return new Promise((resolve) => setTimeout(resolve, ms));
535
+ }
536
+ /**
537
+ * Delete a single page, retrying on 429 up to `maxRetries` times. Honors the
538
+ * server's Retry-After hint; falls back to a linear backoff otherwise.
539
+ */
540
+ async function bulkDeleteWithRetry(ids, deps) {
541
+ const maxRetries = deps.maxRetries ?? exports.BULK_DELETE_MAX_RETRIES;
542
+ const defaultRetryMs = deps.defaultRetryMs ?? 1000;
543
+ let attempt = 0;
544
+ for (;;) {
545
+ try {
546
+ return await deps.deleteFn(ids);
547
+ }
548
+ catch (error) {
549
+ if (isRateLimitError(error) && attempt < maxRetries) {
550
+ const waitMs = getRetryAfterMs(error) ?? defaultRetryMs * (attempt + 1);
551
+ deps.onRateLimited?.(waitMs);
552
+ await deps.sleep(waitMs);
553
+ attempt++;
554
+ continue;
555
+ }
556
+ throw error;
557
+ }
558
+ }
559
+ }
560
+ /** Best-effort human-readable message for a thrown request error. */
561
+ function errorMessage(error) {
562
+ return (error?.data?.error ??
563
+ error?.data?.message ??
564
+ error?.response?.data?.error ??
565
+ error?.message ??
566
+ String(error));
567
+ }
568
+ /**
569
+ * Delete `ids` in sequential ≤100-id pages with a brief pause between pages and
570
+ * per-page 429 retry. Aggregates per-page results.
571
+ *
572
+ * A page that exhausts its 429 retries (or hits a non-429 error) is CAUGHT here
573
+ * rather than allowed to throw past the aggregation (QA MAJOR #2): every id in
574
+ * the failed page is recorded as a failure, `aborted` is set, remaining pages
575
+ * are counted in `notAttempted`, and paging stops. This guarantees the caller
576
+ * always receives a structured partial result — earlier pages' real deletions
577
+ * are never lost, and json-mode callers still get their JSON document.
578
+ */
579
+ async function runBulkDeletePaged(ids, deps) {
580
+ const chunks = chunkIds(ids);
581
+ const agg = {
582
+ deleted: 0,
583
+ failed: 0,
584
+ errors: [],
585
+ requestCount: 0,
586
+ rateLimitedCount: 0,
587
+ aborted: false,
588
+ notAttempted: 0,
589
+ };
590
+ const pagePause = deps.pagePauseMs ?? exports.BULK_DELETE_PAGE_PAUSE_MS;
591
+ for (let i = 0; i < chunks.length; i++) {
592
+ if (i > 0 && pagePause > 0)
593
+ await deps.sleep(pagePause);
594
+ try {
595
+ const res = await bulkDeleteWithRetry(chunks[i], {
596
+ deleteFn: deps.deleteFn,
597
+ sleep: deps.sleep,
598
+ maxRetries: deps.maxRetries,
599
+ defaultRetryMs: deps.defaultRetryMs,
600
+ onRateLimited: () => {
601
+ agg.rateLimitedCount++;
602
+ },
603
+ });
604
+ agg.requestCount++;
605
+ agg.deleted += res.deleted;
606
+ agg.failed += res.failed;
607
+ agg.errors.push(...res.errors);
608
+ deps.onPage?.(i, chunks.length, res);
609
+ }
610
+ catch (error) {
611
+ // The whole page failed to delete (retries exhausted or non-429). Record
612
+ // every id in it and stop — continuing after a rate-limit exhaustion just
613
+ // piles more requests onto an already-throttled limiter.
614
+ const msg = errorMessage(error);
615
+ for (const id of chunks[i]) {
616
+ agg.errors.push({ id, error: msg });
617
+ }
618
+ agg.failed += chunks[i].length;
619
+ agg.aborted = true;
620
+ for (let j = i + 1; j < chunks.length; j++) {
621
+ agg.notAttempted += chunks[j].length;
622
+ }
623
+ break;
624
+ }
625
+ }
626
+ return agg;
627
+ }
628
+ /**
629
+ * Page through the caller's memories (optionally filtered) and collect every
630
+ * id. Used by `--all`. Stops when a page returns fewer than the page size.
631
+ */
632
+ async function collectAllMemoryIds(listFn, filterParams = {}, pageSize = exports.BULK_DELETE_LIST_PAGE_SIZE) {
633
+ const ids = [];
634
+ let offset = 0;
635
+ for (;;) {
636
+ const response = await listFn({ ...filterParams, limit: pageSize, offset });
637
+ const batch = response?.data?.data ?? [];
638
+ if (batch.length === 0)
639
+ break;
640
+ for (const m of batch) {
641
+ if (m?.id)
642
+ ids.push(m.id);
643
+ }
644
+ if (batch.length < pageSize)
645
+ break;
646
+ offset += pageSize;
647
+ }
648
+ return ids;
649
+ }
650
+ /** Issue one bulk-delete request via the SDK and normalize the response. */
651
+ async function bulkDeleteViaSdk(ids) {
652
+ // SDK exposes bulkDeleteMemories (v1.55.1) — no direct-fetch fallback needed
653
+ // (contrast v1.7.27, which hand-rolled the batch-GET call before the SDK had
654
+ // it). The global fetch wrapper in index.ts still attaches X-MX-Client, so
655
+ // rate-limit attribution stays correct.
656
+ const response = await (0, api_client_1.getSdk)().memories.bulkDeleteMemories({
657
+ ids,
658
+ confirmDeletion: true,
659
+ });
660
+ const data = response?.data ?? response;
661
+ return {
662
+ deleted: data?.deleted ?? 0,
663
+ failed: data?.failed ?? 0,
664
+ errors: Array.isArray(data?.errors) ? data.errors : [],
665
+ };
666
+ }
667
+ /**
668
+ * Shared batch-delete flow for --batch / --stdin / --all. Handles the
669
+ * confirmation gate, short-id resolution, paged deletion, and output.
670
+ */
671
+ async function handleBatchDelete(rawIds, options, mode) {
672
+ const jsonMode = options.format === 'json';
673
+ const isNonInteractive = !process.stdin.isTTY || process.env.MX_PROFILE === 'ai';
674
+ // Status/progress goes to stderr so stdout stays pure under --format json.
675
+ // (There is no session-preamble printer on this command path, so the
676
+ // #4176-class stdout-pollution bug cannot occur here — the only stdout write
677
+ // in json mode is the final JSON object below.)
678
+ const status = (msg) => console.error(msg);
679
+ // De-duplicate while preserving order, drop blanks.
680
+ const uniqueIds = [...new Set(rawIds)].filter((id) => id.trim().length > 0);
681
+ if (uniqueIds.length === 0) {
682
+ console.error(chalk_1.default.red('Error: No memory IDs provided for batch delete'));
683
+ process.exitCode = 1;
684
+ return;
685
+ }
686
+ // Resolve short-id prefixes exactly like single delete. Warnings go to stderr
687
+ // to preserve JSON purity on stdout.
688
+ const { resolvedIds } = await resolveShortIds(uniqueIds, {
689
+ warn: (m) => console.error(chalk_1.default.yellow(m)),
690
+ });
691
+ if (resolvedIds.length === 0) {
692
+ console.error(chalk_1.default.red('Error: No valid memory IDs to delete after resolution'));
693
+ process.exitCode = 1;
694
+ return;
695
+ }
696
+ const count = resolvedIds.length;
697
+ // --all always announces the count it is about to delete.
698
+ if (mode === 'all') {
699
+ status(chalk_1.default.yellow(`About to delete ${count} mem` + (count === 1 ? 'ory' : 'ories') + '.'));
700
+ }
701
+ // Confirmation gate. Batch modes REQUIRE explicit --yes non-interactively;
702
+ // interactively, --yes bypasses the prompt, otherwise we confirm with a count.
703
+ if (isNonInteractive) {
704
+ if (!options.yes) {
705
+ console.error(chalk_1.default.red(`Error: Batch delete of ${count} memories requires explicit --yes in non-interactive mode.`));
706
+ console.error(chalk_1.default.yellow('Re-run with --yes to confirm.'));
707
+ process.exitCode = 1;
708
+ return;
709
+ }
710
+ }
711
+ else if (!options.yes) {
712
+ const answers = await inquirer_1.default.prompt([
713
+ {
714
+ type: 'confirm',
715
+ name: 'confirm',
716
+ message: `Delete ${count} memor${count === 1 ? 'y' : 'ies'}? This cannot be undone.`,
717
+ default: false,
718
+ },
719
+ ]);
720
+ if (!answers.confirm) {
721
+ console.error(chalk_1.default.yellow('Delete cancelled'));
722
+ return;
723
+ }
724
+ }
725
+ // Gate passed — now (and only now) do we issue requests with confirmDeletion.
726
+ const spinner = (0, spinner_1.createSpinner)(`Deleting ${count} memories...`, options.format).start();
727
+ try {
728
+ const result = await runBulkDeletePaged(resolvedIds, {
729
+ deleteFn: bulkDeleteViaSdk,
730
+ sleep,
731
+ onPage: (i, total) => {
732
+ if (total > 1 && !jsonMode) {
733
+ spinner.text = `Deleting page ${i + 1} of ${total}...`;
734
+ }
735
+ },
736
+ });
737
+ // Non-zero exit whenever anything failed OR paging aborted early, so
738
+ // scripts can detect partial failure (QA MAJOR #1). Set before any output
739
+ // branch so both JSON and human paths honor it.
740
+ const hadFailure = result.failed > 0 || result.aborted;
741
+ if (hadFailure) {
742
+ process.exitCode = 1;
743
+ }
744
+ if (result.aborted) {
745
+ spinner.fail(`Deleted ${result.deleted}, ${result.failed} failed, ${result.notAttempted} not attempted (stopped early)`);
746
+ }
747
+ else if (result.failed > 0) {
748
+ spinner.warn(`Deleted ${result.deleted}, ${result.failed} failed`);
749
+ }
750
+ else {
751
+ spinner.succeed(`Deleted ${result.deleted} memor${result.deleted === 1 ? 'y' : 'ies'}`);
752
+ }
753
+ if (jsonMode) {
754
+ // Pure JSON on stdout — the ONLY stdout write in this mode. Emitted even
755
+ // on partial failure / early abort so json-mode scripts always get a
756
+ // structured result (QA MAJOR #2).
757
+ process.stdout.write(JSON.stringify({
758
+ deleted: result.deleted,
759
+ failed: result.failed,
760
+ errors: result.errors,
761
+ requestCount: result.requestCount,
762
+ aborted: result.aborted,
763
+ notAttempted: result.notAttempted,
764
+ }) + '\n');
765
+ return;
766
+ }
767
+ if (result.errors.length > 0) {
768
+ console.log(chalk_1.default.yellow(`\n${result.errors.length} failure(s):`));
769
+ for (const e of result.errors) {
770
+ console.log(` ${e.id}: ${e.error}`);
771
+ }
772
+ }
773
+ if (result.aborted && result.notAttempted > 0) {
774
+ console.log(chalk_1.default.yellow(`\nStopped early — ${result.notAttempted} id(s) were not attempted. Re-run to retry them.`));
775
+ }
776
+ }
777
+ catch (error) {
778
+ spinner.fail('Batch delete failed');
779
+ throw error;
780
+ }
781
+ }
352
782
  /**
353
783
  * Register memory management commands
354
784
  * @param program - Commander program instance
@@ -1083,12 +1513,136 @@ function registerMemoriesCommands(program) {
1083
1513
  * Delete a memory
1084
1514
  */
1085
1515
  memories
1086
- .command('delete <id>')
1087
- .description('Delete a memory')
1088
- .option('--force', 'Skip confirmation prompt')
1516
+ .command('delete [id]')
1517
+ .description('Delete one or more memories (single, or --batch/--stdin/--all)')
1518
+ .option('--force', 'Skip confirmation prompt (single delete only)')
1519
+ .option('--batch <ids...>', 'Batch delete multiple memories by ID (space- or comma-separated)')
1520
+ .option('--stdin', 'Batch delete memory IDs read from stdin (one per line or space-separated)')
1521
+ .option('--all', 'Batch delete ALL of your memories (supports --recent/--topics/--before/--after filters)')
1522
+ .option('--yes', 'Confirm batch deletion (required for --batch/--stdin/--all in non-interactive mode)')
1523
+ .option('--i-know-this-deletes-everything', 'Required (with --yes) for unfiltered --all in non-interactive mode')
1524
+ .option('--recent <duration>', 'Filter for --all: memories from last duration (e.g. 7d, 2w, 1h)')
1525
+ .option('--topics <topics>', 'Filter for --all: comma-separated topics (matches ANY)')
1526
+ .option('--before <date>', 'Filter for --all: created before this date (ISO 8601)')
1527
+ .option('--after <date>', 'Filter for --all: created after this date (ISO 8601)')
1528
+ .option('--format <format>', 'Output format (json for pure-JSON scripting output)')
1089
1529
  .action(async (id, options) => {
1090
1530
  try {
1091
1531
  const isNonInteractive = !process.stdin.isTTY || process.env.MX_PROFILE === 'ai';
1532
+ // ── Batch modes: --batch / --stdin / --all (mutually exclusive) ─────
1533
+ const batchProvided = options.batch !== undefined;
1534
+ const stdinProvided = !!options.stdin;
1535
+ const allProvided = !!options.all;
1536
+ const batchModeCount = [batchProvided, stdinProvided, allProvided].filter(Boolean).length;
1537
+ if (batchModeCount > 1) {
1538
+ console.error(chalk_1.default.red('Error: --batch, --stdin, and --all are mutually exclusive; use only one.'));
1539
+ process.exitCode = 1;
1540
+ return;
1541
+ }
1542
+ if (batchModeCount === 1) {
1543
+ if (id) {
1544
+ console.error(chalk_1.default.red('Error: Do not pass a positional <id> together with --batch/--stdin/--all.'));
1545
+ process.exitCode = 1;
1546
+ return;
1547
+ }
1548
+ if (allProvided) {
1549
+ // List-then-delete. Compose the existing list filters when supplied;
1550
+ // otherwise --all deletes everything.
1551
+ const listParams = {};
1552
+ if (options.recent) {
1553
+ const recentTs = (0, date_utils_1.getRecentTimestamp)(options.recent);
1554
+ if (!recentTs) {
1555
+ console.error(chalk_1.default.red(`Error: Invalid duration format: ${options.recent}`));
1556
+ console.error(chalk_1.default.yellow('Supported formats: 30m, 1h, 7d, 2w'));
1557
+ process.exitCode = 1;
1558
+ return;
1559
+ }
1560
+ listParams.from = recentTs;
1561
+ }
1562
+ else if (options.after) {
1563
+ // Validate BEFORE treating as a filter (SEC delta): a typo'd date
1564
+ // (e.g. --after notadate) must NOT silently count toward
1565
+ // hasFilters and skip the --i-know-this-deletes-everything gate —
1566
+ // if the server then ignored the unparseable bound, --all would
1567
+ // wipe everything past the extra-token guard on an operator typo.
1568
+ // Mirror --recent: error and exit on parse failure.
1569
+ if (!isValidIsoDate(options.after)) {
1570
+ console.error(chalk_1.default.red(`Error: --after is not a valid date: ${options.after}`));
1571
+ console.error(chalk_1.default.yellow('Provide an ISO 8601 date (e.g. 2026-07-01 or 2026-07-01T00:00:00Z).'));
1572
+ process.exitCode = 1;
1573
+ return;
1574
+ }
1575
+ listParams.from = options.after;
1576
+ }
1577
+ if (options.before) {
1578
+ if (!isValidIsoDate(options.before)) {
1579
+ console.error(chalk_1.default.red(`Error: --before is not a valid date: ${options.before}`));
1580
+ console.error(chalk_1.default.yellow('Provide an ISO 8601 date (e.g. 2026-07-01 or 2026-07-01T00:00:00Z).'));
1581
+ process.exitCode = 1;
1582
+ return;
1583
+ }
1584
+ listParams.to = options.before;
1585
+ }
1586
+ if (options.topics) {
1587
+ listParams.topics = options.topics
1588
+ .split(',')
1589
+ .map((t) => t.trim())
1590
+ .filter((t) => t.length > 0)
1591
+ .join(',');
1592
+ }
1593
+ const hasFilters = Object.keys(listParams).length > 0;
1594
+ if (!hasFilters) {
1595
+ // Emit the "delete EVERYTHING" warning to stderr REGARDLESS of
1596
+ // format — the automation (--format json) path is exactly where it
1597
+ // matters most, and stderr keeps stdout pure (SEC SHOULD-FIX).
1598
+ console.error(chalk_1.default.yellow('--all: no filters supplied — this will delete EVERY memory you own.'));
1599
+ // Unfiltered --all in non-interactive mode requires a DISTINCT
1600
+ // explicit token in addition to --yes (mirrors the --force
1601
+ // non-interactive block). No bypass.
1602
+ const nonInteractive = !process.stdin.isTTY || process.env.MX_PROFILE === 'ai';
1603
+ if (nonInteractive && !options.iKnowThisDeletesEverything) {
1604
+ console.error(chalk_1.default.red('Error: Unfiltered --all in non-interactive mode requires --i-know-this-deletes-everything (in addition to --yes).'));
1605
+ console.error(chalk_1.default.yellow('Add a filter (--recent/--topics/--before/--after) or pass --i-know-this-deletes-everything to proceed.'));
1606
+ process.exitCode = 1;
1607
+ return;
1608
+ }
1609
+ }
1610
+ const listSpinner = (0, spinner_1.createSpinner)('Collecting memories to delete...', options.format).start();
1611
+ const allIds = await collectAllMemoryIds((params) => (0, api_client_1.getSdk)().memories.listMemories(params), listParams);
1612
+ listSpinner.stop();
1613
+ if (allIds.length === 0) {
1614
+ console.error(chalk_1.default.yellow('No memories matched — nothing to delete.'));
1615
+ if (options.format === 'json') {
1616
+ process.stdout.write(JSON.stringify({
1617
+ deleted: 0,
1618
+ failed: 0,
1619
+ errors: [],
1620
+ requestCount: 0,
1621
+ aborted: false,
1622
+ notAttempted: 0,
1623
+ }) + '\n');
1624
+ }
1625
+ return;
1626
+ }
1627
+ await handleBatchDelete(allIds, options, 'all');
1628
+ return;
1629
+ }
1630
+ let batchIds = [];
1631
+ if (batchProvided) {
1632
+ batchIds = parseBatchIds(options.batch);
1633
+ }
1634
+ else if (stdinProvided) {
1635
+ batchIds = await readIdsFromStdin();
1636
+ }
1637
+ await handleBatchDelete(batchIds, options, batchProvided ? 'batch' : 'stdin');
1638
+ return;
1639
+ }
1640
+ // ── Single delete (unchanged behavior) ──────────────────────────────
1641
+ if (!id) {
1642
+ console.error(chalk_1.default.red('Error: Provide a memory ID, or use --batch/--stdin/--all.'));
1643
+ process.exitCode = 1;
1644
+ return;
1645
+ }
1092
1646
  // Block --force in non-interactive/agent mode to prevent automated bulk deletion
1093
1647
  if (options.force && isNonInteractive) {
1094
1648
  console.error(chalk_1.default.red('Error: The --force flag is blocked in non-interactive/agent mode for safety.'));
@@ -1110,9 +1664,25 @@ function registerMemoriesCommands(program) {
1110
1664
  return;
1111
1665
  }
1112
1666
  }
1113
- // Non-interactive without --force: auto-confirm single delete
1667
+ // Non-interactive without --force: auto-confirm single delete — but
1668
+ // check the cross-process bulk-delete guard first (P0 #4144 fix-forward).
1669
+ // An agent looping `mx memories delete <id>` in separate processes would
1670
+ // bypass any in-process counter; this file-based guard catches it.
1671
+ if (isNonInteractive) {
1672
+ const guardPath = getBulkDeleteGuardPath();
1673
+ const now = Date.now();
1674
+ if (!checkBulkDeleteGuard(guardPath, now)) {
1675
+ process.exitCode = 1;
1676
+ return;
1677
+ }
1678
+ }
1114
1679
  const spinner = (0, spinner_1.createSpinner)('Deleting memory...', 'table').start();
1115
1680
  await (0, api_client_1.getSdk)().memories.deleteMemory(id);
1681
+ // Record this deletion in the cross-process guard file so the next
1682
+ // invocation's threshold check reflects it.
1683
+ if (isNonInteractive) {
1684
+ recordDeletion(getBulkDeleteGuardPath(), Date.now());
1685
+ }
1116
1686
  spinner.succeed(`Memory ${id} deleted successfully`);
1117
1687
  }
1118
1688
  catch (error) {