Skip to content

CanXPAI/maplenode

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

maplenode

A sovereign edge cognition appliance for MapleOS. Local-first semantic memory, document indexing, and an MCP-compatible tool server, running on a Radxa ROCK 4C+ under Armbian.

status arch license

RockPi 4C+ Rockchip is officially supported. Dual-Core Arm Cortex-A72 and Quad-Core Cortex-A53 are officially supported.

What this is

maplenode is a tiny, always-on appliance built around three ideas:

  1. Local-first memory. Vector-searchable semantic store, on-device, with no cloud round-trip on the hot path.
  2. Pluggable embeddings. sentence-transformers/all-MiniLM-L6-v2 (384-d) by default, with a deterministic hash fallback so the API stays up even if heavy ML deps fail to install.
  3. Discoverable. mDNS publishes maplenode.local, and the node broadcasts its capabilities on UDP :8766 every 30s so MapleOS can find it without manual config.

It is not a GPU server. It is an edge memory + orchestration node.

At a glance

Target board Radxa ROCK 4C+ (RK3399, 4GB)
OS Armbian Bookworm (Debian 12), kernel 6.7.x
Stack FastAPI · SQLite · LanceDB · sentence-transformers (CPU)
HTTP :8765
UDP discovery :8766
mDNS maplenode.local, service _maplenode._tcp
Process systemd unit, runs as user maplenode
Auth one setup token, sent via X-MapleSeed-Token

Quick start

On a freshly-flashed ROCK 4C+ with Armbian (headless / multi-user.target):

sudo hostnamectl set-hostname maplenode
git clone https://github.com/CanXPAI/maplenode.git ~/maplenode-src
cd ~/maplenode-src
bash scripts/install.sh

Then, from any machine on the same LAN:

curl http://maplenode.local:8765/health
# {"status":"ok","device":"maplenode","hostname":"maplenode","version":"0.1.0"}

The installer prints the setup token at the end. Grab it:

sudo cat /opt/maplenode/app/data/.setup_token

API tour

TOKEN=<paste the setup token>
BASE=http://maplenode.local:8765

# Open endpoints — no token required
curl -s $BASE/health
curl -s $BASE/capabilities
curl -s $BASE/mcp/tools

# Write endpoints — require X-MapleSeed-Token
curl -s -X POST $BASE/memory/store \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"text":"User prefers local-first agents.","metadata":{"source":"test"}}'

curl -s -X POST $BASE/memory/search \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"agent preferences","top_k":5}'

# Upload a document and index it
curl -s -X POST $BASE/documents/upload \
  -H "X-MapleSeed-Token: $TOKEN" \
  -F file=@notes.txt -F namespace=default
# returns {"id":"<doc-id>", ...}

curl -s -X POST $BASE/documents/index \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"document_id":"<doc-id>"}'

# MCP-style tools
curl -s $BASE/mcp/device_status -H "X-MapleSeed-Token: $TOKEN"
curl -s -X POST $BASE/mcp/run_command_safe \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"command":"uptime"}'

Allowlisted diagnostic commands are: uptime, df, free, cpuinfo, service_status. There is no unrestricted shell endpoint by design.

TypeScript client

A typed client lives in client/MapleSeedClient.ts:

import { MapleSeedClient } from "./MapleSeedClient";

const node = new MapleSeedClient("http://maplenode.local:8765", { token: TOKEN });

await node.storeMemory("User prefers local-first agents.", { source: "test" });
const hits = await node.searchMemory("agent preferences", 5);
const tools = await node.listTools();

Drop it into MapleOS as the integration surface.

Discovery

maplenode is reachable in two complementary ways:

  1. mDNS / Bonjour. maplenode.local resolves to its LAN IP. An avahi service file at /etc/avahi/services/maplenode.service publishes _maplenode._tcp with TXT records: version, capabilities, api.
  2. UDP capability broadcast. Every 30s the node sends a JSON packet on UDP :8766:
    {
      "type": "maplenode.advertise",
      "name": "maplenode",
      "hostname": "maplenode.local",
      "api": "http://maplenode.local:8765",
      "version": "0.1.0",
      "capabilities": ["memory", "documents", "mcp"]
    }
    MapleOS can listen on :8766 to auto-detect nodes joining the LAN.

Pairing flow (MapleOS side)

  1. Discover the node via mDNS or the UDP broadcast.
  2. User opens an SSH/console on the device and runs:
    sudo cat /opt/maplenode/app/data/.setup_token
  3. User pastes the token into MapleOS.
  4. MapleOS stores the token and uses it in X-MapleSeed-Token for all write calls.

The token is generated on first boot (32 bytes of secrets.token_urlsafe) and persists to a 0600 file under /opt/maplenode/app/data/.

Optional: Cognitum Seed integration

A Cognitum Seed is a small USB-attached hardware root of trust for AI — identity keys, witness chains, custody signatures, vector memory on a tamper-evident device. maplenode can optionally pair with one to gain a /cognitum/* API surface and report cognitum: true in /capabilities.

It is entirely opt-in. If no seed is plugged in (or the integration is disabled), the core memory/document features work exactly as before.

One-shot pair

Plug the seed into a USB port on the ROCK 4C+, then:

sudo bash /opt/maplenode/scripts/cognitum-pair.sh

What that does, end to end:

  1. Finds the USB-Ethernet interface the seed exposed and persists 169.254.42.2/24 on it via NetworkManager (connection name cognitum-usb).
  2. Mounts the seed's COGNITUM mass-storage read-only, runs the bundled install-trust.sh to drop its name-constrained CA into /usr/local/share/ca-certificates/cognitum-ca.crt, and runs update-ca-certificates.
  3. Opens the seed's 30-second pairing window and pairs as client maplenode. The bearer token lands at /opt/maplenode/app/data/.cognitum_token (mode 0600, owned by maplenode).
  4. Writes /etc/systemd/system/maplenode.service.d/cognitum.conf setting MAPLENODE_COGNITUM_ENABLED=true and pointing REQUESTS_CA_BUNDLE at the system CA store.
  5. daemon-reload + restart maplenode, then probes /capabilities.

Verify:

curl http://maplenode.local:8765/capabilities
# {"...","cognitum_enabled":true,"cognitum":true}

New API surface (only when paired)

TOKEN=$(sudo cat /opt/maplenode/app/data/.setup_token)
BASE=http://maplenode.local:8765

curl -s $BASE/cognitum/status   -H "X-MapleSeed-Token: $TOKEN"
curl -s $BASE/cognitum/identity -H "X-MapleSeed-Token: $TOKEN"

# Sign/verify are thin pass-throughs to the seed's /api/v1/custody/{sign,verify}.
# Send the exact body the seed expects (currently {"data": <opaque blob>}).
curl -s -X POST $BASE/cognitum/sign \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"data":"sign this opaque blob"}'

curl -s -X POST $BASE/cognitum/verify \
  -H "X-MapleSeed-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"data":"...","signature":"...","witness":"..."}'

/cognitum/mcp/tools and /cognitum/mcp/call exist as experimental thin passthroughs. The seed's MCP endpoint uses MCP Streamable HTTP transport which requires a stateful initialize → Mcp-Session-Id handshake that maplenode does not manage; if you need MCP tool access today, point your MCP-aware client directly at https://169.254.42.1:8443/mcp with the bearer token at /opt/maplenode/app/data/.cognitum_token.

Security note on the CA

The Cognitum CA is name-constrained at install time. Inspect it:

openssl x509 -in /usr/local/share/ca-certificates/cognitum-ca.crt -text \
  | grep -A 5 'Name Constraints'

Its permitted name space is:

  • DNS: *.local, cognitum-<id>.local
  • IP: 169.254.0.0/16, 127.0.0.0/8, 192.168.4.0/24

It cannot sign certificates for public hostnames like github.com — even though it's in the system trust store, the blast radius is contained to link-local + mDNS + a single private subnet.

Disable

sudo rm /etc/systemd/system/maplenode.service.d/cognitum.conf
sudo systemctl daemon-reload && sudo systemctl restart maplenode
# Optional: remove the CA too
sudo rm /usr/local/share/ca-certificates/cognitum-ca.crt
sudo update-ca-certificates --fresh
# Optional: drop the persistent USB connection
sudo nmcli connection delete cognitum-usb

Witness-stamped memory (tamper-proof memory)

Long-form: see docs/witness.md for the architecture, threat model, canonical encoding, end-to-end flows, security properties, and code map.

When a Cognitum Seed is paired, maplenode can ask it to sign every memory and every document chunk with the seed's hardware-rooted Ed25519 key at write time. The signature is persisted alongside the row, and any caller can later ask maplenode to re-verify it. This makes maplenode's memory tamper-evident: if anything mutates the stored text, the verify call fails before it even round-trips to the seed.

The seed remains strictly optional. With no seed paired (or MAPLENODE_COGNITUM_ENABLED=false), /memory/store and /documents/upload keep working unchanged — rows just land with witness_json = NULL.

Three modes

Set with MAPLENODE_WITNESS_MODE in the systemd override:

Mode Seed reachable Seed unreachable
off no sign attempted; witness_json = NULL same
when_available (default) sign and persist; respond witnessed: true log + insert with witness_json = NULL; witnessed: false
required sign and persist refuse the write with HTTP 503; no DB state

What a witness contains

Stored in memories.witness_json (also documents.witness_json, chunks.witness_json):

{
  "schema": "v1",
  "algorithm": "Ed25519",
  "signature": "<128 hex chars>",
  "public_key": "<seed device public key, 64 hex>",
  "device_id": "538babda-82af-436e-a44e-d2472570a172",
  "canonical_hex": "<BLAKE2b-32 of the canonical record bytes>",
  "signed_at": "2026-05-25T..."
}

canonical_hex is what was actually signed: a BLAKE2b-32 digest of a schema-versioned, line-delimited string covering id, namespace, created_at, and SHA-256s of text and metadata. We hash before signing so the seed sees a fixed 64-char hex input regardless of memory size.

Verifying

TOKEN=$(sudo cat /opt/maplenode/app/data/.setup_token)
BASE=http://maplenode.local:8765

# Full row with witness
curl -s $BASE/memory/<id> -H "X-MapleSeed-Token: $TOKEN" | jq

# Re-verify against the seed
curl -s -X POST $BASE/memory/<id>/verify -H "X-MapleSeed-Token: $TOKEN" | jq
# { "valid": true, "algorithm": "Ed25519" }

# Tamper test
sudo -u maplenode sqlite3 /opt/maplenode/app/data/maplenode.db \
  "UPDATE memories SET text = 'tampered' WHERE id = '<id>'"
curl -s -X POST $BASE/memory/<id>/verify -H "X-MapleSeed-Token: $TOKEN" | jq
# { "valid": false, "reason": "canonical_bytes_mismatch" }

Verification short-circuits locally when the stored canonical_hex doesn't match a freshly-recomputed one — no round-trip to the seed needed to know a record has been tampered with. The round-trip only confirms the signature math.

Document chunks have their own witnesses (the document_id is in the canonical bytes, so a chunk witness binds to its document and can't be re-targeted):

curl -s $BASE/documents/<doc-id>                         -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s -X POST $BASE/documents/<doc-id>/verify          -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s $BASE/documents/<doc-id>/chunks/<chunk-id>       -H "X-MapleSeed-Token: $TOKEN" | jq
curl -s -X POST $BASE/documents/<doc-id>/chunks/<chunk-id>/verify \
                                                         -H "X-MapleSeed-Token: $TOKEN" | jq

Caveat: device key rotation

The witness binds to the seed's current device key. If the seed is factory- reset, firmware-rotated, or physically swapped, all previous witnesses become unverifiable under the new device key. This is a property of hardware roots of trust, not something the integration can work around — treat the seed's Ed25519 pubkey as a trust-on-first-use root.

/memory/search results include a lightweight witnessed: bool flag. Existing rows from before the upgrade have witness_json = NULL and are reported as witnessed: false; no automatic backfill in v1.

Updating and backups

# Update from a new checkout
bash /opt/maplenode/scripts/update.sh ~/maplenode-src

# Backup SQLite + LanceDB + uploaded docs + setup token
bash /opt/maplenode/scripts/backup.sh /home/vince/maplenode-backups

The backup is a single timestamped .tar.gz. Restore by extracting it back over /opt/maplenode/app/data/.

Layout

maplenode/
├─ app/
│  ├─ main.py                 FastAPI app + token middleware + lifespan
│  ├─ config.py               paths, ports, capability flags, setup-token mgmt
│  ├─ api/                    HTTP routers: health, memory, documents, mcp, sync, cognitum
│  └─ core/
│     ├─ database.py          SQLite schema + connection helper
│     ├─ embeddings.py        pluggable backend (local-minilm | hash)
│     ├─ vector_store.py      LanceDB wrapper
│     ├─ document_indexer.py  chunker + extractor
│     ├─ discovery.py         UDP capability broadcaster
│     ├─ cognitum.py          optional Cognitum Seed client
│     ├─ witness.py           canonical encoders + sign/verify primitives
│     └─ agent_runtime.py     stub for tiny local inference (Phase 9)
├─ scripts/
│  ├─ install.sh              one-shot installer (apt + venv + systemd + ufw + avahi)
│  ├─ update.sh               rsync + pip refresh + restart
│  ├─ backup.sh               consistent tar.gz of data dir
│  └─ cognitum-pair.sh        pair with a Cognitum Seed over USB
├─ systemd/
│  ├─ maplenode.service       systemd unit
│  └─ maplenode-avahi.service avahi service definition
├─ client/
│  └─ MapleSeedClient.ts      TypeScript client for MapleOS
└─ README.md

Runtime data (not in repo, created by the installer):

/opt/maplenode/app/data/
├─ maplenode.db          SQLite (memories, documents, chunks)
├─ vectors/              LanceDB
├─ documents/            uploaded files
├─ models/               sentence-transformers cache
├─ logs/
└─ .setup_token          chmod 0600

Configuration

All knobs are environment variables, read by app/config.py:

Variable Default Meaning
MAPLENODE_DEVICE_NAME maplenode name returned in /health and the broadcast
MAPLENODE_PORT 8765 HTTP port
MAPLENODE_UDP_PORT 8766 capability broadcast port
MAPLENODE_BROADCAST_INTERVAL 30 seconds between broadcasts
MAPLENODE_EMBED_BACKEND local-minilm local-minilm or hash
MAPLENODE_LOCAL_INFERENCE false toggles a local_inference capability flag
MAPLENODE_CLOUD_SYNC false toggles a cloud_sync capability flag
MAPLENODE_LOG_LEVEL INFO python logging level
MAPLENODE_COGNITUM_ENABLED false mount the /cognitum/* routes and start probing the seed
MAPLENODE_COGNITUM_URL https://169.254.42.1:8443 base URL for the paired Cognitum Seed
MAPLENODE_COGNITUM_TOKEN <DATA_DIR>/.cognitum_token file containing the seed bearer token
MAPLENODE_WITNESS_MODE when_available off, when_available, or required — see "Witness-stamped memory"

To set them under systemd, drop a file at /etc/systemd/system/maplenode.service.d/override.conf:

[Service]
Environment=MAPLENODE_EMBED_BACKEND=hash

Then sudo systemctl daemon-reload && sudo systemctl restart maplenode.

Security posture

  • LAN-only by design. UFW opens 22/tcp, 8765/tcp, 5353/udp (mDNS), 8766/udp (broadcast). Do not port-forward 8765 to the public internet.
  • Write-gated. All non-GET endpoints (and POST /mcp/run_command_safe) require the setup token. GET /health, /capabilities, and /mcp/tools are open so a discovering MapleOS can probe before pairing.
  • No arbitrary shell. Diagnostic commands are a hard-coded allowlist.
  • Process isolation. The systemd unit runs as the maplenode system user with NoNewPrivileges, ProtectSystem=full, ProtectHome=true, and ReadWritePaths=/opt/maplenode/app/data.

Notes for installers

  • The default PyPI torch wheel on aarch64 pulls ~2GB of CUDA libraries that are useless on RK3399. scripts/install.sh installs torch from the CPU-only index (https://download.pytorch.org/whl/cpu) and points pip's TMPDIR at the SD card to avoid filling the 1.9GB /tmp tmpfs.
  • First call to /memory/store or /memory/search will load the 90MB MiniLM model into memory. The installer warms the cache at install time so the first user request isn't slow.

Troubleshooting

Symptom Try
maplenode.local doesn't resolve sudo systemctl status avahi-daemon; make sure the client host has libnss-mdns or systemd-resolved with mDNS enabled; check both are on the same subnet
Service won't start journalctl -u maplenode -n 80 --no-pager
pip install ran out of disk The fix is already in the installer (CPU torch index + TMPDIR=/opt/maplenode/.tmp). If you skipped it, replicate those flags manually.
401 on write endpoints Header must be X-MapleSeed-Token; token at /opt/maplenode/app/data/.setup_token
/memory/search returns nothing First write a memory; first call may pause while the embedding model downloads
Want to swap to hash fallback MAPLENODE_EMBED_BACKEND=hash via the systemd override above
cognitum: false in /capabilities after pairing Check ping 169.254.42.1 (USB link), then curl -v https://169.254.42.1:8443/api/v1/status (TLS / CA). The maplenode service needs REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt in its override (the pair script sets it).

Roadmap

  • Phase 9 — tiny local inference (llama.cpp + small GGUF) for intent classification and summarization
  • Phase 10 — CanXP AI cloud sync
  • USB-gadget pairing mode for direct one-device pairing without LAN
  • Per-namespace ACLs and multi-token auth
  • PDF / HTML / Markdown extractors for documents.index

License

MIT — see LICENSE.

About

Sovereign edge cognition appliance for MapleOS.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors