@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.
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +43 -0
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/memories.d.ts +115 -0
- package/dist/commands/memories.d.ts.map +1 -1
- package/dist/commands/memories.js +578 -8
- package/dist/commands/memories.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
270
|
+
warn(`Warning: No memory found with prefix "${id}"`);
|
|
179
271
|
}
|
|
180
272
|
else if (matches.length > 1) {
|
|
181
|
-
|
|
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
|
|
1087
|
-
.description('Delete
|
|
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) {
|