Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c06906f
feat: add Slack alert for exhausted Walrus uploads
May 27, 2026
584c9c8
Merge pull request #196 from MystenLabs/walm-38-slack-upload-alerts
ducnmm May 27, 2026
ee1a9e9
chore(deps): bump @mysten/walrus 1.0.3 -> 1.1.7, @mysten/sui 2.5.0 ->…
jasong-03 May 27, 2026
1027cac
feat(MEM-34): detect Walrus EWrongVersion, refresh client, alert team
jasong-03 May 27, 2026
289ea63
feat: clean up internal comment references
May 27, 2026
e38d0db
docs(WALM-53): document Namespace + Restore semantics in SKILL.md
jasong-03 May 27, 2026
fd13937
feat(WALM-53): object-style recall() + restore() docs + py limit fix
jasong-03 May 27, 2026
4ac43b9
docs(chatbot,WALM-53): note "use server" files must only export async…
jasong-03 May 27, 2026
0590f46
docs(sdk,WALM-53): mark positional recall() overload as @deprecated
jasong-03 May 27, 2026
65d792b
docs(WALM-53): sweep user-facing examples to object-style recall()
jasong-03 May 27, 2026
0dd930e
Merge pull request #198 from MystenLabs/henrynguyen/cleanup-internal-…
ducnmm May 27, 2026
7153d1f
Add GA4 launch analytics
May 27, 2026
6dc62cf
Merge pull request #201 from MystenLabs/henrynguyen/walm-60-enable-ga…
ducnmm May 27, 2026
95b8059
fix(MEM-34): apply EWrongVersion carve-out to legacy RememberJob path
jasong-03 May 27, 2026
6d9e7c7
fix(MEM-34): dedup Walrus package-upgrade Slack alert per (network, dep)
jasong-03 May 27, 2026
c4f2eed
test(MEM-34): drop @ts-expect-error from sidecar nullish-input test
jasong-03 May 27, 2026
7ce7cbb
refactor(jobs): drop ticket-tag prefix from EWrongVersion comment
jasong-03 May 27, 2026
3828ad4
refactor: drop ticket-tag prefixes from docstrings, JSDoc, and docs
jasong-03 May 27, 2026
9b53977
Merge pull request #199 from MystenLabs/feature/mem-34-handle-walrus-…
ducnmm May 28, 2026
6598084
fix: align SDK docs and release versions
May 28, 2026
3173ffb
Merge pull request #200 from MystenLabs/feature/walm-53-address-dx-ri…
ducnmm May 28, 2026
85104ab
Merge pull request #204 from MystenLabs/dev
ducnmm May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy-app-walrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
VITE_GOOGLE_CLIENT_ID: ${{ vars.VITE_GOOGLE_CLIENT_ID }}
VITE_DOCS_URL: ${{ vars.VITE_DOCS_URL }}
VITE_DEMO_URLS: ${{ vars.VITE_DEMO_URLS }}
VITE_GA_MEASUREMENT_ID: ${{ vars.VITE_GA_MEASUREMENT_ID }}

- name: Deploy to Walrus Site (Mainnet)
id: deploy-mainnet
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const memwal = MemWal.create({

const job = await memwal.remember("User prefers dark mode and uses TypeScript.");
await memwal.waitForRememberJob(job.job_id);
const memories = await memwal.recall("What are the user's preferences?");
const memories = await memwal.recall({ query: "What are the user's preferences?" });
await memwal.restore("demo");
```

Expand Down
99 changes: 94 additions & 5 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ await memwal.rememberAndWait(
);

// Recall by meaning
const result = await memwal.recall("What are the user's preferences?");
const result = await memwal.recall({ query: "What are the user's preferences?" });
console.log(result.results);

// Extract facts from free-form text and wait until all accepted facts are indexed.
Expand Down Expand Up @@ -152,7 +152,7 @@ const stored = await memwal.waitForRememberJob(accepted.job_id, {
|---|---|---|
| `remember(text, namespace?)` | Accept one memory job immediately | `{ job_id, status }` |
| `rememberAndWait(text, namespace?, opts?)` | Store one memory and wait for completion | `{ id, job_id, blob_id, owner, namespace }` |
| `recall(query, limitOrOptions?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` |
| `recall({ query, limit?, namespace?, maxDistance? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` |
| `analyze(text, namespace?)` | Extract facts and accept one memory job per fact | `{ job_ids, facts, fact_count, status, owner }` |
| `analyzeAndWait(text, namespace?, opts?)` | Extract facts and wait for all fact jobs to complete | `{ results, facts, total, succeeded, failed, owner }` |
| `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, total, namespace, owner }` |
Expand Down Expand Up @@ -307,6 +307,79 @@ interface HealthResult {
aligns with the accepted fact jobs; use `analyzeAndWait()` when the UI needs
those facts indexed before continuing.

### Namespace Semantics

A namespace is an **opaque, flat string label** scoped to a single owner. It is the unit of memory isolation: a recall in namespace `A` will never surface entries written to namespace `B`, even for the same owner, and never surfaces other owners' entries even in the same namespace.

#### Validation

The server accepts any non-empty string as a namespace. There is no length cap, no character whitelist, no normalization (whitespace, case, Unicode). Whatever you send is stored verbatim and matched with exact equality. If you omit the namespace, the server falls back to the literal string `"default"`.

> **Implication:** `"my-app"`, `" my-app"` (leading space), `"My-App"`, and `"my-app/"` are four distinct namespaces. Pick a convention and stick to it.

#### Flat, not hierarchical

Slashes and dots have **no special meaning**. `"chat/user-42"` is a single opaque label, not a path. The server uses `WHERE namespace = $1` exact-equality for every read; there is no prefix matching, no parent/child traversal, and no wildcard query. If you need hierarchy, build it in the application layer (e.g. recall across known namespaces and merge client-side).

#### Overwrite behavior — `remember()` is **always append, never upsert**

Every accepted `remember()` call creates a **new memory entry** with a freshly generated UUID. Sending the same text to the same `(owner, namespace)` twice will produce **two separate entries** that both surface in future recalls. The namespace is metadata for filtering, not a key for deduplication.

```ts
await memwal.remember("I prefer dark mode", "prefs");
await memwal.remember("I prefer dark mode", "prefs");
// recall("preferences", { namespace: "prefs" }) → 2 entries, both with the same text
```

If you need uniqueness, either dedupe before calling `remember()`, or delete the prior entry first.

#### Isolation guarantees

| Scenario | Visible to recall? |
|---|---|
| Same owner, same namespace | ✅ |
| Same owner, different namespace | ❌ |
| Different owner, same namespace | ❌ |
| Different owner, different namespace | ❌ |

Cross-namespace and cross-owner reads are not just filtered out of results — the server's SQL `WHERE` clause excludes them entirely, so they are never decrypted or transferred.

### Restore Semantics

`restore(namespace, limit?)` rebuilds **missing** local index entries for a namespace from Walrus. It is a recovery operation, not a sync — already-indexed blobs are left alone.

#### Response fields

| Field | Counts | Notes |
|---|---|---|
| `restored` | Blobs the relayer just rebuilt this call | Pulled from Walrus → SEAL decrypted → re-embedded → inserted as a new row |
| `skipped` | On-chain blobs already in the local index | No work needed; relayer left them as-is |
| `total` | All on-chain blobs the relayer saw for `(owner, namespace)` | Before the limit was applied |
| `namespace` | Echo of the request | |
| `owner` | Resolved owner address | |

**Silent drops.** A blob that *cannot* be decrypted or embedded (e.g. wrong delegate key, malformed ciphertext, embedding API down) is dropped without counting in `restored` *or* `skipped`. `restored + skipped` is therefore a lower bound on healthy entries, not a strict equality with `total`.

#### Default and limit

* `limit` defaults to `10` in both TypeScript and Python SDKs and matches the server-side default. The Python SDK historically defaulted to `50`; it is now realigned with the server.
* `limit` caps the **inspected** blob set, newest-first. It does not cap `restored` independently — if all 10 inspected blobs are already indexed, `restored = 0` and `skipped = 10`.
* There is no enforced server-side maximum, but very large limits will dominate latency (see below).

#### Pagination

**Restore is single-shot — there is no cursor.** To rebuild a namespace larger than your chosen `limit`, call again with a larger `limit`, or delete local rows you want re-imported first. Pagination is on the roadmap; until it lands, treat `restore()` as a "top up to N most recent" operation.

#### Performance

Latency scales linearly in `limit`:

* Up to **10 concurrent** Walrus aggregator downloads
* Up to **3 concurrent** SEAL decrypts (CPU-bound, capped intentionally)
* Embedding requests in parallel (bounded by the relayer's embedding pool)

Expect **seconds per blob** on a cold cache. Use small limits (≤ 50) for interactive flows and run larger restores out-of-band.

### Recall Distance and Filtering

`recall()` returns the closest K memories by vector distance. There is no
Expand All @@ -325,8 +398,9 @@ Lower distance means more similar:
Use SDK-side filtering when you only want clearly relevant results:

```ts
const memories = await memwal.recall("what did I eat yesterday?", {
topK: 10,
const memories = await memwal.recall({
query: "what did I eat yesterday?",
limit: 10,
namespace: "reading-tracker",
maxDistance: 0.7,
});
Expand All @@ -335,7 +409,11 @@ const memories = await memwal.recall("what did I eat yesterday?", {
Equivalent manual filtering:

```ts
const memories = await memwal.recall("what did I eat yesterday?", 10, "reading-tracker");
const memories = await memwal.recall({
query: "what did I eat yesterday?",
limit: 10,
namespace: "reading-tracker",
});
const relevant = memories.results.filter((memory) => memory.distance < 0.7);
```

Expand Down Expand Up @@ -488,6 +566,17 @@ Lifecycle hooks run automatically:

---

## Brand Terminology

Until product confirms a canonical naming pass, these are the **working** assumptions reflected across this doc, the SDKs, and the relayer. Treat them as descriptive, not authoritative.

| Surface | Canonical term | Notes |
|---|---|---|
| Product / docs / UI | **Walrus Memory** | Used in marketing copy, user-facing dashboards, and prose docs |
| Package / env vars / internal shorthand | **memwal** | Used in `@mysten-incubation/memwal`, `pip install memwal`, `MEMWAL_*` env vars, internal logs, and codepaths |

If you're writing user-facing copy, prefer "Walrus Memory". If you're writing an env var, import path, or grep-target, prefer `memwal`. Don't mass-rename existing identifiers — that requires a coordinated migration outside this skill's scope.

## Links

- **Docs**: https://docs.memwal.ai
Expand Down
3 changes: 3 additions & 0 deletions apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ VITE_DOCS_URL=http://localhost:5174
# Example: VITE_DEMO_URLS=Chat Demo|https://chat.example.com,Agent Demo|https://agent.example.com
VITE_DEMO_URLS=

# Google Analytics 4 Measurement ID. Leave blank to disable analytics.
VITE_GA_MEASUREMENT_ID=

# ══════════════════════════════════════════════════════════════
# ▷ INACTIVE: MAINNET (uncomment below, comment out TESTNET above)
# ══════════════════════════════════════════════════════════════
Expand Down
4 changes: 3 additions & 1 deletion apps/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ ARG VITE_ENOKI_API_KEY
ARG VITE_GOOGLE_CLIENT_ID
ARG VITE_DOCS_URL
ARG VITE_DEMO_URLS
ARG VITE_GA_MEASUREMENT_ID

ENV VITE_MEMWAL_SERVER_URL=$VITE_MEMWAL_SERVER_URL
ENV VITE_MEMWAL_PACKAGE_ID=$VITE_MEMWAL_PACKAGE_ID
Expand All @@ -44,7 +45,8 @@ ENV VITE_SUI_NETWORK=$VITE_SUI_NETWORK
ENV VITE_ENOKI_API_KEY=$VITE_ENOKI_API_KEY
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ENV VITE_DOCS_URL=$VITE_DOCS_URL
ARG VITE_DEMO_URLS=$VITE_DEMO_URLS
ENV VITE_DEMO_URLS=$VITE_DEMO_URLS
ENV VITE_GA_MEASUREMENT_ID=$VITE_GA_MEASUREMENT_ID

RUN pnpm --filter @memwal/app build

Expand Down
11 changes: 9 additions & 2 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Dashboard from './pages/Dashboard'
import SetupWizard from './pages/SetupWizard'
import Playground from './pages/Playground'
import ConnectMcp from './pages/ConnectMcp'
import { useRouteAnalytics } from './hooks/useRouteAnalytics'


import '@mysten/dapp-kit/dist/index.css'
Expand Down Expand Up @@ -60,7 +61,7 @@ interface DelegateKeyContextType extends DelegateKeyState {

const DelegateKeyContext = createContext<DelegateKeyContextType | null>(null)

// LOW-32: tunable idle-timeout. 15 minutes by default. Exported so callers/tests can read it.
// tunable idle-timeout. 15 minutes by default. Exported so callers/tests can read it.
export const INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000

// Debounce interval for activity events to avoid excessive timer resets.
Expand Down Expand Up @@ -104,7 +105,7 @@ function DelegateKeyProvider({ children }: { children: React.ReactNode }) {
}, [])

// ============================================================
// LOW-32: Idle-timeout — wipe in-memory key material and disconnect after inactivity.
// Idle-timeout — wipe in-memory key material and disconnect after inactivity.
// ============================================================
const { mutateAsync: disconnect } = useDisconnectWallet()
const hasKey = state.delegateKey !== null
Expand Down Expand Up @@ -219,13 +220,19 @@ function AppContent() {
)
}

function AnalyticsTracker() {
useRouteAnalytics()
return null
}

// ============================================================
// Root App
// ============================================================

export default function App() {
return (
<BrowserRouter>
<AnalyticsTracker />
<QueryClientProvider client={queryClient}>
<SuiClientProvider networks={networkConfig} defaultNetwork={config.suiNetwork}>
<RegisterEnokiWallets />
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const config = {
.split(',').map(s => s.trim()).filter(Boolean) as string[],
sidecarUrl: import.meta.env.VITE_SIDECAR_URL as string || 'http://localhost:9000',
docsUrl: import.meta.env.VITE_DOCS_URL as string || '',
gaMeasurementId: import.meta.env.VITE_GA_MEASUREMENT_ID as string || '',
demoUrls: (import.meta.env.VITE_DEMO_URLS as string || '')
.split(',').map(s => s.trim()).filter(Boolean)
.map(entry => {
Expand Down
85 changes: 85 additions & 0 deletions apps/app/src/hooks/useRouteAnalytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { initAnalytics, trackEvent, trackPageView } from '../utils/analytics'

const SCROLL_THRESHOLDS = [25, 50, 75, 90] as const

interface PageSession {
path: string
startedAt: number
reportedScrollDepths: Set<number>
}

export function useRouteAnalytics() {
const location = useLocation()
const pageSessionRef = useRef<PageSession | null>(null)

const flushPageTime = useCallback(() => {
const pageSession = pageSessionRef.current
if (!pageSession) return

const durationSeconds = Math.round((Date.now() - pageSession.startedAt) / 1000)
if (durationSeconds <= 0) return

trackEvent('page_time', {
page_path: pageSession.path,
duration_seconds: durationSeconds,
})
pageSession.startedAt = Date.now()
}, [])

useEffect(() => {
initAnalytics()

const path = `${location.pathname}${location.search}`
flushPageTime()
pageSessionRef.current = {
path,
startedAt: Date.now(),
reportedScrollDepths: new Set<number>(),
}
trackPageView(path)
}, [flushPageTime, location.pathname, location.search])

useEffect(() => {
const reportScrollDepth = () => {
const pageSession = pageSessionRef.current
if (!pageSession) return

const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight
const percentScrolled = scrollableHeight <= 0
? 100
: Math.min(100, Math.round((window.scrollY / scrollableHeight) * 100))

for (const threshold of SCROLL_THRESHOLDS) {
if (percentScrolled < threshold || pageSession.reportedScrollDepths.has(threshold)) continue
pageSession.reportedScrollDepths.add(threshold)
trackEvent('scroll_depth', {
page_path: pageSession.path,
percent_scrolled: threshold,
})
}
}

window.addEventListener('scroll', reportScrollDepth, { passive: true })
reportScrollDepth()

return () => window.removeEventListener('scroll', reportScrollDepth)
}, [location.pathname, location.search])

useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flushPageTime()
}
const onBeforeUnload = () => flushPageTime()

document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('beforeunload', onBeforeUnload)

return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('beforeunload', onBeforeUnload)
flushPageTime()
}
}, [flushPageTime])
}
2 changes: 1 addition & 1 deletion apps/app/src/hooks/useSponsoredTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function useSponsoredTransaction() {
try {
// 1. Build TransactionKind bytes (without gas data)
const kindBytes = await transaction.build({
client: suiClient as any,
client: suiClient,
onlyTransactionKind: true,
})
const kindBase64 = uint8ArrayToBase64(kindBytes)
Expand Down
Loading
Loading