From b0d7964a8eb48b46f3eedfd298452035e8fc9502 Mon Sep 17 00:00:00 2001 From: Nikola Le <91601109+nikola0x0@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:51:26 +0700 Subject: [PATCH 1/2] =?UTF-8?q?Promote=20dev=20=E2=86=92=20staging=20(#118?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: show recall() scoringWeights on SKILL.md (WALM-616) The TypeScript gap already shipped in 0.1.6. Agent docs still listed the old RecallOptions, which is how GH #708 concluded recency ranking was unreachable. * docs: define ScoringWeights on SKILL.md (WALM-616) Agents reading only this file still could not see the weight fields. * fix(relayer): restart a failed same-key job on the insert-race path too (WALM-670) * fix(sdk): say what the relayer last reported when a remember wait times out (WALM-670) * fix(python-sdk): say what the relayer last reported when a remember wait times out (WALM-670) * fix(python-sdk): say the retry reuses the job only on the same client (WALM-670) * feat(sdk): listMemories() pages every memory without a query (WALM-388) Recall is similarity-ranked and limit-bounded, so a write can't be confirmed and a namespace can't be counted. listMemories() wraps the existing GET /v1/owners/{owner}/memories and reuses resolveOwner(). Metadata only: no blob fetch, no decrypt. The relayer has no namespace filter, so the optional namespace is applied client-side; a filtered page can be empty while has_more is true. Tombstone fields default for relayers older than WALM-363. * fix(relayer): report the paid-recovery state a same-key retry actually reached (WALM-670) A same-key POST on a paid job always answered 202 `uploaded`, even when it lost the recovery claim. And the claim fenced `failed` rows for 60s, so a recovery that had just failed (the wallet job, or an enqueue error that returned before the publish) left a lease behind. The next retry enqueued nothing, still said `uploaded`, and GET kept saying `failed`: the poisoned-key shape for a blob that already exists. The claim now publishes `uploaded` in the same statement. A `failed` row is claimable at once, and only an `uploaded` row keeps the 60s fence. Two racing retries therefore enqueue one recovery, and the loser reports the row's real status. If enqueue fails, the claim is released back to `failed` with the lease cleared, so the next retry takes it immediately. * fix(sdk): tell a timed-out remember wait to keep waiting on the job id (WALM-670) The timeout message said "retry with the same idempotency key" and that remember_and_wait / rememberAndWait does this for you. Neither is reliably true. `remember` drops its key as soon as the POST is accepted, and an explicit idempotencyKey is never stored, so following the advice minted a new key and a second paid write while the first job was still running. A wait only times out on a job that is still pending, running or uploaded (a failed job raises), so the advice is now to call waitForRememberJob / wait_for_remember_job on the job id again, and to pass an explicit key again on any retry. Python also stores the redacted server_error on the exception attribute, not just in the message. * fix(sdk): reject bad mock page sizes and honor recent recall (#1167) * fix(sdk): reject bad mock page sizes and honor recent recall MemWalMock.listNamespaces treated limit 0 as an endless page and a negative limit as slice's drop-the-tail. Bulk writes skipped the client's empty-batch check, idempotency keys were ignored, and sort recent stayed oldest-first. Positional recall(query, options, namespace) also dropped the namespace on both the mock and MemWal. Fixes #1144, #1145, #1146, #1147, #1124, #1038 * test(sdk): cover the mock limit, idempotency, and recent-recall edges The first tests matched the issue repros. These also lock the branches a tie-break-only sort, a 50-row window cap, a byte-length key, and a mutated options object would have left green. * fix(sdk): treat a remember options object as options and fix job status polls (#1168) * fix(sdk): treat a remember options object as options and fix job status polls remember(text, { idempotencyKey }) and rememberAndWait used to send that object as the namespace. Empty job ids no longer hit GET /api/remember/. Duplicate ids in waitForRememberJobs settle in every slot. Bulk status returns namespace, status polls skip the SEAL session, batches over 20 are rejected before signing, and a destroyed client throws instead of reading a zeroed key or probing /version. #1142 #1087 #1121 #1084 #1104 #1135 #1136 #1090 * fix(sdk): reject a destroyed key still in flight and merge remember options An in-flight getPublicKeyHex resolved to the zeroed cache, and a key derived after destroy was the well-known key of the wiped seed. A third options argument was also dropped when the namespace slot was an object. * fix: reject blank text, treat [::1] as local, and pin weekday dates (#1134) * fix: reject blank text, treat [::1] as local, and pin weekday dates Whitespace-only remember, analyze, and embed text is rejected before a job is created (#1129). The TypeScript client treats bracketed ::1 as loopback (#1128). Fact extraction copies a server-built weekday calendar (#1131). AUTH_REJECTED names a pasted wallet address without changing the 401 (#1132). * refactor: one weekday table, inline the loopback host check The English weekday names lived in a match and again in the calendar loop. One Sunday-first array covers both, indexed by num_days_from_sunday. The IPv6 bracket strip stays inside normalizeServerUrl. * fix(server): return each blob once in recall (WALM-594) search_similar kept every vector_entries row, so a remember retry or restore could surface the same Walrus blob twice with identical scores. * fix(server): refill recall limit after collapsing duplicate blobs Duplicate index rows were eating the ANN LIMIT window. Over-fetch by the dropped count, then unique and truncate. Ranker docs no longer narrate WALM-594. * fix(server): grow recall over-fetch by duplicate factor, cap at 4x limit + dropped only added the unique shortfall, so a 2x index crawled many HNSW round-trips. Widen by dropped rows in the current window and stop at limit * 4. * fix(sdk): throw when positional recall gets a namespace string (WALM-161) recall(query, namespace) was silently treated as options and ran against the default namespace. Fail loudly instead of dropping the intended scope. * fix(sdk): point recall TypeError at a call shape both APIs share recallManual has no object-first overload, so hinting method({ query, namespace }) sent callers to query.trim() failure. * fix(sdk): add default condition to exports map and place types first (Fixes #1143, #1040, WALM-721) - Declare "default" fallback condition across all exported subpaths (".", "./account", "./manual", "./ai") so bundlers (Next.js/Turbopack) and Node 22 require(esm) resolve cleanly instead of throwing ERR_PACKAGE_PATH_NOT_EXPORTED - Place "types" condition first for TypeScript nodenext/bundler resolution - Add exports-map regression tests asserting types-first ordering and default fallback * fix(mcp): bind /connect/mcp consent to the local bridge (#368) (#370) * fix(mcp): re-check the bridge before signing connect consent (#368) POST /preflight on load already rejects a phishing link. Two pieces of that fix were still missing: a wallet that connects after the consent screen is showing starts add_delegate_key without a click, and the page does not ask the bridge again immediately before the signature. Gate auto-continue on an explicit click, refuse to sign if the bridge no longer confirms the request, and warn when the relayer is not the default. * test(app): a connected wallet does not sign connect consent on its own The consent screen must stay up until the user clicks, even when a wallet is already connected. * fix(sdk): retain lowest-distance hits under high-relevance-only token budget (Fixes #1103, WALM-756) - In applyTokenBudget, select candidates by ascending distance under high-relevance-only strategy rather than array order - Preserve the caller's requested order (e.g. sort: recent) for the retained lowest-distance hits - Break distance ties stably by original index - Update TruncationStrategy documentation and recall client commentary - Add regression test reproducing the two-hit recency order bug and multi-hit order preservation * fix(sdk): clamp polling delay to deadline and ensure boundary poll check (#1081) - In waitForRememberJob and waitForRememberJobs, clamp the polling sleep delay to Math.max(0, deadline - Date.now()) so the client never sleeps past the wait budget. - Refactor the polling loop so that if the remaining budget expires during sleep, a boundary poll is performed rather than exiting immediately without a status check. - Update pollDeadlineMs to supply a usable minimum timeout (1000ms, clamped to requestTimeoutMs) at or past the boundary so the final check does not immediately abort with a 1ms timeout. - Add regression tests verifying that a job settling before the deadline resolves instead of timing out, and that wait loops do not sleep past the deadline. * fix(server,sdk): scope remember idempotency keys per namespace (WALM-781) - Scope unique partial index on remember_jobs to (owner, namespace, idempotency_key) - Add migration 025 to drop legacy uq_remember_jobs_owner_idempotency_key index - Update conflict handling and idempotency lookups in server routes/remember.rs and routes/analyze.rs - Scope TypeScript MemWalMock idempotency map to namespace + key and document in JSDoc - Add tests covering cross-namespace key re-use and intra-namespace deduplication Fixes WALM-781, #1170 * fix(sdk): derive and forward idempotencyKey in rememberBulk (Fixes #1080) * fix(python-sdk): anchor memory system instruction to start of prompt and catch cross-loop ValueError (Fixes #1159, WALM-817) * fix(python-sdk): support HTTP-date Retry-After and prevent float overflow (Fixes #1151, WALM-815) * fix(python-sdk): retry transport errors and bound polls by deadline in wait_for_remember_job (Fixes #1000, WALM-696) * fix(sdk): validate recall maxTokens and preserve matching total (Fixes #1068) - Add and export validateMaxTokens() to reject negative budgets with RangeError and non-numeric budgets with TypeError before making network requests - Call validateMaxTokens() early in MemWal.recall(), MemWalMock.recall(), and applyTokenBudget() - Preserve matching hit count (processed.total / ranked.length) in recall() results instead of overwriting total with budgeted.length (#1068, #1066) - Add comprehensive unit and e2e tests covering maxTokens validation and total preservation * fix(sdk): warn on recall failure in withMemWal and add onRecallError callback (Fixes #1118, WALM-764) * fix(python-sdk): wrap docstring and tighten mock-compat TypeError inspection in poll loop * fix(python-sdk): fix ruff import sorting/unused imports and document wall clock usage in _http_date_retry_ms * fix(python-sdk): support LangChain multimodal system message, copy model attributes, and fix ruff line lengths * fix(sdk): validate bulk idempotencyKey on MemWalMock and skip prefix length check on derived items * test(python-sdk): freeze time in HTTP-date retry test to eliminate subsecond truncation race * fix(python-sdk): derive content idempotency key in remember (Fixes #1094, WALM-800) Derive content-based idempotency keys r1-{sha256(bucket \0 namespace \0 text)} matching the TypeScript SDK specification (30-minute bucket window) when idempotency_key is omitted in remember() and remember_and_wait(). Previously, Python fell back to str(uuid.uuid4()) on each completed write or new client session, causing identical writes to mint fresh paid blobs rather than collapsing onto the existing job. * fix(sdk): reject an empty namespace at construction MemWalMock already throws "namespace cannot be empty". MemWal and MemWalManual treated "" as a real namespace and sent it on the wire. Omitted namespace still defaults to "default". Fixes #1204 * fix(sdk): require a 32-byte Ed25519 seed at construction hexToBytes accepted any even-length hex, so a 40-byte key or a missing key survived MemWal.create and failed later inside the signer. Fixes #1200 * fix(sdk): reject a malformed listNamespaces cursor in the mock A cursor that JSON-parsed but was not {updated_at, namespace} made date comparisons NaN and returned an empty page with has_more false, so a pagination walk stopped while namespaces still existed. Fixes #1203 * fix(chatbot): rate-limit unauthenticated guest user creation (WALM-415) GET /api/auth/guest inserted a User row on every call. Throttle before signIn so one IP cannot farm guest rows in local or production. * fix(chatbot): consume guest rate limit in authorize and wait for Redis (WALM-415) Guest inserts go through authorize(), not only GET /api/auth/guest. Await Redis connect before fail-closed 503 so cold starts are not blocked. * fix(chatbot): reconnect Redis and map guest limiter through Auth.js A leftover settled connectPromise skipped reconnect after chat warmed the client. Drain on settle, raise the site-wide guest cap, and surface authorize() 429/503 as JSON instead of CallbackRouteError 500. * fix(sdk): default account and manual helpers to a gRPC Sui client (WALM-164) createAccount, addDelegateKey, removeDelegateKey and MemWalManual looked for SuiClient in @mysten/sui/client when the caller omitted suiClient. Current @mysten/sui no longer exports it, so they threw "SuiClient not found" before doing anything. Build SuiGrpcClient against the public fullnode for suiNetwork instead. Those fullnodes no longer serve JSON-RPC, so a JSON-RPC default would construct and then fail on its first call. An explicit suiClient still wins. Fixes #300 * fix(sdk): reject unsafe remember job ids (WALM-833) * fix(sdk): reject unsafe remember job ids and add Python status getRememberStatus and waitForRememberJob no longer sign a job id that is empty or contains a slash, question mark, hash, or whitespace. Python MemWal and MemWalSync gain get_remember_status, and an empty 404 body is not_found instead of json.JSONDecodeError. WALM-833, WALM-832, #1100, #1096, #1216 * fix(sdk): allowlist remember job ids and parse status 404s locally `.` `..` `\` and `%2e%2e` passed the denylist and were rewritten after signing. Both SDKs now accept only one [A-Za-z0-9_-] segment. A UUID object still polls; any other non-string is a TypeError. Empty and non-object 404 bodies are not_found in the status readers, not in the shared signed request. * fix(sdk): stop preflights from consuming recall's abort budget (WALM-598) (#883) * fix(sdk): stop preflights from consuming recall's abort budget (WALM-598) `recall()` armed a single 15s AbortController before calling `signedRequest`, which then awaited two unabortable, untimed preflights: `ensureCompatibleRelayer()` (`GET /version`, falling back to `GET /health`) and `buildSealSession()` (`GET /config` plus a Sui RPC round-trip for `SessionKey.create`). Only the final `/api/recall` fetch received the signal. A relayer whose `/health` p50 had drifted to ~9s therefore spent most of the caller's budget before recall was dispatched, and the resulting `AbortError` surfaced on the recall fetch even when recall itself was healthy (GH #438). Both preflights are memoized, so this hit the first recall of a process and every recall after the 5-minute SEAL session expiry. SDK: - Move the deadline into `signedRequest`, armed after the preflights resolve, so it covers only the request it names. - Give `/version`, `/health` and `/config` their own bounded budget (`preflightTimeoutMs`, default 5000). They previously carried no signal at all and could hang indefinitely. - Make recall's budget configurable via `MemWalConfig.recallTimeoutMs` and `RecallOptions.timeoutMs`; the default stays 15000. - Throw a `TimeoutError` whose `phase` names the round-trip that stalled instead of an unlabeled `AbortError`. Export `isTimeoutError`. - Bound the standalone `health()` and the manual client's `/version` -> `/health` fallback the same way. Server, in support of keeping `/health` a sub-second connectivity check: - Add single-flight to `sidecar_write_ready`. The 2s TTL was checked under the lock but not held across the probe, so every request arriving during an in-flight probe started its own. - Replace the blocking `std::sync::Mutex` with `tokio::sync::RwLock`. Besides parking a worker thread on an async path, the mutex could poison: the read side recovered but the write side was a bare `if let Ok(..)`, so one panic froze the cached timestamp and every subsequent `/health` fanned out to the sidecar for the life of the process. - Instrument the probe with `observe_external` / `record_sidecar_failure` and raise failure logging from `debug!` to `warn!`. The probe's latency and failure rate were previously unmeasurable. The `/health` response shape and its public, unauthenticated status are unchanged. * review: hold the deadline through the response body (WALM-598) fetchWithDeadline cleared its timer as soon as fetch() resolved — which is when the headers arrive, not the body. signedRequest then ran res.json() / res.text() with no deadline and no signal, so a server that answers promptly and trickles the body could outlive the budget the caller named. The old recall AbortController stayed armed through the body, so this was a regression in exactly the budget the PR claims to fix. - The read methods now carry the timer and release it when the body settles. An abort our own timer caused is still relabelled to a phase-tagged TimeoutError, so a slow body does not resurface as a bare AbortError. - The timer is unref'd so a response nobody reads cannot hold the loop open. - Tests for a trickled body and for a normal read releasing the timer. - docs/reference/configuration.md now lists recallTimeoutMs, preflightTimeoutMs and RecallOptions.timeoutMs, so the "full config surface" pointer in api-reference is true again. - Shrink the incident recap on the constants, recall(), the preflights and manual.ts to the contract; fix the admin.rs RwLock rationale, which claimed the old std::sync::Mutex parked a worker across the probe (it was dropped before .send().await). * review: rebase the changelog, make the timer test bite (WALM-598) - Rebased onto origin/dev. Notes fold into the existing `## 0.1.7` alongside the landed restore / 401 / tx.pure bullets instead of a separate `## Unreleased`, and mirror into docs/sdk/changelog.mdx. package.json stays at origin/dev's 0.1.7; no bump. - The "releases the timer" test only asserted `res.json()` returned the right value, which passes whether or not `release()` ran. It now spies the timer and asserts both halves of the fix: the deadline is still armed when fetch() resolves, and gone once the body settles. - Style-guide pass on docs/sdk/api-reference.md:59 and :128. * fix: drop an accidental packages/sdk/node_modules symlink A local node_modules symlink was staged with the previous commit. Root .gitignore lists `node_modules/`, which matches directories only, so a symlink of that name was never ignored. On any other checkout it is a broken link pointing at a machine-local absolute path, and pnpm install fails with ENOENT in packages/sdk. * fix(sdk): let the body deadline fire on an idle event loop (WALM-598) The deadline timer was `unref()`d, so once it became the thing that bounds the body read it could no longer fire when nothing else held the loop open. Node 22 drained the loop mid-test and cancelled the four body-deadline tests with 'Promise resolution is still pending but the event loop has already resolved'; a caller awaiting a trickled body on an otherwise-idle loop hit the same hole. Keep the timer referenced. An unread response body already holds a socket handle open in undici, so unref'ing bought nothing it was supposed to. * rebase: reconcile WALM-598 budgets with the shipped deadline contract dev landed WALM-648 (every request deadline, `MemWalRequestTimeout` + status 504 so a stalled poll is retried) and WALM-623 after this branch was cut. Reconciled rather than re-litigated: - Preflights and recall keep WALM-598's independent budgets, but run through the client's own `fetchWithDeadline`, which bounds the body read too. - `requestTimeoutError` gains `phase` and `timeoutMs`, so the stalled round-trip is named without renaming the shipped error. - `deadline_ms` is derived from the budget actually in force, so a caller's `timeoutMs` moves the relayer's clock with it. * fix(sdk): opt out of a non-positive recall deadline Rebased onto current dev. Admin health uses WRITE_READY_CACHE_TTL and WRITE_READY_PROBE_TIMEOUT. recall({ timeoutMs: 0 }) no longer aborts or sends deadline_ms: 1, and isTimeoutError matches MemWalRequestTimeout. * fix(sdk): drop the unused recall timeout alias CodeQL flagged RECALL_REQUEST_TIMEOUT_MS after recall started using the caller budget directly. * fix(sdk): honor a disabled recall deadline and the distance total A client recallTimeoutMs of 0 now omits deadline_ms, same as a per-call 0. maxDistance reports the filtered hit count again. --------- Co-authored-by: Harry Phan * fix(mcp): mint a fresh delegate key on forced login (WALM-645) (#946) * fix(mcp): mint a fresh delegate key on forced login (WALM-645) `loginFlow` adopted a still-valid pending record unconditionally, so a stranded login could never be abandoned. With `/api/whoami` absent from the default relayer, recovery lands in `unavailable` on every start and keeps the record for its full 24h TTL, while the browser's `add_delegate_key` aborts with `EDelegateKeyAlreadyExists`. `login` skips recovery but still reused the record, so it was not an escape hatch either. `login` now passes `freshKey`, which mints a new keypair and write-aheads it over the stuck one. The `unavailable` notice no longer says no action is needed. * fix(mcp): name the key a fresh login discards, and reach it from the tool (WALM-645) `freshKey` dropped the pending record and `savePendingLogin` overwrote it unconditionally, with no equivalent of `backupIfReplacingAnotherAccount`. That is the loss the write-ahead record exists to prevent: the discarded record was the only copy of a key the browser may already have paid to register, and recovery is skipped on this path, so nothing ever named it. `loginFlow` now reports the discarded record's public key and delegate address before the overwrite, so the registration can be found in the dashboard. `freshKey` was also wired only into the CLI. The `memwal_login` tool path goes through `startOrReuseLoginFlow` from `auth-required` and `bridge`, neither of which passed it, so a user inside an MCP host stayed stuck with no escape but a real terminal. Both now take it from an opt-in `freshKey` tool argument, defaulting to false so an ordinary login still reuses the pending key. The `unavailable` notice gave key collision as the reason to remove the key, which cannot happen to a freshly minted one, and pointed at `memwal-mcp login` rather than the documented npx form. `LOGIN_RETRY_INSTRUCTION` still offered the retry only to users who had not approved the wallet step, and the bridge's failure notice still promised the next start reclaims the key, which it cannot where the relayer has no `/api/whoami`. * fix(mcp): reset inflight login on freshKey so forced retry mints a new key * fix(mcp): close the login listener that freshKey replaces freshKey minted a new key but left the previous listener bound. Its timeout still reported a failure, and a late callback could save credentials or clear the write-ahead record the new login just wrote. Aborting that listener drops both. The tool result names the abandoned public key and address. WALM-645 * fix(mcp): drop a freshKey login aborted while it is still minting A second freshKey aborts the controller before the first flow subscribes. That flow now stops before it writes the pending key or publishes a URL, so the caller does not keep a link to a listener that is already closed. * fix(mcp): do not send a replaced login to the CLI fallback A superseded sign-in tells the caller to open the newer memwal_login result instead of running a second login that would mint over it. --------- Co-authored-by: Harry Phan Co-authored-by: ducnmm <165614309+ducnmm@users.noreply.github.com> * fix(sdk): surface a failed auto-save from flush (#1209) * fix(sdk): surface a failed auto-save from flush Background analyze still does not fail the chat turn. flush() used to report success anyway. It now raises, and onSaveError / on_save_error fires when the save fails. Fixes #1117, #1150 * fix(sdk): isolate a throwing onSaveError from the host process The callback runs inside the background save. If it throws, log it and still report the original analyze failure from flush(). Fixes #1117, #1150 * fix(sdk): keep auto-save failures without pinning request frames The ledger stores a type and a one-line message, not the exception traceback. The callback runs before the record is stored, so a flush inside the callback cannot drop it. flush also records a task that failed or was cancelled. Fixes #1117, #1150 * fix(sdk): keep an auto-save failure when its callback flushes The failure is recorded before onSaveError / on_save_error. A flush from that callback does not clear it, and flush_sync does not join its own worker. * fix(sdk): return from on_save_error before joining workers A flush started inside the callback no longer drains, joins, or clears the ledger. The callback depth is updated under the lock so two overlapping workers cannot join each other or drop an increment. * fix(sdk): only the callback thread skips a failed auto-save A flush from on_save_error still returns without clearing the failure. A flush from any other thread waits and raises, which is the signal #1117 and #1150 ask for. * fix(sdk): preserve matching total under recall maxDistance filter (Fixes #1066, WALM-840) (#1198) * fix(sdk): preserve matching total under recall maxDistance filter (Fixes #1066, WALM-840) - In TypeScript SDK (memwal.ts), avoid overwriting total with filtered.length when maxDistance is applied, preserving the relayer's matching hit count - In Python SDK (client.py), preserve total from response data instead of overwriting with len(memories) after max_distance filtering - Update test suites to assert that total reflects matching memories while results carries post-filter hits, allowing callers to distinguish between an empty namespace and results trimmed by distance cutoff * test(sdk): expect the relayer total after a maxDistance filter The live e2e still asserted total equalled the filtered length. A cutoff that drops rows keeps the page total, including when every row is dropped. Fixes #1066 * fix(sdk): keep the mock recall total when maxDistance drops the page The offline mocks now match the HTTP clients: total is the page length before the client-side cutoff. maxTokens still replaces total with the budgeted length. maxDistance itself is unchanged. * fix(mcp): derive remember idempotency key from resolved namespace (Fixes #1093, WALM-831) (#1195) * fix(mcp): validate hex input in hexToBytes and support uppercase 0X (Fixes #1162) (#1174) * fix(python-sdk): redact IPv6, 0.0.0.0, and localhost subdomains in error messages (Fixes #1161) (#1173) * fix(python-sdk): support uppercase 0X prefix in normalize_private_key (#1172) Allow private keys with uppercase 0X prefix to be normalized without failing during hex decoding. Matches TypeScript SDK behavior. Fixes #1163 * fix(sdk): validate accountId in MemWal.create and MemWalManual.create (Fixes #1155) (#1175) * fix(sdk): validate limit is a positive integer in recall, recallManual, and restore (#1180) * fix(sdk): validate limit is a positive integer in recall, recallManual, and restore - Validate limit is a positive integer before signing requests in recall(), recallManual(), and restore() - Reject limit <= 0, floats, NaN, and invalid types with TypeError - Document relayer cap of 100 on recall and [1, 100] clamp on restore in types JSDoc and api-reference.md - Add comprehensive test coverage in limit-validation.test.mjs Fixes #1086 * fix(sdk): validate limit in MemWalManual.restore recall, recallManual, and MemWal.restore already reject a non-positive or non-integer limit. MemWalManual.restore still forwarded it. Fixes #1086 --------- Co-authored-by: harrymove-ctrl <267878750+harrymove-ctrl@users.noreply.github.com> * fix(sdk): attach SEAL session and timeout in MemWalManual.restore (Fixes #1137) (#1190) * fix(sdk): attach SEAL session and timeout in MemWalManual.restore (Fixes #1137) * test(sdk): drop the unused restore timeout import The tests name the constant in their titles but never reference the binding, which CodeQL flagged on the restore PR. Fixes #1137 * fix(sdk): keep the manual request deadline armed across the body read fetch settles when the headers arrive. Disposing the timer before res.text() or res.json() let a stalled body hang. The deadline now covers that read and is cleared afterwards. Fixes #1137 * fix(sdk): sign manual restore sessions with the delegate key An owner wallet session prompts on every refresh and is broader than the delegate this client already holds. restore() now builds the SEAL session from that delegate key. The timeout test drives restore() and stalls the body after headers. --------- Co-authored-by: harrymove-ctrl <267878750+harrymove-ctrl@users.noreply.github.com> * fix(python-sdk): expose created_at and score in RecallMemory and RecallManualHit (#1178) - Add score and created_at fields to RecallMemory dataclass - Add created_at and importance fields to RecallManualHit dataclass - Forward fields from API response in MemWal.recall and MemWal.recall_manual - Populate score and created_at in MemWalMock.recall - Add regression tests in test_client.py and test_mock.py Fixes #1063 Fixes #1160 * fix(sdk): suppress recall and auto-save on tool execution steps (Fixes #1079) (#1184) * fix(sdk): suppress recall and auto-save on tool execution steps (Fixes #1079) * fix(sdk): keep recalled memory on tool steps without a second recall Auto-save still runs only on a user turn. A later tool step re-injects the cached recall for that same user message, so the answer step does not lose the memory context. Fixes #1079 * fix(sdk): recall when a tool step has no cached memory A matching cache is still reused and does not call recall again. A miss, including the first call on a fresh withMemWal, recalls once and stores that result. Fixes #1079 * fix(sdk): cache an empty recall so tool steps do not call again A user turn with no matching memories used to clear the cache. The next tool step then called /api/recall again. An empty result is now remembered for that turn. Fixes #1079 * fix(sdk): recall again on the next user turn Tool steps still reuse a cached recall, including an empty one. A later user message recalls even when the text matches the previous turn. * fix(python-sdk): suppress recall and auto-save on tool execution steps (Fixes #1107) (#1185) * fix(python-sdk): suppress recall and auto-save on tool execution steps (Fixes #1107) * fix(python-sdk): keep recalled memory on tool steps without a second recall Auto-save still runs only on a user turn. OpenAI and LangChain tool steps re-inject the cached recall for that same user message. Fixes #1107 * fix(python-sdk): treat LangChain generate as one conversation _generate and _agenerate receive one message list, the same list invoke passes. A tool step reuses that turn's cached recall, and a cache miss recalls once instead of dropping the memory. Fixes #1107 * test(python-sdk): call LangChain generate with one conversation The tests added on dev still passed a batch into _agenerate. The wrapper receives one message list, so these calls and their captures match that. * fix(python-sdk): cache an empty recall and refresh on the next user turn Tool steps reuse a recall that matched nothing. A later user message with the same text calls recall again. min_relevance stays 0.3. * fix(python-sdk): sort the same-text recall test imports Ruff I001 failed the Python unit jobs before pytest ran. * feat(app): land the Sept 2026 dashboard branch on dev (#1169) * feat(app): restyle the dashboard to the Sept 2026 design Keep delegate keys, namespaces, and the playground, and hide the Walrus Console promo until its calendar date. Console links open console.wal.app, Claude Code install commands match the docs, and pasting a delegate key checks the current account on chain. * fix(app): point the Walrus Console promo at console.walrus.xyz The date gate is unchanged. The links were opening the wrong host. * fix(app): toggle the Walrus Console promo with a boolean flag Replace the calendar-date gate with VITE_WALRUS_CONSOLE_ENABLED. The promo stays off unless that value is true. * feat(app): restyle the MCP connect screens to match the dashboard Use the same dark column, type, and flat cards for the local MCP login and the Claude consent page. The sign-in steps are unchanged. * fix(app): keep the playground open when this browser has no key Stop sending /playground back to the dashboard. Show the same yellow notice with a paste field, and leave Run disabled until a registered delegate key is attached. * fix(app): keep the playground key notice above the steps The shared dashboard alert order was pushing the yellow notice to the bottom of the playground. * fix(app): finish the playground key gate and MCP consent screen Keep the playground open without a key, and restyle the MCP consent page to the same type as the dashboard. Details stay collapsed until opened. * fix(app): open the playground at the top and drop shouted labels Scrolling from the dashboard no longer lands midway down the playground. Buttons and links keep the wording as written instead of full capitals. * fix(app): restore uppercase labels, including the MCP approve button The sentence-case pass was a mistake. Dashboard, playground, and the MCP consent button use the tracked capitals again. * fix(app): bake the Walrus Console flag into the Docker build Railway already sets VITE_WALRUS_CONSOLE_ENABLED, but the image never received it, so the promo stayed off. * fix(app): align the console button with the install steps Put the Walrus Console link in the same column as the step text, and open up the space between the agent steps. * fix(app): apply the dashboard design review notes Keep the install connector on the first tab, show the SDK quickstart in the app step, and drop the AI SDK block. Tighten mobile key rows, selection, and the paste-key form. * fix(app): space the connect cards and center the Codex tab icon Give the subtitle more room above the two connect modules, and keep the Codex mark centered in its tab. * feat(app): restyle the sign-in page and finish the dashboard review Keep the signed-in dashboard and the signed-out sign-in page on one branch. The playground stops after the basic health, remember, and recall steps. * feat(admin): chart memories by day and match the dashboard Add the admin activity API with a 30-day window, keep balance samples long enough for that comparison, and restyle admin plus the sign-in stage to the Sept 2026 layout. * feat(app): send the funnel to Statsig with key join ids Forward trackEvent and page views to Statsig, and attach the delegate public key plus transaction digest on the two successful key events so the visit can be joined to on-chain activity. * fix(docs): exempt the admin activity route from the public reference The freshness check already leaves the other ADMIN_API_KEY routes undocumented. /api/admin/activity belongs in that same list. * fix(app): lower the sign-in layout and match the glass tiles Shift the desktop and mobile content down with the phone, and fill the sign-in buttons white on hover. The right tile is more transparent and the left tile keeps a darker frost. * fix(app): drop the extra background under playground results The result text was sitting in a second gray box inside the yellow frame. Keep the frame and let the JSON sit directly in it. * fix(app): restyle the dashboard confirm dialogs Match the remove-key, delete-memory, and preview dialogs to the dashboard: black panel, Ratch title, and outline pills. * fix(app): use David's mascot-free Open Graph image Point the social preview at the 1200x630 wordmark image and keep the old filename serving the same file so existing links do not 404. * fix(app): replace the sign-in phone with the laptop graphic David's updated sign-in visual uses the dashboard on a laptop instead of a phone. The glass tiles follow that screen, and the top mobile tile sits behind it. * fix(app): enlarge the mobile sign-in laptop and its tiles Give the laptop and glass tiles a little more size, and open up the space above and below them. * fix(app): use the final page title and meta description Kenton confirmed the title and description, including the Open Graph and Twitter tags. * feat(admin): hourly memory chart, click-to-copy addresses, fuller error details Memories written: a 24h window has one calendar day, so the daily chart drew a single bar across the whole card. For windows of 24h or less the activity route now also returns memories_by_hour (same viewer-offset bucketing as the daily query, hours with no jobs filled with zero, absent for longer windows so existing clients are unaffected), and the chart draws one bar per hour. Midnight ticks show the date, and tooltips and the summary name the date and hour. Addresses: the upload-errors table showed only the first six characters (0x07e5). Every admin address (upload errors, top owners, uploader pool, sponsor wallet) now reads 0x5c3a...907e and copies the full value when clicked. A copy icon appears on hover; on click the text turns green, the icon pops to a check and a "Copied" chip rises and fades (red "Copy failed" if the clipboard is refused). The row copy button used to give no feedback at all, since its copied state was only wired to the modal. Error details: the modal showed a timestamp and the message. It now shows the job ID, full owner (copy, and a Suiscan link for the deployment's network), namespace, status, queued and failed times with the gap between them, the message with its own copy, a clear note when the server recorded none, and "Copy all details" as JSON for a ticket. The server already sent status and created_at; the client dropped them. The content area also scrolls now on short screens instead of being cut off. Rust: 2 new unit tests for hourly buckets (positive and negative offsets, empty window); admin_activity 10/10. App: tsc clean, vitest 112/112. * perf(server): index memory_tombstones by deleted_at for admin activity /api/admin/activity counts tombstones in a deleted_at window with no owner, for the current and the previous window. The only index is (owner, deleted_at, memory_id), which cannot serve that range, so each call scanned the table twice, and the admin view polls every 60s per open tab. Adds 024 with CREATE INDEX CONCURRENTLY in its own file (as 016/018/022) and registers it in CONCURRENTLY_BUILT_INDEXES so an interrupted build is recovered. storage::db tests 19/19, including the wiring and registry checks. * fix(app): keep chain ids out of analytics and redact every 64-hex value 7c9dd917 exempted delegate_public_key and transaction_digest from the redaction rules and sent both on delegate_key_register_complete and delegate_key_add_complete to GA/GTM, PostHog and Statsig. - A transaction digest resolves on-chain to the sender's wallet, so every analytics profile (PostHog id, Statsig stableID) became linkable to an address, which the existing redaction policy exists to prevent. - The exemption was keyed by property name, so a real 64-hex private key passed as delegate_public_key was sent unredacted. The old test only used a 63-character value, so it did not catch this. Both fields are removed from the two events and the exemptions are gone: a 64-hex value is redacted under any property name again. The events, page views and the Statsig sink are otherwise unchanged. If joining the funnel to chain activity is still wanted, it needs a privacy sign-off and an id that is not derivable from public chain data. Tests: 64-hex redacted under public-key names; the two key events no longer carry either field. vitest 122/122, tsc -b clean. * fix(app): pin the Statsig script by SRI and keep analytics off /admin The Statsig client is loaded from jsDelivr with no integrity attribute and there is no CSP, while the same origin keeps the delegate private key and the admin API key in sessionStorage. A changed CDN response would run with access to both. - The script tag now carries sha384 integrity for exactly @statsig/js-client@3.33.5/build/statsig-js-client.min.js. The hash was computed locally and matches jsDelivr's published hash for that file (the file is served immutable). In a browser, the correct hash loads and a tampered one is blocked. - No third-party analytics (GTM, PostHog, Statsig) initialises or sends on /admin, which holds the admin key and has nothing to measure. A CSP for the whole app is still worth adding, but it touches GTM, PostHog and wallet popups, so it is left out of this PR. vitest 124/124, tsc -b clean. * fix(app): clear a pasted delegate key once it is imported After "Use this key" succeeded on the dashboard, the pasted private key stayed in plain text in the textarea: its state lives in ConnectWalrusMemory and nothing cleared it (the Playground version does). - importExistingKey now resolves true once the key is stored, and the form clears the field then. A failed import keeps the value so a typo can be fixed. - The field is masked like a password, has autocomplete, autocorrect and autocapitalize off, and carries data-analytics-redact so the click guard treats it as sensitive. Test: the field keeps the key after a failed import and is empty after a successful one. vitest 125/125, tsc -b clean. * fix(app): pin the desktop sign-in laptop to the right edge A wide window letterboxed the device away from the corner. The wordmark follows the stage. * fix(app): match the Back to Console dialog's top padding to its sides The title sat tighter against the top edge than the left and right padding. * fix(app): center the sign-in hero above a 14-inch width The laptop stays on the right edge through 1512px. Wider windows center the group, and the wordmark lines up with the title. * fix(app): put the sign-in wordmark back in the frame's top left The logo sits 50px from the frame edge, left of the Sign in column, as in the design. * feat(app): animate the dashboard copy buttons Copy icons crossfade to a drawn check (or a cross on failure), the wallet address blurs to Copied at a locked width, and the button presses 1px. Copies fall back to execCommand, report failures, and announce the result to screen readers. Motion is CSS-only and off under reduced motion. * fix(app): lay the signed-in pages out for phones Under 640px the Connect Walrus Memory timeline starts flush left on a 48px rail instead of hanging off the first tab, commands scroll sideways instead of breaking mid-word, and the playground title fits. Under 480px the client icons sit above the card title. Under 420px Sign out collapses to its icon so the address no longer runs into the logo. * fix(app): pass VITE_CONSOLE_URL and the legal URLs into the Docker build (#1113) The Dockerfile had no ARG for them, so the Railway values never reached Vite and the Back to Console dialog was compiled out on every deploy. * fix(app): bake the current Console and deployment defaults The Walrus Site build never received the console and legal URLs, and the image still fell back to the pre-cutover packages. Defaults now match the live Railway deployments, and an unset Console URL points at that network's Memory tab. * fix(app): center the sign-in hero on a short 14-inch window A 14-inch MacBook at 100% zoom is shorter than the Figma frame, so pinning the stage right left a wide gap on the left. Center that window. Keep the laptop on the right edge only while the window is at least as tall as a 14-inch fullscreen. * fix(app): grow a short sign-in window until the laptop meets the right edge Use the updated laptop screen and the purple bloom behind it. On a window shorter than a 14-inch display, scale the stage to the full width so the laptop touches the right edge. * fix(app): keep the sign-in size from a 1.9 window and scroll past it Fit the desktop frame to 880px so the empty tail under the laptop tile does not shrink the page. At 1.9:1 and wider the composition stays the size it has at that ratio for the current width, and the page scrolls with the scrollbar hidden. Spare width still leaves 100px of the laptop clipped. --------- Co-authored-by: Harry Phan Co-authored-by: Nikola Le <91601109+nikola0x0@users.noreply.github.com> * docs: add a guide for importing existing agent memory (#1197) * docs: add a guide for importing existing agent memory Workshop developers asked for a page that copies local memory, standing context, and session transcripts into Walrus Memory in batches, then keeps using that account. * docs: cover import rate limits, timeouts, and Antigravity Hosted mainnet allows 120 weighted requests per minute and 1000 per hour per account, and 60 per minute per delegate key. A bulk timeout means the upload may still be running, so the guide says to poll those job ids instead of sending the batch again. * docs: drop the staging relayer from the import sample Kimberly confirmed staging stays closed to the public, so the sample no longer points readers at relayer-staging. * docs: make the import sample safe to copy Send Claude memory files through analyze, keep only user and assistant text from a session transcript, and retry a refused call after Retry-After without resubmitting job ids the relayer already accepted. * docs: apply the import-guide review * fix(sdk): drop the dead Walrus import and split recallManual results (#1222) /manual still dynamically imported @mysten/walrus for a client nothing called, which pulled the WASM package into Turbopack and Vite. Downloads stay on the HTTP aggregator. MemWal.recallManual returns RecallManualHitResult. MemWalManual.recallManual returns RecallManualMemoryResult. RecallManualResult stays as a deprecated alias of those two, not a mixed list. WALM-145, WALM-163 * fix(server): hold back migration 025 until the next release (#1225) Keep the legacy (owner, idempotency_key) unique index for this release so a relayer rollback stays safe. The (owner, namespace, idempotency_key) index from 013 and the WALM-781 code still ship; 025 returns in the release after. Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> * fix(indexer): decode checkpoints carrying the Validity expiration (WALM-668) (#1224) testnet-v1.79.0 added `TransactionExpiration::Validity` as variant index 3. The indexer was pinned to testnet-v1.75.1, whose `TransactionExpiration` has three variants, so once testnet began producing `Validity` transactions every checkpoint carrying one failed to convert: transaction.bcs: invalid value: integer 3, expected variant index 0 <= i < 3 The indexer decodes whole checkpoints rather than individual events, so this is a hard stop and not a skipped field: `accounts_v1` retried the same checkpoint indefinitely and its watermark backlog grew to 48,527 pending commits on staging, leaving GET /api/accounts/{owner}/exists answering false for accounts that exist on-chain. Move both Sui pins to testnet-v1.80.0 (ceaaff1c), the current testnet release. The remote store layout is unchanged between the two revisions (same `{checkpoint}.binpb.zst` objects, same zstd + protobuf decode), so this needs no ingestion or deployment configuration change and the saved watermark stays valid. Cover it two ways: `tests/affected_checkpoint.rs` decodes testnet checkpoint 384534623 — the one named in the incident, committed as a fixture in the exact form the remote store serves — through the real `StoreIngestionClient`, and `tests/checkpoint_decoding.rs` pins `Validity` to variant index 3 while holding the earlier variants at their own indices, which already-indexed history depends on. (cherry picked from commit ca3f72d5e96e001c7cda67e43df321739b995696) (cherry picked from commit 69908acc81fce294309d3c85b8ce9db5c87f404f) Co-authored-by: Harry Phan * fix(oauth): don't burn the session when Sui is unavailable (WALM-605) (#879) * fix(oauth): don't burn the session when Sui is unavailable (WALM-605) `session_complete` consumed the one-time authorization session and only then verified the delegate key on-chain, mapping every `verify_delegate_key_onchain` failure — a Sui `GetObject` 429 included — to `invalid_request("delegate key is not registered on-chain")`. A throttled fullnode therefore looked identical to a genuinely unregistered key, and because the session was already spent the user could not retry: they had to restart `/oauth/authorize` from scratch. Follow-up to WALM-429 (#811), which taught signed HTTP auth the same distinction but left this callback behind. - Classify verify failures with `OnchainVerifyError::is_unavailable()`, the same primitive `auth::cache_reverify_action` uses, so the OAuth callback and signed recall cannot drift apart on what a 429 means. - Move the atomic claim below the on-chain read: only a definitive answer (registered or not registered) spends the session; an unreachable chain leaves the row `pending` so the same request can be re-submitted. Single-use is unchanged — no code is minted until the `WHERE status = 'pending'` update wins, and the session-bound wallet proof still gates everything above it. - Return `temporarily_unavailable` 503 with `Retry-After: 5` instead of a dead-end 400, matching signed auth's `AUTH_UPSTREAM_RETRY_AFTER_SECS`. * review: let the consent UI re-send /complete alone (WALM-605) Sparing the session only helps if something re-POSTs /complete. ConnectClaude did not: any /complete failure went to the terminal error step, and a reload re-ran handleConnect including POST /account, so a first-time connect that had already landed add_delegate_key re-submitted the same pubkey, aborted EDelegateKeyAlreadyExists (code 0), and the SPA reported "this wallet is not the owner". The WALM-605 journey still dead-ended. - fetchJson keeps the HTTP status and the OAuth `error` code. - The /complete request moves behind completeSession(), which holds its payload; on temporarily_unavailable / 503 the page shows a retry step that re-sends that request and nothing else. Definitive rejections still land in the terminal error state. - Tests cover both: the retry path re-POSTs /complete once, issues no second preflight and no second transaction; a 400 stays terminal. - session_complete claims inside its match arms, so the Verified path no longer carries an Option + expect() panic on a request path. - Trim the before/after WALM-605 narration and the test-module essay. * review: survive a reload during OAuth recovery (WALM-605) The retry button worked, but it hung off a useRef. A reload dropped it, sent the user back to consent, and the first-time path then re-ran handleConnect, re-submitted add_delegate_key for a key already on chain, and aborted with code 0 — which the SPA still reports as "this wallet is not the owner". Reloading is the obvious thing to try when a page says "retry", so that was still the reported WALM-605 dead end. - The /complete payload is mirrored into sessionStorage under its session id, so a remount resumes at the retry step and re-sends only /complete. The entry is scoped to the session so one authorization's wallet signature can never be replayed against another, and it is cleared on success. - Auto-connect is suppressed while a recovery payload exists, so the resume cannot fall through into a second /account. - Retry copy no longer claims the key is registered. A 503 means Sui could not be consulted, so it says the authorization is still pending instead. - Tests: a reload resumes and completes with no second /account and no second transaction; a payload from another session is ignored. * review: do not leave a resumable crumb after a spent session (WALM-605) The payload was written to sessionStorage at the start of every `/complete` and cleared only on 200. A definitive 400 (Unregistered has already claimed the row) therefore left it behind, and since `session_view` does not filter on `status = 'pending'`, a reload showed the Sui-busy retry step for a session that was already spent, looping there because nothing ever cleared the key. - The ref is still set up front, so a hung in-flight `/complete` can resume. sessionStorage is written only on the retryable branch, and the terminal branch clears it. - Test: a 400 followed by a remount stays on the error step. - Corrected the comment that claimed the payload joins the existing breadcrumb. It gets its own key precisely because the mount effect rewrites `memwal_claude_connect` to `{ session }` and would drop it. * fix(app): stop mapping add_delegate_key abort 0 to an owner mismatch (WALM-605) `account.move` defines EDelegateKeyAlreadyExists = 0 and ENotOwner = 4, so abort 0 means the key is already registered — not that the wallet is wrong. That is the state a WALM-605 connect lands in: the tx went through and the verify behind it hit a Sui 429, so re-running the flow re-submitted the same pubkey and told the user to switch wallets. Treat abort 0 as already-on-chain and go straight to /complete; report the owner mismatch on abort 4, where it belongs. * review: decide the duplicate-key skip on chain, not from the tx error (WALM-605)`alreadyOnChain` could never fire. ConnectClaude signs through `useSponsoredTransaction`, which throws `new Error(sponsorFailureMessage(...))`, and `sponsor::mask_upstream` collapses every upstream 5xx into a bare 502 `sponsor_upstream_error` that never echoes the upstream body. An Enoki dry-run of a duplicate `add_delegate_key` therefore reaches the SPA as "The transaction was rejected by the sponsor" — no abort code, no function name. The abort 4 and abort 2 matches were dead for the same reason. The test that "covered" this mocked a raw `MoveAbort ... abort code: 0` string, which is not what production throws, so it passed while the user dead-ended.Move the decision to where the answer exists:- `/account` verifies this session's own pending key against the chain and returns `resume_pending_registration`. Verified means an earlier attempt landed the tx, so the UI skips straight to `/complete`. - A Sui outage during that check returns a retryable 503 rather than guessing. Registering walks into the masked 502; skipping sends `/complete` at a key that is not there, which is definitive Unregistered and burns the session. The preflight claims nothing, so a retry costs only that request. - The classification goes through `classify_delegate_verify`, so the preflight and `session_complete` cannot drift on what a Sui 429 means (WALM-429). - `session_account` now sanitises `account_id` before the DB lookup and the on-chain read, matching `session_complete`.Client:- Drop all three abort-code branches. The owner-mismatch guidance they carried was unreachable; `session_complete` still reports a real mismatch as a 400. - Write `PENDING_COMPLETE_KEY` before the request instead of only on a retryable failure. A reload while `/complete` is in flight has no 503 to react to, and that is the one gap the ref cannot cover. Terminal exits still clear it, so a spent session cannot loop on the retry screen. - `tx_digest` is `already-on-chain` when resuming, not `reused-delegate`.Tests:- The sponsor 502 is built from the real `SponsorHttpError` / `sponsorFailureMessage` exports, so the fixture cannot drift from the wrapper, and asserts `/complete` is NOT sent — folding every 502 into "already registered" would push `ENotOwner` / `ETooManyDelegateKeys` into `/complete` and burn the session. - `/account` 503 submits no transaction. - A reload with `/complete` still in flight resumes at the retry step with no second signature and no second preflight. - Four Rust tests pin the preflight decision, including that it and `session_complete` agree on which errors are definitive. * review: return a retryable /account 503 to consent (WALM-605) A preflight POST /account 503 leaves the authorization session pending, but the catch sent it to the terminal error, which has no way back. Return to consent and show the reason so the user can start the request again. * review: use the restyled error class on the retry screens (WALM-605) The rebase onto dev dropped errorTextStyle. Both retry messages now use mcp-error, and the finish button uses mcp-btn. --------- Co-authored-by: Harry Phan * docs: list requestTimeoutMs and the accepted key formats (#1231) The configuration reference and API reference described key as hex-only and omitted the request deadline. * fix(sdk): reject an invalid analyze date in the mock (#1229) MemWalMock.analyze accepted an Invalid Date that MemWal throws on. A valid anchor is still not rewritten; the mock has no extractor. * fix(sdk): reject an invalid recall query, limit, or weight (#1228) * fix(sdk): reject an invalid recall query, limit, or weight The real client signed a blank query, a NaN limit, and a NaN scoring weight. Those now fail before the request. * test(sdk): expect the positive integer recall limit error dev already rejects limit 0 and below with TypeError. This branch keeps that check. * fix(sdk): reject a non-array bulk status id list (#1226) A string or null jobIds list threw TypeError in the mock. Both clients now throw the same validation error before any request. * fix(sdk): reject a non-finite recall maxDistance (#1227) NaN and a negative cutoff used to drop every hit and look like an empty namespace. * fix(sdk): reject an object passed as remember text (#1230) * fix(sdk): reject an object passed as remember text remember, rememberAsync, and rememberAndWait throw TypeError before signing when text is not a string. * review: reject an object remember text in the mock MemWal throws TypeError. The mock used to fall through to "text cannot be empty". * fix(sdk): turn an empty or non-JSON 2xx body into an SDK error (#1207) * fix(sdk): turn an empty or non-JSON 2xx body into an SDK error health() and signed requests called res.json() on success and leaked a raw SyntaxError. Callers now get an error that names the HTTP status. Fixes #1201 * fix(sdk): name empty JSON bodies in MemWalManual and MCP The same 2xx parse guard from MemWal now covers manual restore, compatibility, embed, Walrus upload, and the MCP relayer check. * fix(sdk): give an unreadable 2xx body its HTTP status A remember wait treated a status-less parse error as HTTP 0 and polled until timeout. * review: await the restore body and test the empty JSON path Without await, the deadline disposed before text() finished, so a stalled body became a TypeError. The empty-202 tests now stub the SEAL session and hit /api/remember. --------- Co-authored-by: HoangDucBach Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Co-authored-by: ducnmm <165614309+ducnmm@users.noreply.github.com> Co-authored-by: Hoang Duc Bach <124645604+HoangDucBach@users.noreply.github.com> Co-authored-by: harrymove-ctrl Co-authored-by: Harry Phan Co-authored-by: harrymove-ctrl <267878750+harrymove-ctrl@users.noreply.github.com> --- SKILL.md | 17 +- apps/app/src/index.css | 167 +- apps/app/src/pages/ConnectClaude.test.tsx | 370 ++++ apps/app/src/pages/ConnectClaude.tsx | 268 ++- apps/app/src/pages/ConnectMcp.test.tsx | 16 + apps/app/src/pages/ConnectMcp.tsx | 110 +- apps/app/src/pages/LandingPage.tsx | 4 + .../app/(auth)/api/auth/guest/route.ts | 42 +- apps/chatbot/app/(auth)/auth.ts | 18 +- apps/chatbot/lib/ratelimit.ts | 301 ++- apps/chatbot/lib/ratelimit.unit.test.ts | 353 ++++ docs/docs.json | 2 + docs/getting-started/choose-your-path.md | 2 + docs/guides/import-existing-memory.md | 358 ++++ docs/mcp/changelog.mdx | 6 + docs/mcp/overview.md | 2 + docs/python-sdk/api-reference.md | 19 +- docs/python-sdk/changelog.mdx | 10 + docs/reference/configuration.md | 11 +- docs/relayer/api-reference.md | 10 +- docs/sdk/api-reference.md | 82 +- docs/sdk/changelog.mdx | 18 +- docs/troubleshooting/overview.md | 1 + packages/mcp/CHANGELOG.md | 4 + packages/mcp/src/auth-required.ts | 45 +- packages/mcp/src/bridge.ts | 38 +- packages/mcp/src/compatibility.ts | 19 +- packages/mcp/src/crypto.ts | 16 +- packages/mcp/src/index.ts | 2 +- packages/mcp/src/login.ts | 131 +- packages/mcp/src/messages.ts | 24 +- packages/mcp/src/recovery.ts | 9 +- packages/mcp/test/compatibility-json.test.mjs | 24 + .../mcp/test/login-failure-notice.test.mjs | 24 +- .../mcp/test/login-prompt-unified.test.mjs | 14 + .../mcp/test/login-recovery-signing.test.mjs | 21 + packages/mcp/test/login-singleflight.test.mjs | 93 + packages/mcp/test/login-write-ahead.test.mjs | 90 + packages/python-sdk-memwal/CHANGELOG.md | 8 + packages/python-sdk-memwal/README.md | 1 + packages/python-sdk-memwal/memwal/client.py | 333 +++- .../python-sdk-memwal/memwal/middleware.py | 471 +++-- packages/python-sdk-memwal/memwal/mock.py | 17 +- packages/python-sdk-memwal/memwal/types.py | 4 + packages/python-sdk-memwal/memwal/utils.py | 4 +- .../tests/test_auth_rejected_message.py | 1 + .../tests/test_blank_text.py | 22 + .../python-sdk-memwal/tests/test_client.py | 349 +++- .../tests/test_middleware.py | 523 +++++- packages/python-sdk-memwal/tests/test_mock.py | 9 + .../tests/test_remember_derived_key.py | 250 +++ .../tests/test_retry_after_http_date.py | 160 ++ .../python-sdk-memwal/tests/test_signing.py | 9 + .../tests/test_transport_retry.py | 195 ++ packages/sdk/CHANGELOG.md | 27 + packages/sdk/package.json | 12 +- packages/sdk/src/account.ts | 20 +- packages/sdk/src/ai/middleware.ts | 110 +- packages/sdk/src/index.ts | 16 + packages/sdk/src/manual-entry.ts | 4 +- packages/sdk/src/manual.ts | 394 ++-- packages/sdk/src/memwal.ts | 782 ++++++-- packages/sdk/src/mock.ts | 333 +++- packages/sdk/src/recall-args.ts | 61 + packages/sdk/src/sui-client.ts | 35 + packages/sdk/src/tokens.ts | 66 +- packages/sdk/src/types.ts | 135 +- packages/sdk/src/utils.ts | 232 ++- packages/sdk/test/bulk-input.test.mjs | 63 + .../create-account-id-validation.test.mjs | 65 + packages/sdk/test/default-sui-client.test.mjs | 118 ++ packages/sdk/test/e2e/live.e2e.mjs | 60 +- packages/sdk/test/empty-namespace.test.mjs | 31 + packages/sdk/test/exports-map.test.mjs | 57 + packages/sdk/test/json-body.test.mjs | 110 ++ packages/sdk/test/key-length.test.mjs | 38 + packages/sdk/test/limit-validation.test.mjs | 173 ++ packages/sdk/test/list-memories.test.mjs | 191 ++ packages/sdk/test/manual-restore.test.mjs | 118 ++ .../sdk/test/manual-seal-identity.test.mjs | 10 + .../sdk/test/mock-namespace-cursor.test.mjs | 33 + packages/sdk/test/mock-occurred-at.test.mjs | 25 + packages/sdk/test/mock.test.mjs | 332 +++- .../sdk/test/normalize-server-url.test.mjs | 46 + .../sdk/test/private-key-formats.test.mjs | 1 + packages/sdk/test/recall-args.test.mjs | 36 + packages/sdk/test/recall-input.test.mjs | 47 + .../test/recall-max-distance-input.test.mjs | 47 + .../sdk/test/recall-max-distance.test.mjs | 15 +- .../recall-max-tokens-validation.test.mjs | 301 +++ .../test/recall-positional-namespace.test.mjs | 81 + .../sdk/test/recall-timeout-budget.test.mjs | 348 ++++ .../sdk/test/recall-token-budget-e2e.test.mjs | 4 +- .../recall-token-budget-relevance.test.mjs | 53 + .../test/remember-bulk-idempotency.test.mjs | 173 ++ .../sdk/test/remember-call-shape.test.mjs | 435 +++++ .../sdk/test/remember-idempotency.test.mjs | 103 ++ packages/sdk/test/remember-job-poll.test.mjs | 19 +- .../sdk/test/remember-object-form.test.mjs | 40 + .../sdk/test/sanitize-server-error.test.mjs | 2 +- .../sdk/test/wait-deadline-clamp.test.mjs | 133 ++ packages/sdk/test/withMemWal-flush.test.mjs | 91 + .../sdk/test/withMemWal-recall-error.test.mjs | 138 ++ .../sdk/test/withMemWal-tool-steps.test.mjs | 229 +++ services/indexer/Cargo.lock | 1607 ++++++++++------- services/indexer/Cargo.toml | 20 +- services/indexer/tests/affected_checkpoint.rs | 57 + services/indexer/tests/checkpoint_decoding.rs | 82 + .../tests/fixtures/384534623.binpb.zst | Bin 0 -> 9663 bytes .../013_remember_write_idempotency_index.sql | 4 +- .../remember-idempotency-key.test.ts | 125 ++ .../server/scripts/mcp/tools/remember-wait.ts | 3 +- services/server/scripts/mcp/tools/remember.ts | 3 +- services/server/src/oauth.rs | 30 + services/server/src/routes/admin.rs | 262 ++- services/server/src/routes/analyze.rs | 12 +- services/server/src/routes/oauth.rs | 428 ++++- services/server/src/routes/recall.rs | 27 +- services/server/src/routes/remember.rs | 696 ++++--- services/server/src/services/extractor.rs | 128 +- .../server/src/services/prompts/extract.txt | 16 +- services/server/src/storage/db.rs | 362 +++- services/server/src/types.rs | 23 + 123 files changed, 13094 insertions(+), 1881 deletions(-) create mode 100644 apps/app/src/pages/ConnectClaude.test.tsx create mode 100644 apps/chatbot/lib/ratelimit.unit.test.ts create mode 100644 docs/guides/import-existing-memory.md create mode 100644 packages/mcp/test/compatibility-json.test.mjs create mode 100644 packages/python-sdk-memwal/tests/test_blank_text.py create mode 100644 packages/python-sdk-memwal/tests/test_remember_derived_key.py create mode 100644 packages/python-sdk-memwal/tests/test_retry_after_http_date.py create mode 100644 packages/python-sdk-memwal/tests/test_transport_retry.py create mode 100644 packages/sdk/src/recall-args.ts create mode 100644 packages/sdk/src/sui-client.ts create mode 100644 packages/sdk/test/bulk-input.test.mjs create mode 100644 packages/sdk/test/create-account-id-validation.test.mjs create mode 100644 packages/sdk/test/default-sui-client.test.mjs create mode 100644 packages/sdk/test/empty-namespace.test.mjs create mode 100644 packages/sdk/test/exports-map.test.mjs create mode 100644 packages/sdk/test/json-body.test.mjs create mode 100644 packages/sdk/test/key-length.test.mjs create mode 100644 packages/sdk/test/limit-validation.test.mjs create mode 100644 packages/sdk/test/list-memories.test.mjs create mode 100644 packages/sdk/test/manual-restore.test.mjs create mode 100644 packages/sdk/test/mock-namespace-cursor.test.mjs create mode 100644 packages/sdk/test/mock-occurred-at.test.mjs create mode 100644 packages/sdk/test/normalize-server-url.test.mjs create mode 100644 packages/sdk/test/recall-args.test.mjs create mode 100644 packages/sdk/test/recall-input.test.mjs create mode 100644 packages/sdk/test/recall-max-distance-input.test.mjs create mode 100644 packages/sdk/test/recall-max-tokens-validation.test.mjs create mode 100644 packages/sdk/test/recall-positional-namespace.test.mjs create mode 100644 packages/sdk/test/recall-timeout-budget.test.mjs create mode 100644 packages/sdk/test/recall-token-budget-relevance.test.mjs create mode 100644 packages/sdk/test/remember-bulk-idempotency.test.mjs create mode 100644 packages/sdk/test/remember-call-shape.test.mjs create mode 100644 packages/sdk/test/remember-object-form.test.mjs create mode 100644 packages/sdk/test/wait-deadline-clamp.test.mjs create mode 100644 packages/sdk/test/withMemWal-recall-error.test.mjs create mode 100644 packages/sdk/test/withMemWal-tool-steps.test.mjs create mode 100644 services/indexer/tests/affected_checkpoint.rs create mode 100644 services/indexer/tests/checkpoint_decoding.rs create mode 100644 services/indexer/tests/fixtures/384534623.binpb.zst create mode 100644 services/server/scripts/mcp/__tests__/remember-idempotency-key.test.ts diff --git a/SKILL.md b/SKILL.md index 5d628ed84..028368dee 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,9 +150,9 @@ const stored = await memwal.waitForRememberJob(accepted.job_id, { | Method | Description | Returns | |---|---|---| -| `remember(text, namespace?)` | Accept one memory job immediately | `{ job_id, status }` | +| `remember(text, namespaceOrOptions?, options?)` | Accept one memory job immediately. A plain object in the namespace slot is options (`{ idempotencyKey }`), not a namespace | `{ job_id, status }` | | `rememberAndWait(text, namespace?, opts?)` | Store one memory and wait for completion | `{ id, job_id, blob_id, owner, namespace }` | -| `recall({ query, limit?, topK?, namespace?, maxDistance? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` | +| `recall({ query, limit?, topK?, namespace?, maxDistance?, sort?, scoringWeights? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` | | `analyze(text, namespace?)` | Extract facts and accept one memory job per fact | `{ job_ids, facts, fact_count, status, owner }` | | `analyzeAndWait(text, namespace?, opts?)` | Extract facts and wait for all fact jobs to complete | `{ results, facts, total, succeeded, failed, owner }` | | `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, failed, total, namespace, owner, truncated }` | @@ -208,9 +208,19 @@ interface RecallOptions { topK?: number; // alias of limit; if both are set, topK wins namespace?: string; maxDistance?: number; + sort?: "relevance" | "recent"; + scoringWeights?: ScoringWeights; // sent as scoring_weights; omit to keep cosine order +} + +interface ScoringWeights { + semantic?: number; // default 1 + recency?: number; // default 0 + recencyHalfLifeDays?: number; // default 30 + importance?: number; // default 0 } // `topK` and `limit` are aliases for the same value; if both are provided, `topK` takes precedence. +// `sort: "recent"` is newest-wins (over-fetch then write-time). `scoringWeights` only re-ranks the cosine window. interface RememberBulkAcceptedResult { job_ids: string[]; @@ -236,6 +246,7 @@ interface AnalyzeResult { interface RememberBulkStatusItem { job_id: string; status: "pending" | "running" | "uploaded" | "done" | "failed" | "not_found"; + namespace?: string; // present when the job row exists; omitted for not_found blob_id?: string; error?: string; } @@ -574,7 +585,7 @@ Lifecycle hooks run automatically: | `health()` returns error | Check relayer URL is correct and reachable | | `recall()` returns empty | Verify namespace matches what was used in `remember()` | | `recall()` returns unrelated filler | Recall is top-K without a default relevance threshold; filter by `distance`, for example `distance < 0.8`, and calibrate the cutoff against your data | -| `401 Unauthorized` | Usually wrong `MEMWAL_PRIVATE_KEY`, key not registered on the account, account ID mismatch, or staging/mainnet mismatch. Check `.env.local` and dashboard credentials | +| `401 Unauthorized` | Usually wrong `MEMWAL_PRIVATE_KEY`, key not registered on the account, account ID mismatch (a wallet address is not a MemWal account ID), or staging/mainnet mismatch. Check `.env.local` and dashboard credentials | | SDK import errors | Run `pnpm add @mysten-incubation/memwal` — check Node.js ≥ 18 | | Manual client errors | Install peer deps: `@mysten/sui @mysten/seal @mysten/walrus` | | Direct Sui reads fail or examples look stale | Prefer `SuiGrpcClient` from `@mysten/sui/grpc`; JSON-RPC snippets using `SuiClient` / `getFullnodeUrl` may be stale | diff --git a/apps/app/src/index.css b/apps/app/src/index.css index f26280567..72c4e9314 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -14827,6 +14827,22 @@ body:has(.signin-proposed) { overflow: hidden; background: #000000; color: #faf8f5; + /* The artboard is 966 tall, but the laptop tile ends near 857. Fit to + 880 so the empty tail does not shrink the page. Centered when shorter + than the window. From 1.9:1 upward the size stays what it is at 1.9 + for this width, and the page scrolls. Spare width pulls the laptop + until 100px stays clipped. */ + --signin-frame-height: 880px; + --signin-scale: min(100vw / 1440px, 100vh / var(--signin-frame-height)); + --signin-stage-left: calc(100vw - 1440px * var(--signin-scale)); + --signin-pull: max(0px, min(var(--signin-stage-left), 191px * var(--signin-scale) - 100px)); + --signin-frame-top: max(0px, (100vh - var(--signin-frame-height) * var(--signin-scale)) / 2); +} + +@media (min-width: 720px) and (aspect-ratio >= 1.9) { + .signin-proposed { + --signin-scale: calc(100vw / (1.9 * var(--signin-frame-height))); + } } .signin-fit { @@ -14836,8 +14852,11 @@ body:has(.signin-proposed) { } .signin-fit--desktop { - width: min(100vw, calc(100vh * 1440 / 966)); - height: min(100vh, calc(100vw * 966 / 1440)); + width: 100vw; + height: calc(var(--signin-frame-height) * var(--signin-scale)); + margin-top: var(--signin-frame-top); + margin-left: 0; + margin-right: 0; } .signin-fit--mobile { @@ -14857,9 +14876,9 @@ body:has(.signin-proposed) { } .signin-stage--desktop { - width: 1440px; + width: calc(100vw / var(--signin-scale)); height: 966px; - transform: scale(calc(100cqw / 1440px)); + transform: scale(var(--signin-scale)); } .signin-stage--mobile { @@ -14928,7 +14947,32 @@ body:has(.signin-proposed) { display: none; } +.signin-scroll { + height: 100%; + overflow: hidden; +} + +.signin-sheet { + position: relative; + display: flow-root; + width: 100%; + min-height: 100%; + overflow: clip; +} + @media (min-width: 720px) { + .signin-proposed { + align-items: stretch; + justify-content: flex-start; + } + + .signin-scroll { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + } + .signin-stage--desktop { background: transparent; } @@ -14938,108 +14982,67 @@ body:has(.signin-proposed) { display: none; } + .signin-fit--desktop { + z-index: 1; + } + .signin-glow--pin { display: block; - position: fixed; + position: absolute; z-index: 0; + top: calc(var(--signin-frame-top) - 252px * var(--signin-scale)); + left: calc(var(--signin-stage-left) - var(--signin-pull) - 187px * var(--signin-scale)); + width: calc(1814px * var(--signin-scale)); + height: calc(776px * var(--signin-scale)); background-image: url('/signin/glow-desktop.png'); } - .signin-fit--desktop { - z-index: 1; - } - .signin-logo--pin { display: block; - position: fixed; - top: 28px; + position: absolute; + top: calc(var(--signin-frame-top) + 28px * var(--signin-scale)); + left: calc(50px * var(--signin-scale)); z-index: 30; - width: calc(117px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - height: calc(53.929px * min(100cqw, 100cqh * 1440 / 966) / 1440px); + width: calc(117px * var(--signin-scale)); + height: calc(53.929px * var(--signin-scale)); + } + + /* The stage spans the viewport. Slide the laptop group toward the right, + then back left by the spare width so more of the clipped screen shows. */ + .signin-stage--desktop .signin-hero { + position: absolute; + inset: 0; + translate: calc((var(--signin-stage-left) - var(--signin-pull)) / var(--signin-scale)) 0; } /* Same ellipse as Figma, but outside the stage so the blur is not cut off at the stage edge and the purple continues into the right corner. */ .signin-bloom--pin { display: block; - position: fixed; + position: absolute; z-index: 0; - width: calc(368px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - height: calc(480px * min(100cqw, 100cqh * 1440 / 966) / 1440px); - top: calc( - (100cqh - min(100cqh, 100cqw * 966 / 1440)) / 2 - + 224px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); + top: calc(var(--signin-frame-top) + 224px * var(--signin-scale)); + left: calc(var(--signin-stage-left) - var(--signin-pull) + 882px * var(--signin-scale)); + width: calc(368px * var(--signin-scale)); + height: calc(480px * var(--signin-scale)); border-radius: 50%; background: linear-gradient(to bottom, #2f43cb, #9550c2); - filter: blur(calc(387px * min(100cqw, 100cqh * 1440 / 966) / 1440px)); + filter: blur(calc(387px * var(--signin-scale))); pointer-events: none; } } -/* Pin right only while the window is at least as tall as a 14-inch fullscreen - (1512×982). The spare width there is about 48px, so the laptop still meets - the edge. */ -@media (min-width: 720px) and (aspect-ratio <= 1512 / 982) { - .signin-fit--desktop { - margin-left: auto; - } - - .signin-glow--pin { - top: calc(-252px * 100cqw / 1440px); - left: calc(-187px * 100cqw / 1440px); - width: calc(1814px * 100cqw / 1440px); - height: calc(776px * 100cqw / 1440px); - } - - .signin-logo--pin { - left: calc( - (100cqw - min(100cqw, 100cqh * 1440 / 966)) - + 50px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); - } - - .signin-bloom--pin { - left: calc( - (100cqw - min(100cqw, 100cqh * 1440 / 966)) - + 882px * min(100cqw, 100cqh * 1440 / 966) / 1440px - ); - } -} - -/* Shorter than a 14-inch window: grow the whole stage to the window width - so the laptop meets the right edge. The bottom of the artboard clips. */ -@media (min-width: 720px) and (aspect-ratio > 1512 / 982) { - .signin-proposed { - align-items: flex-start; - } - - .signin-fit--desktop { - width: 100vw; - height: calc(100vw * 966 / 1440); - margin-left: 0; - } - - .signin-glow--pin { - top: calc(-252px * 100cqw / 1440px); - left: calc(-187px * 100cqw / 1440px); - width: calc(1814px * 100cqw / 1440px); - height: calc(776px * 100cqw / 1440px); - } - - .signin-logo--pin { - top: calc(28px * 100cqw / 1440px); - left: calc(50px * 100cqw / 1440px); - width: calc(117px * 100cqw / 1440px); - height: calc(53.929px * 100cqw / 1440px); +@media (min-width: 720px) and (aspect-ratio >= 1.9) { + .signin-scroll { + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; } - .signin-bloom--pin { - top: calc(224px * 100cqw / 1440px); - left: calc(882px * 100cqw / 1440px); - width: calc(368px * 100cqw / 1440px); - height: calc(480px * 100cqw / 1440px); - filter: blur(calc(387px * 100cqw / 1440px)); + .signin-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; } } diff --git a/apps/app/src/pages/ConnectClaude.test.tsx b/apps/app/src/pages/ConnectClaude.test.tsx new file mode 100644 index 000000000..697fabc18 --- /dev/null +++ b/apps/app/src/pages/ConnectClaude.test.tsx @@ -0,0 +1,370 @@ +/** + * WALM-605 recovery path. + * + * A Sui outage during `POST /complete` leaves the authorization session + * `pending` instead of burning it. That only helps if the consent UI can + * re-send *that one request*: re-running the whole flow re-asks the wallet to + * sign and re-submits `add_delegate_key` for a key already on chain. + * + * The duplicate submission is decided server-side, by `/account`'s on-chain + * check. It cannot be decided here: this page signs through the sponsor proxy, + * and `sponsor::mask_upstream` replaces every upstream 5xx with a bare 502 that + * carries no Move abort, so the consent UI never sees which abort fired. + */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + address: '0xowner', + accountId: '0xaccount', + signAndExecute: vi.fn(), + signPersonalMessage: vi.fn(), +})) + +vi.mock('../config', () => ({ + config: { + memwalServerUrl: 'https://relayer.test', + memwalPackageId: '0xpkg', + memwalRegistryId: '0xreg', + suiNetwork: 'testnet', + docsUrl: 'https://docs.test', + }, +})) +vi.mock('@mysten/dapp-kit', () => ({ + ConnectModal: () => null, + useCurrentAccount: () => ({ address: mocks.address }), + useSuiClient: () => ({ waitForTransaction: vi.fn().mockResolvedValue({}) }), + useSignPersonalMessage: () => ({ mutateAsync: mocks.signPersonalMessage }), +})) +vi.mock('../hooks/useSponsoredTransaction', () => ({ + useSponsoredTransaction: () => ({ mutateAsync: mocks.signAndExecute }), +})) +vi.mock('../utils/suiClientCompat', () => ({ + fetchAccountIdForOwner: vi.fn().mockResolvedValue(mocks.accountId), +})) +vi.mock('../utils/analytics', () => ({ + trackEvent: vi.fn(), + getAnalyticsErrorType: () => 'unknown', +})) + +import ConnectClaude from './ConnectClaude' + +const SESSION = `mws_${'a'.repeat(24)}` + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const SESSION_VIEW = { + client_name: 'Claude', + redirect_host: 'claude.ai', + scopes: ['memory.read'], + delegate_public_key: 'ab'.repeat(32), + delegate_sui_address: '0xdelegate', + expires_at: new Date(Date.now() + 600_000).toISOString(), +} + +/** + * Serves the session GET and the `/account` preflight, and hands each + * `/complete` POST to the next entry of `completeResponses`. Returns a counter + * of how many times each endpoint was called. + */ +function stubRelayer( + completeResponses: (() => Response | Promise)[], + { + needsOnchainRegistration = false, + resumePendingRegistration = false, + accountResponse, + }: { + needsOnchainRegistration?: boolean + resumePendingRegistration?: boolean + accountResponse?: () => Response + } = {}, +) { + const calls = { account: 0, complete: 0, completeBodies: [] as string[] } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith(`/session/${SESSION}`)) return json(SESSION_VIEW) + if (url.endsWith('/account')) { + calls.account += 1 + if (accountResponse) return accountResponse() + // Default: reused delegate, so no add_delegate_key transaction + // is needed and the test isolates the /complete round-trip. + return json({ + needs_onchain_registration: needsOnchainRegistration, + delegate_public_key: SESSION_VIEW.delegate_public_key, + delegate_sui_address: SESSION_VIEW.delegate_sui_address, + resume_pending_registration: resumePendingRegistration, + }) + } + if (url.endsWith('/complete')) { + const next = completeResponses[calls.complete] ?? completeResponses.at(-1) + calls.complete += 1 + calls.completeBodies.push(String(init?.body ?? '')) + return next!() + } + throw new Error(`unexpected fetch: ${url}`) + }), + ) + return calls +} + +async function approve() { + render( + + + , + ) + await userEvent.click(await screen.findByRole('button', { name: 'Approve' })) +} + +beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() + mocks.signPersonalMessage.mockResolvedValue({ signature: 'sig' }) + // jsdom refuses a real navigation; the page only needs the call to not throw. + vi.stubGlobal('location', { ...window.location, replace: vi.fn() }) +}) + +describe('ConnectClaude /complete recovery', () => { + it('offers a retry of /complete alone when Sui is unavailable', async () => { + const calls = stubRelayer([ + () => + json( + { + error: 'temporarily_unavailable', + error_description: 'could not verify the delegate key on-chain right now', + }, + 503, + ), + () => json({ redirect_url: 'https://claude.ai/callback?code=abc' }), + ]) + + await approve() + + // Not the terminal error state: the session is still pending. + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + expect(screen.queryByText('Something went wrong')).toBeNull() + + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // The retry re-sent /complete only — no second preflight, so no second + // add_delegate_key submission. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + await screen.findByText(/Redirecting you back/) + }) + + it('a reload mid-recovery resumes at the retry step, not at consent', async () => { + // The ref does not survive a remount. Without the sessionStorage copy + // the user lands back on consent, and a first-time connect re-asks the + // wallet and re-submits add_delegate_key for a key already on chain. + const calls = stubRelayer([ + () => json({ error: 'temporarily_unavailable', error_description: 'sui unavailable' }, 503), + () => json({ redirect_url: 'https://claude.ai/callback?code=abc' }), + ]) + + await approve() + await screen.findByRole('button', { name: /finish connecting/i }) + expect(calls.account).toBe(1) + + // Remount, as a browser reload would. + cleanup() + render( + + + , + ) + + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // Still no second preflight and no second transaction after the reload. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + }) + + it('a definitive rejection leaves nothing to resume after a reload', async () => { + // The session is spent on a 400, so a reload must land on the error and + // not loop on the "Sui is busy" retry screen. + stubRelayer([ + () => json({ error: 'invalid_request', error_description: 'delegate key is not registered on-chain' }, 400), + ]) + + await approve() + await screen.findByText('Something went wrong') + + cleanup() + render( + + + , + ) + + await screen.findByRole('button', { name: 'Approve' }) + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) + + it('does not replay a stored payload against a different session', async () => { + sessionStorage.setItem( + 'memwal_claude_pending_complete', + JSON.stringify({ + session: 'mws_' + 'z'.repeat(24), + payload: { account_id: '0xa', owner_address: '0xb', owner_signature: 'sig', tx_digest: 'd', reused_delegate: true }, + }), + ) + stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })]) + + render( + + + , + ) + + // Lands on consent, not on someone else's half-finished authorization. + await screen.findByRole('button', { name: 'Approve' }) + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) + + it('a reload while /complete is in flight resumes at the retry step', async () => { + // The gap a ref cannot cover, and the reason the crumb is written + // before the request rather than after it fails: /complete never + // answered, so there is no 503 to react to, yet the transaction above + // has already landed and the wallet has already signed. + // Never settles: a reload destroys the context that issued it, so this + // response is never processed by anyone. Resolving it here instead + // would let the dead page run its success path and clear the crumb, + // which no real reload can do. + const inFlight = new Promise(() => {}) + const calls = stubRelayer( + [() => inFlight, () => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], + { needsOnchainRegistration: true }, + ) + mocks.signAndExecute.mockResolvedValue({ digest: '0xdigest' }) + + await approve() + await waitFor(() => expect(calls.complete).toBe(1)) + expect(mocks.signAndExecute).toHaveBeenCalledTimes(1) + + // Reload before the relayer answers. + cleanup() + render( + + + , + ) + + const retry = await screen.findByRole('button', { name: /finish connecting/i }) + await userEvent.click(retry) + + await waitFor(() => expect(calls.complete).toBe(2)) + // No second preflight, and above all no second wallet signature or + // second add_delegate_key for a key the chain already took. + expect(calls.account).toBe(1) + expect(mocks.signAndExecute).toHaveBeenCalledTimes(1) + }) + + it('skips add_delegate_key when /account says this session key is already on chain', async () => { + // The WALM-605 journey: an earlier attempt landed the transaction and + // then hit a 429 during verify. The chain, not a transaction error, is + // what tells us there is nothing left to submit. + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + needsOnchainRegistration: false, + resumePendingRegistration: true, + }) + + await approve() + + await screen.findByText(/Redirecting you back/) + expect(mocks.signAndExecute).not.toHaveBeenCalled() + expect(calls.complete).toBe(1) + // Named honestly: this is not a delegate reused from an earlier grant. + expect(JSON.parse(calls.completeBodies[0]!).tx_digest).toBe('already-on-chain') + }) + + it('does not read a duplicate-key abort out of the masked sponsor 502', async () => { + // Built from the real exports, so the fixture cannot drift from the + // wrapper the way a hand-written 'MoveAbort ... abort code: 0' string + // did: that string is not what production throws, so a test using it + // passes while the user dead-ends. + const { SponsorHttpError, sponsorFailureMessage } = await vi.importActual< + typeof import('../hooks/useSponsoredTransaction') + >('../hooks/useSponsoredTransaction') + const masked = sponsorFailureMessage( + new SponsorHttpError( + 'sponsor', + 502, + JSON.stringify({ + error: 'Sponsor service error', + code: 'sponsor_upstream_error', + traceId: 'tr_1', + }), + ), + ) + // The premise: an Enoki dry-run abort reaches the SPA with no Move text. + expect(masked).not.toMatch(/abort code/) + expect(masked).not.toMatch(/add_delegate_key/) + + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + needsOnchainRegistration: true, + }) + mocks.signAndExecute.mockRejectedValue(new Error(masked)) + + await approve() + + await screen.findByText('Something went wrong') + // The load-bearing assertion: a sponsor 502 must not be treated as + // "already registered" and POSTed to /complete. ENotOwner, + // ETooManyDelegateKeys and EDelegateKeyAlreadyExists are + // indistinguishable here, and guessing the last one burns the session + // as Unregistered for the other two. + expect(calls.complete).toBe(0) + }) + + it('returns to consent when the /account preflight cannot reach Sui', async () => { + const calls = stubRelayer([() => json({ redirect_url: 'https://claude.ai/callback?code=abc' })], { + accountResponse: () => + json( + { error: 'temporarily_unavailable', error_description: 'sui unavailable' }, + 503, + ), + }) + + await approve() + + // The session is still pending. The terminal error has no way back, + // so the user must be able to start this request again. + expect(screen.queryByText('Something went wrong')).toBeNull() + expect(await screen.findByText('sui unavailable')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() + expect(mocks.signAndExecute).not.toHaveBeenCalled() + expect(calls.complete).toBe(0) + }) + + it('still fails terminally on a definitive rejection', async () => { + stubRelayer([ + () => + json( + { + error: 'invalid_request', + error_description: 'delegate key is not registered on-chain', + }, + 400, + ), + ]) + + await approve() + + await screen.findByText('Something went wrong') + expect(screen.queryByRole('button', { name: /finish connecting/i })).toBeNull() + }) +}) diff --git a/apps/app/src/pages/ConnectClaude.tsx b/apps/app/src/pages/ConnectClaude.tsx index 0a1f1af8e..c01c7c09f 100644 --- a/apps/app/src/pages/ConnectClaude.tsx +++ b/apps/app/src/pages/ConnectClaude.tsx @@ -28,7 +28,7 @@ * 6. `window.location.replace(redirect_url)` — hands control back to * Claude (or whatever OAuth client started the flow). */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ConnectModal, useCurrentAccount, @@ -44,11 +44,46 @@ import { fetchAccountIdForOwner } from '../utils/suiClientCompat' +/** The `POST /complete` body, plus the analytics flag that goes with it. */ +type CompletePayload = { + account_id: string + owner_address: string + owner_signature: string + tx_digest: string + reused_delegate: boolean +} + +/** Where the in-flight `POST /complete` payload survives a reload. */ +const PENDING_COMPLETE_KEY = 'memwal_claude_pending_complete' + +/** + * The saved `POST /complete` payload for this session, if any. + * + * Session-scoped on purpose: one authorization's wallet signature must never be + * replayed against another. + */ +function loadStoredComplete(sessionId: string): CompletePayload | null { + try { + const raw = sessionStorage.getItem(PENDING_COMPLETE_KEY) + if (!raw) return null + const saved = JSON.parse(raw) as { session?: string; payload?: CompletePayload } + if (saved.session !== sessionId || !saved.payload?.owner_signature) return null + return saved.payload + } catch { + return null + } +} + +function hasStoredComplete(sessionId: string): boolean { + return loadStoredComplete(sessionId) !== null +} + type Step = | 'loading' | 'consent' | 'signing' | 'finishing' + | 'retry-complete' | 'redirecting' | 'no-account' | 'error' @@ -82,6 +117,8 @@ async function resolveAccountId( } } +type OAuthRequestError = Error & { status: number; oauthError?: string } + async function fetchJson(url: string, init?: RequestInit): Promise { const res = await fetch(url, { ...init, @@ -89,15 +126,31 @@ async function fetchJson(url: string, init?: RequestInit): Promise { }) const body = await res.json().catch(() => null) if (!res.ok) { + const envelope = body && typeof body === 'object' ? (body as Record) : null const description = - body && typeof body === 'object' && 'error_description' in body - ? String((body as { error_description: unknown }).error_description) + envelope && 'error_description' in envelope + ? String(envelope.error_description) : `request failed (${res.status})` - throw new Error(description) + const err = new Error(description) as OAuthRequestError + err.status = res.status + if (envelope && typeof envelope.error === 'string') err.oauthError = envelope.error + throw err } return body as T } +/** + * The relayer could not reach Sui, so it left the authorization session + * `pending` instead of burning it (WALM-605). Re-POSTing `/complete` alone is + * the recovery: the wallet signature and the on-chain registration from the + * first attempt are both still good, so restarting `handleConnect` would only + * re-ask the wallet and re-submit a transaction the chain has already taken. + */ +function isRetryableComplete(err: unknown): boolean { + const e = err as Partial | null + return e?.oauthError === 'temporarily_unavailable' || e?.status === 503 +} + export default function ConnectClaude() { const [params] = useSearchParams() const sessionId = params.get('session') ?? '' @@ -126,7 +179,10 @@ export default function ConnectClaude() { .then((view) => { if (cancelled) return setSession(view) - setStep('consent') + // A reload mid-recovery resumes at the retry step. Landing on + // consent would re-run handleConnect, which re-asks the wallet + // to sign and re-sends a transaction the chain already took. + setStep(hasStoredComplete(sessionId) ? 'retry-complete' : 'consent') }) .catch((err) => { if (cancelled) return @@ -146,6 +202,109 @@ export default function ConnectClaude() { sessionStorage.setItem('memwal_claude_connect', JSON.stringify({ session: sessionId })) }, [sessionValid, sessionId]) + + // Everything `POST /complete` needs, kept so a retry re-sends exactly this + // request instead of re-running `handleConnect`, which would re-ask the + // wallet for a signature and re-submit `add_delegate_key` for a key that + // is already on chain. + // + // A ref does not survive a reload, and a reload is the obvious thing to try + // when the relayer says "retry", so the payload also goes to sessionStorage. + // It gets its own key rather than joining `memwal_claude_connect`, which + // the mount effect rewrites to `{ session }` on every load and would drop + // the payload. + const pendingComplete = useRef(null) + + const forgetPendingComplete = useCallback(() => { + pendingComplete.current = null + try { + sessionStorage.removeItem(PENDING_COMPLETE_KEY) + } catch { + // Nothing to clean up if storage is unavailable. + } + }, []) + + const rememberPendingComplete = useCallback( + (payload: CompletePayload) => { + pendingComplete.current = payload + try { + sessionStorage.setItem( + PENDING_COMPLETE_KEY, + JSON.stringify({ session: sessionId, payload }), + ) + } catch { + // Private mode or a full quota: the in-page button still works. + } + }, + [sessionId], + ) + + const readPendingComplete = useCallback((): CompletePayload | null => { + if (pendingComplete.current) return pendingComplete.current + const saved = loadStoredComplete(sessionId) + if (saved) pendingComplete.current = saved + return saved + }, [sessionId]) + + const completeSession = useCallback(async (payload: CompletePayload) => { + // Durable before the request, not after it fails. A reload while + // /complete is still in flight is the gap a ref cannot cover, and on + // the first-time path it is the expensive one: without the crumb the + // page lands on consent, re-runs handleConnect, and re-submits + // add_delegate_key for a key the tx above already put on chain. That + // no longer dead-ends now that /account detects it, but it still costs + // a wallet round-trip and a sponsored transaction. + // + // Writing early is safe because every terminal exit clears it: success + // below, and a definitive 400 in the catch. A spent session therefore + // cannot leave a crumb that loops the user on "Sui is busy". + rememberPendingComplete(payload) + setStep('finishing') + try { + const { redirect_url } = await fetchJson<{ redirect_url: string }>( + `${apiBase}/api/oauth/session/${sessionId}/complete`, + { + method: 'POST', + body: JSON.stringify({ + account_id: payload.account_id, + owner_address: payload.owner_address, + owner_signature: payload.owner_signature, + tx_digest: payload.tx_digest, + }), + }, + ) + sessionStorage.removeItem('memwal_claude_connect') + sessionStorage.removeItem(PENDING_COMPLETE_KEY) + setStep('redirecting') + trackEvent('claude_connect_complete', { reused_delegate: payload.reused_delegate }) + window.location.replace(redirect_url) + } catch (err) { + if (!isRetryableComplete(err)) { + // Definitive: the session is spent. Clear the crumb so a reload + // lands on the error rather than looping on "Sui is busy". + forgetPendingComplete() + throw err + } + // The session is still pending, so offer this one request again + // rather than dropping into the terminal error state. The crumb + // written above is what the retry step and a reload both read. + setErrorMsg(err instanceof Error ? err.message : String(err)) + setStep('retry-complete') + trackEvent('claude_connect_failed', { error_type: 'sui_unavailable' }) + } + }, [apiBase, sessionId, rememberPendingComplete, forgetPendingComplete]) + + const handleRetryComplete = useCallback(async () => { + const payload = readPendingComplete() + if (!payload) return + try { + await completeSession(payload) + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)) + setStep('error') + trackEvent('claude_connect_failed', { error_type: getAnalyticsErrorType(err) }) + } + }, [completeSession, readPendingComplete]) const handleConnect = useCallback(async () => { if (!session) return if (!currentAccount) { @@ -168,6 +327,9 @@ export default function ConnectClaude() { needs_onchain_registration: boolean delegate_public_key: string delegate_sui_address: string + // Absent on a relayer older than WALM-605, which is the + // pre-existing behaviour: no resume, just the first-time path. + resume_pending_registration?: boolean }>(`${apiBase}/api/oauth/session/${sessionId}/account`, { method: 'POST', body: JSON.stringify({ account_id: accountId, owner_address: currentAccount.address }), @@ -187,30 +349,18 @@ export default function ConnectClaude() { tx.object('0x6'), ], }) - let result - try { - result = await signAndExecute({ transaction: tx }) - } catch (txErr: unknown) { - const m = txErr instanceof Error ? txErr.message : String(txErr) - if (m.includes('abort code: 0') && m.includes('add_delegate_key')) { - setErrorMsg( - `This wallet (${currentAccount.address.slice(0, 10)}…${currentAccount.address.slice(-6)}) is not the owner of Walrus Memory account ${accountId.slice(0, 10)}…${accountId.slice(-6)}. ` + - `Switch to the wallet that created this account, or run /setup for a new one.` - ) - trackEvent('claude_connect_failed', { error_type: 'owner_mismatch' }) - setStep('error') - return - } - if (m.includes('abort code: 2') && m.includes('add_delegate_key')) { - setErrorMsg( - `This account already has the maximum number of delegate keys (20). Go to /dashboard and revoke an unused key, then try again.` - ) - trackEvent('claude_connect_failed', { error_type: 'max_delegate_keys' }) - setStep('error') - return - } - throw txErr - } + // Deliberately no `account.move` abort-code branches here. + // This page signs through `useSponsoredTransaction`, which + // throws `new Error(sponsorFailureMessage(...))`, and the + // relayer's `sponsor::mask_upstream` collapses any upstream + // 5xx into a bare 502 `sponsor_upstream_error` that never + // echoes the Move abort. `EDelegateKeyAlreadyExists` (0), + // `ETooManyDelegateKeys` (2) and `ENotOwner` (4) therefore all + // reach us as the same opaque sponsor string; matching on them + // read like recovery while never firing. The duplicate-key + // case is settled before this point, by `/account`'s on-chain + // check (`resume_pending_registration`). + const result = await signAndExecute({ transaction: tx }) await suiClient.waitForTransaction({ digest: result.digest }) txDigest = result.digest } @@ -220,28 +370,31 @@ export default function ConnectClaude() { `Walrus Memory OAuth authorization\nsession:${sessionId}\naccount:${accountId.toLowerCase()}\nowner:${currentAccount.address.toLowerCase()}`, ) const { signature: ownerSignature } = await signPersonalMessage({ message: proofMessage }) - const { redirect_url } = await fetchJson<{ redirect_url: string }>( - `${apiBase}/api/oauth/session/${sessionId}/complete`, - { - method: 'POST', - body: JSON.stringify({ - account_id: accountId, - owner_address: currentAccount.address, - owner_signature: ownerSignature, - tx_digest: txDigest || 'reused-delegate', - }), - }, - ) - sessionStorage.removeItem('memwal_claude_connect') - setStep('redirecting') - trackEvent('claude_connect_complete', { reused_delegate: !preflight.needs_onchain_registration }) - window.location.replace(redirect_url) + const resuming = preflight.resume_pending_registration === true + await completeSession({ + account_id: accountId, + owner_address: currentAccount.address, + owner_signature: ownerSignature, + // `/complete` requires a non-empty digest. When no tx was sent + // this call names why, rather than claiming a reused delegate + // for a key this session registered on an earlier attempt. + tx_digest: txDigest || (resuming ? 'already-on-chain' : 'reused-delegate'), + reused_delegate: !preflight.needs_onchain_registration && !resuming, + }) } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)) + if (isRetryableComplete(err)) { + // The session is still pending. The terminal error has no + // button back, so return to consent and let the user start + // this request again. + setStep('consent') + trackEvent('claude_connect_failed', { error_type: 'sui_unavailable' }) + return + } setStep('error') trackEvent('claude_connect_failed', { error_type: getAnalyticsErrorType(err) }) } - }, [session, currentAccount, suiClient, signAndExecute, signPersonalMessage, apiBase, sessionId]) + }, [session, currentAccount, suiClient, signAndExecute, signPersonalMessage, completeSession]) const handleCancel = useCallback(async () => { try { @@ -260,6 +413,7 @@ export default function ConnectClaude() { // Auto-proceed once the wallet popup resolves. useEffect(() => { if (!walletPickerOpen && currentAccount && step === 'consent') { + if (hasStoredComplete(sessionId)) return void handleConnect() } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -284,7 +438,7 @@ export default function ConnectClaude() { )} {step === 'consent' && session && ( - + )} {step === 'signing' && ( @@ -322,6 +476,23 @@ export default function ConnectClaude() { )} + {step === 'retry-complete' && ( +
+

Sui is busy — try again

+

+ We couldn't reach Sui to verify your delegate key, so this authorization is + still pending rather than spent. Nothing needs redoing — press the button to + finish. +

+

{errorMsg}

+
+ +
+
+ )} + {step === 'error' && (

Something went wrong

@@ -339,11 +510,13 @@ export default function ConnectClaude() { function ConsentCard({ session, wallet, + error, onConnect, onCancel, }: { session: SessionView wallet: string | null + error?: string onConnect: () => void onCancel: () => void }) { @@ -381,6 +554,7 @@ function ConsentCard({
+ {error ?

{error}

: null}