Skip to content

Promote dev → staging - #1239

Merged
ducnmm merged 92 commits into
stagingfrom
dev
Oct 16, 2026
Merged

ducnmm merged 92 commits into
stagingfrom
dev

Conversation

@ducnmm

@ducnmm ducnmm commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Promotes dev into staging, frozen at 256ac7f0. The merge result is that tree.

#1182 was squash-merged, so this PR's commit list does not share those commits and GitHub shows 126 files. The staging tree already contains that promotion. Against the current staging tree, only #1235 is new:

  • services/server/scripts/sidecar/enoki.ts
  • services/server/scripts/sidecar/routes/walrus-upload.ts
  • services/server/scripts/__tests__/walrus-wal-destroy-zero.test.ts

Changes

Not in this promotion

Test plan

  • CI green on this PR at 256ac7f0
  • Staging relayer: an upload that hits the price-up balance::split retries against a refreshed Walrus client

HoangDucBach and others added 30 commits September 11, 2026 11:17
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.
Agents reading only this file still could not see the weight fields.
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.
…y 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.
…d (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

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.
…us 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.
…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.
search_similar kept every vector_entries row, so a remember retry or restore could surface the same Walrus blob twice with identical scores.
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.
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.
…all-returns-duplicate-records-for-the-same

fix(server): return each blob once in recall (WALM-594)
…161)

recall(query, namespace) was silently treated as options and ran against the default namespace. Fail loudly instead of dropping the intended scope.
recallManual has no object-first overload, so hinting
method({ query, namespace }) sent callers to query.trim() failure.
…itional-overload-silently-accepts-string-as

fix(sdk): throw when positional recall gets a namespace string (WALM-161)
…ixes #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): 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.
…on-to-list-stored-blobs-writes-are-unauditable-and

feat(sdk): listMemories() pages every memory without a query (WALM-388)
fix(relayer,sdk): stop a slow remember job reading as a dead key (WALM-670)
… 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
…budget-relevance

fix(sdk): retain lowest-distance hits under high-relevance-only token budget (Fixes #1103, WALM-756)
fix(sdk): add default condition to exports map and place types first (Fixes #1143, #1040, WALM-721)
…eck (#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.
…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
harrymove-ctrl and others added 24 commits October 14, 2026 20:47
…#1172)

Allow private keys with uppercase 0X prefix to be normalized without failing during hex decoding. Matches TypeScript SDK behavior.

Fixes #1163
…l, 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>
 #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>
…llManualHit (#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
…#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.
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): 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

7c9dd91 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 <phanhoangvinhhien@gmail.com>
Co-authored-by: Nikola Le <91601109+nikola0x0@users.noreply.github.com>
* 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
…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
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>
…LM-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 ca3f72d)
(cherry picked from commit 69908ac)

Co-authored-by: Harry Phan <phanhoangvinhhien@gmail.com>
…#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 <phanhoangvinhhien@gmail.com>
The configuration reference and API reference described key as hex-only and omitted the request deadline.
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

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.
A string or null jobIds list threw TypeError in the mock. Both clients now throw the same validation error before any request.
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

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

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.
#1235)

A cached storage price below the live price makes reserve_space abort in balance::split. The durable path only rebuilt the client on destroy_zero, so every wallet attempt reused the same snapshot.
@ducnmm
ducnmm requested a review from harrymove-ctrl October 16, 2026 09:10
@ducnmm
ducnmm merged commit f41df1a into staging Oct 16, 2026
47 of 48 checks passed
harrymove-ctrl added a commit that referenced this pull request Oct 18, 2026
* Promote dev → staging (#1182)

* 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 <phanhoangvinhhien@gmail.com>

* 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 <phanhoangvinhhien@gmail.com>
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 <phanhoangvinhhien@gmail.com>
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 <phanhoangvinhhien@gmail.com>

* 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 <phanhoangvinhhien@gmail.com>

* 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 <hoangbach0985@gmail.com>
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 <harry.phan@commandoss.com>
Co-authored-by: Harry Phan <phanhoangvinhhien@gmail.com>
Co-authored-by: harrymove-ctrl <267878750+harrymove-ctrl@users.noreply.github.com>

* Promote dev → staging (#1239)

* 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)

…
This was referenced Oct 20, 2026

This branch had an error being deployed

1 failed and 1 inactive deployments
Walrus Memory / dev — 256ac7f0 Deployed Oct 16, 2026 by railway-app[bot]
benchmark-dev — 256ac7f0 Deployed Oct 16, 2026 by ducnmm via Memory API Latency #457
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants