@memnexus-ai/cli 1.7.217 → 1.7.219

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,7 @@ 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;
39
+ exports.StdinTooLargeError = exports.BATCH_CREATE_ALLOWED_KEYS = exports.BATCH_CREATE_MAX_LINE_BYTES = exports.BATCH_CREATE_MAX_STDIN_BYTES = exports.BATCH_CREATE_MAX_RETRIES = exports.BATCH_CREATE_CONCURRENCY_MAX = exports.BATCH_CREATE_CONCURRENCY_MIN = exports.BATCH_CREATE_DEFAULT_CONCURRENCY = exports.sleep = exports.getRetryAfterMs = exports.isQuotaError = exports.isServiceUnavailableError = exports.isRateLimitError = 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
40
  exports.getBulkDeleteGuardPath = getBulkDeleteGuardPath;
41
41
  exports.readDeleteGuardTimestamps = readDeleteGuardTimestamps;
42
42
  exports.recordDeletion = recordDeletion;
@@ -45,12 +45,17 @@ exports.parseCodeContextFlag = parseCodeContextFlag;
45
45
  exports.parseBatchIds = parseBatchIds;
46
46
  exports.isValidIsoDate = isValidIsoDate;
47
47
  exports.chunkIds = chunkIds;
48
- exports.isRateLimitError = isRateLimitError;
49
- exports.getRetryAfterMs = getRetryAfterMs;
50
- exports.sleep = sleep;
51
48
  exports.bulkDeleteWithRetry = bulkDeleteWithRetry;
52
49
  exports.runBulkDeletePaged = runBulkDeletePaged;
53
50
  exports.collectAllMemoryIds = collectAllMemoryIds;
51
+ exports.isRetryableCreateError = isRetryableCreateError;
52
+ exports.parseJsonlCreateLine = parseJsonlCreateLine;
53
+ exports.resolveCreateConcurrency = resolveCreateConcurrency;
54
+ exports.batchCreateWithRetry = batchCreateWithRetry;
55
+ exports.runBatchCreate = runBatchCreate;
56
+ exports.buildBatchCreateItems = buildBatchCreateItems;
57
+ exports.readStdinRaw = readStdinRaw;
58
+ exports.handleStreamingCreate = handleStreamingCreate;
54
59
  exports.registerMemoriesCommands = registerMemoriesCommands;
55
60
  exports.registerMemoryRelationshipCommands = registerMemoryRelationshipCommands;
56
61
  exports.registerMemoryTimelineCommand = registerMemoryTimelineCommand;
@@ -64,6 +69,12 @@ const chalk_1 = __importDefault(require("chalk"));
64
69
  const api_client_1 = require("../lib/api-client");
65
70
  const config_1 = require("../lib/config");
66
71
  const errors_1 = require("../lib/errors");
72
+ const retry_1 = require("../lib/retry");
73
+ Object.defineProperty(exports, "isRateLimitError", { enumerable: true, get: function () { return retry_1.isRateLimitError; } });
74
+ Object.defineProperty(exports, "isServiceUnavailableError", { enumerable: true, get: function () { return retry_1.isServiceUnavailableError; } });
75
+ Object.defineProperty(exports, "isQuotaError", { enumerable: true, get: function () { return retry_1.isQuotaError; } });
76
+ Object.defineProperty(exports, "getRetryAfterMs", { enumerable: true, get: function () { return retry_1.getRetryAfterMs; } });
77
+ Object.defineProperty(exports, "sleep", { enumerable: true, get: function () { return retry_1.sleep; } });
67
78
  const formatters_1 = require("../lib/formatters");
68
79
  const js_yaml_1 = __importDefault(require("js-yaml"));
69
80
  const spinner_1 = require("../lib/spinner");
@@ -260,11 +271,11 @@ async function resolveShortIds(ids, opts = {}) {
260
271
  }
261
272
  else {
262
273
  // Resolve short ID
263
- const listResponse = await (0, api_client_1.getSdk)().memories.listMemories({
274
+ const listResponse = await retryRead(() => (0, api_client_1.getSdk)().memories.listMemories({
264
275
  limit: 10,
265
276
  offset: 0,
266
277
  idPrefix: id,
267
- });
278
+ }));
268
279
  const matches = listResponse.data?.data || [];
269
280
  if (matches.length === 0) {
270
281
  warn(`Warning: No memory found with prefix "${id}"`);
@@ -382,10 +393,10 @@ async function handleBatchMemoryGet(ids, options) {
382
393
  }
383
394
  spinner.text = `Fetching ${resolvedIds.length} memories...`;
384
395
  const batchDetail = options.detail || 'minimal';
385
- const response = await (0, api_client_1.getSdk)().memories.getMemoriesBatch({
396
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoriesBatch({
386
397
  ids: resolvedIds,
387
398
  detail: batchDetail,
388
- });
399
+ }));
389
400
  const result = response.data;
390
401
  if (result && result.meta && result.meta.missing && result.meta.missing.length > 0) {
391
402
  spinner.warn(`Found ${result.meta.found} of ${result.meta.requested} memories`);
@@ -496,42 +507,46 @@ function chunkIds(ids, size = exports.BULK_DELETE_MAX_IDS) {
496
507
  }
497
508
  return chunks;
498
509
  }
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;
510
+ // ── Single-shot retry wiring (PO CLI-latency directive, item 3) ──────────────
511
+ // These thin wrappers apply the shared withSingleShotRetry helper at the plain
512
+ // single-request SDK call sites. The idempotency flag is passed PER CALL SITE —
513
+ // that is the crux of the retry matrix, and it cannot be inferred centrally
514
+ // because the same SDK method (createMemory) serves BOTH single create (retry)
515
+ // and the batch --stdin-jsonl path (already retries via batchCreateWithRetry).
516
+ // Wrapping per single-shot site is therefore the correct, non-double-wrapping
517
+ // chokepoint; the batch helpers are left untouched.
518
+ /** STDERR-only retry notice. Never touches stdout, so `--format json` stays pure. */
519
+ function emitRetryNotice(info) {
520
+ const reason = info.status === 429 ? 'rate limited' : `transient error ${info.status ?? ''}`.trim();
521
+ console.error(chalk_1.default.dim(`⏳ ${reason}; retry ${info.attempt}/${info.maxRetries} in ${(info.waitMs / 1000).toFixed(1)}s`));
503
522
  }
504
523
  /**
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.
524
+ * Wrap an idempotent single-shot read (GET search/get/list/recall) or a
525
+ * naturally idempotent write (DELETE — deleting an already-deleted id is a
526
+ * no-op). Retries 429 and transient 5xx (502/503/504/408). Do NOT use this for
527
+ * named-memory PUT: it is a version bump that is only idempotent when
528
+ * expectedHeadId is supplied, and the CLI/SDK never supply it — use retryWrite.
508
529
  */
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;
530
+ function retryRead(fn) {
531
+ return (0, retry_1.withSingleShotRetry)(fn, {
532
+ idempotent: true,
533
+ sleep: retry_1.sleep,
534
+ maxRetries: retry_1.SINGLE_SHOT_MAX_RETRIES,
535
+ onRetry: emitRetryNotice,
536
+ });
531
537
  }
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));
538
+ /**
539
+ * Wrap a non-idempotent single-shot write (plain POST create, non-CAS update).
540
+ * Retries ONLY 429 (server rejected before processing — no duplicate risk);
541
+ * a transient 5xx is NOT auto-retried because the write may already have landed.
542
+ */
543
+ function retryWrite(fn) {
544
+ return (0, retry_1.withSingleShotRetry)(fn, {
545
+ idempotent: false,
546
+ sleep: retry_1.sleep,
547
+ maxRetries: retry_1.SINGLE_SHOT_MAX_RETRIES,
548
+ onRetry: emitRetryNotice,
549
+ });
535
550
  }
536
551
  /**
537
552
  * Delete a single page, retrying on 429 up to `maxRetries` times. Honors the
@@ -546,8 +561,8 @@ async function bulkDeleteWithRetry(ids, deps) {
546
561
  return await deps.deleteFn(ids);
547
562
  }
548
563
  catch (error) {
549
- if (isRateLimitError(error) && attempt < maxRetries) {
550
- const waitMs = getRetryAfterMs(error) ?? defaultRetryMs * (attempt + 1);
564
+ if ((0, retry_1.isRateLimitError)(error) && attempt < maxRetries) {
565
+ const waitMs = (0, retry_1.getRetryAfterMs)(error) ?? defaultRetryMs * (attempt + 1);
551
566
  deps.onRateLimited?.(waitMs);
552
567
  await deps.sleep(waitMs);
553
568
  attempt++;
@@ -727,7 +742,7 @@ async function handleBatchDelete(rawIds, options, mode) {
727
742
  try {
728
743
  const result = await runBulkDeletePaged(resolvedIds, {
729
744
  deleteFn: bulkDeleteViaSdk,
730
- sleep,
745
+ sleep: retry_1.sleep,
731
746
  onPage: (i, total) => {
732
747
  if (total > 1 && !jsonMode) {
733
748
  spinner.text = `Deleting page ${i + 1} of ${total}...`;
@@ -779,6 +794,496 @@ async function handleBatchDelete(rawIds, options, mode) {
779
794
  throw error;
780
795
  }
781
796
  }
797
+ // ── Streaming batch create (PO CLI-latency directive, iteration 2) ────────────
798
+ // Scripted single-creates pay node's cold-start (0.3-0.5s) PLUS one HTTP round
799
+ // trip PER memory: 53 memories ≈ 1-2 min end-to-end. There is deliberately NO
800
+ // batch-create server endpoint yet (deferred — an L-effort item on the triage
801
+ // plan). This is a CLIENT-SIDE fix only: ONE process reads JSONL from stdin and
802
+ // fires N createMemory calls with connection reuse and bounded concurrency,
803
+ // targeting ~20-40s for the same 53 memories. Each item is still one HTTP
804
+ // request against the server; we cut the cold-start tax and serialize latency,
805
+ // not the request count.
806
+ /** Default parallel creates when MX_CREATE_CONCURRENCY is unset. */
807
+ exports.BATCH_CREATE_DEFAULT_CONCURRENCY = 4;
808
+ /** Clamp band for MX_CREATE_CONCURRENCY (mirrors the RECALL_MAX_MS clamp convention). */
809
+ exports.BATCH_CREATE_CONCURRENCY_MIN = 1;
810
+ exports.BATCH_CREATE_CONCURRENCY_MAX = 8;
811
+ /** Max retries per item on 429/503 before giving up (mirrors batch delete). */
812
+ exports.BATCH_CREATE_MAX_RETRIES = 3;
813
+ /**
814
+ * Hard cap on total stdin bytes read (128 MB) before we abort. A mistaken
815
+ * multi-GB pipe would otherwise accumulate unbounded and OOM the CLI
816
+ * (CWE-400, client-side); we fail closed with a clear error instead. Matches
817
+ * the 64KB-class file-bound precedent from v3.89a — generous enough that any
818
+ * legitimate JSONL batch fits, small enough that a runaway pipe stops early.
819
+ */
820
+ exports.BATCH_CREATE_MAX_STDIN_BYTES = 128 * 1024 * 1024;
821
+ /**
822
+ * Hard cap on a single JSONL line length (1 MB). A line longer than this is
823
+ * rejected as a per-line parse error BEFORE JSON.parse (bounding parser memory),
824
+ * preserving the one-result-line-per-input-line contract instead of aborting
825
+ * the whole stream. A single memory body never legitimately approaches 1 MB.
826
+ */
827
+ exports.BATCH_CREATE_MAX_LINE_BYTES = 1024 * 1024;
828
+ /**
829
+ * Fields a JSONL create line may carry. Mirrors the single-create option map
830
+ * (createMemory body). Any other key is rejected per-line — a typo like
831
+ * "contnet" fails that line loudly instead of silently dropping the content.
832
+ * `supersedes` is intentionally excluded: it is a separate relationship call in
833
+ * single-create, out of scope for the streaming path (noted in the PR).
834
+ */
835
+ exports.BATCH_CREATE_ALLOWED_KEYS = new Set([
836
+ 'content',
837
+ 'name',
838
+ 'topics',
839
+ 'conversationId',
840
+ 'memoryType',
841
+ 'context',
842
+ 'role',
843
+ 'importance',
844
+ 'codeContext',
845
+ 'taskId',
846
+ 'outcome',
847
+ 'distill',
848
+ ]);
849
+ /** True when a create should be retried: 429 (rate limited) or 503 (transient). */
850
+ function isRetryableCreateError(error) {
851
+ return (0, retry_1.isRateLimitError)(error) || (0, retry_1.isServiceUnavailableError)(error);
852
+ }
853
+ /**
854
+ * Parse and validate one JSONL line into a createMemory body.
855
+ *
856
+ * Returns `{ data }` on success or `{ error }` on failure. Rejects: non-JSON,
857
+ * non-object lines, unknown keys, and a missing/blank `content`. `conversationId`
858
+ * defaults to "NEW" (mirrors single-create). `topics` accepts a JSON array or a
859
+ * comma-separated string; `importance` accepts a number or numeric string.
860
+ */
861
+ function parseJsonlCreateLine(raw) {
862
+ // Bound per-line memory BEFORE JSON.parse: reject an oversized line as a
863
+ // per-line error so the stream keeps going (one result line per input line).
864
+ const lineBytes = Buffer.byteLength(raw, 'utf8');
865
+ if (lineBytes > exports.BATCH_CREATE_MAX_LINE_BYTES) {
866
+ return {
867
+ error: `line too large (${lineBytes} bytes > ${exports.BATCH_CREATE_MAX_LINE_BYTES} byte limit)`,
868
+ };
869
+ }
870
+ let parsed;
871
+ try {
872
+ parsed = JSON.parse(raw);
873
+ }
874
+ catch {
875
+ return { error: 'invalid JSON' };
876
+ }
877
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
878
+ return { error: 'line must be a JSON object' };
879
+ }
880
+ const obj = parsed;
881
+ const unknown = Object.keys(obj).filter((k) => !exports.BATCH_CREATE_ALLOWED_KEYS.has(k));
882
+ if (unknown.length > 0) {
883
+ return { error: `unknown field(s): ${unknown.join(', ')}` };
884
+ }
885
+ if (typeof obj.content !== 'string' || obj.content.trim().length === 0) {
886
+ return { error: 'content is required and must be a non-empty string' };
887
+ }
888
+ if ('codeContext' in obj && obj.codeContext !== undefined) {
889
+ const cc = obj.codeContext;
890
+ if (cc === null || typeof cc !== 'object' || Array.isArray(cc)) {
891
+ return { error: 'codeContext must be a JSON object' };
892
+ }
893
+ }
894
+ let topics;
895
+ if ('topics' in obj && obj.topics !== undefined) {
896
+ if (Array.isArray(obj.topics)) {
897
+ topics = obj.topics.map((t) => String(t));
898
+ }
899
+ else if (typeof obj.topics === 'string') {
900
+ topics = obj.topics
901
+ .split(',')
902
+ .map((t) => t.trim())
903
+ .filter((t) => t.length > 0);
904
+ }
905
+ else {
906
+ return { error: 'topics must be an array or comma-separated string' };
907
+ }
908
+ }
909
+ let importance;
910
+ if ('importance' in obj && obj.importance !== undefined) {
911
+ const n = typeof obj.importance === 'string' ? parseFloat(obj.importance) : obj.importance;
912
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
913
+ return { error: 'importance must be a number between 0 and 1' };
914
+ }
915
+ importance = n;
916
+ }
917
+ const conversationId = typeof obj.conversationId === 'string' && obj.conversationId.trim().length > 0
918
+ ? obj.conversationId
919
+ : 'NEW';
920
+ const data = {
921
+ conversationId,
922
+ content: obj.content,
923
+ };
924
+ if (obj.name !== undefined)
925
+ data.name = obj.name;
926
+ if (topics !== undefined)
927
+ data.topics = topics;
928
+ // Defaults parity with single-create (memories.ts create action): role
929
+ // defaults to 'user'; memoryType defaults by name-presence (named memories are
930
+ // 'procedural', otherwise 'episodic'). Applying them here means batch and
931
+ // single create produce identical memories for identical input fields.
932
+ data.role = obj.role !== undefined ? obj.role : 'user';
933
+ data.memoryType =
934
+ obj.memoryType !== undefined
935
+ ? obj.memoryType
936
+ : obj.name !== undefined
937
+ ? 'procedural'
938
+ : 'episodic';
939
+ if (obj.context !== undefined)
940
+ data.context = obj.context;
941
+ if (importance !== undefined)
942
+ data.importance = importance;
943
+ if (obj.codeContext !== undefined)
944
+ data.codeContext = obj.codeContext;
945
+ if (obj.taskId !== undefined)
946
+ data.taskId = obj.taskId;
947
+ if (obj.outcome !== undefined)
948
+ data.outcome = obj.outcome;
949
+ if (obj.distill !== undefined)
950
+ data.distill = obj.distill;
951
+ return { data };
952
+ }
953
+ /**
954
+ * Resolve the create concurrency from MX_CREATE_CONCURRENCY, clamped to
955
+ * [MIN, MAX]. Out-of-band values are clamped to the nearest bound (non-numeric →
956
+ * default) and warn once on stderr — mirrors the RECALL_MAX_MS clamp convention
957
+ * (import-time band + single WARN).
958
+ */
959
+ function resolveCreateConcurrency(raw, warn = (m) => console.warn(chalk_1.default.yellow(m))) {
960
+ if (raw === undefined || raw === '')
961
+ return exports.BATCH_CREATE_DEFAULT_CONCURRENCY;
962
+ const n = parseInt(raw, 10);
963
+ if (!Number.isFinite(n)) {
964
+ warn(`MX_CREATE_CONCURRENCY=${raw} is not a number, using default ${exports.BATCH_CREATE_DEFAULT_CONCURRENCY}`);
965
+ return exports.BATCH_CREATE_DEFAULT_CONCURRENCY;
966
+ }
967
+ if (n < exports.BATCH_CREATE_CONCURRENCY_MIN || n > exports.BATCH_CREATE_CONCURRENCY_MAX) {
968
+ const clamped = Math.max(exports.BATCH_CREATE_CONCURRENCY_MIN, Math.min(exports.BATCH_CREATE_CONCURRENCY_MAX, n));
969
+ warn(`MX_CREATE_CONCURRENCY=${raw} out of range [${exports.BATCH_CREATE_CONCURRENCY_MIN},${exports.BATCH_CREATE_CONCURRENCY_MAX}], clamped to ${clamped}`);
970
+ return clamped;
971
+ }
972
+ return n;
973
+ }
974
+ /**
975
+ * Create one memory, retrying on 429/503 up to `maxRetries` times, honoring the
976
+ * server's Retry-After hint (falls back to linear backoff). A 402 (quota) is
977
+ * NOT retried — it rethrows immediately so the pool can stop the stream early.
978
+ */
979
+ async function batchCreateWithRetry(data, deps) {
980
+ const maxRetries = deps.maxRetries ?? exports.BATCH_CREATE_MAX_RETRIES;
981
+ const defaultRetryMs = deps.defaultRetryMs ?? 1000;
982
+ let attempt = 0;
983
+ for (;;) {
984
+ try {
985
+ return await deps.createFn(data);
986
+ }
987
+ catch (error) {
988
+ if ((0, retry_1.isQuotaError)(error))
989
+ throw error;
990
+ if (isRetryableCreateError(error) && attempt < maxRetries) {
991
+ const waitMs = (0, retry_1.getRetryAfterMs)(error) ?? defaultRetryMs * (attempt + 1);
992
+ deps.onRateLimited?.(waitMs);
993
+ await deps.sleep(waitMs);
994
+ attempt++;
995
+ continue;
996
+ }
997
+ throw error;
998
+ }
999
+ }
1000
+ }
1001
+ /**
1002
+ * Run `items` through a bounded-concurrency worker pool. Each valid line becomes
1003
+ * one createMemory call with per-item 429/503 retry; each parse-error line
1004
+ * resolves immediately as a failure. Results are emitted via `onResult` in
1005
+ * COMPLETION order, with the input line number attached.
1006
+ *
1007
+ * A 402 (quota exhausted) STOPS the stream early: the offending line is reported
1008
+ * as failed, no new lines are pulled, in-flight creates drain, and every
1009
+ * not-yet-pulled line is emitted as a not-attempted failure in line order. This
1010
+ * mirrors the batch-delete early-abort contract — the caller always gets one
1011
+ * result line per input line, never a dropped report (the v3.90 lesson).
1012
+ *
1013
+ * If the sink (`onResult`, i.e. stdout) THROWS — the classic `... | head`
1014
+ * EPIPE — that is a distinct terminal condition, NOT a create failure. The
1015
+ * create for that line already succeeded and is counted; we stop pulling new
1016
+ * work, emit nothing further to the dead sink, and set `outputClosed` so the
1017
+ * caller can print an accounting to stderr (which is still open). `emit` is
1018
+ * guarded so a sink throw can never be misread as a create failure (which would
1019
+ * duplicate the line) nor propagate uncaught.
1020
+ */
1021
+ async function runBatchCreate(items, deps) {
1022
+ const concurrency = Math.max(1, deps.concurrency);
1023
+ const summary = {
1024
+ total: items.length,
1025
+ ok: 0,
1026
+ failed: 0,
1027
+ notAttempted: 0,
1028
+ quotaStopped: false,
1029
+ rateLimitedCount: 0,
1030
+ delivered: 0,
1031
+ outputClosed: false,
1032
+ };
1033
+ const attempted = new Array(items.length).fill(false);
1034
+ let next = 0;
1035
+ let stopped = false;
1036
+ let sinkClosed = false;
1037
+ // Guarded sink write: never throws. A sink failure (EPIPE) becomes a terminal
1038
+ // stop condition instead of an exception that the create catch would misread.
1039
+ const emit = (r) => {
1040
+ if (sinkClosed)
1041
+ return;
1042
+ try {
1043
+ deps.onResult?.(r);
1044
+ summary.delivered++;
1045
+ }
1046
+ catch {
1047
+ sinkClosed = true;
1048
+ stopped = true;
1049
+ summary.outputClosed = true;
1050
+ }
1051
+ };
1052
+ async function worker() {
1053
+ for (;;) {
1054
+ if (stopped)
1055
+ return;
1056
+ const i = next++;
1057
+ if (i >= items.length)
1058
+ return;
1059
+ attempted[i] = true;
1060
+ const item = items[i];
1061
+ // Compute the result BEFORE emitting. The success emit lives OUTSIDE the
1062
+ // create try/catch so a sink throw on a successful line is handled by the
1063
+ // guarded emit — never caught here as a bogus create failure.
1064
+ let result;
1065
+ if (item.parseError) {
1066
+ summary.failed++;
1067
+ result = { line: item.line, ok: false, error: item.parseError, code: 'PARSE_ERROR' };
1068
+ }
1069
+ else {
1070
+ try {
1071
+ const res = await batchCreateWithRetry(item.data, {
1072
+ createFn: deps.createFn,
1073
+ sleep: deps.sleep,
1074
+ maxRetries: deps.maxRetries,
1075
+ defaultRetryMs: deps.defaultRetryMs,
1076
+ onRateLimited: () => {
1077
+ summary.rateLimitedCount++;
1078
+ },
1079
+ });
1080
+ summary.ok++;
1081
+ result = { line: item.line, ok: true, id: res?.id };
1082
+ }
1083
+ catch (error) {
1084
+ summary.failed++;
1085
+ if ((0, retry_1.isQuotaError)(error)) {
1086
+ // Quota exhausted — pointless to keep hammering. Stop pulling new
1087
+ // lines; in-flight workers finish, remaining lines are not-attempted.
1088
+ stopped = true;
1089
+ summary.quotaStopped = true;
1090
+ emit({ line: item.line, ok: false, error: errorMessage(error), code: 'QUOTA_STOPPED' });
1091
+ return;
1092
+ }
1093
+ result = {
1094
+ line: item.line,
1095
+ ok: false,
1096
+ error: errorMessage(error),
1097
+ code: 'CREATE_FAILED',
1098
+ };
1099
+ }
1100
+ }
1101
+ emit(result);
1102
+ // If the sink died on that write, stop cleanly (no further stdout writes).
1103
+ if (stopped)
1104
+ return;
1105
+ }
1106
+ }
1107
+ const workerCount = Math.min(concurrency, items.length);
1108
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1109
+ // Lines the pool never pulled (only possible after an early stop — quota or
1110
+ // sink close) are reported as not-attempted in line order — one result line
1111
+ // per input line. Under a sink close these emits are no-ops (sink is dead) but
1112
+ // are still COUNTED so the stderr accounting is complete.
1113
+ if (stopped) {
1114
+ const closed = summary.outputClosed;
1115
+ const code = closed ? 'OUTPUT_CLOSED' : 'NOT_ATTEMPTED';
1116
+ const reason = closed
1117
+ ? 'not attempted (output pipe closed early)'
1118
+ : 'not attempted (stream stopped after quota exhaustion)';
1119
+ for (let i = 0; i < items.length; i++) {
1120
+ if (!attempted[i]) {
1121
+ summary.notAttempted++;
1122
+ emit({ line: items[i].line, ok: false, error: reason, code });
1123
+ }
1124
+ }
1125
+ }
1126
+ return summary;
1127
+ }
1128
+ /**
1129
+ * Split raw stdin into non-blank lines, each paired with its 1-based input line
1130
+ * number and parse result. Blank/whitespace-only lines are skipped silently (a
1131
+ * trailing newline is normal in JSONL and is not an error).
1132
+ */
1133
+ function buildBatchCreateItems(raw) {
1134
+ const items = [];
1135
+ const lines = raw.split(/\r?\n/);
1136
+ for (let i = 0; i < lines.length; i++) {
1137
+ const line = lines[i];
1138
+ if (line.trim().length === 0)
1139
+ continue;
1140
+ const parsed = parseJsonlCreateLine(line);
1141
+ items.push({ line: i + 1, data: parsed.data, parseError: parsed.error });
1142
+ }
1143
+ return items;
1144
+ }
1145
+ /** Thrown by readStdinRaw when accumulated stdin exceeds the byte cap. */
1146
+ class StdinTooLargeError extends Error {
1147
+ bytes;
1148
+ limit;
1149
+ code = 'STDIN_TOO_LARGE';
1150
+ constructor(bytes, limit) {
1151
+ super(`stdin exceeded the ${limit}-byte limit (read > ${bytes} bytes)`);
1152
+ this.bytes = bytes;
1153
+ this.limit = limit;
1154
+ this.name = 'StdinTooLargeError';
1155
+ }
1156
+ }
1157
+ exports.StdinTooLargeError = StdinTooLargeError;
1158
+ /**
1159
+ * Read all of stdin as a single string (no whitespace splitting), aborting once
1160
+ * accumulated bytes exceed `maxBytes`. Without the cap a mistaken multi-GB pipe
1161
+ * would grow `data` unbounded and OOM the CLI (CWE-400); we fail closed with a
1162
+ * StdinTooLargeError instead. `maxBytes` is injectable for tests; production
1163
+ * uses BATCH_CREATE_MAX_STDIN_BYTES.
1164
+ */
1165
+ async function readStdinRaw(maxBytes = exports.BATCH_CREATE_MAX_STDIN_BYTES) {
1166
+ if (process.stdin.isTTY)
1167
+ return '';
1168
+ return new Promise((resolve, reject) => {
1169
+ let data = '';
1170
+ let bytes = 0;
1171
+ let aborted = false;
1172
+ process.stdin.setEncoding('utf8');
1173
+ process.stdin.on('readable', () => {
1174
+ if (aborted)
1175
+ return;
1176
+ let chunk;
1177
+ while ((chunk = process.stdin.read()) !== null) {
1178
+ bytes += Buffer.byteLength(chunk, 'utf8');
1179
+ if (bytes > maxBytes) {
1180
+ aborted = true;
1181
+ // Stop draining and tear down the stream rather than keep buffering.
1182
+ if (typeof process.stdin.destroy === 'function') {
1183
+ process.stdin.destroy();
1184
+ }
1185
+ reject(new StdinTooLargeError(bytes, maxBytes));
1186
+ return;
1187
+ }
1188
+ data += chunk;
1189
+ }
1190
+ });
1191
+ process.stdin.on('end', () => {
1192
+ if (!aborted)
1193
+ resolve(data);
1194
+ });
1195
+ process.stdin.on('error', () => {
1196
+ if (!aborted)
1197
+ resolve(data);
1198
+ });
1199
+ });
1200
+ }
1201
+ /** Issue one createMemory via the SDK and normalize the id out of the response. */
1202
+ async function createOneViaSdk(data) {
1203
+ // The global fetch wrapper in index.ts attaches X-MX-Client AND the v3.89a
1204
+ // per-project attribution headers, so batch-created memories carry attribution
1205
+ // automatically — no per-item work here. codeContext supplied on a line still
1206
+ // rides in the request body untouched.
1207
+ const response = await (0, api_client_1.getSdk)().memories.createMemory(data);
1208
+ const memory = response?.data?.data ?? response?.data;
1209
+ return { id: memory?.id };
1210
+ }
1211
+ /**
1212
+ * `mx memories create --stdin-jsonl`: streaming batch create. Reads JSONL from
1213
+ * stdin (one createMemory body per line), fires bounded-concurrency creates, and
1214
+ * writes one JSONL result line per input line to stdout in completion order.
1215
+ *
1216
+ * stdout is PURE JSONL: the ONLY writes are the per-line result objects below.
1217
+ * Every status/warning/summary goes to stderr. There is no session-preamble
1218
+ * printer on this command path, so the #4176-class stdout-pollution bug cannot
1219
+ * occur here — but the split is enforced deliberately so a future preamble can
1220
+ * never leak into the machine-readable stream.
1221
+ */
1222
+ async function handleStreamingCreate(maxStdinBytes = exports.BATCH_CREATE_MAX_STDIN_BYTES) {
1223
+ const concurrency = resolveCreateConcurrency(process.env.MX_CREATE_CONCURRENCY);
1224
+ let raw;
1225
+ try {
1226
+ raw = await readStdinRaw(maxStdinBytes);
1227
+ }
1228
+ catch (error) {
1229
+ if (error?.code === 'STDIN_TOO_LARGE') {
1230
+ // Fail closed: nothing was attempted, so nothing is emitted on stdout.
1231
+ console.error(chalk_1.default.red(`Error: stdin exceeded the ${maxStdinBytes}-byte limit; aborted before creating anything. ` +
1232
+ 'Split the input into smaller batches.'));
1233
+ process.exitCode = 1;
1234
+ return;
1235
+ }
1236
+ throw error;
1237
+ }
1238
+ const items = buildBatchCreateItems(raw);
1239
+ if (items.length === 0) {
1240
+ console.error(chalk_1.default.red('Error: --stdin-jsonl expects one JSON object per line on stdin; got none.'));
1241
+ process.exitCode = 1;
1242
+ return;
1243
+ }
1244
+ // Auto-attach the Claude Code session id to every line, exactly as single
1245
+ // create does (create action: readSessionContext() when in AI-agent context),
1246
+ // so batch-created memories group into the same conversation/session.
1247
+ const claudeSession = config_1.config.isAIAgentContext() ? (0, session_reader_1.readSessionContext)() : undefined;
1248
+ const claudeSessionId = claudeSession?.sessionId || undefined;
1249
+ const summary = await runBatchCreate(items, {
1250
+ createFn: (data) => createOneViaSdk(claudeSessionId ? { ...data, claudeSessionId } : data),
1251
+ sleep: retry_1.sleep,
1252
+ concurrency,
1253
+ onResult: (r) => {
1254
+ // Pure JSONL on stdout — the ONLY stdout write on this path. A broken pipe
1255
+ // (reader closed early) throws EPIPE here; runBatchCreate's guarded emit
1256
+ // catches it and stops cleanly (summary.outputClosed), handled below.
1257
+ process.stdout.write(JSON.stringify(r) + '\n');
1258
+ },
1259
+ });
1260
+ // Terminal: the reader closed stdout early (EPIPE). stdout is dead but stderr
1261
+ // is still open — write a distinct accounting so the caller knows exactly how
1262
+ // many results were delivered vs. created vs. never attempted. Creates are not
1263
+ // idempotent, so this reconciliation matters.
1264
+ if (summary.outputClosed) {
1265
+ console.error(chalk_1.default.red(`Error: output pipe closed early — ${summary.delivered} result line(s) delivered, ` +
1266
+ `${summary.ok} created, ${summary.notAttempted} not attempted. ` +
1267
+ 'Reconcile against the delivered result lines before re-running (creates are not idempotent).'));
1268
+ process.exitCode = 1;
1269
+ return;
1270
+ }
1271
+ // Summary + any early-stop notice go to stderr so stdout stays pure JSONL.
1272
+ if (summary.quotaStopped) {
1273
+ console.error(chalk_1.default.red(`Stopped early on quota (402): ${summary.ok} created, ${summary.failed} failed, ${summary.notAttempted} not attempted.`));
1274
+ }
1275
+ else {
1276
+ const rl = summary.rateLimitedCount > 0
1277
+ ? `, ${summary.rateLimitedCount} rate-limited retr${summary.rateLimitedCount === 1 ? 'y' : 'ies'}`
1278
+ : '';
1279
+ console.error(`Batch create: ${summary.ok} created, ${summary.failed} failed of ${summary.total}${rl} (concurrency ${concurrency}).`);
1280
+ }
1281
+ // Non-zero exit when anything failed or was not attempted, so scripts detect
1282
+ // partial failure — partial results are still fully emitted above.
1283
+ if (summary.failed > 0 || summary.notAttempted > 0) {
1284
+ process.exitCode = 1;
1285
+ }
1286
+ }
782
1287
  /**
783
1288
  * Register memory management commands
784
1289
  * @param program - Commander program instance
@@ -809,7 +1314,7 @@ function registerMemoriesCommands(program) {
809
1314
  // Named memories filter — fetch and filter client-side
810
1315
  if (options.named) {
811
1316
  const spinner = (0, spinner_1.createSpinner)('Fetching named memories...', options.format).start();
812
- const namedResponse = await (0, api_client_1.getSdk)().memories.listMemories({ limit: 100, offset: 0 });
1317
+ const namedResponse = await retryRead(() => (0, api_client_1.getSdk)().memories.listMemories({ limit: 100, offset: 0 }));
813
1318
  const namedMemories = (namedResponse.data?.data || []).filter((m) => m.name);
814
1319
  spinner.succeed(`Found ${namedMemories.length} named memories`);
815
1320
  if (options.format === 'table' || !options.format) {
@@ -910,7 +1415,7 @@ function registerMemoriesCommands(program) {
910
1415
  .filter((t) => t.length > 0)
911
1416
  .join(',');
912
1417
  }
913
- const response = await (0, api_client_1.getSdk)().memories.listMemories(listParams);
1418
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.listMemories(listParams));
914
1419
  const result = response.data;
915
1420
  spinner.succeed(`Found ${result?.data?.length || 0} memories`);
916
1421
  // Format for table display
@@ -969,9 +1474,9 @@ function registerMemoriesCommands(program) {
969
1474
  // Named memory lookup — short-circuits the entire ID-based flow
970
1475
  if (options.name) {
971
1476
  const spinner = (0, spinner_1.createSpinner)('Fetching named memory...', options.format).start();
972
- const response = await (0, api_client_1.getSdk)().memories.getMemoryByName(options.name, {
1477
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryByName(options.name, {
973
1478
  detail: options.detail || 'standard',
974
- });
1479
+ }));
975
1480
  const memory = response.data?.data;
976
1481
  if (!memory) {
977
1482
  spinner.fail(`No memory found with name '${options.name}'`);
@@ -1011,9 +1516,9 @@ function registerMemoriesCommands(program) {
1011
1516
  if (isNamedKey) {
1012
1517
  const spinner = (0, spinner_1.createSpinner)('Fetching named memory...', options.format).start();
1013
1518
  try {
1014
- const response = await (0, api_client_1.getSdk)().memories.getMemoryByName(singleId, {
1519
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryByName(singleId, {
1015
1520
  detail: options.detail || 'standard',
1016
- });
1521
+ }));
1017
1522
  const memory = response.data?.data;
1018
1523
  if (!memory) {
1019
1524
  spinner.fail(`No memory found with name '${singleId}'`);
@@ -1042,11 +1547,11 @@ function registerMemoriesCommands(program) {
1042
1547
  let resolvedMemory = null;
1043
1548
  if (isShortId) {
1044
1549
  const spinner = (0, spinner_1.createSpinner)('Searching for matching memory...', options.format).start();
1045
- const listResponse = await (0, api_client_1.getSdk)().memories.listMemories({
1550
+ const listResponse = await retryRead(() => (0, api_client_1.getSdk)().memories.listMemories({
1046
1551
  limit: 10,
1047
1552
  offset: 0,
1048
1553
  idPrefix: singleId,
1049
- });
1554
+ }));
1050
1555
  const matches = listResponse.data?.data || [];
1051
1556
  if (matches.length === 0) {
1052
1557
  spinner.fail(`No memory found with ID prefix: ${singleId}`);
@@ -1087,9 +1592,9 @@ function registerMemoriesCommands(program) {
1087
1592
  });
1088
1593
  }
1089
1594
  else {
1090
- const response = await (0, api_client_1.getSdk)().memories.getMemoryById(memoryId, {
1595
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryById(memoryId, {
1091
1596
  detail,
1092
- });
1597
+ }));
1093
1598
  const memory = response.data?.data;
1094
1599
  printMemoryWithDetail(memory, options.format, {
1095
1600
  includeVectors: !!options.includeVectors,
@@ -1132,9 +1637,27 @@ function registerMemoriesCommands(program) {
1132
1637
  .option('--outcome <outcome>', 'Task outcome: resolved | unresolved')
1133
1638
  .option('--distill', 'Request LLM distillation into structured 5-section notebook (Pro/Enterprise only)')
1134
1639
  .option('--interactive', 'Interactive mode')
1640
+ .option('--stdin-jsonl', 'Streaming batch create: read JSONL from stdin (one createMemory object per line), ' +
1641
+ 'create with bounded concurrency (MX_CREATE_CONCURRENCY, default 4), and emit one ' +
1642
+ 'JSONL result line per input line on stdout ({line, ok, id?, error?, code?}). On ' +
1643
+ 'failure, code is one of PARSE_ERROR | CREATE_FAILED | QUOTA_STOPPED | NOT_ATTEMPTED | ' +
1644
+ 'OUTPUT_CLOSED. Exits non-zero if any line failed.')
1135
1645
  .option('--format <format>', 'Output format (json|table|yaml)')
1136
1646
  .action(async (options) => {
1137
1647
  try {
1648
+ // Streaming batch create path. Mutually exclusive with the single-create
1649
+ // content/interactive flags — the input is stdin JSONL, not CLI flags.
1650
+ if (options.stdinJsonl) {
1651
+ const conflicting = ['content', 'interactive', 'supersedes'].filter((k) => options[k]);
1652
+ if (conflicting.length > 0) {
1653
+ console.error(chalk_1.default.red(`Error: --stdin-jsonl cannot be combined with --${conflicting.join(', --')}. ` +
1654
+ 'Pass all fields as JSON per line on stdin instead.'));
1655
+ process.exitCode = 1;
1656
+ return;
1657
+ }
1658
+ await handleStreamingCreate();
1659
+ return;
1660
+ }
1138
1661
  // Build codeContext from --code-context JSON and/or convenience flags
1139
1662
  let codeContext;
1140
1663
  if (options.codeContext) {
@@ -1261,7 +1784,12 @@ function registerMemoriesCommands(program) {
1261
1784
  let response;
1262
1785
  let upserted = false;
1263
1786
  try {
1264
- response = await (0, api_client_1.getSdk)().memories.createMemory(memoryData);
1787
+ // Single create is non-idempotent → retryWrite (429-only: a rejected
1788
+ // 429 never reached processing, so a retry cannot duplicate the create;
1789
+ // a transient 5xx is NOT retried here to avoid a duplicate). A 409
1790
+ // (named already exists) is not retryable and falls through to the
1791
+ // auto-upsert branch below unchanged.
1792
+ response = await retryWrite(() => (0, api_client_1.getSdk)().memories.createMemory(memoryData));
1265
1793
  }
1266
1794
  catch (createError) {
1267
1795
  // 409 = named memory already exists → auto-upsert via updateNamedMemory
@@ -1283,7 +1811,15 @@ function registerMemoriesCommands(program) {
1283
1811
  // intentionally NOT forwarded here: it is applied uniformly for both the
1284
1812
  // create and upsert paths by the createMemoryRelationship call below, so
1285
1813
  // forwarding it in the body too would create a duplicate SUPERSEDES edge.
1286
- response = await (0, api_client_1.getSdk)().memories.updateNamedMemory(memoryData.name, {
1814
+ // Named update is NOT idempotent on this path: the CLI/SDK never
1815
+ // supply expectedHeadId, so PUT /named/:name does an UNCONDITIONAL
1816
+ // version bump (buildNamedVersionBumpCypher). retryWrite → 429-only:
1817
+ // the named-write gate acquires the permit BEFORE this.write, so a
1818
+ // 429 is pre-write and safe to retry; a 5xx is ambiguous (the bump
1819
+ // may already have committed — e.g. Kong 504 after Neo4j commits)
1820
+ // and MUST NOT be retried, or it silently creates a second identical
1821
+ // version (memory-history corruption).
1822
+ response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateNamedMemory(memoryData.name, {
1287
1823
  content: memoryData.content,
1288
1824
  ...(options.memoryType && { memoryType: options.memoryType }),
1289
1825
  ...(memoryData.topics && { topics: memoryData.topics }),
@@ -1297,7 +1833,7 @@ function registerMemoriesCommands(program) {
1297
1833
  options.conversationId.toUpperCase() !== 'NEW' && {
1298
1834
  conversationId: options.conversationId,
1299
1835
  }),
1300
- });
1836
+ }));
1301
1837
  upserted = true;
1302
1838
  }
1303
1839
  else {
@@ -1330,12 +1866,12 @@ function registerMemoriesCommands(program) {
1330
1866
  if (options.supersedes && memory?.id) {
1331
1867
  const relSpinner = (0, spinner_1.createSpinner)('Creating SUPERSEDES relationship...', options.format).start();
1332
1868
  try {
1333
- await (0, api_client_1.getSdk)().memories.createMemoryRelationship(memory.id, {
1869
+ await retryWrite(() => (0, api_client_1.getSdk)().memories.createMemoryRelationship(memory.id, {
1334
1870
  targetMemoryId: options.supersedes,
1335
1871
  type: 'SUPERSEDES',
1336
1872
  confidence: 1.0,
1337
1873
  reason: 'Created via --supersedes flag',
1338
- });
1874
+ }));
1339
1875
  relSpinner.succeed(`Relationship created: ${memory.id.substring(0, 8)}... SUPERSEDES ${options.supersedes.substring(0, 8)}...`);
1340
1876
  }
1341
1877
  catch (relError) {
@@ -1400,7 +1936,7 @@ function registerMemoriesCommands(program) {
1400
1936
  }
1401
1937
  updates.name = options.assignName;
1402
1938
  const spinner = (0, spinner_1.createSpinner)('Assigning name...', options.format).start();
1403
- const response = await (0, api_client_1.getSdk)().memories.updateMemory(id, updates);
1939
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateMemory(id, updates));
1404
1940
  const memory = response.data?.data;
1405
1941
  spinner.succeed(`Memory ${id} named '${options.assignName}'`);
1406
1942
  (0, formatters_1.printOutput)(memory, options.format);
@@ -1411,15 +1947,15 @@ function registerMemoriesCommands(program) {
1411
1947
  if (options.name) {
1412
1948
  // Look up current memory by name, then update its name
1413
1949
  const spinner = (0, spinner_1.createSpinner)('Renaming...', options.format).start();
1414
- const getResponse = await (0, api_client_1.getSdk)().memories.getMemoryByName(options.name);
1950
+ const getResponse = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryByName(options.name));
1415
1951
  const currentMemory = getResponse.data?.data;
1416
1952
  if (!currentMemory?.id) {
1417
1953
  spinner.fail(`Named memory '${options.name}' not found`);
1418
1954
  return;
1419
1955
  }
1420
- const response = await (0, api_client_1.getSdk)().memories.updateMemory(currentMemory.id, {
1956
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateMemory(currentMemory.id, {
1421
1957
  name: options.rename,
1422
- });
1958
+ }));
1423
1959
  const memory = response.data?.data;
1424
1960
  spinner.succeed(`Named memory '${options.name}' renamed to '${options.rename}'`);
1425
1961
  (0, formatters_1.printOutput)(memory, options.format);
@@ -1428,7 +1964,7 @@ function registerMemoriesCommands(program) {
1428
1964
  if (id) {
1429
1965
  updates.name = options.rename;
1430
1966
  const spinner = (0, spinner_1.createSpinner)('Renaming...', options.format).start();
1431
- const response = await (0, api_client_1.getSdk)().memories.updateMemory(id, updates);
1967
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateMemory(id, updates));
1432
1968
  const memory = response.data?.data;
1433
1969
  spinner.succeed(`Memory ${id} renamed to '${options.rename}'`);
1434
1970
  (0, formatters_1.printOutput)(memory, options.format);
@@ -1444,7 +1980,13 @@ function registerMemoriesCommands(program) {
1444
1980
  // Named memory update — creates new version
1445
1981
  if (options.name) {
1446
1982
  const spinner = (0, spinner_1.createSpinner)('Creating new version...', options.format).start();
1447
- const response = await (0, api_client_1.getSdk)().memories.updateNamedMemory(options.name, updates);
1983
+ // Named update is NOT idempotent without expectedHeadId (which the
1984
+ // CLI/SDK never supply): PUT /named/:name unconditionally bumps the
1985
+ // version. retryWrite → 429-only: the gate takes the permit before the
1986
+ // write, so a 429 is pre-write and safe to retry; a 5xx is ambiguous
1987
+ // (the bump may already have committed) and MUST NOT be retried, or it
1988
+ // creates a second identical version (silent history corruption).
1989
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateNamedMemory(options.name, updates));
1448
1990
  const memory = response.data?.data;
1449
1991
  const meta = response.data?.meta;
1450
1992
  spinner.succeed(`Named memory '${options.name}' updated to v${meta?.version}`);
@@ -1455,7 +1997,7 @@ function registerMemoriesCommands(program) {
1455
1997
  return;
1456
1998
  }
1457
1999
  const spinner = (0, spinner_1.createSpinner)('Updating memory...', options.format).start();
1458
- const response = await (0, api_client_1.getSdk)().memories.updateMemory(id, updates);
2000
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.updateMemory(id, updates));
1459
2001
  const memory = response.data?.data;
1460
2002
  spinner.succeed(`Memory ${id} updated successfully`);
1461
2003
  (0, formatters_1.printOutput)(memory, options.format);
@@ -1476,7 +2018,7 @@ function registerMemoriesCommands(program) {
1476
2018
  .action(async (name, options) => {
1477
2019
  try {
1478
2020
  const spinner = (0, spinner_1.createSpinner)('Fetching version history...', options.format).start();
1479
- const response = await (0, api_client_1.getSdk)().memories.getNamedMemoryHistory(name);
2021
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getNamedMemoryHistory(name));
1480
2022
  const data = response.data;
1481
2023
  if (!data?.versions?.length) {
1482
2024
  spinner.fail(`No memory found with name '${name}'`);
@@ -1677,7 +2219,9 @@ function registerMemoriesCommands(program) {
1677
2219
  }
1678
2220
  }
1679
2221
  const spinner = (0, spinner_1.createSpinner)('Deleting memory...', 'table').start();
1680
- await (0, api_client_1.getSdk)().memories.deleteMemory(id);
2222
+ // DELETE is idempotent (deleting an already-deleted id is a no-op), so a
2223
+ // transient 5xx is retry-safe as well as a 429.
2224
+ await retryRead(() => (0, api_client_1.getSdk)().memories.deleteMemory(id));
1681
2225
  // Record this deletion in the cross-process guard file so the next
1682
2226
  // invocation's threshold check reflects it.
1683
2227
  if (isNonInteractive) {
@@ -1898,7 +2442,7 @@ function registerMemoriesCommands(program) {
1898
2442
  searchRequest.order = options.order;
1899
2443
  }
1900
2444
  // Use GET endpoint for better performance (Issue #79)
1901
- const response = await (0, api_client_1.getSdk)().memories.searchMemoriesGet(searchRequest);
2445
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.searchMemoriesGet(searchRequest));
1902
2446
  const searchData = response.data;
1903
2447
  // Normalize: API may return flat objects or {memory, score} wrappers.
1904
2448
  // Ensure consistent {memory, score, matchedVia} shape for display code.
@@ -2316,19 +2860,19 @@ async function handleMemoryDiscovery(memoryId, options) {
2316
2860
  const sdk = (0, api_client_1.getSdk)();
2317
2861
  if (options.similar) {
2318
2862
  discoveryType = 'similar';
2319
- const response = await sdk.memories.getSimilarMemories(memoryId, { limit: options.limit });
2863
+ const response = await retryRead(() => sdk.memories.getSimilarMemories(memoryId, { limit: options.limit }));
2320
2864
  result = response.data;
2321
2865
  }
2322
2866
  else if (options.conversation) {
2323
2867
  discoveryType = 'conversation';
2324
- const response = await sdk.memories.getConversationMemories(memoryId, {
2868
+ const response = await retryRead(() => sdk.memories.getConversationMemories(memoryId, {
2325
2869
  limit: options.limit,
2326
- });
2870
+ }));
2327
2871
  result = response.data;
2328
2872
  }
2329
2873
  else if (options.related) {
2330
2874
  discoveryType = 'related';
2331
- const response = await sdk.memories.getRelatedMemories(memoryId, { limit: options.limit });
2875
+ const response = await retryRead(() => sdk.memories.getRelatedMemories(memoryId, { limit: options.limit }));
2332
2876
  result = response.data;
2333
2877
  }
2334
2878
  else {
@@ -2429,9 +2973,9 @@ function registerMemoryRelationshipCommands(memories) {
2429
2973
  return;
2430
2974
  }
2431
2975
  const spinner = (0, spinner_1.createSpinner)('Fetching relationships...', options.format).start();
2432
- const response = await (0, api_client_1.getSdk)().memories.getMemoryRelationships(id, {
2976
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryRelationships(id, {
2433
2977
  direction: options.direction,
2434
- });
2978
+ }));
2435
2979
  const result = response.data;
2436
2980
  spinner.succeed(`Found ${result?.data?.length || 0} relationships`);
2437
2981
  if (options.format === 'table' || !options.format) {
@@ -2537,7 +3081,7 @@ function registerMemoryRelationshipCommands(memories) {
2537
3081
  };
2538
3082
  }
2539
3083
  const spinner = (0, spinner_1.createSpinner)('Creating relationship...', options.format).start();
2540
- const response = await (0, api_client_1.getSdk)().memories.createMemoryRelationship(id, relationshipData);
3084
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.createMemoryRelationship(id, relationshipData));
2541
3085
  const relationship = response.data?.data;
2542
3086
  spinner.succeed(`Relationship created: ${id} ${relationship?.type} ${relationshipData.targetMemoryId}`);
2543
3087
  (0, formatters_1.printOutput)(relationship, options.format);
@@ -2580,7 +3124,7 @@ function registerMemoryRelationshipCommands(memories) {
2580
3124
  }
2581
3125
  // Non-interactive without --force: auto-confirm single delete
2582
3126
  const spinner = (0, spinner_1.createSpinner)('Deleting relationship...', 'table').start();
2583
- await (0, api_client_1.getSdk)().memories.deleteMemoryRelationship(id, relationshipId);
3127
+ await retryRead(() => (0, api_client_1.getSdk)().memories.deleteMemoryRelationship(id, relationshipId));
2584
3128
  spinner.succeed(`Relationship ${relationshipId} deleted successfully`);
2585
3129
  }
2586
3130
  catch (error) {
@@ -2608,7 +3152,7 @@ function registerMemoryTimelineCommand(memories) {
2608
3152
  const before = parseInt(options.before, 10);
2609
3153
  const after = parseInt(options.after, 10);
2610
3154
  const spinner = (0, spinner_1.createSpinner)('Fetching timeline context...', options.format).start();
2611
- const response = await (0, api_client_1.getSdk)().memories.getMemoryTimeline(id, { before, after });
3155
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.getMemoryTimeline(id, { before, after }));
2612
3156
  const result = response.data;
2613
3157
  spinner.succeed('Timeline context retrieved');
2614
3158
  if (options.format === 'table' || !options.format) {
@@ -2671,12 +3215,14 @@ function registerDetectRelationshipsCommand(memories) {
2671
3215
  const spinner = (0, spinner_1.createSpinner)(options.autoCreate
2672
3216
  ? 'Detecting and creating relationships...'
2673
3217
  : 'Detecting relationships...', options.format).start();
2674
- const response = await (0, api_client_1.getSdk)().memories.detectMemoryRelationships(id, {
3218
+ // detect with --auto-create writes relationship edges, so it is
3219
+ // non-idempotent → retryWrite (429-only) to avoid duplicate edges on 5xx.
3220
+ const response = await retryWrite(() => (0, api_client_1.getSdk)().memories.detectMemoryRelationships(id, {
2675
3221
  body: {
2676
3222
  autoCreate: options.autoCreate || false,
2677
3223
  minConfidence,
2678
3224
  },
2679
- });
3225
+ }));
2680
3226
  const result = response.data;
2681
3227
  const suggestions = result?.data || [];
2682
3228
  spinner.succeed(`Found ${suggestions.length} potential relationships`);
@@ -2816,7 +3362,7 @@ function registerDigestCommand(memories) {
2816
3362
  sdk.config.timeoutMs = RECALL_MAX_MS;
2817
3363
  let response;
2818
3364
  try {
2819
- response = await sdk.memories.generateMemoryDigest(body);
3365
+ response = await retryRead(() => sdk.memories.generateMemoryDigest(body));
2820
3366
  }
2821
3367
  finally {
2822
3368
  if (sdk.config)
@@ -2905,7 +3451,7 @@ function registerStatusCommand(memories) {
2905
3451
  sdk.config.timeoutMs = 180000;
2906
3452
  let response;
2907
3453
  try {
2908
- response = await sdk.memories.generateMemoryDigest(body);
3454
+ response = await retryRead(() => sdk.memories.generateMemoryDigest(body));
2909
3455
  }
2910
3456
  finally {
2911
3457
  if (sdk.config)
@@ -3051,7 +3597,7 @@ function registerRecapCommand(memories) {
3051
3597
  };
3052
3598
  if (excludeTopicsParam)
3053
3599
  searchRequest.excludeTopics = excludeTopicsParam;
3054
- const response = await (0, api_client_1.getSdk)().memories.searchMemoriesGet(searchRequest);
3600
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.searchMemoriesGet(searchRequest));
3055
3601
  // Normalize: API may return flat objects or {memory, score} wrappers.
3056
3602
  results = (response.data?.data || []).map((r) => r.memory ? r : { memory: r, score: r.score, matchedVia: r.matchedVia });
3057
3603
  }
@@ -3067,7 +3613,7 @@ function registerRecapCommand(memories) {
3067
3613
  };
3068
3614
  if (excludeTopicsParam)
3069
3615
  listRequest.excludeTopics = excludeTopicsParam;
3070
- const response = await (0, api_client_1.getSdk)().memories.listMemories(listRequest);
3616
+ const response = await retryRead(() => (0, api_client_1.getSdk)().memories.listMemories(listRequest));
3071
3617
  // Wrap in search-result shape expected by groupByConversation / displayRecap
3072
3618
  results = (response.data?.data || []).map((m) => ({ memory: m }));
3073
3619
  }
@@ -3305,7 +3851,7 @@ function registerBuildContextCommand(memories) {
3305
3851
  body.files = options.files;
3306
3852
  }
3307
3853
  const sdk = (0, api_client_1.getSdk)();
3308
- const response = await sdk.memories.buildContext(body);
3854
+ const response = await retryRead(() => sdk.memories.buildContext(body));
3309
3855
  const data = response?.data || response;
3310
3856
  if (!data) {
3311
3857
  spinner.fail('No response data received');