diff --git a/.github/workflows/benchmark-live.yml b/.github/workflows/benchmark-live.yml index e8cda913..9c1a214f 100644 --- a/.github/workflows/benchmark-live.yml +++ b/.github/workflows/benchmark-live.yml @@ -39,6 +39,10 @@ on: description: Remember runs required: false default: '3' + post_remember_sleep_secs: + description: Seconds to wait after remember before recall phases + required: false + default: '30' cold_runs: description: Cold-path runs required: false @@ -67,6 +71,7 @@ jobs: QUERY: ${{ github.event.inputs.query || 'benchmark memory' }} LIMIT: ${{ github.event.inputs.limit || '5' }} REMEMBER_RUNS: ${{ github.event.inputs.remember_runs || '3' }} + POST_REMEMBER_SLEEP_SECS: ${{ github.event.inputs.post_remember_sleep_secs || '30' }} COLD_RUNS: ${{ github.event.inputs.cold_runs || '3' }} WARM_RUNS: ${{ github.event.inputs.warm_runs || '10' }} BENCH_DELEGATE_KEY: ${{ secrets.BENCH_DELEGATE_KEY }} @@ -113,6 +118,7 @@ jobs: --namespace "$NAMESPACE" \ --limit "$LIMIT" \ --remember-runs "$REMEMBER_RUNS" \ + --post-remember-sleep-secs "$POST_REMEMBER_SLEEP_SECS" \ --cold-runs "$COLD_RUNS" \ --warm-runs "$WARM_RUNS" \ --output benchmark-results/memory-api.json \ diff --git a/.github/workflows/release-python-sdk.yml b/.github/workflows/release-python-sdk.yml new file mode 100644 index 00000000..31fa9cb2 --- /dev/null +++ b/.github/workflows/release-python-sdk.yml @@ -0,0 +1,182 @@ +name: Release Python SDK + +on: + push: + branches: + - main + - staging + - dev + paths: + - 'packages/python-sdk-memwal/**' + - '.github/workflows/release-python-sdk.yml' + workflow_dispatch: + inputs: + release_channel: + description: "Release channel to use for a manual run from this ref" + required: true + default: dev + type: choice + options: + - dev + - staging + - current + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Build & Publish + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip build + python -m pip install -e "packages/python-sdk-memwal[dev]" + + - name: Prepare package version + id: version + env: + RELEASE_CHANNEL: ${{ inputs.release_channel || 'current' }} + run: | + python <<'PY' + import json + import os + import re + import urllib.error + import urllib.request + from pathlib import Path + + package = "memwal" + ref_name = os.environ["GITHUB_REF_NAME"] + event_name = os.environ["GITHUB_EVENT_NAME"] + release_channel = os.environ.get("RELEASE_CHANNEL", "current") + branch = ( + release_channel + if event_name == "workflow_dispatch" and release_channel != "current" + else ref_name + ) + pkg_dir = Path("packages/python-sdk-memwal") + pyproject = pkg_dir / "pyproject.toml" + init_file = pkg_dir / "memwal" / "__init__.py" + + text = pyproject.read_text() + match = re.search(r'^version = "([^"]+)"', text, re.MULTILINE) + if not match: + raise SystemExit("Could not find project.version in pyproject.toml") + + base_version = match.group(1) + + def pypi_versions() -> list[str]: + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{package}/json", + timeout=20, + ) as response: + payload = json.load(response) + except urllib.error.HTTPError as err: + if err.code == 404: + return [] + raise + return list(payload.get("releases", {}).keys()) + + versions = pypi_versions() + publish = branch in {"main", "staging", "dev"} + + if branch == "main": + version = base_version + elif branch == "staging": + pattern = re.compile(rf"^{re.escape(base_version)}rc(\d+)$") + latest = max( + (int(m.group(1)) for v in versions if (m := pattern.match(v))), + default=-1, + ) + version = f"{base_version}rc{latest + 1}" + elif branch == "dev": + pattern = re.compile(rf"^{re.escape(base_version)}\.dev(\d+)$") + latest = max( + (int(m.group(1)) for v in versions if (m := pattern.match(v))), + default=-1, + ) + version = f"{base_version}.dev{latest + 1}" + else: + version = base_version + + exists = version in versions + + pyproject.write_text( + re.sub( + r'^version = "[^"]+"', + f'version = "{version}"', + text, + count=1, + flags=re.MULTILINE, + ) + ) + init_file.write_text( + re.sub( + r'^__version__ = "[^"]+"', + f'__version__ = "{version}"', + init_file.read_text(), + count=1, + flags=re.MULTILINE, + ) + ) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={version}\n") + output.write(f"publish={str(publish).lower()}\n") + output.write(f"exists={str(exists).lower()}\n") + + print( + f"Prepared {package} {version} " + f"(ref={ref_name}, channel={branch}, publish={publish}, exists={exists})" + ) + PY + + - name: Run tests + working-directory: packages/python-sdk-memwal + run: python -m pytest tests/test_signing.py tests/test_client.py tests/test_middleware.py -q + + - name: Build package + working-directory: packages/python-sdk-memwal + run: python -m build + + - name: Publish to PyPI + if: steps.version.outputs.publish == 'true' && steps.version.outputs.exists != 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/python-sdk-memwal/dist + print-hash: true + + - name: Skip existing version + if: steps.version.outputs.publish == 'true' && steps.version.outputs.exists == 'true' + run: echo "memwal ${{ steps.version.outputs.version }} already exists on PyPI; skipping publish" + + - name: Create GitHub Release + if: github.ref == 'refs/heads/main' && steps.version.outputs.exists != 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version.outputs.version }}'; + const tag = `memwal-python@${version}`; + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: tag, + body: `Release memwal Python SDK v${version}`, + draft: false, + prerelease: false, + }); diff --git a/.gitignore b/.gitignore index f4fd0694..0a7c0a84 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,12 @@ railway-*.md # Personal/internal planning notes — never include in repo plans/ .local-plans/ + +# Benchmark archives + working analysis notes +# Kept locally under +# /Users/toiancom/WorkProjects/memwal/whole-system-documents/benchmark-archive/ +# instead — see that README. Result JSONs are large (~10-25MB per run), +# add no value to source-tree clones, and have already lived their +# reviewable life in past PRs. +services/server/review/ +claudedocs/ diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 5c6d5227..2dae54f2 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -1286,6 +1286,32 @@ h1, h2, h3 { box-shadow: 2px 2px 0 #000000; } +.setup-import-textarea { + box-shadow: var(--neo-shadow); +} + +.setup-import-textarea:focus { + box-shadow: var(--neo-shadow); +} + +.setup-import-button { + width: 100%; + justify-content: center; + border-radius: 12px; + box-shadow: var(--neo-shadow); + transition: transform 0.15s, box-shadow 0.15s; +} + +.setup-import-button:hover:not(:disabled) { + transform: translate(-2px, -2px); + box-shadow: 5px 5px 0 #000000; +} + +.setup-import-button:active:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 #000000; +} + /* ========== Spinner ========== */ .spinner { diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index 0d8f65c8..336b22d2 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -38,6 +38,20 @@ function isMaxDelegateKeysError(err: unknown): boolean { return message.includes('abort code: 2') && message.includes('add_delegate_key') } +function normalizePrivateKeyHex(raw: string): string { + return raw.trim().replace(/^0x/i, '').replace(/\s+/g, '').toLowerCase() +} + +function bytesToHex(bytes: Uint8Array | number[]): string { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') +} + +function hexToBytes(hex: string): Uint8Array { + return new Uint8Array( + Array.from({ length: hex.length / 2 }, (_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16)) + ) +} + async function getAccountObjectId(suiClient: ReturnType, ownerAddress: string): Promise { try { const registryObj = await suiClient.getObject({ @@ -77,6 +91,24 @@ async function getDelegateKeyCount(suiClient: ReturnType, a return 0 } +async function getRegisteredDelegatePublicKeys(suiClient: ReturnType, accountId: string): Promise { + const obj = await suiClient.getObject({ + id: accountId, + options: { showContent: true }, + }) + if (obj?.data?.content && 'fields' in obj.data.content) { + const fields = obj.data.content.fields as any + const keys = fields?.delegate_keys ?? [] + return keys.map((k: any) => { + const f = k.fields ?? k + const pkBytes: number[] = f.public_key ?? [] + return bytesToHex(pkBytes) + }) + } + + return [] +} + export default function SetupWizard() { const currentAccount = useCurrentAccount() const { mutateAsync: disconnect } = useDisconnectWallet() @@ -92,6 +124,8 @@ export default function SetupWizard() { const [confirmed, setConfirmed] = useState(false) const [txStatus, setTxStatus] = useState('') const [error, setError] = useState('') + const [importKeyHex, setImportKeyHex] = useState('') + const [importingKey, setImportingKey] = useState(false) const [suiAddress, setSuiAddress] = useState('') const setupRunningRef = useRef(false) @@ -107,26 +141,28 @@ export default function SetupWizard() { } }, [step, navigate]) - // ── Generate Ed25519 keypair (shared) ── - const generateKeys = useCallback(async () => { + const deriveDelegateKey = useCallback(async (privateKeyHexValue: string) => { const ed = await import('@noble/ed25519') const { blake2b } = await import('@noble/hashes/blake2.js') - const privateKey = new Uint8Array(32) - crypto.getRandomValues(privateKey) + const privateKey = hexToBytes(privateKeyHexValue) const publicKey = await ed.getPublicKeyAsync(privateKey) - const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') - const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') - const input = new Uint8Array(33) input[0] = 0x00 input.set(publicKey, 1) const addressBytes = blake2b(input, { dkLen: 32 }) - const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') + const suiAddr = '0x' + bytesToHex(new Uint8Array(addressBytes)) - return { privHex, pubHex, suiAddr } + return { privHex: privateKeyHexValue, pubHex: bytesToHex(publicKey), suiAddr } }, []) + // ── Generate Ed25519 keypair (shared) ── + const generateKeys = useCallback(async () => { + const privateKey = new Uint8Array(32) + crypto.getRandomValues(privateKey) + return deriveDelegateKey(bytesToHex(privateKey)) + }, [deriveDelegateKey]) + // ── Register delegate key on-chain (shared) ── const registerOnchain = useCallback(async ( ownerAddress: string, @@ -233,6 +269,43 @@ export default function SetupWizard() { } }, [generateKeys]) + const handleImportKey = useCallback(async () => { + if (setupRunningRef.current) return + + const normalizedKey = normalizePrivateKeyHex(importKeyHex) + if (!/^[0-9a-f]{64}$/.test(normalizedKey)) { + setError('delegate key must be a 64-character hex private key.') + return + } + + setupRunningRef.current = true + setImportingKey(true) + setError('') + + try { + const accountId = await getAccountObjectId(suiClient, address) + if (!accountId) { + throw new Error('no MemWal account found for this wallet. generate a new delegate key first.') + } + + const { pubHex } = await deriveDelegateKey(normalizedKey) + const registeredPublicKeys = await getRegisteredDelegatePublicKeys(suiClient, accountId) + if (!registeredPublicKeys.includes(pubHex)) { + throw new Error('this delegate key is not registered on-chain for the connected wallet.') + } + + setDelegateKeys(normalizedKey, pubHex, accountId) + setImportKeyHex('') + setStep('done') + } catch (err) { + const message = err instanceof Error ? err.message : 'failed to import delegate key. please try again.' + setError(message) + } finally { + setImportingKey(false) + setupRunningRef.current = false + } + }, [address, importKeyHex, deriveDelegateKey, suiClient, setDelegateKeys]) + // ── Wallet: register on-chain after user confirms key ── const executeOnchain = useCallback(async () => { if (setupRunningRef.current) return @@ -332,6 +405,46 @@ export default function SetupWizard() { + +
+ or +
+ +
+
+