@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.
- package/dist/commands/memories.d.ts +171 -10
- package/dist/commands/memories.d.ts.map +1 -1
- package/dist/commands/memories.js +630 -84
- package/dist/commands/memories.js.map +1 -1
- package/dist/lib/retry.d.ts +98 -0
- package/dist/lib/retry.d.ts.map +1 -0
- package/dist/lib/retry.js +183 -0
- package/dist/lib/retry.js.map +1 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
/**
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|