feat: 导入 MemRelay 初始源码

This commit is contained in:
Nixevol
2026-09-23 21:40:46 +08:00
commit 0ade3bb318
167 changed files with 53511 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
param(
[string]$ComposeFile = "compose.yaml",
[string]$DataPath = "./data",
[string]$OutputDirectory = "./backups"
)
$ErrorActionPreference = "Stop"
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
$resolvedData = (Resolve-Path -LiteralPath $DataPath).Path
$output = [System.IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $output | Out-Null
$archive = Join-Path $output "memrelay-$timestamp.tar.gz"
$manifest = Join-Path $output "memrelay-$timestamp.json"
$archiveName = [System.IO.Path]::GetFileName($archive)
$images = @(& docker compose -f $ComposeFile config --images)
if ($LASTEXITCODE -ne 0) {
throw "Unable to read Compose image versions."
}
& docker compose -f $ComposeFile exec -T memrelay python -c `
"import os, sqlite3; p=os.path.join(os.environ.get('MEMRELAY_DATA_DIR', '/data/memrelay'), 'db', 'memrelay.sqlite3'); c=sqlite3.connect(p); print(c.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()); c.close()" | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Unable to checkpoint the MemRelay database."
}
docker compose -f $ComposeFile stop | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Unable to stop MemRelay services."
}
try {
& docker run --rm --user "1000:1000" `
--mount "type=bind,source=$resolvedData,target=/source,readonly" `
--mount "type=bind,source=$output,target=/backup" `
alpine:3.22 tar -czf "/backup/$archiveName" -C /source .
if ($LASTEXITCODE -ne 0) {
throw "Unable to create the backup archive."
}
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
@{
product = "MemRelay"
created_at = (Get-Date).ToUniversalTime().ToString("o")
archive = $archiveName
sha256 = $hash
compose_file = $ComposeFile
images = $images
} | ConvertTo-Json | Set-Content -Encoding UTF8 -LiteralPath $manifest
"$hash $archiveName" | Set-Content -Encoding ASCII -LiteralPath "$archive.sha256"
Write-Host "Backup created: $archive"
}
catch {
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $archive, $manifest, "$archive.sha256"
throw
}
finally {
docker compose -f $ComposeFile start | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "Backup finished, but MemRelay services could not be restarted."
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
compose_file="${COMPOSE_FILE:-compose.yaml}"
data_path="${MEMRELAY_DATA_PATH:-./data}"
output_dir="${BACKUP_DIR:-./backups}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
archive="$output_dir/memrelay-$timestamp.tar.gz"
manifest="$output_dir/memrelay-$timestamp.json"
mkdir -p "$output_dir"
data_path="$(cd "$data_path" && pwd -P)"
output_dir="$(cd "$output_dir" && pwd -P)"
archive="$output_dir/memrelay-$timestamp.tar.gz"
manifest="$output_dir/memrelay-$timestamp.json"
images="$(docker compose -f "$compose_file" config --images | sort | paste -sd ',' -)"
docker compose -f "$compose_file" exec -T memrelay python -c \
"import os, sqlite3; p=os.path.join(os.environ.get('MEMRELAY_DATA_DIR', '/data/memrelay'), 'db', 'memrelay.sqlite3'); c=sqlite3.connect(p); print(c.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()); c.close()"
docker compose -f "$compose_file" stop
trap 'docker compose -f "$compose_file" start' EXIT INT TERM
if ! docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,source=$data_path,target=/source,readonly" \
--mount "type=bind,source=$output_dir,target=/backup" \
alpine:3.22 tar -czf "/backup/$(basename "$archive")" -C /source .; then
rm -f "$archive" "$archive.sha256" "$manifest"
exit 1
fi
hash="$(sha256sum "$archive" | awk '{print $1}')"
printf '%s %s\n' "$hash" "$(basename "$archive")" > "$archive.sha256"
printf '{"product":"MemRelay","created_at":"%s","archive":"%s","sha256":"%s","compose_file":"%s","images":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(basename "$archive")" "$hash" "$compose_file" "$images" > "$manifest"
echo "Backup created: $archive"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
import {
createCipheriv,
createHmac,
generateKeyPairSync,
pbkdf2Sync,
randomBytes,
} from "node:crypto";
function hkdfExpand(key, info, length) {
const output = [];
let previous = Buffer.alloc(0);
let counter = 1;
while (Buffer.concat(output).length < length) {
previous = createHmac("sha256", key)
.update(Buffer.concat([previous, Buffer.from(info, "utf8"), Buffer.from([counter])]))
.digest();
output.push(previous);
counter += 1;
}
return Buffer.concat(output).subarray(0, length);
}
function encrypt(plaintext, key) {
const encryptionKey = key.subarray(0, 32);
const macKey = key.subarray(32, 64);
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-cbc", encryptionKey, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const mac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
return `2.${iv.toString("base64")}|${ciphertext.toString("base64")}|${mac.toString("base64")}`;
}
async function main() {
const [server, rawEmail, password] = process.argv.slice(2);
if (!server || !rawEmail || !password) {
throw new Error("Usage: create_test_vault_account.mjs <server> <email> <password>");
}
const email = rawEmail.trim().toLowerCase();
const iterations = 600_000;
const masterKey = pbkdf2Sync(password, email, iterations, 32, "sha256");
const masterPasswordHash = pbkdf2Sync(masterKey, password, 1, 32, "sha256").toString(
"base64",
);
const stretchedMasterKey = Buffer.concat([
hkdfExpand(masterKey, "enc", 32),
hkdfExpand(masterKey, "mac", 32),
]);
const userKey = randomBytes(64);
const key = encrypt(userKey, stretchedMasterKey);
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "der" },
privateKeyEncoding: { type: "pkcs8", format: "der" },
});
const response = await fetch(`${server.replace(/\/$/, "")}/identity/accounts/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email,
name: "MemRelay Stage Zero",
masterPasswordHash,
masterPasswordHint: null,
key,
keys: {
encryptedPrivateKey: encrypt(privateKey, userKey),
publicKey: publicKey.toString("base64"),
},
kdf: 0,
kdfIterations: iterations,
kdfMemory: null,
kdfParallelism: null,
}),
});
const body = await response.text();
if (!response.ok) {
throw new Error(`Vault registration failed (${response.status}): ${body}`);
}
console.log(body);
}
await main();
+32
View File
@@ -0,0 +1,32 @@
"""Download and validate the local embedding model used by Basic Memory."""
from __future__ import annotations
import argparse
from fastembed import TextEmbedding
DEFAULT_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--cache-dir", default="/data/basic-memory/cache")
parser.add_argument("--threads", type=int, default=2)
args = parser.parse_args()
embedding = TextEmbedding(
model_name=args.model,
cache_dir=args.cache_dir,
threads=args.threads,
)
vectors = list(embedding.embed(["记忆服务", "memory service"]))
if len(vectors) != 2 or len(vectors[0]) != 384:
raise RuntimeError("Unexpected embedding model output")
print(f"model={args.model} vectors={len(vectors)} dimensions={len(vectors[0])}")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
param(
[Parameter(Mandatory = $true)][string]$Archive,
[string]$ComposeFile = "compose.yaml",
[string]$DataPath = "./data",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$resolvedArchive = (Resolve-Path -LiteralPath $Archive).Path
$target = [System.IO.Path]::GetFullPath($DataPath)
$targetRoot = [System.IO.Path]::GetPathRoot($target).TrimEnd('\', '/')
if ($target.TrimEnd('\', '/') -eq $targetRoot) {
throw "Refusing to restore into a filesystem root."
}
$checksumFile = "$resolvedArchive.sha256"
if (Test-Path -LiteralPath $checksumFile) {
$expected = ((Get-Content -LiteralPath $checksumFile -TotalCount 1) -split '\s+')[0]
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedArchive).Hash.ToLowerInvariant()
if ($actual -ne $expected.ToLowerInvariant()) {
throw "Backup checksum verification failed."
}
}
New-Item -ItemType Directory -Force -Path $target | Out-Null
if ((Get-ChildItem -Force -LiteralPath $target | Select-Object -First 1) -and -not $Force) {
throw "Target data directory is not empty. Re-run with -Force after verifying the archive."
}
docker compose -f $ComposeFile stop | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Unable to stop MemRelay services."
}
try {
if ($Force) {
Get-ChildItem -Force -LiteralPath $target | Remove-Item -Recurse -Force
}
$archiveDirectory = [System.IO.Path]::GetDirectoryName($resolvedArchive)
$archiveName = [System.IO.Path]::GetFileName($resolvedArchive)
& docker run --rm --user "1000:1000" `
--mount "type=bind,source=$archiveDirectory,target=/backup,readonly" `
--mount "type=bind,source=$target,target=/target" `
alpine:3.22 tar -xzf "/backup/$archiveName" -C /target
if ($LASTEXITCODE -ne 0) {
throw "Unable to extract the backup archive."
}
Write-Host "Restore completed: $target"
}
finally {
docker compose -f $ComposeFile up -d | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "Restore finished, but MemRelay services could not be started."
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <archive.tar.gz> [--force]" >&2
exit 2
fi
archive="$1"
force="${2:-}"
compose_file="${COMPOSE_FILE:-compose.yaml}"
data_path="${MEMRELAY_DATA_PATH:-./data}"
archive="$(cd "$(dirname "$archive")" && pwd -P)/$(basename "$archive")"
if [ -f "$archive.sha256" ]; then
(cd "$(dirname "$archive")" && sha256sum -c "$(basename "$archive").sha256")
fi
mkdir -p "$data_path"
data_path="$(cd "$data_path" && pwd -P)"
if [ "$data_path" = "/" ]; then
echo "Refusing to restore into the filesystem root." >&2
exit 1
fi
if [ -n "$(find "$data_path" -mindepth 1 -maxdepth 1 -print -quit)" ] && [ "$force" != "--force" ]; then
echo "Target data directory is not empty. Re-run with --force after verifying the archive." >&2
exit 1
fi
docker compose -f "$compose_file" stop
trap 'docker compose -f "$compose_file" up -d' EXIT INT TERM
if [ "$force" = "--force" ]; then
find "$data_path" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
fi
docker run --rm --user "$(id -u):$(id -g)" \
--mount "type=bind,source=$(dirname "$archive"),target=/backup,readonly" \
--mount "type=bind,source=$data_path,target=/target" \
alpine:3.22 tar -xzf "/backup/$(basename "$archive")" -C /target
echo "Restore completed: $data_path"
+199
View File
@@ -0,0 +1,199 @@
"""Validate the Basic Memory MCP contract used by MemRelay."""
from __future__ import annotations
import argparse
import asyncio
import json
from typing import Any
from fastmcp import Client
REQUIRED_TOOLS = {
"delete_note",
"list_directory",
"move_note",
"read_note",
"search_notes",
"write_note",
}
async def call(client: Client, name: str, arguments: dict[str, Any]) -> Any:
result = await client.call_tool(name, arguments)
if result.data is not None:
return result.data
return [getattr(item, "text", str(item)) for item in result.content]
async def delete_fixture(client: Client, identifier: str) -> None:
await call(
client,
"delete_note",
{"identifier": identifier, "output_format": "json"},
)
async def validate(endpoint: str, semantic: bool) -> dict[str, Any]:
async with Client(endpoint) as client:
tools = {tool.name for tool in await client.list_tools()}
missing = REQUIRED_TOOLS - tools
if missing:
raise RuntimeError(f"Basic Memory is missing required tools: {sorted(missing)}")
await delete_fixture(client, "Stage Zero Note")
for index in range(5):
await delete_fixture(client, f"Concurrent Note {index}")
await delete_fixture(client, "Credential Memory")
await delete_fixture(client, "Weather Memory")
written = await call(
client,
"write_note",
{
"title": "Stage Zero Note",
"content": (
"# Stage Zero Note\n\n"
"中文语义与 English context.\n\n"
"- [decision] Markdown remains authoritative."
),
"directory": "global",
"tags": ["stage-zero", "multilingual"],
"note_type": "decision",
"metadata": {
"stable_id": "stage-zero-001",
"scope": "global",
"revision": 1,
"status": "active",
},
"overwrite": True,
"output_format": "json",
},
)
read = await call(
client,
"read_note",
{
"identifier": "Stage Zero Note",
"output_format": "json",
"include_frontmatter": True,
},
)
search = await call(
client,
"search_notes",
{
"query": "Markdown authoritative",
"search_type": "text",
"output_format": "json",
},
)
concurrent = await asyncio.gather(
*(
call(
client,
"write_note",
{
"title": f"Concurrent Note {index}",
"content": f"Concurrent content {index}",
"directory": "projects/stage-zero",
"metadata": {"request_id": f"stage0-{index}"},
"overwrite": True,
"output_format": "json",
},
)
for index in range(5)
)
)
moved = await call(
client,
"move_note",
{
"identifier": "Stage Zero Note",
"destination_path": "archive/stage-zero-note.md",
"output_format": "json",
},
)
archived = await call(
client,
"read_note",
{
"identifier": "archive/stage-zero-note",
"output_format": "json",
"include_frontmatter": True,
},
)
semantic_result = None
if semantic:
await call(
client,
"write_note",
{
"title": "Credential Memory",
"content": "团队账号密码和访问令牌应统一保存在密码库中。",
"directory": "projects/stage-zero",
"overwrite": True,
"output_format": "json",
},
)
await call(
client,
"write_note",
{
"title": "Weather Memory",
"content": "今天的天气预报是晴天,午后温度较高。",
"directory": "projects/stage-zero",
"overwrite": True,
"output_format": "json",
},
)
semantic_result = await call(
client,
"search_notes",
{
"query": "Where should team passwords and access tokens be stored?",
"search_type": "vector",
"min_similarity": 0,
"output_format": "json",
},
)
titles = [item["title"] for item in semantic_result.get("results", [])]
if "Credential Memory" not in titles:
raise RuntimeError("Cross-language semantic search did not return the target note")
await delete_fixture(client, "Stage Zero Note")
for index in range(5):
await delete_fixture(client, f"Concurrent Note {index}")
await delete_fixture(client, "Credential Memory")
await delete_fixture(client, "Weather Memory")
return {
"available_tool_count": len(tools),
"write": written,
"read": read,
"search": search,
"concurrent_write_count": len(concurrent),
"move": moved,
"archive_read": archived,
"semantic_search": semantic_result,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", default="http://127.0.0.1:8000/mcp")
parser.add_argument("--semantic", action="store_true")
args = parser.parse_args()
print(
json.dumps(
asyncio.run(validate(args.endpoint, args.semantic)),
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
set -eu
: "${BW_SERVER:?BW_SERVER is required}"
: "${BW_EMAIL:?BW_EMAIL is required}"
: "${BW_PASSWORD:?BW_PASSWORD is required}"
if [ "${BW_OFFLINE_ONLY:-0}" = "1" ]; then
BW_SESSION="$(bw unlock "$BW_PASSWORD" --raw)"
export BW_SESSION
items="$(bw list items)"
count="$(printf '%s' "$items" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).length)))')"
if [ "$count" -lt 1 ]; then
echo "Offline cache is empty" >&2
exit 1
fi
echo "Bitwarden CLI offline validation passed"
exit 0
fi
bw logout >/dev/null 2>&1 || true
bw config server "$BW_SERVER" >/dev/null
BW_SESSION="$(bw login "$BW_EMAIL" "$BW_PASSWORD" --raw)"
export BW_SESSION
bw sync >/dev/null
item_json='{"type":1,"name":"MemRelay Stage Zero","login":{"username":"stage0","password":"test-secret","totp":"JBSWY3DPEHPK3PXP"}}'
encoded="$(printf '%s' "$item_json" | bw encode)"
created="$(bw create item "$encoded")"
item_id="$(printf '%s' "$created" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).id))')"
totp="$(bw get totp "$item_id")"
case "$totp" in
??????) ;;
*) echo "Unexpected TOTP response" >&2; exit 1 ;;
esac
pids=""
for _index in 1 2 3 4 5; do
bw get item "$item_id" >/dev/null &
pids="$pids $!"
done
for pid in $pids; do
wait "$pid"
done
bw lock >/dev/null
BW_SESSION="$(bw unlock "$BW_PASSWORD" --raw)"
export BW_SESSION
bw get item "$item_id" >/dev/null
bw sync >/dev/null
echo "Bitwarden CLI online validation passed"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
set -eu
prefix="memrelay-vault-stage0"
network="${prefix}-network"
vault="${prefix}-server"
tls="${prefix}-tls"
cli="${prefix}-cli"
config_volume="${prefix}-config"
npm_volume="${prefix}-npm"
email="stage0@memrelay.local"
password="MemRelay-Stage0-2026!"
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
script_source="${MEMRELAY_SCRIPT_SOURCE:-$script_dir}"
cleanup() {
docker rm -f "$cli" "$tls" "$vault" >/dev/null 2>&1 || true
docker volume rm "$config_volume" "$npm_volume" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
docker network create "$network" >/dev/null
docker volume create "$config_volume" >/dev/null
docker volume create "$npm_volume" >/dev/null
docker run -d --name "$vault" --network "$network" \
-e I_REALLY_WANT_VOLATILE_STORAGE=true \
-e SIGNUPS_ALLOWED=true \
-e DOMAIN="https://$tls" \
vaultwarden/server:1.37.1 >/dev/null
docker run -d --name "$tls" --network "$network" \
-e HTTP_PROXY= -e HTTPS_PROXY= -e http_proxy= -e https_proxy= \
caddy:2-alpine caddy reverse-proxy \
--from "https://$tls" \
--to "http://$vault:80" \
--internal-certs >/dev/null
docker run -d --name "$cli" --network "$network" \
-e HTTP_PROXY= -e HTTPS_PROXY= -e http_proxy= -e https_proxy= \
-v "$config_volume:/root/.config" \
-v "$npm_volume:/root/.npm" \
--mount "type=bind,source=$script_source,target=/opt/memrelay-tests,readonly" \
node:22-bookworm-slim sh -lc \
"npm install -g @bitwarden/cli@2026.7.0 >/tmp/npm-install.log 2>&1 && touch /tmp/ready && sleep infinity" \
>/dev/null
attempt=0
until docker exec "$cli" test -f /tmp/ready; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 120 ]; then
docker logs "$cli" >&2
exit 1
fi
sleep 1
done
docker exec "$cli" node /opt/memrelay-tests/create_test_vault_account.mjs \
"http://$vault" "$email" "$password" >/dev/null
docker exec \
-e NODE_TLS_REJECT_UNAUTHORIZED=0 \
-e BW_SERVER="https://$tls" \
-e BW_EMAIL="$email" \
-e BW_PASSWORD="$password" \
"$cli" sh /opt/memrelay-tests/validate_bitwarden_cli.sh
docker stop "$tls" >/dev/null
docker exec \
-e NODE_TLS_REJECT_UNAUTHORIZED=0 \
-e BW_SERVER="https://$tls" \
-e BW_EMAIL="$email" \
-e BW_PASSWORD="$password" \
-e BW_OFFLINE_ONLY=1 \
"$cli" sh /opt/memrelay-tests/validate_bitwarden_cli.sh
echo "Vault integration validation passed"