@echomem/mcp 1.4.20 → 1.4.22
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/README.md +1 -1
- package/assets/hud/echo-pricing-free-sticker.png +0 -0
- package/assets/hud/echo-pricing-power-sticker.png +0 -0
- package/assets/hud/echo-pricing-pro-sticker.png +0 -0
- package/assets/hud/scan-loop/frame-01-ready.png +0 -0
- package/assets/hud/scan-loop/frame-02-open.png +0 -0
- package/assets/hud/scan-loop/frame-03-pull.png +0 -0
- package/assets/hud/scan-loop/frame-04-read.png +0 -0
- package/assets/hud/scan-loop/frame-05-file.png +0 -0
- package/assets/hud/scan-loop/frame-06-close.png +0 -0
- package/dist/crypto.js +4 -0
- package/dist/encryption.js +14 -2
- package/dist/index.js +52 -117
- package/dist/migrate.js +38 -1
- package/dist/setup-page/client-core.js +286 -26
- package/dist/setup-page/client-extraction.js +322 -101
- package/dist/setup-page/client-lifecycle.js +46 -44
- package/dist/setup-page/client-report-audit.js +1 -1
- package/dist/setup-page/client-report-city.js +35 -22
- package/dist/setup-page/document.js +1 -1
- package/dist/setup-page/styles-extraction.js +455 -0
- package/dist/setup-page/styles-mvp.js +976 -0
- package/dist/setup-page/styles-website-alignment.js +237 -5
- package/dist/setup-page/styles.js +2 -0
- package/dist/setup-page.js +1 -0
- package/dist/setup-preview.js +142 -37
- package/dist/setup.js +527 -61
- package/dist/v1-contract.js +1 -11
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ HUD; the granular commands below still work.
|
|
|
66
66
|
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
67
67
|
| `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
|
|
68
68
|
| `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
|
|
69
|
-
| `npx -y @echomem/mcp@latest update --all` | One-shot update: repoint detected client configs
|
|
69
|
+
| `npx -y @echomem/mcp@latest update --all` | One-shot update: install the latest bridge durably and repoint detected client configs, with no browser login |
|
|
70
70
|
| `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
|
|
71
71
|
| `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
|
|
72
72
|
| `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/crypto.js
CHANGED
|
@@ -95,6 +95,10 @@ export async function decrypt(ciphertext, key) {
|
|
|
95
95
|
const plainBuffer = await subtle.decrypt({ name: ALGORITHM, iv }, key, data);
|
|
96
96
|
return new TextDecoder().decode(plainBuffer);
|
|
97
97
|
}
|
|
98
|
+
/** Create a server-verifiable token for a newly configured encryption key. */
|
|
99
|
+
export async function createVerification(key) {
|
|
100
|
+
return encrypt(VERIFICATION_PLAINTEXT, key);
|
|
101
|
+
}
|
|
98
102
|
/** Verify a key against a stored verification token (current + legacy plaintexts). */
|
|
99
103
|
export async function verifyKey(key, token) {
|
|
100
104
|
try {
|
package/dist/encryption.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey,
|
|
1
|
+
import { createVerification, deriveKey, exportKeyToBase64, generateSalt, importKeyFromBase64, decrypt, saltFromBase64, saltToBase64, verifyKey, } from "./crypto.js";
|
|
2
|
+
const DEFAULT_KEY_ITERATIONS = 600_000;
|
|
2
3
|
/** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
|
|
3
4
|
export async function fetchEncryptionConfig(axios) {
|
|
4
5
|
// Bounded: this is on the startup/tool-listing path; an unbounded call on a flaky network would hang
|
|
@@ -21,11 +22,22 @@ export async function deriveAndVerifyKey(passphrase, config) {
|
|
|
21
22
|
if (!config.enabled || !config.salt || !config.verification)
|
|
22
23
|
return null;
|
|
23
24
|
const salt = saltFromBase64(config.salt);
|
|
24
|
-
const key = await deriveKey(passphrase, salt, config.iterations ??
|
|
25
|
+
const key = await deriveKey(passphrase, salt, config.iterations ?? DEFAULT_KEY_ITERATIONS);
|
|
25
26
|
if (!(await verifyKey(key, config.verification)))
|
|
26
27
|
return null;
|
|
27
28
|
return exportKeyToBase64(key);
|
|
28
29
|
}
|
|
30
|
+
/** Create a fresh account encryption config locally. The passphrase/key never leaves the device. */
|
|
31
|
+
export async function setupNewEncryptionKey(passphrase, iterations = DEFAULT_KEY_ITERATIONS) {
|
|
32
|
+
const salt = generateSalt();
|
|
33
|
+
const key = await deriveKey(passphrase, salt, iterations);
|
|
34
|
+
return {
|
|
35
|
+
keyBase64: await exportKeyToBase64(key),
|
|
36
|
+
saltBase64: saltToBase64(salt),
|
|
37
|
+
verification: await createVerification(key),
|
|
38
|
+
iterations,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
29
41
|
/** Verify an already-exported base64 key against the server's verification token. */
|
|
30
42
|
export async function verifyKeyB64(keyBase64, config) {
|
|
31
43
|
if (!config.enabled || !config.verification)
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { EventLogger, hashText } from "./events.js";
|
|
|
10
10
|
import { buildReportText } from "./report.js";
|
|
11
11
|
import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
-
import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
|
|
13
|
+
import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
|
|
14
14
|
import { runCli } from "./setup.js";
|
|
15
15
|
import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
|
|
16
16
|
import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
|
|
@@ -23,6 +23,9 @@ class NoTokenError extends Error {
|
|
|
23
23
|
/** Thrown when an encrypted account has no usable key — the model gets an unlock nudge, never ciphertext. */
|
|
24
24
|
class LockedError extends Error {
|
|
25
25
|
}
|
|
26
|
+
/** Thrown when the bridge cannot prove whether the account is encrypted. */
|
|
27
|
+
class EncryptionStatusUnavailableError extends Error {
|
|
28
|
+
}
|
|
26
29
|
function stringifyErrorValue(value) {
|
|
27
30
|
if (value == null) {
|
|
28
31
|
return "";
|
|
@@ -121,7 +124,7 @@ function formatUpgradeRequiredResult(error) {
|
|
|
121
124
|
pricingUrl,
|
|
122
125
|
});
|
|
123
126
|
return [
|
|
124
|
-
"ACTION REQUIRED: tell the user to start a
|
|
127
|
+
"ACTION REQUIRED: tell the user to start a 14-day Pro trial or choose a subscription at this exact URL:",
|
|
125
128
|
pricingUrl,
|
|
126
129
|
"When replying, include the exact URL above. Do not reply only with \"connect\" or \"upgrade\".",
|
|
127
130
|
"",
|
|
@@ -130,6 +133,15 @@ function formatUpgradeRequiredResult(error) {
|
|
|
130
133
|
code ? `Reason: ${code}.` : "",
|
|
131
134
|
].filter(Boolean).join("\n");
|
|
132
135
|
}
|
|
136
|
+
function formatReconnectRequiredResult(error) {
|
|
137
|
+
if (!axios.isAxiosError(error) || error.response?.status !== 401)
|
|
138
|
+
return null;
|
|
139
|
+
return [
|
|
140
|
+
"🔌 EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
|
|
141
|
+
"Action required from the user: run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.",
|
|
142
|
+
"No editor restart is needed. Keep all credentials out of chat.",
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
133
145
|
/** Map a thrown error to the telemetry error_kind taxonomy. */
|
|
134
146
|
function classifyError(error) {
|
|
135
147
|
if (error instanceof NoTokenError)
|
|
@@ -564,8 +576,8 @@ class EchoMemApiClient {
|
|
|
564
576
|
if (!this.encConfigPromise) {
|
|
565
577
|
this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
|
|
566
578
|
this.encConfigPromise = null; // allow retry next call
|
|
567
|
-
console.error(`[encryption] config fetch failed: ${describeError(error)} —
|
|
568
|
-
|
|
579
|
+
console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
|
|
580
|
+
throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
|
|
569
581
|
});
|
|
570
582
|
}
|
|
571
583
|
return this.encConfigPromise;
|
|
@@ -576,18 +588,20 @@ class EchoMemApiClient {
|
|
|
576
588
|
* handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
|
|
577
589
|
*/
|
|
578
590
|
async encState() {
|
|
579
|
-
// A stored key ONLY exists because the user unlocked an ENCRYPTED vault, so treat it as
|
|
580
|
-
// authoritative: a flaky/failed `/account/encryption` fetch (which defaults to enabled:false) must
|
|
581
|
-
// NOT downgrade an unlocked encrypted account to plaintext — that causes a 422 on save and leaks
|
|
582
|
-
// ciphertext on read. So if we hold a usable key, we're encrypted-and-unlocked, period.
|
|
583
|
-
const key = this.store.getKey();
|
|
584
|
-
if (key)
|
|
585
|
-
return { enabled: true, key };
|
|
586
|
-
// No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
|
|
587
591
|
const cfg = await this.getEncryptionConfig();
|
|
588
|
-
if (cfg.enabled)
|
|
592
|
+
if (!cfg.enabled)
|
|
593
|
+
return { enabled: false };
|
|
594
|
+
const key = this.store.getKey();
|
|
595
|
+
if (!key)
|
|
589
596
|
throw new LockedError("EchoMem vault locked");
|
|
590
|
-
|
|
597
|
+
if (!(await verifyKeyB64(key, cfg))) {
|
|
598
|
+
// A file-backed stale key can be removed safely; environment-provided
|
|
599
|
+
// keys remain under the caller's control and are ignored until corrected.
|
|
600
|
+
if (!process.env.ECHO_ENCRYPTION_KEY)
|
|
601
|
+
this.store.clearKey();
|
|
602
|
+
throw new LockedError("EchoMem vault key is invalid");
|
|
603
|
+
}
|
|
604
|
+
return { enabled: true, key };
|
|
591
605
|
}
|
|
592
606
|
pruneDeleteConfirmations(now = Date.now()) {
|
|
593
607
|
for (const [memoryId, confirmation] of this.deleteConfirmations) {
|
|
@@ -608,13 +622,6 @@ class EchoMemApiClient {
|
|
|
608
622
|
}
|
|
609
623
|
return this.whoamiCache;
|
|
610
624
|
}
|
|
611
|
-
cleanMemoryDescription(desc) {
|
|
612
|
-
if (!desc)
|
|
613
|
-
return "";
|
|
614
|
-
// The tuned snapshot wraps text as "Here is a memory: …Content: <text>".
|
|
615
|
-
const idx = desc.indexOf("Content:");
|
|
616
|
-
return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
|
|
617
|
-
}
|
|
618
625
|
async fetchMemoryById(id, enc) {
|
|
619
626
|
try {
|
|
620
627
|
const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
|
|
@@ -630,34 +637,6 @@ class EchoMemApiClient {
|
|
|
630
637
|
throw error;
|
|
631
638
|
}
|
|
632
639
|
}
|
|
633
|
-
async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
|
|
634
|
-
const endpoint = "/api/extension/memories/search";
|
|
635
|
-
let response;
|
|
636
|
-
try {
|
|
637
|
-
response = await this.axios.post(endpoint, {
|
|
638
|
-
query,
|
|
639
|
-
k: opts.limit,
|
|
640
|
-
similarityThreshold: opts.threshold,
|
|
641
|
-
timeFrameDays: opts.timeFrameDays,
|
|
642
|
-
requestId: opts.requestId,
|
|
643
|
-
sessionId: this.sessionId,
|
|
644
|
-
});
|
|
645
|
-
trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
|
|
646
|
-
}
|
|
647
|
-
catch (error) {
|
|
648
|
-
trace?.({
|
|
649
|
-
requestId: opts.requestId,
|
|
650
|
-
endpoint,
|
|
651
|
-
httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
|
|
652
|
-
errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
|
|
653
|
-
});
|
|
654
|
-
throw error;
|
|
655
|
-
}
|
|
656
|
-
const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
657
|
-
return retrievalOnly && isRecord(data)
|
|
658
|
-
? { ...data, retrievalOnly: true, tuned: false }
|
|
659
|
-
: data;
|
|
660
|
-
}
|
|
661
640
|
async searchMemoriesRetrieved(query, opts, enc, trace) {
|
|
662
641
|
const endpoint = "/api/extension/memories/deep-search/candidates";
|
|
663
642
|
let response;
|
|
@@ -732,48 +711,6 @@ class EchoMemApiClient {
|
|
|
732
711
|
memories,
|
|
733
712
|
};
|
|
734
713
|
}
|
|
735
|
-
// Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
|
|
736
|
-
// (the same route the Chrome extension uses): it verifies our ec_ token, derives
|
|
737
|
-
// user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
|
|
738
|
-
// never hits dg-web directly, so no new auth is needed and no other caller is
|
|
739
|
-
// disrupted. NOTE: encrypted users still get server-side plaintext only; the
|
|
740
|
-
// phase-1 + bridge-local-decrypt + phase-2 flow (spec §11) is the remaining work.
|
|
741
|
-
async searchMemoriesTuned(query, requestId, trace) {
|
|
742
|
-
const endpoint = "/api/extension/memories/deep-search";
|
|
743
|
-
let response;
|
|
744
|
-
try {
|
|
745
|
-
response = await this.axios.post(endpoint, {
|
|
746
|
-
query,
|
|
747
|
-
requestId,
|
|
748
|
-
logicalRequestId: requestId,
|
|
749
|
-
sessionId: this.sessionId,
|
|
750
|
-
userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
751
|
-
});
|
|
752
|
-
trace?.({ requestId, endpoint, httpStatus: response.status });
|
|
753
|
-
}
|
|
754
|
-
catch (error) {
|
|
755
|
-
trace?.({
|
|
756
|
-
requestId,
|
|
757
|
-
endpoint,
|
|
758
|
-
httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
|
|
759
|
-
errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
|
|
760
|
-
});
|
|
761
|
-
throw error;
|
|
762
|
-
}
|
|
763
|
-
const data = response.data ?? {};
|
|
764
|
-
if (data.success === false) {
|
|
765
|
-
trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
|
|
766
|
-
throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
|
|
767
|
-
}
|
|
768
|
-
const primary = (data.retrievedMemorySnapshot?.primary ?? []);
|
|
769
|
-
const memories = primary.map((m) => ({
|
|
770
|
-
key: String(m.key ?? ""),
|
|
771
|
-
description: this.cleanMemoryDescription(String(m.description ?? "")),
|
|
772
|
-
time: m.time,
|
|
773
|
-
similarity: typeof m.similarity_score === "number" ? m.similarity_score : undefined,
|
|
774
|
-
}));
|
|
775
|
-
return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
|
|
776
|
-
}
|
|
777
714
|
async searchMemories(args, trace) {
|
|
778
715
|
const parsed = searchMemoriesSchema.parse(args ?? {});
|
|
779
716
|
const query = parsed.query?.trim();
|
|
@@ -800,18 +737,11 @@ class EchoMemApiClient {
|
|
|
800
737
|
timeFrameDays: parsed.timeFrameDays,
|
|
801
738
|
requestId: randomUUID(),
|
|
802
739
|
};
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
if (enc.enabled) {
|
|
809
|
-
return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
|
|
810
|
-
}
|
|
811
|
-
// A synthesized answer is metered through the legacy route. Do not fall
|
|
812
|
-
// back to raw retrieval after a ledger outage: that would return a result
|
|
813
|
-
// without a durable usage fact.
|
|
814
|
-
return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
|
|
740
|
+
// MCP recall is retrieval-only. `includeAnswer` remains accepted by the
|
|
741
|
+
// parser for older clients, but it must never select an EchoMem/dg-web LLM
|
|
742
|
+
// path. The MCP host model receives the ranked memories and writes the
|
|
743
|
+
// final answer itself.
|
|
744
|
+
return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
|
|
815
745
|
}
|
|
816
746
|
/** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
|
|
817
747
|
async decryptResult(data, key) {
|
|
@@ -1227,12 +1157,31 @@ class EchoMemMCPServer {
|
|
|
1227
1157
|
],
|
|
1228
1158
|
};
|
|
1229
1159
|
}
|
|
1160
|
+
if (error instanceof EncryptionStatusUnavailableError) {
|
|
1161
|
+
return {
|
|
1162
|
+
content: [{
|
|
1163
|
+
type: "text",
|
|
1164
|
+
text: [
|
|
1165
|
+
"⚠️ EchoMem could not verify whether this account's vault is encrypted.",
|
|
1166
|
+
"No memory data was returned. Retry this action after the service recovers.",
|
|
1167
|
+
].join("\n"),
|
|
1168
|
+
}],
|
|
1169
|
+
isError: true,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1230
1172
|
if (error instanceof ZodError) {
|
|
1231
1173
|
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
|
|
1232
1174
|
}
|
|
1233
1175
|
if (error instanceof McpError) {
|
|
1234
1176
|
throw error;
|
|
1235
1177
|
}
|
|
1178
|
+
const reconnectRequired = formatReconnectRequiredResult(error);
|
|
1179
|
+
if (reconnectRequired) {
|
|
1180
|
+
return {
|
|
1181
|
+
content: [{ type: "text", text: reconnectRequired }],
|
|
1182
|
+
isError: true,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1236
1185
|
const upgradeRequired = formatUpgradeRequiredResult(error);
|
|
1237
1186
|
if (upgradeRequired) {
|
|
1238
1187
|
return {
|
|
@@ -1345,20 +1294,6 @@ class EchoMemMCPServer {
|
|
|
1345
1294
|
.join("\n\n");
|
|
1346
1295
|
return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
|
|
1347
1296
|
}
|
|
1348
|
-
// Tuned two-phase path: synthesized brief + ranked source memories.
|
|
1349
|
-
if (result?.tuned) {
|
|
1350
|
-
const { answer, memories } = result;
|
|
1351
|
-
if (!answer && !memories?.length) {
|
|
1352
|
-
return { content: [{ type: "text", text: "No relevant memories found." }] };
|
|
1353
|
-
}
|
|
1354
|
-
const sources = (memories || [])
|
|
1355
|
-
.map((m, idx) => `[${idx + 1}] ${m.key}${typeof m.similarity === "number" ? ` (sim ${m.similarity.toFixed(3)})` : ""}\n${m.description}`)
|
|
1356
|
-
.join("\n\n");
|
|
1357
|
-
const text = [answer && `Recall:\n${answer}`, sources && `---\nSource memories:\n\n${sources}`]
|
|
1358
|
-
.filter(Boolean)
|
|
1359
|
-
.join("\n\n");
|
|
1360
|
-
return { content: [{ type: "text", text }] };
|
|
1361
|
-
}
|
|
1362
1297
|
// Fallback: untuned / time-range shape.
|
|
1363
1298
|
const { success, memories, error } = result;
|
|
1364
1299
|
if (!success)
|
package/dist/migrate.js
CHANGED
|
@@ -525,6 +525,37 @@ export async function cancelQueuedImportSession(client, sessionId) {
|
|
|
525
525
|
throw new Error("IMPORT_SESSION_ID_REQUIRED");
|
|
526
526
|
await client.patch(`/api/extension/import-sessions/${encodeURIComponent(sessionId)}`, { action: "cancel" });
|
|
527
527
|
}
|
|
528
|
+
async function findRecoverableImportSession(client, sessions, bareConversationId, signal) {
|
|
529
|
+
const items = sessions.map((session) => ({
|
|
530
|
+
conversationId: bareConversationId(session),
|
|
531
|
+
platform: session.source,
|
|
532
|
+
sourceDate: session.firstTs,
|
|
533
|
+
}));
|
|
534
|
+
const response = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
|
|
535
|
+
const data = response.data && typeof response.data === "object"
|
|
536
|
+
? response.data
|
|
537
|
+
: {};
|
|
538
|
+
const required = new Set(sessions.map((session) => mapKey(session.source, bareConversationId(session))));
|
|
539
|
+
for (const candidate of data.recoverableSessions ?? []) {
|
|
540
|
+
const id = typeof candidate.id === "string" ? candidate.id : "";
|
|
541
|
+
const jobs = Array.isArray(candidate.jobs)
|
|
542
|
+
? candidate.jobs
|
|
543
|
+
.filter((job) => typeof job.id === "string"
|
|
544
|
+
&& typeof job.platform === "string"
|
|
545
|
+
&& typeof job.conversation_id === "string")
|
|
546
|
+
.map((job) => ({
|
|
547
|
+
id: job.id,
|
|
548
|
+
platform: job.platform,
|
|
549
|
+
conversation_id: job.conversation_id,
|
|
550
|
+
}))
|
|
551
|
+
: [];
|
|
552
|
+
const covered = new Set(jobs.map((job) => mapKey(job.platform, job.conversation_id)));
|
|
553
|
+
if (id && required.size === jobs.length && [...required].every((key) => covered.has(key))) {
|
|
554
|
+
return { id, concurrency: MIGRATE_CONCURRENCY, jobs };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
528
559
|
function isRecord(value) {
|
|
529
560
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
530
561
|
}
|
|
@@ -984,7 +1015,13 @@ export async function startMigration(opts) {
|
|
|
984
1015
|
throw codedError("FORBIDDEN_SCOPE");
|
|
985
1016
|
const data = responseData(e);
|
|
986
1017
|
const max = Number(data.maxConversations);
|
|
987
|
-
if (responseStatus(e) ===
|
|
1018
|
+
if (responseStatus(e) === 409 && data.error === "IMPORT_ALREADY_IN_PROGRESS") {
|
|
1019
|
+
const recovered = await findRecoverableImportSession(client, toImport, bareId, opts.signal);
|
|
1020
|
+
if (!recovered)
|
|
1021
|
+
throw e;
|
|
1022
|
+
session = recovered;
|
|
1023
|
+
}
|
|
1024
|
+
else if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
|
|
988
1025
|
capped = max;
|
|
989
1026
|
toImport = opts.pending.slice(0, max);
|
|
990
1027
|
try {
|