diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e45e17 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.gitignore +.dockerignore +.pytest_cache +.mypy_cache +.ruff_cache +__pycache__ +node_modules +vendor/nosqlite/target +dist +.next +.vinext +.wrangler +lecore-site +*.py[cod] +*.pyo +.venv +venv +env +.env +.env.* +metrics +holostuff.zip +current backlogs +*_backlog.md +PANEL_*_backlog.md +RENDER_PIPELINE_BACKLOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 795c577..b3df1fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.txt -r requirements-x402.txt - name: Run the test suite # Two speed levers work together here: @@ -309,7 +309,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.txt -r requirements-x402.txt - name: Sanity-check the partition (exact cover, disjoint, deterministic) run: python tools/shard_tests.py --selfcheck --num-shards 4 diff --git a/.gitignore b/.gitignore index b84605c..47a53bd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ holographic_vsa_complete.zip /dist /build /build_pkg +vendor/nosqlite/target/ *.egg-info repo.zip /temp diff --git a/API_QUICKREF.md b/API_QUICKREF.md index 7a32471..0a8dd26 100644 --- a/API_QUICKREF.md +++ b/API_QUICKREF.md @@ -2,6 +2,111 @@ *A scannable, one-line-per-symbol map of the app-building surface -- auto-generated by `apiquickref.py`. For the full engine (every module), see REFERENCE.md.* +## Product wedge + +### `holographic_product` +*holographic_product.py -- the small product-facing leCore facade.* + +- **class `MemoryEntry`** -- One stored memory item. + - `to_dict(self)` -- Return a JSON-safe representation of this memory entry. + - `from_dict(cls, data)` -- Build a memory entry from `to_dict` data. +- **class `LocalAgentCore`** -- Product facade for local agent memory, skill routing, and evidence. + - `entries(self)` -- A copy of the stored entries, in insertion order. + - `memory_summary(self)` -- Return constant-time memory status without running evidence probes. + - `remember(self, text, label=None, metadata=None, id=None)` -- Store one local memory. + - `remember_many(self, items)` -- Store several memories. + - `get_memory(self, memory_id)` -- Return one stored memory by id, or ``None`` when it is absent. + - `list_memories(self, limit=50, cursor=None)` -- Return an insertion-ordered page with an opaque-enough stable id cursor. + - `forget(self, memory_id)` -- Delete one memory by id and return it; missing ids are idempotent. + - `update_memory(self, memory_id, text=_UNSET, label=_UNSET, metadata=_UNSET)` -- Replace selected fields of one memory and return it, or ``None``. + - `recall(self, query, k=3, abstain=None)` -- Return the nearest stored memories for `query`, best first. + - `suggest(self, task, k=5)` -- Suggest capabilities for a plain-English task. + - `route(self, task)` -- Route a task to one capability when confident, otherwise return options. + - `evidence(self)` -- Return a machine-readable product readiness snapshot. + - `dashboard(self, html=False)` -- Return the evidence dashboard as a dict, or static HTML with `html=True`. + - `dashboard_html(data)` -- Render an evidence snapshot as a dependency-free static HTML dashboard. + - `to_state(self)` -- Serialize configuration and entries. + - `from_state(cls, state)` -- Rebuild a core from `to_state` data. + - `save(self, path)` -- Atomically write the product state to JSON and return the path. + - `load(cls, path)` -- Load a product state saved by `save`. +- `demo()` -- Build a tiny ready-to-query product demo. + +### `holographic_x402_api` +*holographic_x402_api.py -- publish the leCore Agent Memory & Routing API.* + +- **class `PaidRoute`** -- One x402-protected route. + - `key(self)` -- The route key shape expected by x402 middleware, e.g. +- `x402_payment_required_responses()` -- OpenAPI response metadata shared by every x402-protected operation. +- `paid_request_openapi(required, properties, example, example_summary)` -- Return an accurate OpenAPI request body while runtime validation stays compatible. +- `paid_operation_responses(success, invalid_detail, backend_unavailable=False, idempotency_conflict=False)` -- Document paid success, payment, tenant, and validation responses. +- `health_success_openapi(paid, private_tenants_enabled, memory_backend, nosqlite_shadow, nosqlite_configured, durable_transactions, encrypted_storage, plaintext_migration_enabled)` -- Document the free health and deployment-state response. +- `pricing_success_openapi(config, private_tenants_enabled, memory_backend, nosqlite_shadow, nosqlite_configured, durable_transactions, encrypted_storage, plaintext_migration_enabled)` -- Document the free x402 discovery manifest. +- `recall_success_openapi()` -- Document the successful memory-recall response. +- `memory_write_success_openapi()` -- Document the successful private-tenant memory write response. +- `memory_list_success_openapi()` -- Document private-tenant memory listing and direct lookup. +- `memory_delete_success_openapi()` -- Document idempotent private-tenant memory deletion. +- `memory_update_success_openapi()` -- Document a successful private-tenant memory update. +- `memory_update_request_openapi()` -- Document a partial update that requires at least one mutable field. +- `route_success_openapi()` -- Document the successful capability-routing response. +- `dashboard_success_openapi()` -- Document the successful service-readiness response. +- `public_response_headers(path, status_code, public_url, content_type='', network=DEFAULT_NETWORK)` -- Return browser and cache policy headers for one public response. +- **class `X402Config`** -- Seller configuration for the x402-paid API. + - `from_env(cls, require_pay_to=True)` -- Build config from LECORE_X402_* environment variables. + - `to_public_dict(self)` -- Public, JSON-safe view of the payment configuration. +- `optional_dependency_help()` -- Install hint for the optional paid API dependencies. +- `normalize_tenant_id(value)` -- Return a path-safe tenant id for private memory routing. +- `tenant_access_token(tenant_id, secret)` -- Deterministic tenant bearer token derived from a server-side secret. +- `normalize_idempotency_key(value)` -- Validate an optional caller-provided retry key without persisting the raw value. +- `normalize_memory_id(value)` -- Validate a memory id before lookup or deletion. +- **class `MemoryStateError`** -- Durable memory could not be authenticated, decoded, or migrated safely. +- **class `MemoryKeyring`** -- A small versioned set of 256-bit application data-encryption keys. + - `from_json(cls, value)` -- Parse the Secrets Manager value used by ``LECORE_X402_MEMORY_KEYS``. +- **class `MemoryStateCodec`** -- Compress and authenticate durable JSON records before they touch disk. + - `read_json(self, path, context)` -- Read one record, migrating plaintext or an old key while locked. + - `write_json(self, path, value, context)` -- Serialize, compress, encrypt, and atomically replace one record. +- **class `TenantCoreStore`** -- Thread-safe LocalAgentCore registry with optional per-tenant persistence. + - `loaded_tenants(self)` -- Return tenant ids currently loaded in memory. + - `summary(self, tenant_id)` -- Return a cheap cached status summary without probing capabilities. + - `read(self, tenant_id, fn)` -- Run a read-style operation while holding the tenant lock. + - `write(self, tenant_id, fn)` -- Run a mutating operation, then persist that tenant if configured. + - `mutate(self, tenant_id, fn)` -- Persist a callback result only when it reports that state changed. +- **class `NoSQLiteError`** -- Raised when the optional NoSQLite command process cannot serve a request. +- **class `NoSQLiteProcess`** -- Serialize JSON-line requests to one long-lived NoSQLite CLI process. + - `generation(self)` -- Return the number of successful NoSQLite process starts. + - `running(self)` -- Return whether the managed NoSQLite process is currently alive. + - `ensure_started(self)` -- Start the child lazily and return its generation number. + - `command(self, payload)` -- Send one command and return the object response from NoSQLite. + - `close(self)` -- Release the child process and its filesystem writer lock. +- **class `NoSQLiteMemoryStore`** -- Tenant-isolated semantic memory backed by the pinned NoSQLite CLI. + - `running(self)` -- Return whether the underlying NoSQLite process is currently alive. + - `remember(self, tenant_id, memory)` -- Persist one LocalAgentCore-compatible memory entry in its tenant collection. + - `replace(self, tenant_id, memory)` -- Idempotently replace one projected memory, including its embedding. + - `delete(self, tenant_id, memory_id)` -- Idempotently delete one projected memory. + - `sync(self, tenant_id, memories)` -- Reconcile a tenant projection from its authoritative encrypted snapshot. + - `recall(self, tenant_id, query, k, abstain=None)` -- Return NoSQLite semantic hits in the LocalAgentCore response shape. + - `close(self)` -- Release the underlying NoSQLite process and writer lock. +- **class `MemoryTransactionError`** -- The durable memory write journal could not be read or completed safely. +- **class `MemoryTransactionConflict`** -- One idempotency key was reused for a different memory write. +- **class `MemoryMirrorPending`** -- A durable core commit needs the same transaction projected to NoSQLite. +- **class `TenantMemoryTransactions`** -- Durable, idempotent memory writes spanning LocalAgentCore and NoSQLite. + - `remember(self, tenant_id, text, label, metadata, idempotency_key, mirror)` -- Commit one memory and return its stable transaction status. + - `resume(self, tenant_id, transaction_id, mirror)` -- Resume a known journal record without minting a second transaction. + - `recover_pending(self, mirror)` -- Replay incomplete durable writes, leaving unavailable mirrors pending. + - `mark_deleted(self, tenant_id, memory_id)` -- Tombstone the originating idempotency record before deleting memory. +- `pricing_summary(config)` -- Describe the customer-facing price and whether it is a production charge. +- `normalize_memory_backend(value)` -- Validate the memory backend selector without accepting silent fallbacks. +- `env_flag(value)` -- Parse the small explicit boolean surface used by deployment settings. +- `memory_state_codec(value, allow_plaintext_migration=False)` -- Resolve optional versioned encryption material without a silent fallback. +- `landing_page_html(config)` -- Render the buyer-facing landing page served from `/`. +- `documentation_manifest(config)` -- Return canonical public documentation URLs for discovery responses. +- `public_dashboard(data)` -- Translate the embedded SDK dashboard into the hosted API vocabulary. +- `payment_manifest(config)` -- Plain JSON route manifest, useful for docs, `/pricing`, and tests. +- `x402_route_configs(config)` -- Build x402 SDK RouteConfig objects for the protected routes. +- `x402_resource_server(config)` -- Create an x402 resource server wired to the configured facilitator. +- `create_app(core=None, config=None, paid=True, admin_token=None, tenant_secret=None, tenant_state_dir=None, memory_keys=None, allow_plaintext_migration=None, memory_backend=None, nosqlite_binary=None, nosqlite_data_dir=None, nosqlite_durability=None, nosqlite_shadow=None)` -- Create the FastAPI application for paid or unpaid development serving. +- `load_core(path)` -- Load a persisted core if present, otherwise return the demo core. +- `main(argv=None)` -- CLI entry point for running the x402 API service. + ## Scene authoring ### `holographic_scene_doc` diff --git a/AWS_X402_DEPLOY.md b/AWS_X402_DEPLOY.md new file mode 100644 index 0000000..f04997b --- /dev/null +++ b/AWS_X402_DEPLOY.md @@ -0,0 +1,399 @@ +# AWS x402 Deployment + +This is the production shape for serving the hosted leCore Agent Memory & +Routing API with x402 payments on AWS. The service is backed internally by +`LocalAgentCore`. + +## Short Answer + +Yes, we can launch this on AWS. For the **seller** side of x402, the service +does **not** need a wallet private key in the container. It only needs: + +- the public receiving wallet address (`LECORE_X402_PAY_TO`) +- x402/facilitator configuration +- an admin token for seller-only memory writes +- a tenant-token secret if private customer memory is enabled +- a separate versioned memory-encryption keyring for durable customer memory + +The receiving wallet should be a cold wallet, hardware wallet, Safe/multisig, +or a custody wallet. The API simply tells x402 where funds should go. + +Only build an AWS-hosted signing wallet if the app itself must **spend** funds +or pay upstream APIs as a buyer. + +## Recommended AWS Architecture + +- **ECS Fargate** runs the `Dockerfile.x402` container. +- The image includes a pinned NoSQLite CLI for an optional semantic-memory + backend; it is disabled by default. +- **Application Load Balancer** terminates HTTPS and forwards to port `4021`. +- **ECR** stores the container image. +- **Secrets Manager** stores `LECORE_X402_ADMIN_TOKEN` and production + facilitator credentials. + Store `LECORE_X402_TENANT_SECRET` there too when private tenants are enabled. + Store `LECORE_X402_MEMORY_KEYS` as a separate secret; never derive it from or + reuse an admin, tenant-token, facilitator, or wallet secret. +- **SSM Parameter Store or plain task env** stores non-secret config like + `LECORE_X402_PAY_TO`, `LECORE_X402_PRICE`, `LECORE_X402_NETWORK`, + `LECORE_X402_PUBLIC_URL`, and `LECORE_X402_TENANT_STATE_DIR`. +- **CloudWatch Logs** captures service logs. +- **AWS WAF** can rate-limit and block bad traffic at the ALB. + +Protected paid routes: + +- `POST /v1/memory` (private tenant + idempotency key required) +- `GET /v1/memory` (private tenant listing or exact retrieval) +- `PATCH /v1/memory` (private tenant selected-field update) +- `DELETE /v1/memory` (private tenant idempotent deletion) +- `POST /v1/recall` +- `POST /v1/route` +- `GET /v1/dashboard` + +Free routes: + +- `GET /health` +- `GET /pricing` + +Seller-only route: + +- `POST /admin/remember`, guarded by `X-Admin-Token` +- `POST /admin/tenant-token`, guarded by `X-Admin-Token` + +## Build And Push + +The deployed preview runs in `us-east-1` from the existing +`lecore-x402-api` ECR repository. Build a unique ARM64 image, then pin the +deployment to its digest. Never deploy `latest`. + +```bash +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +REGION="us-east-1" +REPOSITORY="lecore-x402-api" +REGISTRY="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com" +REVISION="$(git rev-parse HEAD)" +IMAGE_TAG="${REVISION:0:12}-$(date -u +%Y%m%dT%H%M%SZ)" +IMAGE="$REGISTRY/$REPOSITORY:$IMAGE_TAG" + +aws ecr get-login-password --region "$REGION" \ + | docker login --username AWS --password-stdin "$REGISTRY" + +docker build --platform linux/arm64 \ + --label "org.opencontainers.image.revision=$REVISION" \ + -f Dockerfile.x402 -t "$IMAGE" . +docker push "$IMAGE" + +DIGEST="$(aws ecr describe-images --region "$REGION" \ + --repository-name "$REPOSITORY" --image-ids imageTag="$IMAGE_TAG" \ + --query 'imageDetails[0].imageDigest' --output text)" +PINNED_IMAGE="$REGISTRY/$REPOSITORY@$DIGEST" + +aws ecr wait image-scan-complete --region "$REGION" \ + --repository-name "$REPOSITORY" --image-id imageDigest="$DIGEST" +aws ecr describe-image-scan-findings --region "$REGION" \ + --repository-name "$REPOSITORY" --image-id imageDigest="$DIGEST" \ + --query 'imageScanFindings.findingSeverityCounts' +``` + +Review the scan before registration. Do not deploy if the new image regresses +against the active image or violates the release vulnerability policy. + +## Runtime Environment + +Non-secret environment variables: + +```text +LECORE_X402_PAY_TO=0xYourReceivingWallet +LECORE_X402_PRICE=$0.0011 +LECORE_X402_NETWORK=eip155:8453 +LECORE_X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 +LECORE_X402_PUBLIC_URL=https://lecore.rati.foundation +LECORE_X402_TENANT_STATE_DIR=/data/tenants +LECORE_X402_MEMORY_BACKEND=core +``` + +Secrets Manager values: + +```text +LECORE_X402_ADMIN_TOKEN= +LECORE_X402_TENANT_SECRET= +LECORE_X402_MEMORY_KEYS={"active":"v1","keys":{"v1":"<32-byte base64 key>"}} +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= +``` + +Use ECS task definition `secrets` entries for secrets, not literal environment +variables in the task definition. + +Generate the first keyring offline and send it directly to Secrets Manager; +never print the deployed value in logs or commit it: + +```bash +umask 077 +python -c 'import base64,json,secrets; print(json.dumps({"active":"v1","keys":{"v1":base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}}))' \ + | aws secretsmanager create-secret --region us-east-1 \ + --name /lecore/x402/memory-keys --secret-string file:///dev/stdin +``` + +Grant the ECS execution role `secretsmanager:GetSecretValue` only on that exact +secret ARN, and add it to the `app` container's task-definition `secrets` array +as `LECORE_X402_MEMORY_KEYS`. + +## Durable Memory Security Boundary + +The production `core` backend compresses each tenant file and durable retry +journal, derives a per-record key with HKDF-SHA256, and encrypts/authenticates it +with AES-256-GCM before the atomic write. Tenant/file identity is authenticated +as associated data, so copying ciphertext between tenants is rejected. The +application keyring is independent from EFS encryption at rest and TLS in +transit; all three layers remain enabled. + +Paid `/v1/memory` requires durable state, a valid private-tenant token, and an +`Idempotency-Key`. The service refuses to start paid durable mode without the +memory keyring. It also refuses NoSQLite or NoSQLite shadow mode while the +application encryption keyring is configured, because those files do not yet +use this envelope. + +For key rotation, add a new key while retaining the old one, make the new id +`active`, and deploy. Startup rewraps every tenant and journal record under the +active key. Verify clean startup and encrypted storage, then remove the old key +and launch fresh tasks. A secret update alone is insufficient: ECS injects the +value only when a task starts. + +For the one-time migration from legacy plaintext, do not use the normal +overlapping 100/200 rollout: an old task cannot read the new ciphertext and can +still write plaintext. Drain to zero or otherwise guarantee one writer, add +`LECORE_X402_ALLOW_PLAINTEXT_MIGRATION=1`, and start one new task with the +keyring. After it rewrites and verifies every file, remove the flag and perform +a fresh deployment. The flag must never remain enabled during normal service. + +## ECS Rollout + +The live service is `lonely-forest-cluster/lecore-x402-api`. Treat its current +task definition as the rollback target and change only the `app` container +image. This preserves the task roles, ARM64 runtime, CPU and memory, logging, +EFS volume, environment, and secret references. + +The commands below assume `REGION`, `PINNED_IMAGE`, and `DIGEST` are still set +from the build step and that `jq` is installed. + +```bash +CLUSTER="lonely-forest-cluster" +SERVICE="lecore-x402-api" +umask 077 +DEPLOY_DIR="$(mktemp -d /tmp/lecore-x402-deploy.XXXXXX)" +trap 'rm -rf -- "$DEPLOY_DIR"' EXIT +ROLLBACK_TASK_DEF="$(aws ecs describe-services --region "$REGION" \ + --cluster "$CLUSTER" --services "$SERVICE" \ + --query 'services[0].taskDefinition' --output text)" + +aws ecs describe-task-definition --region "$REGION" \ + --task-definition "$ROLLBACK_TASK_DEF" --include TAGS \ + --output json > "$DEPLOY_DIR/described-task.json" +jq '.taskDefinition' "$DEPLOY_DIR/described-task.json" \ + > "$DEPLOY_DIR/base-task.json" +TASK_TAGS="$(jq -c '.tags // []' "$DEPLOY_DIR/described-task.json")" + +jq --arg image "$PINNED_IMAGE" ' + if ([.containerDefinitions[] | select(.name == "app")] | length) != 1 + then error("expected exactly one app container") else . end + | + del( + .taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy, .deregisteredAt + ) + | (.containerDefinitions[] | select(.name == "app").image) = $image +' "$DEPLOY_DIR/base-task.json" > "$DEPLOY_DIR/next-task.json" + +jq -S ' + del( + .taskDefinitionArn, .revision, .status, .requiresAttributes, + .compatibilities, .registeredAt, .registeredBy, .deregisteredAt + ) + | (.containerDefinitions[] | select(.name == "app").image) = "__IMAGE__" +' "$DEPLOY_DIR/base-task.json" > "$DEPLOY_DIR/base.normalized.json" +jq -S ' + (.containerDefinitions[] | select(.name == "app").image) = "__IMAGE__" +' "$DEPLOY_DIR/next-task.json" > "$DEPLOY_DIR/next.normalized.json" +cmp "$DEPLOY_DIR/base.normalized.json" "$DEPLOY_DIR/next.normalized.json" + +CURRENT_TASK_DEF="$(aws ecs describe-services --region "$REGION" \ + --cluster "$CLUSTER" --services "$SERVICE" \ + --query 'services[0].taskDefinition' --output text)" +test "$CURRENT_TASK_DEF" = "$ROLLBACK_TASK_DEF" + +NEW_TASK_DEF="$(aws ecs register-task-definition --region "$REGION" \ + --cli-input-json "file://$DEPLOY_DIR/next-task.json" --tags "$TASK_TAGS" \ + --query 'taskDefinition.taskDefinitionArn' --output text)" + +aws ecs update-service --region "$REGION" --cluster "$CLUSTER" \ + --service "$SERVICE" --task-definition "$NEW_TASK_DEF" +aws ecs wait services-stable --region "$REGION" \ + --cluster "$CLUSTER" --services "$SERVICE" +``` + +If the equality check fails, another operator changed the service after this +rollout began. Stop, inspect that task definition, and rebuild the candidate +from the new base rather than overwriting it. + +Normal rolling deployment is safe for later image-only changes while +`LECORE_X402_MEMORY_BACKEND=core` and every task already has the same keyring. +The first plaintext-to-encrypted migration is deliberately a drain-and-replace +operation. Do not turn on the single-writer NoSQLite backend in the same rollout. + +## Verify And Roll Back + +Verify all of the following before considering the rollout complete: + +- ECS reports one running task, none pending, and the service references + `NEW_TASK_DEF`. +- The running `app` container's `imageDigest` equals `DIGEST`. +- The ALB target is healthy. +- `GET /health` and `GET /pricing` return `200`. +- `GET /v1/dashboard` returns `402`, and its decoded `payment-required` header + advertises exactly + `https://lecore.rati.foundation/v1/dashboard`. +- `/health` still reports private tenancy and durable transactions, and the + EFS-backed memory state is present. +- `/health.memory_backend.storage` reports `durable=true`, `encrypted=true`, + `cipher=AES-256-GCM`, `compression=zlib`, and + `plaintext_migration_enabled=false` after migration. +- `POST /v1/memory` without payment returns `402`; after an authorized testnet + payment, tenant token, and idempotency key, it stores once and can be recalled. +- CloudWatch logs since the rollout contain no new errors, tracebacks, or + exceptions. + +Roll back on a stability wait failure, an unhealthy target, a wrong image +digest, any `5xx`, missing durable state, or an incorrect payment resource URL: + +```bash +aws ecs update-service --region "$REGION" --cluster "$CLUSTER" \ + --service "$SERVICE" --task-definition "$ROLLBACK_TASK_DEF" +aws ecs wait services-stable --region "$REGION" \ + --cluster "$CLUSTER" --services "$SERVICE" +``` + +Re-run the endpoint and digest checks after rollback. Do not deregister the +rollback task definition or delete its ECR digest. + +## Optional NoSQLite Cutover (Not Compatible With Encrypted Production Memory) + +The container has `/usr/local/bin/nosqlite` built from the vendored source +snapshot pinned at `8964da27670c752121b8e6d26d113577429b02f6`. To use it for +`/v1/recall`, add: + +```text +LECORE_X402_MEMORY_BACKEND=nosqlite +LECORE_X402_NOSQLITE_BIN=/usr/local/bin/nosqlite +LECORE_X402_NOSQLITE_DATA_DIR=/data/nosqlite +LECORE_X402_NOSQLITE_DURABILITY=sync +``` + +NoSQLite currently sits outside the authenticated application-encryption +boundary. The API fails closed if it is selected together with +`LECORE_X402_MEMORY_KEYS`; do not use this mode for customer memory. + +For an explicitly unencrypted development deployment, mount `/data/nosqlite` on durable storage. NoSQLite deliberately takes a +nonblocking exclusive writer lock for the whole process, so a single data path +must have exactly one active ECS writer. Use a deliberate drain-and-replace +maintenance deployment for the cutover; do not rely on the normal overlapping +rolling deployment. The service currently stays on `core` until that operation +is scheduled. + +For a no-serving-impact validation phase, use: + +```text +LECORE_X402_MEMORY_BACKEND=core +LECORE_X402_NOSQLITE_SHADOW=1 +LECORE_X402_NOSQLITE_BIN=/usr/local/bin/nosqlite +LECORE_X402_NOSQLITE_DATA_DIR=/data/nosqlite +``` + +That mirrors admin writes and compares recall internally while preserving the +existing LocalAgentCore response as the source of truth. + +## Wallet Storage Decision + +### Seller API, Recommended + +Do **not** store a private key in AWS. + +The API receives payments; it does not spend. x402 payment verification and +settlement happen through the facilitator. The service only advertises +`payTo`. + +Best receiving wallet options: + +- Safe/multisig +- hardware wallet +- cold wallet +- custodial account dedicated to receipts + +### Buyer/Spender API, If Needed Later + +If the leCore agent itself needs to pay other x402 APIs, use a separate signer +service: + +1. Create an AWS KMS asymmetric signing key with `ECC_SECG_P256K1`. +2. Derive the public Ethereum address from `kms:GetPublicKey`. +3. Allow only a narrow IAM role to call `kms:Sign`. +4. Sign EIP-712/EIP-3009 payload digests through KMS. +5. Enforce spend limits in application logic before every signing request. +6. Log every signing request with CloudTrail and app-level audit records. + +This keeps the private key non-exportable: it never appears in the container. + +### High-Assurance Signer + +For larger balances or stronger isolation, put the signing service in **AWS +Nitro Enclaves** and allow KMS decrypt/sign only when enclave attestation +matches the expected image measurement. + +### Last Resort + +Storing a raw private key in Secrets Manager is acceptable only for testnet or +very small hot-wallet balances. If used, wrap it with strict IAM, rotation +plans, spend limits, CloudTrail alarms, and a tiny blast radius. + +## First Production Checklist + +- Use mainnet network id and production facilitator URL. +- Put the ALB behind HTTPS only. +- Set `LECORE_X402_PUBLIC_URL` to the canonical HTTPS endpoint so payment + challenges never depend on forwarded request headers. +- Keep `/admin/remember` private or blocked from the public ALB path. +- Keep `/admin/tenant-token` private or blocked from the public ALB path. +- Keep paid route configs explicit; avoid wildcard paid routes at first. +- Add WAF rate limits. +- Add CloudWatch alarms on 5xx, 402 spikes, and admin write attempts. +- Use tenant tokens plus isolated tenant state before offering private customer + memory. +- Mount `LECORE_X402_TENANT_STATE_DIR` on shared durable storage. Tenant writes + reload under an OS-level lock and use atomic replacement, so rolling ECS tasks + do not overwrite one another. +- Preserve the `.x402-memory-transactions` directory inside tenant state. It is + the durable outbox for core-to-NoSQLite writes; callers should send an + `Idempotency-Key` on `/admin/remember` retries so a timeout cannot duplicate + a memory. +- Do not enable NoSQLite on the same EFS directory in overlapping ECS tasks; + schedule a single-writer drain-and-replace cutover instead. +- Do not put secrets or PII in x402 route descriptions or payment metadata. + +## Pre-deployment Smoke Test + +```bash +pip install ".[x402]" +export LECORE_X402_PAY_TO="0xYourReceivingWallet" +export LECORE_X402_ADMIN_TOKEN="dev-admin-secret" +export LECORE_X402_TENANT_SECRET="dev-tenant-secret" +python holographic_x402_api.py --unpaid-dev --host 127.0.0.1 --port 4021 +``` + +Then: + +```bash +curl http://127.0.0.1:4021/health +curl http://127.0.0.1:4021/pricing +curl -X POST http://127.0.0.1:4021/v1/route \ + -H "Content-Type: application/json" \ + -d '{"task":"search tenant-scoped agent memory"}' +``` diff --git a/CAPABILITIES.md b/CAPABILITIES.md index b436846..b934386 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -448,6 +448,14 @@ import lecore; m=lecore.UnifiedMind(dim=256,seed=0); print(m.suggest_pipeline('t ``` *Find it by:* how do I get from points to a mesh, chain capabilities, build a pipeline, route between datatypes, what steps turn X into Y +### x402 paid API publisher +publish the leCore Agent Memory & Routing API as a hosted HTTP service: FastAPI routes for encrypted private-memory store/list/get/update/delete, tenant-scoped recall, task routing, and the readiness dashboard protected by x402 middleware, with free health/pricing/docs routes and separate tenant/admin authorization.. + +```python +from holographic_x402_api import create_app, X402Config; app = create_app(config=X402Config(pay_to='0x...'), paid=False) +``` +*Find it by:* x402, paid api, payment required, 402, monetize api, micropayment, agent payments, pay per request + ## Memory, search & recall *store things and get them back by CONTENT, not by exact key.* @@ -664,6 +672,14 @@ mind.navigator_benchmark() # recall + the fixed-beam baseline ``` *Find it by:* navigator, adaptive search, learned search, search a tree, nearest neighbour search, beam search, spend less effort on easy queries, reflex cache +### Local agent core (memory + routing) +the PRODUCT-FACING wedge: LocalAgentCore gives a local agent deterministic text memory (remember/recall), skill routing over the live capability catalog, JSON persistence, and a readiness dashboard with C-kernel status. This is the small stable door for embedding leCore without learning the whole research surface first.. + +```python +from holographic_product import LocalAgentCore; core = LocalAgentCore(); core.remember('local agent memory'); core.recall('agent memory') +``` +*Find it by:* product, productization, agent memory, local memory, durable memory, recall, skill routing, dashboard + ### Memoize a pure function (the purity gate is the point) skip re-execution of PURE work whose inputs repeat. mind.memoize_pure(fn) keys on (the function's EXACT canonical source, its arguments) and REFUSES a function that is not pure -- is_pure rejects the clock, RNG, IO, global writes, and transitive impurity through a call-graph fixpoint, while accepting a locally-allocated container. A cache over an impure function returns a stale answer silently, so the gate raises instead. MEASURED: 36x on a repeated 256x256 SVD, bit-identical. THE BACKLOG CALLS THIS 'shape-keyed memoization', AND THAT NAME IS A BUG: a canonical shape erases identifiers and constants, so `def f(x): return x + 1` and `def g(x): return x + 2` have the SAME shape and would share a cache entry. mind.canonical_shape(fn) exists, and is a COMPRESSION primitive, never a cache key. KEPT NEGATIVE: the key costs O(input bytes) -- fingerprinting a 512x512 array costs 1.747 ms while A.sum() costs 0.084 ms, so a cheap function of a large array loses 21x; ask mind.machine_place with the function's own cost as the baseline. TWO BACKLOG NUMBERS DID NOT REPRODUCE: shape reuse is 1.13x (node type + depth) or 1.87x (control flow), not 2.36x -- it is a property of the equivalence relation, not the code; and tree purity is 35.4% (781 of 2,188 module-level functions), not 76%. HONEST SCOPE: the gate resolves callees within ONE module, so a function that calls an IMPORTED helper is refused as unresolved (sound, and why tucker.rank_gate is rejected -- it reaches fix_eigvec_signs from another module). Cross-module resolution wants types.. @@ -4989,4 +5005,4 @@ import lecore; m=lecore.UnifiedMind(); print([n for n,_ in m.workflow_neighbors( --- -*638 capability homes. Regenerate this file with `python capdoc.py` (it reads the live catalog, so it stays in step with the engine).* +*640 capability homes. Regenerate this file with `python capdoc.py` (it reads the live catalog, so it stays in step with the engine).* diff --git a/Dockerfile.x402 b/Dockerfile.x402 new file mode 100644 index 0000000..8bae2d3 --- /dev/null +++ b/Dockerfile.x402 @@ -0,0 +1,30 @@ +FROM rust:1.85-slim AS nosqlite-builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src/nosqlite + +COPY vendor/nosqlite /src/nosqlite + +RUN cargo build --release --locked --bin nosqlite + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + LECORE_X402_NOSQLITE_BIN=/usr/local/bin/nosqlite + +WORKDIR /app + +COPY --from=nosqlite-builder /src/nosqlite/target/release/nosqlite /usr/local/bin/nosqlite +COPY . /app + +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir -r requirements-x402.txt \ + && python -m pip install --no-cache-dir . + +EXPOSE 4021 + +CMD ["python", "holographic_x402_api.py", "--host", "0.0.0.0", "--port", "4021"] diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..874fac0 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,52 @@ +# leCore Product Wedge + +The first product surface is **LocalAgentCore**: a small facade for local agent +memory, capability routing, and readiness evidence. + +It deliberately narrows the promise. The full repo is a broad research engine; +this surface is the five-minute path for a builder who wants a deterministic, +inspectable local substrate. + +```python +from holographic_product import LocalAgentCore + +core = LocalAgentCore(dim=512, seed=0) +core.remember("local agents need deterministic durable memory", label="memory") +core.remember("capability routing should act when confident", label="routing") + +print(core.recall("deterministic local memory")[0]) +print(core.route("render a scene with global illumination")) +print(core.dashboard()) +``` + +The same object is available from the friendly import surface: + +```python +import lecore + +core = lecore.product.LocalAgentCore() +``` + +## What It Productizes + +- **Memory:** local text memories encoded through `UniversalEncoder` and recalled + through the shared `Index` home. +- **Routing:** plain-English tasks routed through the existing skill catalog. +- **Evidence:** a JSON/static-HTML dashboard with memory counts, capability + counts, determinism checks, and optional C-kernel availability. +- **Persistence:** `save(path)` and `LocalAgentCore.load(path)` round-trip the + stable state as JSON. Vectors are rebuilt from seed, text context, and entries. +- **Paid API publishing:** `holographic_x402_api.py` serves the product wedge as + an optional x402-paid FastAPI service with compressed authenticated storage + and paid store/list/get/update/delete/recall operations for durable private-tenant + memory. See [`X402_API.md`](X402_API.md). + +## Honest Scope + +This is not a neural database, a hosted service, or a general semantic model. +Out of the box it matches by the deterministic holographic text geometry it is +given. Better domain recall comes from adding domain memories, teaching text +context, or layering a specialized encoder on the same facade. + +The product rule is simple: the public wedge stays small, auditable, local, and +measured. The research garden remains available behind it. diff --git a/README.md b/README.md index 9cb2592..376b73b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ pip install "leos-core[symbolic]" # pip install .[symbolic] design-ti pip install "leos-core[zig]" # pip install .[zig] native batch kernels, 2-5x (ziglang -- whole # toolchain in one wheel, bit-identical in safe mode) pip install "leos-core[images]" # pip install .[images] jpg/webp/... image I/O (Pillow, no Flask) +pip install "leos-core[x402]" # pip install .[x402] paid API publishing, Python 3.10+ (x402, FastAPI) pip install "leos-core[dev]" # pip install .[dev] run the tests and make plots (pytest, matplotlib) pip install "leos-core[all]" # pip install .[all] everything portable, one shot pip install "leos-core[ui,jit]" # pip install .[ui,jit] ...or combine whichever you want @@ -187,6 +188,14 @@ Like leOS, leCore is **free and open source**, and the work that keeps it free i `find_capability` first, wire every capability to a mind faculty (so it is `/invoke`-able), register it in the catalog so it is discoverable, and run the reachability/gap audits — the discipline that keeps the codebase from growing gaps or isolating code in tests. Read this before making code changes. +- **[`PRODUCT.md`](PRODUCT.md)** — the **narrow embedded SDK wedge**: `LocalAgentCore`, a small stable in-process + facade for agent memory, capability routing, persistence, and the readiness dashboard. Start here if you want the + five-minute "embed it in an agent" path rather than the whole research surface. +- **[`X402_API.md`](X402_API.md)** — the **hosted leCore Agent Memory & Routing API guide**: serve tenant-scoped + memory and routing over FastAPI with x402 payment, per-route pricing, encrypted private-memory CRUD, and operator + provisioning. +- **[`AWS_X402_DEPLOY.md`](AWS_X402_DEPLOY.md)** — the **AWS launch guide**: ECS/Fargate deployment, Secrets Manager + config, and when to use KMS or Nitro Enclaves for wallet signing. - **[`CAPABILITIES.md`](CAPABILITIES.md)** — the **front-door menu**: a plain-language, grouped list of what leCore can do and the one call that starts each job. The friendliest place to begin if you're deciding whether the engine already does the thing you need. Generated from the live capability catalog by `capdoc.py` and kept in sync by CI. @@ -200,7 +209,8 @@ Like leOS, leCore is **free and open source**, and the work that keeps it free i way around. It's generated from the code by `docgen.py` and kept in sync automatically by CI, so it never drifts from what's actually there. - **[`API_QUICKREF.md`](API_QUICKREF.md)** — the **app-builder's quick reference**: one scannable line per public - class/function for the modules you actually touch when building on leCore (scene, mesh, camera, render, ship). + class/function for the modules you actually touch when building on leCore (product, scene, mesh, camera, render, + ship). - **[`SERVICE.md`](SERVICE.md)** — the **standalone HTTP service**: every endpoint (data store, jobs, and the agent-facing skills API) with `curl` examples, for driving leCore as an app rather than a library. - **[`GALLERY.md`](GALLERY.md)** — a **visual showcase**: renders, procedural patterns, memory/reconstruction demos, and performance charts, straight from the engine's tests (the visual companion to the code reference). diff --git a/REFERENCE.md b/REFERENCE.md index 84893a0..eabf32a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1,7 +1,7 @@ # leCore -- Code Reference *Auto-generated by `docgen.py` -- do not edit by hand; edit the module docstrings instead and re-run it.* -*620 modules, 216,511 lines of engine code.* +*622 modules, 220,515 lines of engine code.* > **New here? Read this first.** leCore represents *everything* -- memory, geometry, physics, rendering -- as > points in one very high-dimensional space (hypervectors), and combines them with a tiny algebra: **bind** @@ -81,7 +81,7 @@ | [`holographic_splatprune.py`](#holographic-splatprune) | Splat prune / merge + a quality-budget LOD chain (holographic_splatprune). | 187 | | [`holographic_splatsharpen.py`](#holographic-splatsharpen) | C4 probe (cross-cutting: XDATA-3 negative-lobe sharpening -> splat/archive reconstruction). KEPT NEGATIVE. | 87 | -### Core & standalone (578) +### Core & standalone (580) | module | what it is | lines | |---|---|---| @@ -144,7 +144,7 @@ | [`holographic_catalog_p01.py`](#holographic-catalog-p01) | holographic_catalog_p01 -- part 1/6 of the capability registry (split from holographic_catalog). | 785 | | [`holographic_catalog_p02.py`](#holographic-catalog-p02) | holographic_catalog_p02 -- part 2/6 of the capability registry (split from holographic_catalog). | 570 | | [`holographic_catalog_p03.py`](#holographic-catalog-p03) | holographic_catalog_p03 -- part 3/6 of the capability registry (split from holographic_catalog). | 1478 | -| [`holographic_catalog_p04.py`](#holographic-catalog-p04) | holographic_catalog_p04 -- part 4/6 of the capability registry (split from holographic_catalog). | 1541 | +| [`holographic_catalog_p04.py`](#holographic-catalog-p04) | holographic_catalog_p04 -- part 4/6 of the capability registry (split from holographic_catalog). | 1562 | | [`holographic_catalog_p05.py`](#holographic-catalog-p05) | holographic_catalog_p05 -- part 5/6 of the capability registry (split from holographic_catalog). | 966 | | [`holographic_catalog_p06.py`](#holographic-catalog-p06) | holographic_catalog_p06 -- part 6/6 of the capability registry (split from holographic_catalog). | 2345 | | [`holographic_ccrun.py`](#holographic-ccrun) | holographic_ccrun.py -- compile emitted C kernels with the system C compiler and batch-run them. | 149 | @@ -187,8 +187,8 @@ | [`holographic_cosamp.py`](#holographic-cosamp) | SPEED-3 -- CoSaMP batch-selection recovery (holographic_cosamp). | 172 | | [`holographic_cosmic.py`](#holographic-cosmic) | Local structure classification of a point cloud -- the 'cosmic web' method, extracted from leOS | 143 | | [`holographic_cosserat.py`](#holographic-cosserat) | holographic_cosserat.py -- H2b: TWIST for hair, via a Cosserat rod with orientation frames. | 278 | -| [`holographic_creature.py`](#holographic-creature) | holographic_creature.py | 2366 | | [`holographic_creature.py`](#holographic-creature) | Spore-style CREATURE builder: a spine with attachable limbs, bilateral symmetry, constraints (holographic_crea | 479 | +| [`holographic_creature.py`](#holographic-creature) | holographic_creature.py | 2366 | | [`holographic_creature_mind.py`](#holographic-creature-mind) | CreatureMind -- the reference DEMO of building a specialized mind ON the one UnifiedMind. | 108 | | [`holographic_creatureconv.py`](#holographic-creatureconv) | CONVOLUTION SURFACES over contiguous skeletons -- the right tool for hands, feet and digits. | 427 | | [`holographic_creatureeditor.py`](#holographic-creatureeditor) | The creature EDITOR session -- the API a Spore-like app drives: edit, undo, save, validate, build. | 562 | @@ -452,6 +452,7 @@ | [`holographic_procbridge.py`](#holographic-procbridge) | Procedural bridges (S3): where the SDF / procedural layer connects to the rest of the stack -- MEASURED. | 158 | | [`holographic_procgen.py`](#holographic-procgen) | Procedural generation (S2): 3D objects from a seed, greebled & fractal models, vegetated terrain. | 223 | | [`holographic_proctex.py`](#holographic-proctex) | Procedural textures (the standard 3D-app set, 2D and 3D) + the mask-edge REFRACTION effect. | 626 | +| [`holographic_product.py`](#holographic-product) | holographic_product.py -- the small product-facing leCore facade. | 459 | | [`holographic_projectivetower.py`](#holographic-projectivetower) | holographic_projectivetower.py -- the ceiling of the transform tower, and where the "word" analogy breaks. | 258 | | [`holographic_protocol.py`](#holographic-protocol) | Protocol-as-data auditing (backlog D1): the honesty discipline as a STRUCTURAL property of a program | 197 | | [`holographic_provenance.py`](#holographic-provenance) | holographic_provenance.py -- tag a vector with WHERE it came from, one model for the whole stack. | 73 | @@ -661,6 +662,7 @@ | [`holographic_worstview.py`](#holographic-worstview) | M16 -- find the GLOBAL worst view of a mesh over the sphere of directions, without a dense turntable sweep. | 194 | | [`holographic_wos.py`](#holographic-wos) | holographic_wos.py -- #7 / M1 from the SIGGRAPH list: WALK ON SPHERES. Solve PDEs on ANY geometry, no mesh. | 174 | | [`holographic_wost.py`](#holographic-wost) | holographic_wost.py -- Walk on Spheres / Walk on *Stars*: a grid-free Laplace/Poisson solver on an SDF. | 253 | +| [`holographic_x402_api.py`](#holographic-x402-api) | holographic_x402_api.py -- publish the leCore Agent Memory & Routing API. | 3524 | | [`holographic_zigmarch.py`](#holographic-zigmarch) | holographic_zigmarch.py -- the one-kernel-two-runtimes raymarch demo, EXECUTED (backlog Z4). | 230 | | [`holographic_zigrun.py`](#holographic-zigrun) | holographic_zigrun.py -- compile emitted Zig kernels to shared libraries and batch-run them (backlog Z2 + Z3). | 354 | @@ -4751,47 +4753,6 @@ ### holographic_creature.py -> holographic_creature.py -> ======================= -> -> A creature brain built on the holographic engine in holographic_ai.py. -> -> It learns to forage in a little grid world -- find food, avoid poison -- with -> NO neural net and NO training loop in the gradient sense. It simply remembers -> what happened (state, action, how it turned out) and, faced with a new -> situation, does whatever worked in similar situations before. Similarity is -> measured holographically; the "value" of an action is the reward of its nearest -> neighbours in memory. That is instance-based reinforcement learning, and it -> maps cleanly onto leOS's reflex arc + "semantic compass" (lean toward what -> succeeded) + "void/curiosity" (try what you haven't, where you're unsure). -> -> The one trick that makes it learn fast: the creature senses the world -> EGOCENTRICALLY -- "food is to my east", not "food is at (5,2)". Because the -> state is relative, a lesson learned in one corner of the map applies -> everywhere, so it never has to visit every cell. -> -> Run: python3 holographic_creature.py -> Needs: numpy, and holographic_ai.py beside it. - -**Public API:** - -- `class HolographicMind` -- Perceive -> decide -> learn, by remembering experiences as PROTOTYPES. -- `class CreatureEncoder` -- Turn the creature's egocentric senses into a single unit vector -- the creature DOMAIN's encoder. -- `class FastCreatureEncoder` -- Compiled, fully in-VSA perception: the per-step role/filler BIND (an FFT convolution) is the last -- `class GridWorld` -- A small grid with one creature, one star (food), some poison cells, and -- `def run_episode(world, encoder, mind, learn, explore, eval_epsilon, gamma, max_steps, mem, corridor_reflex, danger_reflex, wall_reflex, curiosity, return_trajectory)` -- Live one episode; return (total_reward, stars_collected). -- `def demo_creature()` -- `def demo_memory(seeds, episodes, steps)` -- Scene C: with limited vision, show that a working memory of recent moves -- `def demo_obstacles(seeds, episodes)` -- Scene D: obstacles. First random WALLS in the forage world (the creature -- `def demo_introspect(episodes, seed)` -- Scene E: the creature's memory is the same holographic kit as the image -- `def learn_maze(world_factory, dim, episodes, gamma, mem, max_steps, candidates, probe, accept, seed, k, bootstrap)` -- Learn to escape a maze reliably -- the rat-in-a-maze protocol, hardened for -- `def demo_self_maintaining(dim, seed)` -- The orchestrator brain keeping ITSELF fresh, with no thresholds to tune. We -- `def capture_route(world_factory, encoder, mind, mem, max_steps, trials)` -- Run a trained maze brain and capture its successful escape routes as -- `def replay_plan(world, route, reset)` -- Drive navigation from a DISCOVERED route plan instead of re-deciding every -- `class WorldView` -- The creature's world as a COUNTABLE, DIFFABLE composite -- the scene - -### holographic_creature.py - > Spore-style CREATURE builder: a spine with attachable limbs, bilateral symmetry, constraints (holographic_creature). > > WHY THIS MODULE EXISTS @@ -4842,6 +4803,47 @@ - `def centaur_spec(body)` -- THE HYBRID REGRESSION SPEC (backlog D-1 / Tier 9): a horse body with a humanoid torso rising - `def quadruped_spec(body)` -- A ready-made body plan: a quadruped -- a spine with two pairs of legs (front + back) and a head. A concrete +### holographic_creature.py + +> holographic_creature.py +> ======================= +> +> A creature brain built on the holographic engine in holographic_ai.py. +> +> It learns to forage in a little grid world -- find food, avoid poison -- with +> NO neural net and NO training loop in the gradient sense. It simply remembers +> what happened (state, action, how it turned out) and, faced with a new +> situation, does whatever worked in similar situations before. Similarity is +> measured holographically; the "value" of an action is the reward of its nearest +> neighbours in memory. That is instance-based reinforcement learning, and it +> maps cleanly onto leOS's reflex arc + "semantic compass" (lean toward what +> succeeded) + "void/curiosity" (try what you haven't, where you're unsure). +> +> The one trick that makes it learn fast: the creature senses the world +> EGOCENTRICALLY -- "food is to my east", not "food is at (5,2)". Because the +> state is relative, a lesson learned in one corner of the map applies +> everywhere, so it never has to visit every cell. +> +> Run: python3 holographic_creature.py +> Needs: numpy, and holographic_ai.py beside it. + +**Public API:** + +- `class HolographicMind` -- Perceive -> decide -> learn, by remembering experiences as PROTOTYPES. +- `class CreatureEncoder` -- Turn the creature's egocentric senses into a single unit vector -- the creature DOMAIN's encoder. +- `class FastCreatureEncoder` -- Compiled, fully in-VSA perception: the per-step role/filler BIND (an FFT convolution) is the last +- `class GridWorld` -- A small grid with one creature, one star (food), some poison cells, and +- `def run_episode(world, encoder, mind, learn, explore, eval_epsilon, gamma, max_steps, mem, corridor_reflex, danger_reflex, wall_reflex, curiosity, return_trajectory)` -- Live one episode; return (total_reward, stars_collected). +- `def demo_creature()` +- `def demo_memory(seeds, episodes, steps)` -- Scene C: with limited vision, show that a working memory of recent moves +- `def demo_obstacles(seeds, episodes)` -- Scene D: obstacles. First random WALLS in the forage world (the creature +- `def demo_introspect(episodes, seed)` -- Scene E: the creature's memory is the same holographic kit as the image +- `def learn_maze(world_factory, dim, episodes, gamma, mem, max_steps, candidates, probe, accept, seed, k, bootstrap)` -- Learn to escape a maze reliably -- the rat-in-a-maze protocol, hardened for +- `def demo_self_maintaining(dim, seed)` -- The orchestrator brain keeping ITSELF fresh, with no thresholds to tune. We +- `def capture_route(world_factory, encoder, mind, mem, max_steps, trials)` -- Run a trained maze brain and capture its successful escape routes as +- `def replay_plan(world, route, reset)` -- Drive navigation from a DISCOVERED route plan instead of re-deciding every +- `class WorldView` -- The creature's world as a COUNTABLE, DIFFABLE composite -- the scene + ### holographic_creature_mind.py > CreatureMind -- the reference DEMO of building a specialized mind ON the one UnifiedMind. @@ -17300,6 +17302,33 @@ - `def values_to_texture(values, normalize)` -- ASSIGN arbitrary numbers to a texture: an (H,W) / (H,W,C) / (N,) / (N,C) array becomes a - `def mask_refraction(image, mask, strength, ior, profile, edge_width, chromatic, ripple, seed)` -- Refract `image` through a 2D shape given by `mask` (H,W bool/0-1): the LENS reading of a mask. +### holographic_product.py + +> holographic_product.py -- the small product-facing leCore facade. +> +> WHY THIS EXISTS +> --------------- +> The research engine is intentionally broad: memory, geometry, rendering, +> simulation, jobs, skills, and more all share the same holographic substrate. +> That is useful for research, but a first-time product user needs one narrow, +> reliable door. +> +> `LocalAgentCore` is that door. It packages the current production wedge: +> +> * local deterministic text memory (`remember` / `recall`) +> * agent skill routing through the existing capability catalog (`route`) +> * an evidence snapshot and static HTML dashboard (`dashboard`) +> +> It does not replace `UnifiedMind` or hide the research surface. It is a small, +> boring facade over the stable pieces, meant to be easy to install, test, demo, +> and embed. + +**Public API:** + +- `class MemoryEntry` -- One stored memory item. +- `class LocalAgentCore` -- Product facade for local agent memory, skill routing, and evidence. +- `def demo()` -- Build a tiny ready-to-query product demo. + ### holographic_projectivetower.py > holographic_projectivetower.py -- the ceiling of the transform tower, and where the "word" analogy breaks. @@ -26787,6 +26816,73 @@ - `def solve_laplace(sdf_eval, points, boundary_value, walks, max_steps, eps, seed, source, dirichlet_sdf, dim)` -- Solve the Laplace (or Poisson) equation at `points`, grid-free, by Walk on Spheres / Stars. +### holographic_x402_api.py + +> holographic_x402_api.py -- publish the leCore Agent Memory & Routing API. +> +> WHY THIS EXISTS +> --------------- +> `LocalAgentCore` remains the embedded implementation facade. This module +> translates it into a hosted HTTP API without making x402, FastAPI, or uvicorn +> core dependencies. +> +> The boundary is intentionally conservative: +> +> * public read/compute routes are x402-paid +> * health/pricing routes are free +> * memory writes are admin-token gated, not pay-to-write +> +> That keeps the paid surface useful while preventing customers from poisoning a +> shared memory store just because they paid for one request. + +**Public API:** + +- `class PaidRoute` -- One x402-protected route. +- `def x402_payment_required_responses()` -- OpenAPI response metadata shared by every x402-protected operation. +- `def paid_request_openapi(required, properties, example, example_summary)` -- Return an accurate OpenAPI request body while runtime validation stays compatible. +- `def paid_operation_responses(success, invalid_detail, backend_unavailable, idempotency_conflict)` -- Document paid success, payment, tenant, and validation responses. +- `def health_success_openapi(paid, private_tenants_enabled, memory_backend, nosqlite_shadow, nosqlite_configured, durable_transactions, encrypted_storage, plaintext_migration_enabled)` -- Document the free health and deployment-state response. +- `def pricing_success_openapi(config, private_tenants_enabled, memory_backend, nosqlite_shadow, nosqlite_configured, durable_transactions, encrypted_storage, plaintext_migration_enabled)` -- Document the free x402 discovery manifest. +- `def recall_success_openapi()` -- Document the successful memory-recall response. +- `def memory_write_success_openapi()` -- Document the successful private-tenant memory write response. +- `def memory_list_success_openapi()` -- Document private-tenant memory listing and direct lookup. +- `def memory_delete_success_openapi()` -- Document idempotent private-tenant memory deletion. +- `def memory_update_success_openapi()` -- Document a successful private-tenant memory update. +- `def memory_update_request_openapi()` -- Document a partial update that requires at least one mutable field. +- `def route_success_openapi()` -- Document the successful capability-routing response. +- `def dashboard_success_openapi()` -- Document the successful service-readiness response. +- `def public_response_headers(path, status_code, public_url, content_type, network)` -- Return browser and cache policy headers for one public response. +- `class X402Config` -- Seller configuration for the x402-paid API. +- `def optional_dependency_help()` -- Install hint for the optional paid API dependencies. +- `def normalize_tenant_id(value)` -- Return a path-safe tenant id for private memory routing. +- `def tenant_access_token(tenant_id, secret)` -- Deterministic tenant bearer token derived from a server-side secret. +- `def normalize_idempotency_key(value)` -- Validate an optional caller-provided retry key without persisting the raw value. +- `def normalize_memory_id(value)` -- Validate a memory id before lookup or deletion. +- `class MemoryStateError` -- Durable memory could not be authenticated, decoded, or migrated safely. +- `class MemoryKeyring` -- A small versioned set of 256-bit application data-encryption keys. +- `class MemoryStateCodec` -- Compress and authenticate durable JSON records before they touch disk. +- `class TenantCoreStore` -- Thread-safe LocalAgentCore registry with optional per-tenant persistence. +- `class NoSQLiteError` -- Raised when the optional NoSQLite command process cannot serve a request. +- `class NoSQLiteProcess` -- Serialize JSON-line requests to one long-lived NoSQLite CLI process. +- `class NoSQLiteMemoryStore` -- Tenant-isolated semantic memory backed by the pinned NoSQLite CLI. +- `class MemoryTransactionError` -- The durable memory write journal could not be read or completed safely. +- `class MemoryTransactionConflict` -- One idempotency key was reused for a different memory write. +- `class MemoryMirrorPending` -- A durable core commit needs the same transaction projected to NoSQLite. +- `class TenantMemoryTransactions` -- Durable, idempotent memory writes spanning LocalAgentCore and NoSQLite. +- `def pricing_summary(config)` -- Describe the customer-facing price and whether it is a production charge. +- `def normalize_memory_backend(value)` -- Validate the memory backend selector without accepting silent fallbacks. +- `def env_flag(value)` -- Parse the small explicit boolean surface used by deployment settings. +- `def memory_state_codec(value, allow_plaintext_migration)` -- Resolve optional versioned encryption material without a silent fallback. +- `def landing_page_html(config)` -- Render the buyer-facing landing page served from `/`. +- `def documentation_manifest(config)` -- Return canonical public documentation URLs for discovery responses. +- `def public_dashboard(data)` -- Translate the embedded SDK dashboard into the hosted API vocabulary. +- `def payment_manifest(config)` -- Plain JSON route manifest, useful for docs, `/pricing`, and tests. +- `def x402_route_configs(config)` -- Build x402 SDK RouteConfig objects for the protected routes. +- `def x402_resource_server(config)` -- Create an x402 resource server wired to the configured facilitator. +- `def create_app(core, config, paid, admin_token, tenant_secret, tenant_state_dir, memory_keys, allow_plaintext_migration, memory_backend, nosqlite_binary, nosqlite_data_dir, nosqlite_durability, nosqlite_shadow)` -- Create the FastAPI application for paid or unpaid development serving. +- `def load_core(path)` -- Load a persisted core if present, otherwise return the demo core. +- `def main(argv)` -- CLI entry point for running the x402 API service. + ### holographic_zigmarch.py > holographic_zigmarch.py -- the one-kernel-two-runtimes raymarch demo, EXECUTED (backlog Z4). diff --git a/X402_API.md b/X402_API.md new file mode 100644 index 0000000..1c03a94 --- /dev/null +++ b/X402_API.md @@ -0,0 +1,336 @@ +# leCore Agent Memory & Routing API + +leCore is available as a hosted memory and capability-routing API with x402 +payment on the protected routes. + +Public reference: + +- [Swagger UI](https://lecore.rati.foundation/docs) +- [ReDoc reference](https://lecore.rati.foundation/redoc) +- [OpenAPI 3.1 schema](https://lecore.rati.foundation/openapi.json) +- [Pricing and route manifest](https://lecore.rati.foundation/pricing) + +The implementation lives in `holographic_x402_api.py`. It exposes +tenant-scoped agent memory and routing through FastAPI, backed internally by +`LocalAgentCore`, and applies x402 middleware only to the public memory/compute +routes: + +- `POST /v1/memory` — encrypted, idempotent private-tenant writes +- `GET /v1/memory` — bounded listing or exact-id retrieval +- `PATCH /v1/memory` — atomic selected-field updates +- `DELETE /v1/memory` — idempotent deletion with durable tombstones +- `POST /v1/recall` +- `POST /v1/route` +- `GET /v1/dashboard` + +Free routes: + +- `GET /health` +- `GET /pricing` + +Admin route: + +- `POST /admin/remember`, guarded by `X-Admin-Token` +- `POST /admin/tenant-token`, guarded by `X-Admin-Token` + +This split is deliberate. Paid customers can store and recall their own private +tenant memory, but cannot mutate the shared public dataset. A private write +requires both a tenant token and a stable `Idempotency-Key`; x402 proves payment, +not tenant authorization. Admin routes remain available for provisioning and +are not included in the public OpenAPI schema. + +Durable core files and write journals are compressed before authenticated +encryption with AES-256-GCM. HKDF-SHA256 derives a distinct data key for each +tenant/file context from a versioned service keyring. The tenant/file identity +is authenticated as associated data, files are replaced atomically with mode +`0600`, altered ciphertext is rejected, and paid durable mode fails closed when +the keyring is absent. AWS volume encryption remains a second, independent +layer rather than the only protection. + +## Public Preview Quickstart + +Read the free discovery manifest before signing anything: + +```bash +curl -sS https://lecore.rati.foundation/pricing +``` + +Make an unsigned request to see the exact x402 contract without moving testnet +funds: + +```bash +curl -i https://lecore.rati.foundation/v1/dashboard +``` + +The response is `402 Payment Required` with a base64 `Payment-Required` header. +Configure an x402 v2 client using the +[official buyer quickstart](https://docs.x402.org/getting-started/quickstart-for-buyers), +sign one accepted option, and retry with `Payment-Signature`. A successful paid +response includes `Payment-Response` with the settlement result. + +The public OpenAPI contract documents request bodies, successful response +shapes, payment headers, tenant authorization failures, facilitator errors, and +the recall backend's availability response. + +## Install + +```bash +pip install ".[x402]" +``` + +The core package still needs only NumPy. The `x402` extra pulls in the optional +FastAPI/x402/uvicorn stack. + +## Testnet Run + +The default network is Base Sepolia (`eip155:84532`) and the default facilitator +is the signup-free x402.org testnet facilitator. This is a **developer preview**: +the listed `$0.0011` request price is displayed as `$1.10 per 1,000 requests`, +uses testnet USDC, and does not accept production payments. + +```bash +export LECORE_X402_PAY_TO="0xYourReceivingWallet" +export LECORE_X402_PRICE="$0.0011" +export LECORE_X402_PUBLIC_URL="http://127.0.0.1:4021" +export LECORE_X402_ADMIN_TOKEN="dev-admin-secret" +export LECORE_X402_TENANT_SECRET="dev-tenant-secret" +export LECORE_X402_TENANT_STATE_DIR="./tenant-state" +export LECORE_X402_MEMORY_KEYS="$(python -c 'import base64,json,secrets; print(json.dumps({"active":"v1","keys":{"v1":base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}}))')" + +python holographic_x402_api.py --host 127.0.0.1 --port 4021 +``` + +Inspect pricing: + +```bash +curl http://127.0.0.1:4021/pricing +``` + +Add memories through the operator endpoint: + +```bash +curl -X POST http://127.0.0.1:4021/admin/remember \ + -H "Content-Type: application/json" \ + -H "X-Admin-Token: dev-admin-secret" \ + -H "Idempotency-Key: initial-memory-001" \ + -d '{"text":"agents need deterministic durable memory","label":"memory"}' +``` + +When `LECORE_X402_TENANT_STATE_DIR` is configured, admin writes use a small +durable transaction journal. Reuse the same `Idempotency-Key` after a timeout: +the API returns the original memory rather than creating another entry. Reusing +one key with a different request is rejected with `409 Conflict`. + +For an enabled NoSQLite mirror, the journal records the core commit before +projecting the same stable memory id to NoSQLite. A temporary NoSQLite failure +leaves that projection pending; the same idempotent retry, or the next app +startup, resumes it without duplicating core memory. The implementation does +not advertise cross-store rollback it cannot provide. + +Issue a private tenant token: + +```bash +curl -X POST http://127.0.0.1:4021/admin/tenant-token \ + -H "Content-Type: application/json" \ + -H "X-Admin-Token: dev-admin-secret" \ + -d '{"tenant":"acme"}' +``` + +Use that token with paid calls for private tenant memory: + +```bash +curl -X POST http://127.0.0.1:4021/v1/memory \ + -H "Content-Type: application/json" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " \ + -H "Idempotency-Key: session-42-preference-001" \ + -d '{"text":"the user prefers concise answers","label":"preference"}' +``` + +In paid mode that unsigned request first returns `402`; an x402 client signs +and retries the same body and idempotency key. Repeating the completed request +returns the original memory. Reusing the key for different content returns +`409 Conflict`. + +Recall it: + +```bash +curl -X POST http://127.0.0.1:4021/v1/recall \ + -H "Content-Type: application/json" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " \ + -d '{"query":"deterministic agent memory"}' +``` + +List the tenant's memories in insertion order, at most 100 per page: + +```bash +curl "http://127.0.0.1:4021/v1/memory?limit=50" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " +``` + +Pass the returned `next_cursor` to continue, or retrieve one exact record: + +```bash +curl "http://127.0.0.1:4021/v1/memory?memory_id=" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " +``` + +Update any combination of text, label, and metadata. Omitted fields are +preserved, `label: null` clears the label, and `{}` clears metadata: + +```bash +curl -X PATCH "http://127.0.0.1:4021/v1/memory?memory_id=" \ + -H "Content-Type: application/json" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " \ + -d '{"text":"the user prefers concise release notes","metadata":{"confirmed":true}}' +``` + +An update is atomic with the encrypted tenant snapshot. A no-op update does +not rewrite the file. Retrying the original create request after changing the +record returns `409` instead of silently overwriting the newer value. + +Delete one record idempotently: + +```bash +curl -X DELETE "http://127.0.0.1:4021/v1/memory?memory_id=" \ + -H "X-leCore-Tenant: acme" \ + -H "X-leCore-Tenant-Token: " +``` + +Deletion writes an encrypted tombstone into the originating retry journal +before removing the core entry. Retrying the old store request cannot resurrect +the deleted memory; it returns `409` and requires a new idempotency key. + +Requests to paid routes return `402 Payment Required` unless the client retries +with a valid x402 payment payload: + +```bash +curl -X POST http://127.0.0.1:4021/v1/recall \ + -H "Content-Type: application/json" \ + -d '{"query":"deterministic agent memory"}' +``` + +## Unpaid Development Smoke Test + +Use this only for development: + +```bash +python holographic_x402_api.py --unpaid-dev --host 127.0.0.1 --port 4021 +``` + +Unpaid development may omit the keyring and use plaintext files. That fallback +is intentionally unavailable to a paid app with durable state. + +## Storage Performance + +The current v1 envelope is tuned for small and medium tenant snapshots: + +- zlib level 6 balances compression ratio and CPU time; +- current in-process tenants are not decrypted and rebuilt before every write; +- cross-task file-version changes still force a safe reload under the lock; +- no-op updates and missing/idempotent deletes do not rewrite the tenant file; +- listing is bounded to 100 records per response and uses stable cursors. + +When NoSQLite semantic indexing is enabled in an unpaid deployment, updates +replace the document and embedding and deletes remove the projection. The +durable core snapshot remains authoritative; a fresh process or an interrupted +projection reconciles the tenant collection from that snapshot before serving +semantic recall. Application-encrypted paid memory continues to reject the +plaintext NoSQLite backend and shadow instead of silently weakening storage. + +An actual mutation still atomically rewrites one compressed tenant snapshot. +That is simple and crash-safe, but it is `O(tenant memory size)`. Before very +large tenants, introduce a version-2 encrypted append log or fixed-size +encrypted segments with background compaction. Keep v1 as the migration and +recovery format rather than adding an unauthenticated side index. + +## Key Rotation And Legacy Migration + +`LECORE_X402_MEMORY_KEYS` is a Secrets Manager JSON value with one active key +and up to seven retained decryption keys: + +```json +{"active":"v2","keys":{"v1":"","v2":""}} +``` + +Deploy the expanded keyring first. Startup authenticates every tenant and +journal file and atomically re-encrypts records not using `active`. After every +record has been verified under `v2`, remove `v1` and launch fresh tasks. + +Existing plaintext state is refused by default. For a controlled one-time +migration, deploy the keyring with +`LECORE_X402_ALLOW_PLAINTEXT_MIGRATION=1`, drain old writers, start exactly one +new task, and verify every durable file now begins with the `LECMEM01` envelope +magic. Then remove the migration flag immediately; leaving it enabled would +allow plaintext to bypass ciphertext authentication. + +## Optional NoSQLite Memory Backend (Unencrypted Development Only) + +`Dockerfile.x402` builds the vendored NoSQLite source snapshot pinned at +`8964da2` into the service image. The default remains `core`: +`LocalAgentCore` is the serving backend and the existing per-tenant JSON state +remains the durable control-plane mirror. + +To cut semantic recall over to NoSQLite, configure a durable mounted directory: + +```bash +export LECORE_X402_MEMORY_BACKEND=nosqlite +export LECORE_X402_NOSQLITE_BIN=/usr/local/bin/nosqlite +export LECORE_X402_NOSQLITE_DATA_DIR=/data/nosqlite +export LECORE_X402_NOSQLITE_DURABILITY=sync +export LECORE_X402_TENANT_STATE_DIR=/data/tenants +``` + +NoSQLite does not yet use the application encryption envelope. The service +therefore refuses NoSQLite serving or shadow mode whenever memory encryption is +configured. Do not use it for the hosted paid API until NoSQLite gains an +equivalent authenticated-encryption boundary. + +In an explicitly unencrypted development deployment, the API keeps each tenant in a separate hashed collection, writes the same +admin-created entry to `LocalAgentCore` for routing/dashboard continuity, and +uses NoSQLite's deterministic `holographic-hash-v1` encoder plus neural +candidate routing and cosine reranking for `/v1/recall`. Responses retain the +existing `id`, `text`, `label`, `metadata`, and `score` shape. + +Before cutover, set `LECORE_X402_NOSQLITE_SHADOW=1` while leaving +`LECORE_X402_MEMORY_BACKEND=core`. Admin writes are mirrored; recall continues +to serve from the core while differences are logged without query text or +tenant identifiers. + +NoSQLite-enabled writes require `LECORE_X402_TENANT_STATE_DIR`, which is also +where the transaction journal lives. Keep that directory on durable shared +storage with the tenant state; do not delete `.x402-memory-transactions` during +normal deployment cleanup. + +NoSQLite filesystem mode holds one nonblocking exclusive writer lock for the +life of its process. Run exactly one active writer against a given data +directory. A rolling ECS replacement must drain the old writer before enabling +the new one, so the initial deployed configuration keeps this feature disabled +until that maintenance window is scheduled. + +## Production Notes + +- Use a real receiving wallet and a production facilitator. +- Put the API behind HTTPS and set `LECORE_X402_PUBLIC_URL` to its canonical + public base URL, for example `https://lecore.rati.foundation`. Each payment + challenge advertises that configured URL rather than trusting forwarded + request headers. +- Keep route prices explicit; avoid wildcard paid route configs for this first + product surface. +- Keep writes admin-only. Use `LECORE_X402_TENANT_SECRET` and + `LECORE_X402_TENANT_STATE_DIR` for durable public and private memory. Writes + use per-tenant process locks plus atomic replacement on shared storage. +- If NoSQLite is enabled, mount `LECORE_X402_NOSQLITE_DATA_DIR` on the same + durable storage and keep the service at a single active writer for that path. +- Treat x402 payment metadata as public enough to avoid putting secrets or PII + in route descriptions. + +The implementation follows the current x402 seller shape: FastAPI middleware, +`RouteConfig`, `PaymentOption`, an `exact` EVM scheme, and a facilitator-backed +resource server. + +For AWS hosting, see [`AWS_X402_DEPLOY.md`](AWS_X402_DEPLOY.md). diff --git a/apiquickref.py b/apiquickref.py index dec17c4..d66a14b 100644 --- a/apiquickref.py +++ b/apiquickref.py @@ -21,11 +21,12 @@ # ---------------------------------------------------------------------------------------------------------- # THE CURATED SURFACE. Edit this list to change what the quick reference covers. Grouped by the job a builder -# is doing, in the order they meet it: author a scene -> model geometry -> aim a camera -> render -> ship. +# is doing, in the order they meet it: product wedge -> author a scene -> model geometry -> aim a camera -> render -> ship. # Kept deliberately SHORT -- the point is a page you can scan, not a full index (that is REFERENCE.md). # ---------------------------------------------------------------------------------------------------------- CURATED = [ + ("Product wedge", ["holographic_product", "holographic_x402_api"]), ("Scene authoring", ["holographic_scene_doc", "holographic_modifier"]), ("Geometry / SDF", ["holographic_sdf", "holographic_sdfscene", "holographic_mesh"]), ("Transforms", ["holographic_transform"]), diff --git a/capabilities.json b/capabilities.json index 8c3d3c9..8e9c3ab 100644 --- a/capabilities.json +++ b/capabilities.json @@ -5841,6 +5841,30 @@ "semantic": null, "theme": "Geometry, modeling & rendering" }, + { + "aliases": [ + "product", + "productization", + "agent memory", + "local memory", + "durable memory", + "recall", + "skill routing", + "dashboard", + "first user", + "facade", + "local agent core" + ], + "consumes": [], + "does": "the PRODUCT-FACING wedge: LocalAgentCore gives a local agent deterministic text memory (remember/recall), skill routing over the live capability catalog, JSON persistence, and a readiness dashboard with C-kernel status. This is the small stable door for embedding leCore without learning the whole research surface first.", + "example": "from holographic_product import LocalAgentCore; core = LocalAgentCore(); core.remember('local agent memory'); core.recall('agent memory')", + "method": null, + "name": "Local agent core (memory + routing)", + "native": true, + "produces": [], + "semantic": null, + "theme": "Memory, search & recall" + }, { "aliases": [ "look ahead linter", @@ -14921,9 +14945,34 @@ "produces": [], "semantic": null, "theme": "Scenes you can describe & adjust" + }, + { + "aliases": [ + "x402", + "paid api", + "payment required", + "402", + "monetize api", + "micropayment", + "agent payments", + "pay per request", + "fastapi", + "api publishing", + "sell api", + "paid route" + ], + "consumes": [], + "does": "publish the leCore Agent Memory & Routing API as a hosted HTTP service: FastAPI routes for encrypted private-memory store/list/get/update/delete, tenant-scoped recall, task routing, and the readiness dashboard protected by x402 middleware, with free health/pricing/docs routes and separate tenant/admin authorization.", + "example": "from holographic_x402_api import create_app, X402Config; app = create_app(config=X402Config(pay_to='0x...'), paid=False)", + "method": null, + "name": "x402 paid API publisher", + "native": false, + "produces": [], + "semantic": null, + "theme": "Discover & drive it (for agents)" } ], - "count": 638, + "count": 640, "schema_version": "1.0", "scope": "curated capability homes only -- the full live catalog is served at runtime by mind.find_capability / mind.pipeline_map / GET /tools" } diff --git a/docgen.py b/docgen.py index 69601b7..43db055 100644 --- a/docgen.py +++ b/docgen.py @@ -37,7 +37,9 @@ def find_modules(root): for name in sorted(filenames): if name.startswith("holographic_") and name.endswith(".py") and not name.startswith("test_"): mods.append(Path(dirpath) / name) - return sorted(mods, key=lambda p: p.name) + # Basenames are not unique across capability families. Tie-break by the + # full path so os.walk/filesystem order cannot churn the generated file. + return sorted(mods, key=lambda p: (p.name, p.as_posix())) # ------------------------------------------------------------------------------------------------------------ @@ -72,7 +74,7 @@ def read_module(path): try: tree = ast.parse(source) except SyntaxError: - return dict(name=path.name, summary="(could not parse)", doc="", api=[], loc=loc) + return dict(name=path.name, path=path.as_posix(), summary="(could not parse)", doc="", api=[], loc=loc) mod_doc = ast.get_docstring(tree) or "" api = [] @@ -83,7 +85,12 @@ def read_module(path): kind = "class" if isinstance(node, ast.ClassDef) else "def" sig = node.name if kind == "class" else signature(node) api.append((kind, sig, first_line(ast.get_docstring(node)))) - return dict(name=path.name, summary=first_line(mod_doc), doc=mod_doc, api=api, loc=loc) + return dict(name=path.name, path=path.as_posix(), summary=first_line(mod_doc), doc=mod_doc, api=api, loc=loc) + + +def _module_sort_key(module): + """Stable order for modules whose basenames are duplicated across families.""" + return module["name"], module.get("path", "") # ------------------------------------------------------------------------------------------------------------ @@ -117,11 +124,11 @@ def group_modules(mods): standalone = [] for family, members in by_family.items(): if len(members) >= 3: - grouped[family] = sorted(members, key=lambda m: m["name"]) + grouped[family] = sorted(members, key=_module_sort_key) else: standalone.extend(members) # singletons and pairs go together if standalone: - grouped["Core & standalone"] = sorted(standalone, key=lambda m: m["name"]) + grouped["Core & standalone"] = sorted(standalone, key=_module_sort_key) return grouped @@ -163,7 +170,7 @@ def write_reference(mods, out_path): w("## Module map") w("") for fam in sorted(grouped, key=lambda f: (f == "Core & standalone", f)): # families first, "Core" last - members = sorted(grouped[fam], key=lambda m: m["name"]) + members = sorted(grouped[fam], key=_module_sort_key) title = "`%s*` family" % fam if fam != "Core & standalone" else fam w("### %s (%d)" % (title, len(members))) w("") @@ -180,7 +187,7 @@ def write_reference(mods, out_path): w("") w("## Modules in detail") w("") - for m in sorted(mods, key=lambda m: m["name"]): + for m in sorted(mods, key=_module_sort_key): w("### %s" % m["name"]) w("") if m["doc"]: diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 13ab0cf..3c464b0 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -132,6 +132,7 @@ The core requires **only NumPy**. Everything else is declared as a named "extra" | `zig` | `ziglang` | native batch kernels + raymarcher (`holographic_zigrun`, `zigmarch`); ships the whole Zig toolchain, no system compiler needed | | `wgsl` | `wgpu` | **the vendor-neutral GPU path** (`holographic_wgpurun`): compute on Vulkan / Metal / DX12 / WebGPU, so it works on Apple silicon, AMD and Intel Arc as well as NVIDIA. Prebuilt wheels, no system toolchain | | `gpu` | `cupy` | the CuPy backend (`holographic_backend`) — **NVIDIA/CUDA only**; see the CuPy note | +| `x402` | `x402[fastapi,evm]`, `uvicorn` | paid API publishing (`holographic_x402_api`) | | `ui` | `flask`, `pillow` | the browser UI (`app.py`) and image load/save | | `images` | `pillow` | image I/O beyond stdlib PNG (jpg/webp/…) without pulling in Flask — a headless subset of `ui` | | `dev` | `pytest`, `matplotlib`, `nltk` | running the test suite, generating plots, and loading the text corpora the benchmarks/ablations use | diff --git a/docs/PIPELINE_MAP.md b/docs/PIPELINE_MAP.md index 9fcd573..4f509a1 100644 --- a/docs/PIPELINE_MAP.md +++ b/docs/PIPELINE_MAP.md @@ -2,7 +2,7 @@ *The workflow graph, auto-derived by `pipelinemap.py` from the catalog's `consumes`/`produces` tags. Nodes are io-kinds; an edge means some capability turns the source kind into the target kind. This is a VIEW of the live tags -- to change it, tag capabilities, not this file.* -> **Coverage: 110 of 2919 capabilities carry io-kind tags (3%).** The graph below is that tagged subset. Untagged capabilities are real but do not yet declare a typed edge -- backfilling tags grows the map. +> **Coverage: 110 of 2921 capabilities carry io-kind tags (3%).** The graph below is that tagged subset. Untagged capabilities are real but do not yet declare a typed edge -- backfilling tags grows the map. ```mermaid graph LR diff --git a/holographic/caching_and_storage/holographic_catalog_p04.py b/holographic/caching_and_storage/holographic_catalog_p04.py index e0bb2f9..fc19a42 100644 --- a/holographic/caching_and_storage/holographic_catalog_p04.py +++ b/holographic/caching_and_storage/holographic_catalog_p04.py @@ -1202,6 +1202,27 @@ def register_p04(c): "llm bridge", "notify the agent", "push notification", "on render done", "connect an agent", "send message to agent", "mailbox", "inbox", "trigger the llm", "watch for events", "task done event")) + # --- product and paid API publishing --------------------------------------------------------------- + c.register_capability( + "Local agent core (memory + routing)", + "the PRODUCT-FACING wedge: LocalAgentCore gives a local agent deterministic text memory " + "(remember/recall), skill routing over the live capability catalog, JSON persistence, and " + "a readiness dashboard with C-kernel status. This is the small stable door for embedding " + "leCore without learning the whole research surface first.", + example="from holographic_product import LocalAgentCore; core = LocalAgentCore(); core.remember('local agent memory'); core.recall('agent memory')", + native=True, + aliases=("product", "productization", "agent memory", "local memory", "durable memory", "recall", + "skill routing", "dashboard", "first user", "facade", "local agent core")) + c.register_capability( + "x402 paid API publisher", + "publish the leCore Agent Memory & Routing API as a hosted HTTP service: FastAPI routes " + "for encrypted private-memory store/list/get/update/delete, tenant-scoped recall, task routing, and " + "the readiness dashboard protected by x402 middleware, with free health/pricing/docs " + "routes and separate tenant/admin authorization.", + example="from holographic_x402_api import create_app, X402Config; app = create_app(config=X402Config(pay_to='0x...'), paid=False)", + native=False, + aliases=("x402", "paid api", "payment required", "402", "monetize api", "micropayment", + "agent payments", "pay per request", "fastapi", "api publishing", "sell api", "paid route")) # --- agent-friendly discovery: describe / suggest / route / autocomplete over the whole engine --- c.register_capability("Agent skills (discover & route)", "the AGENT-FRIENDLY layer: mind.skills() lists every " "capability + method with how to CALL it (skill descriptions, real signatures); " diff --git a/holographic_product.py b/holographic_product.py new file mode 100644 index 0000000..f93d48a --- /dev/null +++ b/holographic_product.py @@ -0,0 +1,458 @@ +"""holographic_product.py -- the small product-facing leCore facade. + +WHY THIS EXISTS +--------------- +The research engine is intentionally broad: memory, geometry, rendering, +simulation, jobs, skills, and more all share the same holographic substrate. +That is useful for research, but a first-time product user needs one narrow, +reliable door. + +`LocalAgentCore` is that door. It packages the current production wedge: + + * local deterministic text memory (`remember` / `recall`) + * agent skill routing through the existing capability catalog (`route`) + * an evidence snapshot and static HTML dashboard (`dashboard`) + +It does not replace `UnifiedMind` or hide the research surface. It is a small, +boring facade over the stable pieces, meant to be easy to install, test, demo, +and embed. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import html +import importlib +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Any, Dict, Iterable, List, Optional + +import numpy as np + +from holographic.caching_and_storage.holographic_index import Index +from holographic.agents_and_reasoning.holographic_mind import UniversalEncoder + + +_WORD_RE = re.compile(r"[a-z0-9_]+") +_UNSET = object() + + +def _tokens(text: Any) -> List[str]: + """Deterministic product tokenization: lower-case content tokens, no hidden NLP dependency.""" + if isinstance(text, (list, tuple)): + return [str(t).lower() for t in text if str(t).strip()] + return _WORD_RE.findall(str(text).lower()) + + +@dataclass +class MemoryEntry: + """One stored memory item.""" + + id: str + text: str + label: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Return a JSON-safe representation of this memory entry.""" + return { + "id": self.id, + "text": self.text, + "label": self.label, + "metadata": dict(self.metadata), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MemoryEntry": + """Build a memory entry from `to_dict` data.""" + return cls( + id=str(data["id"]), + text=str(data.get("text", "")), + label=data.get("label"), + metadata=dict(data.get("metadata") or {}), + ) + + +class LocalAgentCore: + """Product facade for local agent memory, skill routing, and evidence. + + The API is deliberately small: + + core = LocalAgentCore() + core.remember("local agents need deterministic memory", label="memory") + core.recall("deterministic local memory") + core.route("render a scene") + core.dashboard() + + Text memory uses the existing `UniversalEncoder` and `Index` homes. It is + deterministic, local-only, and query-safe: `recall()` does not mutate the + stored corpus or teach the encoder new query words. + """ + + def __init__(self, dim: int = 512, seed: int = 0, route_threshold: float = 0.6): + self.dim = int(dim) + self.seed = int(seed) + self.route_threshold = float(route_threshold) + self._entries: List[MemoryEntry] = [] + self._encoder = UniversalEncoder(self.dim, seed=self.seed) + self._vectors: Optional[np.ndarray] = None + self._index: Optional[Index] = None + self._next_id = 1 + + # ---- memory --------------------------------------------------------------------------------------- + @property + def entries(self) -> List[MemoryEntry]: + """A copy of the stored entries, in insertion order.""" + return list(self._entries) + + def memory_summary(self) -> Dict[str, Any]: + """Return constant-time memory status without running evidence probes.""" + return { + "entries": len(self._entries), + "dim": self.dim, + "index_method": self._index.method if self._index is not None else None, + "query_mutates_store": False, + } + + def remember( + self, + text: Any, + label: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + id: Optional[str] = None, + ) -> Dict[str, Any]: + """Store one local memory. Returns the stored entry as a plain dict.""" + entry_id = str(id) if id is not None else self._allocate_id() + if any(e.id == entry_id for e in self._entries): + raise ValueError("memory id already exists: %s" % entry_id) + entry = MemoryEntry(entry_id, str(text), label, dict(metadata or {})) + self._entries.append(entry) + self._rebuild_index() + return entry.to_dict() + + def remember_many(self, items: Iterable[Any]) -> List[Dict[str, Any]]: + """Store several memories. Each item may be text or a dict with text/label/metadata/id.""" + stored = [] + for item in items: + if isinstance(item, dict): + stored.append(self.remember( + item.get("text", ""), + label=item.get("label"), + metadata=item.get("metadata"), + id=item.get("id"), + )) + else: + stored.append(self.remember(item)) + return stored + + def get_memory(self, memory_id: Any) -> Optional[Dict[str, Any]]: + """Return one stored memory by id, or ``None`` when it is absent.""" + wanted = str(memory_id) + for entry in self._entries: + if entry.id == wanted: + return entry.to_dict() + return None + + def list_memories( + self, + limit: int = 50, + cursor: Optional[str] = None, + ) -> Dict[str, Any]: + """Return an insertion-ordered page with an opaque-enough stable id cursor.""" + if isinstance(limit, bool) or not isinstance(limit, (int, np.integer)) or not 1 <= int(limit) <= 100: + raise ValueError("limit must be between 1 and 100") + start = 0 + if cursor is not None: + cursor = str(cursor) + for index, entry in enumerate(self._entries): + if entry.id == cursor: + start = index + 1 + break + else: + raise ValueError("cursor does not identify a stored memory") + stop = min(start + int(limit), len(self._entries)) + items = [entry.to_dict() for entry in self._entries[start:stop]] + next_cursor = items[-1]["id"] if stop < len(self._entries) and items else None + return {"items": items, "next_cursor": next_cursor} + + def forget(self, memory_id: Any) -> Optional[Dict[str, Any]]: + """Delete one memory by id and return it; missing ids are idempotent.""" + wanted = str(memory_id) + for index, entry in enumerate(self._entries): + if entry.id != wanted: + continue + removed = self._entries.pop(index) + self._rebuild_index() + return removed.to_dict() + return None + + def update_memory( + self, + memory_id: Any, + *, + text: Any = _UNSET, + label: Any = _UNSET, + metadata: Any = _UNSET, + ) -> Optional[Dict[str, Any]]: + """Replace selected fields of one memory and return it, or ``None``.""" + wanted = str(memory_id) + for entry in self._entries: + if entry.id != wanted: + continue + text_changed = text is not _UNSET and str(text) != entry.text + if text is not _UNSET: + entry.text = str(text) + if label is not _UNSET: + entry.label = label + if metadata is not _UNSET: + entry.metadata = dict(metadata or {}) + if text_changed: + self._rebuild_index() + return entry.to_dict() + return None + + def recall(self, query: Any, k: int = 3, abstain: Optional[float] = None) -> List[Dict[str, Any]]: + """Return the nearest stored memories for `query`, best first. + + `abstain` is passed to `Index.nearest`; when set, noisy matches can + return an empty list instead of a guess. + """ + if not self._entries or self._index is None: + return [] + if isinstance(k, bool) or not isinstance(k, (int, np.integer)) or int(k) < 1: + raise ValueError("k must be a positive integer") + if abstain is not None: + if isinstance(abstain, bool) or not isinstance(abstain, (int, float, np.number)): + raise ValueError("abstain must be a number between 0 and 1") + if not 0.0 <= float(abstain) <= 1.0: + raise ValueError("abstain must be between 0 and 1") + if not _tokens(query): + return [] + q = self._encode_text(query) + hits = self._index.nearest(q, k=min(int(k), len(self._entries)), abstain=abstain) + by_id = {entry.id: entry for entry in self._entries} + out = [] + for entry_id, score in hits: + entry = by_id[str(entry_id)] + row = entry.to_dict() + row["score"] = float(score) + out.append(row) + return out + + # ---- agent routing -------------------------------------------------------------------------------- + def suggest(self, task: str, k: int = 5) -> List[Dict[str, Any]]: + """Suggest capabilities for a plain-English task.""" + from holographic.misc import holographic_skills as skills + + return skills.suggest(task, k=k) + + def route(self, task: str) -> Dict[str, Any]: + """Route a task to one capability when confident, otherwise return options.""" + from holographic.misc import holographic_skills as skills + + routed = skills.route(task, act_threshold=self.route_threshold) + out = {"task": str(task)} + out.update(routed) + return out + + # ---- evidence / dashboard ------------------------------------------------------------------------- + def evidence(self) -> Dict[str, Any]: + """Return a machine-readable product readiness snapshot.""" + from holographic.caching_and_storage.holographic_catalog import default_catalog + + c_kernel = self._c_kernel_status() + route_probe = self.route("search a big pile of vectors") + return { + "name": "leCore LocalAgentCore", + "status": "ready" if self._deterministic_probe() else "check", + "memory": { + "entries": len(self._entries), + "dim": self.dim, + "index_method": self._index.method if self._index is not None else None, + "query_mutates_store": False, + }, + "routing": { + "capabilities": len(default_catalog()), + "probe_decision": route_probe.get("decision"), + "probe_skill": (route_probe.get("skill") or {}).get("name"), + }, + "c_kernel": c_kernel, + "checks": { + "deterministic_encoding": self._deterministic_probe(), + "local_only": True, + "no_model_weights": True, + }, + } + + def dashboard(self, html: bool = False) -> Any: + """Return the evidence dashboard as a dict, or static HTML with `html=True`.""" + data = self.evidence() + return self.dashboard_html(data) if html else data + + @staticmethod + def dashboard_html(data: Dict[str, Any]) -> str: + """Render an evidence snapshot as a dependency-free static HTML dashboard.""" + memory = data.get("memory", {}) + routing = data.get("routing", {}) + c_kernel = data.get("c_kernel", {}) + checks = data.get("checks", {}) + + def esc(value: Any) -> str: + return html.escape("" if value is None else str(value)) + + rows = [ + ("Status", data.get("status")), + ("Memories", memory.get("entries")), + ("Dimension", memory.get("dim")), + ("Index", memory.get("index_method") or "empty"), + ("Capabilities", routing.get("capabilities")), + ("Route Probe", "%s: %s" % (routing.get("probe_decision"), routing.get("probe_skill"))), + ("C Kernel", "available" if c_kernel.get("available") else "not built"), + ("C Path", c_kernel.get("path") or ""), + ("Deterministic", checks.get("deterministic_encoding")), + ("Local Only", checks.get("local_only")), + ("No Model Weights", checks.get("no_model_weights")), + ] + body = "\n".join( + "%s%s" % (esc(k), esc(v)) + for k, v in rows + ) + return """ + + + + leCore LocalAgentCore Dashboard + + + +
+

leCore LocalAgentCore

+

Local deterministic memory, skill routing, and readiness evidence.

+ + %s +
+
+ +""" % body + + # ---- persistence ---------------------------------------------------------------------------------- + def to_state(self) -> Dict[str, Any]: + """Serialize configuration and entries. Vectors are seed/context-derived and rebuilt on load.""" + return { + "dim": self.dim, + "seed": self.seed, + "route_threshold": self.route_threshold, + "next_id": self._next_id, + "entries": [entry.to_dict() for entry in self._entries], + } + + @classmethod + def from_state(cls, state: Dict[str, Any]) -> "LocalAgentCore": + """Rebuild a core from `to_state` data.""" + core = cls( + dim=int(state.get("dim", 512)), + seed=int(state.get("seed", 0)), + route_threshold=float(state.get("route_threshold", 0.6)), + ) + core._entries = [MemoryEntry.from_dict(row) for row in state.get("entries", [])] + core._next_id = int(state.get("next_id", len(core._entries) + 1)) + core._rebuild_index() + return core + + def save(self, path: Any) -> str: + """Atomically write the product state to JSON and return the path.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(self.to_state(), indent=2, sort_keys=True) + fd, temporary = tempfile.mkstemp(prefix=".%s." % p.name, suffix=".tmp", dir=str(p.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, p) + except Exception: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + return str(p) + + @classmethod + def load(cls, path: Any) -> "LocalAgentCore": + """Load a product state saved by `save`.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + return cls.from_state(data) + + # ---- internals ------------------------------------------------------------------------------------ + def _allocate_id(self) -> str: + entry_id = "m%d" % self._next_id + self._next_id += 1 + return entry_id + + def _encode_text(self, text: Any) -> np.ndarray: + toks = _tokens(text) + return self._encoder.encode(toks, modality="text") + + def _rebuild_index(self) -> None: + self._encoder = UniversalEncoder(self.dim, seed=self.seed) + for entry in self._entries: + toks = _tokens(entry.text) + if toks: + self._encoder.learn_text([toks]) + if not self._entries: + self._vectors = None + self._index = None + return + self._vectors = np.stack([self._encode_text(entry.text) for entry in self._entries]) + self._index = Index(self._vectors, labels=[entry.id for entry in self._entries], method="exact", seed=self.seed) + + def _deterministic_probe(self) -> bool: + a = self._encode_text("deterministic local memory") + b = self._encode_text("deterministic local memory") + return bool(np.allclose(a, b)) + + @staticmethod + def _c_kernel_status() -> Dict[str, Any]: + try: + holographic_c = importlib.import_module("holographic_c") + + return { + "available": bool(holographic_c.available()), + "path": holographic_c.backend_path(), + } + except Exception as exc: # pragma: no cover - defensive dashboard reporting + return {"available": False, "path": None, "error": "%s: %s" % (type(exc).__name__, exc)} + + +def demo() -> LocalAgentCore: + """Build a tiny ready-to-query product demo.""" + core = LocalAgentCore(dim=512, seed=0) + core.remember("local agents need deterministic durable memory", label="agent-memory") + core.remember("capability routing should act when confident and choose when ambiguous", label="routing") + core.remember("the C kernel accelerates the audited vector algebra hot path", label="c-kernel") + return core + + +def _selftest() -> None: + core = demo() + assert core.recall("deterministic local memory")[0]["label"] == "agent-memory" + assert core.route("start pause resume cancel a job")["decision"] == "act" + assert core.dashboard()["checks"]["deterministic_encoding"] + print("OK: holographic_product self-test passed") + + +if __name__ == "__main__": + _selftest() diff --git a/holographic_x402_api.py b/holographic_x402_api.py new file mode 100644 index 0000000..db1c840 --- /dev/null +++ b/holographic_x402_api.py @@ -0,0 +1,3523 @@ +"""holographic_x402_api.py -- publish the leCore Agent Memory & Routing API. + +WHY THIS EXISTS +--------------- +`LocalAgentCore` remains the embedded implementation facade. This module +translates it into a hosted HTTP API without making x402, FastAPI, or uvicorn +core dependencies. + +The boundary is intentionally conservative: + + * public read/compute routes are x402-paid + * health/pricing routes are free + * memory writes are admin-token gated, not pay-to-write + +That keeps the paid surface useful while preventing customers from poisoning a +shared memory store just because they paid for one request. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +import base64 +import hashlib +import hmac +from html import escape +import argparse +import json +import logging +import os +from pathlib import Path +import queue +import re +import struct +from string import Template +import subprocess +import threading +import time +from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.parse import urlsplit +import zlib + +from holographic_product import LocalAgentCore, demo +from lecore import __version__ as LECORE_VERSION + + +DEFAULT_FACILITATOR_URL = "https://x402.org/facilitator" +DEFAULT_NETWORK = "eip155:84532" # Base Sepolia, safe default for testnet publishing. +DEFAULT_PRICE = "$0.0011" +DEFAULT_PUBLIC_URL = "https://lecore.rati.foundation" +DEFAULT_TENANT_ID = "public" +TENANT_HEADER = "X-leCore-Tenant" +TENANT_TOKEN_HEADER = "X-leCore-Tenant-Token" +IDEMPOTENCY_HEADER = "Idempotency-Key" +_TENANT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,63}$") +_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{1,256}$") +_MEMORY_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +MAX_QUERY_CHARS = 8192 +MAX_TASK_CHARS = 8192 +MAX_MEMORY_CHARS = 65536 +MAX_MEMORY_LABEL_CHARS = 256 +MAX_MEMORY_METADATA_BYTES = 16384 +MAX_RECALL_K = 100 +MEMORY_BACKEND_CORE = "core" +MEMORY_BACKEND_NOSQLITE = "nosqlite" +NOSQLITE_ENCODER = "lecore_text" +NOSQLITE_INDEX = "embedding_neural" +NOSQLITE_DIMENSIONS = 384 +MEMORY_KEY_ENV = "LECORE_X402_MEMORY_KEYS" +MEMORY_MIGRATION_ENV = "LECORE_X402_ALLOW_PLAINTEXT_MIGRATION" +MEMORY_CIPHER = "AES-256-GCM" +MEMORY_COMPRESSION = "zlib" +MEMORY_KDF = "HKDF-SHA256" + + +LOG = logging.getLogger(__name__) + +SERVICE_NAME = "leCore Agent Memory & Routing API" +HERO_TITLE = "Agent memory and routing, paid per call." +X402_BUYER_GUIDE_URL = "https://docs.x402.org/getting-started/quickstart-for-buyers" +API_DESCRIPTION = """Hosted, encrypted tenant-scoped agent memory, capability +routing, and readiness data over HTTPS, with x402 payment on each protected request. + +## Request flow + +1. Read `GET /pricing` for the network, asset, price, and protected-route manifest. +2. Call a protected `/v1/*` route. An unsigned request returns `402 Payment Required`. +3. Decode the `Payment-Required` response header with an x402 v2 client. +4. Sign the selected payment option and retry with the resulting `Payment-Signature` header. +5. Decode the successful `Payment-Response` header for settlement details. + +The interactive reference describes the contract but does not sign payments. See the +[x402 buyer quickstart](https://docs.x402.org/getting-started/quickstart-for-buyers) +for wallet and client setup. +`GET /health`, `GET /pricing`, `/docs`, `/redoc`, and `/openapi.json` +are free. Private tenant calls additionally require `X-leCore-Tenant` and +`X-leCore-Tenant-Token`; payment proves payment, not tenant authorization. +`POST /v1/memory` also requires an `Idempotency-Key`, refuses shared-public writes, +and stores the private record through compressed authenticated encryption. +`GET /v1/memory` provides bounded cursor pagination or exact-id retrieval, and +`PATCH /v1/memory` atomically replaces selected fields without rewriting a no-op, while +`DELETE /v1/memory` removes one private record idempotently without resurrection. +""" +OPENAPI_TAGS = [ + { + "name": "Discovery", + "description": "Free service health, pricing, network, and route discovery.", + }, + { + "name": "Paid API", + "description": "Hosted read and compute operations protected by the x402 v2 payment flow.", + }, +] + + +@dataclass(frozen=True) +class PaidRoute: + """One x402-protected route.""" + + method: str + path: str + description: str + price: Optional[str] = None + mime_type: str = "application/json" + + @property + def key(self) -> str: + """The route key shape expected by x402 middleware, e.g. `POST /v1/recall`.""" + return "%s %s" % (self.method.upper(), self.path) + + +REGULAR_PAID_ROUTES: Tuple[PaidRoute, ...] = ( + PaidRoute("POST", "/v1/memory", "Store one entry in encrypted tenant-scoped agent memory"), + PaidRoute("GET", "/v1/memory", "List or retrieve encrypted tenant-scoped agent memory"), + PaidRoute("PATCH", "/v1/memory", "Update one entry in encrypted tenant-scoped agent memory"), + PaidRoute("DELETE", "/v1/memory", "Delete one entry from encrypted tenant-scoped agent memory"), + PaidRoute("POST", "/v1/recall", "Recall nearest memories from tenant-scoped agent memory"), + PaidRoute("POST", "/v1/route", "Route a plain-English task to a leCore capability"), + PaidRoute("GET", "/v1/dashboard", "Read the service readiness dashboard"), +) + +DEFAULT_PAID_ROUTES: Tuple[PaidRoute, ...] = REGULAR_PAID_ROUTES +TESTNET_NETWORKS = frozenset({"eip155:84532"}) + + +def _price_amount(price: str) -> Decimal: + """Parse a dollar-denominated x402 price without a floating-point round trip.""" + try: + amount = Decimal(price[1:]) + except (InvalidOperation, ValueError) as exc: + raise ValueError("x402 price must be a positive dollar amount, e.g. '$0.001'") from exc + if not amount.is_finite() or amount <= 0: + raise ValueError("x402 price must be a positive dollar amount, e.g. '$0.001'") + return amount + + +def _normalize_public_url(value: str) -> str: + """Return a canonical public base URL safe to advertise in x402 challenges.""" + if not isinstance(value, str): + raise ValueError("public_url must be a string") + value = value.strip().rstrip("/") + if not value or any(char.isspace() or char == "\\" or ord(char) == 127 for char in value): + raise ValueError("public_url must be an absolute http(s) URL") + try: + parts = urlsplit(value) + _ = parts.port + except ValueError as exc: + raise ValueError("public_url must be an absolute http(s) URL") from exc + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.netloc.endswith(":") + or parts.hostname is None + or not parts.hostname.strip(".") + ): + raise ValueError("public_url must be an absolute http(s) URL") + if parts.username is not None or parts.password is not None: + raise ValueError("public_url must not contain credentials") + if parts.query or parts.fragment or "?" in value or "#" in value: + raise ValueError("public_url must not contain a query or fragment") + return value + + +def x402_payment_required_responses() -> Dict[int, Dict[str, Any]]: + """OpenAPI response metadata shared by every x402-protected operation.""" + return { + 402: { + "description": ( + "Payment required or settlement failed. An unsigned or invalid-payment " + "request includes Payment-Required; a paid request whose settlement " + "fails can instead include Payment-Response." + ), + "headers": { + "Payment-Required": { + "description": ( + "Base64-encoded x402 v2 PaymentRequired challenge, present for " + "unsigned or invalid-payment requests." + ), + "schema": {"type": "string", "format": "byte"}, + }, + "Payment-Response": { + "description": ( + "Base64-encoded x402 v2 settlement response, present when a " + "paid request reaches the handler but settlement fails." + ), + "schema": {"type": "string", "format": "byte"}, + }, + }, + "content": { + "application/json": { + "schema": {"type": "object", "maxProperties": 0}, + "example": {}, + }, + "text/html": { + "schema": {"type": "string"}, + "example": "Payment Required", + }, + }, + }, + } + + +def paid_request_openapi( + required: List[str], + properties: Dict[str, Dict[str, Any]], + example: Dict[str, Any], + example_summary: str, +) -> Dict[str, Any]: + """Return an accurate OpenAPI request body while runtime validation stays compatible.""" + return { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": required, + "properties": properties, + "additionalProperties": True, + }, + "examples": { + "public": { + "summary": example_summary, + "value": example, + }, + }, + }, + }, + }, + } + + +def _json_success_response( + description: str, + schema: Dict[str, Any], + example: Dict[str, Any], + *, + payment_receipt: bool = True, +) -> Dict[str, Any]: + """Return one documented JSON success response.""" + response = { + "description": description, + "content": { + "application/json": { + "schema": schema, + "example": example, + }, + }, + } + if payment_receipt: + response["headers"] = { + "Payment-Response": { + "description": "Base64-encoded x402 v2 settlement response.", + "schema": {"type": "string", "format": "byte"}, + }, + } + return response + + +def _error_response(description: str, detail: str) -> Dict[str, Any]: + """Return the shared JSON error envelope used by FastAPI routes.""" + return { + "description": description, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["detail"], + "properties": {"detail": {"type": "string"}}, + "additionalProperties": False, + }, + "example": {"detail": detail}, + }, + }, + } + + +def paid_operation_responses( + success: Dict[str, Any], + *, + invalid_detail: str, + backend_unavailable: bool = False, + idempotency_conflict: bool = False, +) -> Dict[int, Dict[str, Any]]: + """Document paid success, payment, tenant, and validation responses.""" + responses = { + 200: success, + 400: _error_response("Invalid request.", invalid_detail), + 401: _error_response("Private-tenant authorization failed.", "invalid tenant token"), + 403: _error_response( + "The deployment cannot authorize the selected private tenant.", + "private tenants require LECORE_X402_TENANT_SECRET", + ), + 502: { + "description": "The x402 facilitator could not verify or settle the payment.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["error"], + "properties": {"error": {"type": "string"}}, + "additionalProperties": True, + }, + "example": {"error": "Payment verification failed"}, + }, + }, + }, + } + responses.update(x402_payment_required_responses()) + if backend_unavailable: + responses[503] = _error_response( + "The configured memory backend is temporarily unavailable.", + "NoSQLite memory backend is unavailable", + ) + if idempotency_conflict: + responses[409] = _error_response( + "The idempotency key was already used for different memory content.", + "Idempotency-Key was already used for a different memory write", + ) + return responses + + +def _capability_openapi_schema() -> Dict[str, Any]: + """Return the stable public shape of one routed capability.""" + return { + "type": "object", + "required": ["name", "does", "call"], + "properties": { + "name": {"type": "string"}, + "does": {"type": "string"}, + "call": {"type": "string"}, + }, + "additionalProperties": False, + } + + +def health_success_openapi( + *, + paid: bool, + private_tenants_enabled: bool, + memory_backend: str, + nosqlite_shadow: bool, + nosqlite_configured: bool, + durable_transactions: bool, + encrypted_storage: bool, + plaintext_migration_enabled: bool, +) -> Dict[str, Any]: + """Document the free health and deployment-state response.""" + schema = { + "type": "object", + "required": ["ok", "name", "paid", "memory", "memory_backend", "tenancy"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "name": {"type": "string"}, + "paid": {"type": "boolean"}, + "memory": { + "type": "object", + "required": ["entries", "dim", "index_method", "query_mutates_store"], + "properties": { + "entries": {"type": "integer", "minimum": 0}, + "dim": {"type": "integer", "minimum": 1}, + "index_method": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "query_mutates_store": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "memory_backend": { + "type": "object", + "required": ["backend", "nosqlite_shadow", "nosqlite_configured", "durable_transactions", "storage"], + "properties": { + "backend": {"type": "string", "enum": ["core", "nosqlite"]}, + "nosqlite_shadow": {"type": "boolean"}, + "nosqlite_configured": {"type": "boolean"}, + "durable_transactions": {"type": "boolean"}, + "storage": { + "type": "object", + "required": ["durable", "encrypted", "cipher", "compression", "plaintext_migration_enabled"], + "properties": { + "durable": {"type": "boolean"}, + "encrypted": {"type": "boolean"}, + "cipher": {"anyOf": [{"type": "string", "const": MEMORY_CIPHER}, {"type": "null"}]}, + "compression": {"anyOf": [{"type": "string", "const": MEMORY_COMPRESSION}, {"type": "null"}]}, + "plaintext_migration_enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + }, + "tenancy": { + "type": "object", + "required": ["default_tenant", "loaded_tenants", "private_tenants_enabled"], + "properties": { + "default_tenant": {"type": "string"}, + "loaded_tenants": {"type": "integer", "minimum": 1}, + "private_tenants_enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "name": SERVICE_NAME, + "paid": paid, + "memory": { + "entries": 3, + "dim": 512, + "index_method": "exact", + "query_mutates_store": False, + }, + "memory_backend": { + "backend": memory_backend, + "nosqlite_shadow": nosqlite_shadow, + "nosqlite_configured": nosqlite_configured, + "durable_transactions": durable_transactions, + "storage": { + "durable": durable_transactions, + "encrypted": encrypted_storage, + "cipher": MEMORY_CIPHER if encrypted_storage else None, + "compression": MEMORY_COMPRESSION if encrypted_storage else None, + "plaintext_migration_enabled": plaintext_migration_enabled, + }, + }, + "tenancy": { + "default_tenant": DEFAULT_TENANT_ID, + "loaded_tenants": 1, + "private_tenants_enabled": private_tenants_enabled, + }, + } + return _json_success_response( + "Service health and deployment state returned.", + schema, + example, + payment_receipt=False, + ) + + +def pricing_success_openapi( + config: X402Config, + *, + private_tenants_enabled: bool, + memory_backend: str, + nosqlite_shadow: bool, + nosqlite_configured: bool, + durable_transactions: bool, + encrypted_storage: bool, + plaintext_migration_enabled: bool, +) -> Dict[str, Any]: + """Document the free x402 discovery manifest.""" + string_map = {"type": "object", "additionalProperties": {"type": "string"}} + schema = { + "type": "object", + "required": ["ok", "documentation", "x402", "pricing", "tenancy", "memory_backend", "routes"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "documentation": { + "type": "object", + "required": ["swagger_ui", "reference", "openapi_schema"], + "properties": { + "swagger_ui": {"type": "string", "format": "uri"}, + "reference": {"type": "string", "format": "uri"}, + "openapi_schema": {"type": "string", "format": "uri"}, + }, + "additionalProperties": False, + }, + "x402": { + "type": "object", + "required": ["pay_to", "price", "network", "facilitator_url", "scheme", "public_url"], + "properties": { + "pay_to": {"type": "string"}, + "price": {"type": "string"}, + "network": {"type": "string"}, + "facilitator_url": {"type": "string", "format": "uri"}, + "scheme": {"type": "string"}, + "public_url": {"type": "string", "format": "uri"}, + }, + "additionalProperties": False, + }, + "pricing": string_map, + "tenancy": { + "type": "object", + "required": ["default_tenant", "tenant_header", "tenant_token_header", "private_tenants_enabled"], + "properties": { + "default_tenant": {"type": "string"}, + "tenant_header": {"type": "string"}, + "tenant_token_header": {"type": "string"}, + "private_tenants_enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "memory_backend": { + "type": "object", + "required": ["backend", "nosqlite_shadow", "nosqlite_configured", "durable_transactions", "storage"], + "properties": { + "backend": {"type": "string", "enum": ["core", "nosqlite"]}, + "nosqlite_shadow": {"type": "boolean"}, + "nosqlite_configured": {"type": "boolean"}, + "durable_transactions": {"type": "boolean"}, + "storage": { + "type": "object", + "required": ["durable", "encrypted", "cipher", "compression", "plaintext_migration_enabled"], + "properties": { + "durable": {"type": "boolean"}, + "encrypted": {"type": "boolean"}, + "cipher": {"anyOf": [{"type": "string", "const": MEMORY_CIPHER}, {"type": "null"}]}, + "compression": {"anyOf": [{"type": "string", "const": MEMORY_COMPRESSION}, {"type": "null"}]}, + "plaintext_migration_enabled": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + }, + "routes": { + "type": "array", + "items": { + "type": "object", + "required": ["route", "description", "mime_type", "accepts"], + "properties": { + "route": {"type": "string"}, + "description": {"type": "string"}, + "mime_type": {"type": "string"}, + "accepts": { + "type": "array", + "items": { + "type": "object", + "required": ["scheme", "price", "network", "pay_to"], + "properties": { + "scheme": {"type": "string"}, + "price": {"type": "string"}, + "network": {"type": "string"}, + "pay_to": {"type": "string"}, + }, + "additionalProperties": False, + }, + }, + }, + "additionalProperties": False, + }, + }, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "documentation": documentation_manifest(config), + "x402": config.to_public_dict(), + "pricing": pricing_summary(config), + "tenancy": { + "default_tenant": DEFAULT_TENANT_ID, + "tenant_header": TENANT_HEADER, + "tenant_token_header": TENANT_TOKEN_HEADER, + "private_tenants_enabled": private_tenants_enabled, + }, + "memory_backend": { + "backend": memory_backend, + "nosqlite_shadow": nosqlite_shadow, + "nosqlite_configured": nosqlite_configured, + "durable_transactions": durable_transactions, + "storage": { + "durable": durable_transactions, + "encrypted": encrypted_storage, + "cipher": MEMORY_CIPHER if encrypted_storage else None, + "compression": MEMORY_COMPRESSION if encrypted_storage else None, + "plaintext_migration_enabled": plaintext_migration_enabled, + }, + }, + "routes": payment_manifest(config), + } + return _json_success_response( + "Pricing and x402 discovery manifest returned.", + schema, + example, + payment_receipt=False, + ) + + +def recall_success_openapi() -> Dict[str, Any]: + """Document the successful memory-recall response.""" + hit_schema = { + "type": "object", + "required": ["id", "text", "label", "metadata", "score"], + "properties": { + "id": {"type": "string"}, + "text": {"type": "string"}, + "label": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "metadata": {"type": "object", "additionalProperties": True}, + "score": {"type": "number"}, + }, + "additionalProperties": False, + } + schema = { + "type": "object", + "required": ["ok", "tenant", "query", "hits"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "query": {"type": "string"}, + "hits": {"type": "array", "items": hit_schema}, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "public", + "query": "deterministic memory", + "hits": [{ + "id": "m2", + "text": "Prefer explicit capability routing when confidence is low.", + "label": "routing", + "metadata": {"source": "public-preview"}, + "score": 0.82, + }], + } + return _json_success_response("Memory recall completed.", schema, example) + + +def memory_write_success_openapi() -> Dict[str, Any]: + """Document the successful private-tenant memory write response.""" + schema = { + "type": "object", + "required": ["ok", "tenant", "memory", "transaction"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "memory": { + "type": "object", + "required": ["id", "text", "label", "metadata"], + "properties": { + "id": {"type": "string"}, + "text": {"type": "string"}, + "label": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "metadata": {"type": "object", "additionalProperties": True}, + }, + "additionalProperties": False, + }, + "transaction": { + "type": "object", + "required": ["id", "state", "idempotent"], + "properties": { + "id": {"type": "string"}, + "state": {"type": "string", "enum": ["complete", "core_committed"]}, + "idempotent": {"type": "boolean", "const": True}, + }, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "acme", + "memory": { + "id": "tx_8f1e6aaf6dcb44f0a8a6434f135c6338", + "text": "The customer prefers concise release notes.", + "label": "preference", + "metadata": {"source": "agent-session"}, + }, + "transaction": { + "id": "8f1e6aaf6dcb44f0a8a6434f135c6338f31821a279d4865476d08db1b2cecf0f", + "state": "complete", + "idempotent": True, + }, + } + return _json_success_response("Memory was durably stored once.", schema, example) + + +def memory_list_success_openapi() -> Dict[str, Any]: + """Document private-tenant memory listing and direct lookup.""" + item = { + "type": "object", + "required": ["id", "text", "label", "metadata"], + "properties": { + "id": {"type": "string"}, + "text": {"type": "string"}, + "label": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "metadata": {"type": "object", "additionalProperties": True}, + }, + "additionalProperties": False, + } + schema = { + "type": "object", + "required": ["ok", "tenant", "items", "next_cursor"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "items": {"type": "array", "items": item, "maxItems": 100}, + "next_cursor": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "acme", + "items": [{ + "id": "tx_8f1e6aaf6dcb44f0a8a6434f135c6338", + "text": "The customer prefers concise release notes.", + "label": "preference", + "metadata": {"source": "agent-session"}, + }], + "next_cursor": None, + } + return _json_success_response("Private-tenant memory page returned.", schema, example) + + +def memory_delete_success_openapi() -> Dict[str, Any]: + """Document idempotent private-tenant memory deletion.""" + schema = { + "type": "object", + "required": ["ok", "tenant", "memory_id", "deleted"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "memory_id": {"type": "string"}, + "deleted": {"type": "boolean"}, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "acme", + "memory_id": "tx_8f1e6aaf6dcb44f0a8a6434f135c6338", + "deleted": True, + } + return _json_success_response("Memory deletion completed idempotently.", schema, example) + + +def memory_update_success_openapi() -> Dict[str, Any]: + """Document a successful private-tenant memory update.""" + response = memory_write_success_openapi() + schema = response["content"]["application/json"]["schema"] + schema["required"].remove("transaction") + schema["properties"].pop("transaction") + response["description"] = "Memory fields were updated atomically." + response["content"]["application/json"]["example"] = { + "ok": True, + "tenant": "acme", + "memory": { + "id": "tx_8f1e6aaf6dcb44f0a8a6434f135c6338", + "text": "The customer prefers concise release notes and changelogs.", + "label": "preference", + "metadata": {"source": "agent-session", "confirmed": True}, + }, + } + return response + + +def memory_update_request_openapi() -> Dict[str, Any]: + """Document a partial update that requires at least one mutable field.""" + request = paid_request_openapi( + required=[], + properties={ + "text": { + "type": "string", + "minLength": 1, + "maxLength": MAX_MEMORY_CHARS, + "pattern": r"\S", + "description": "Replacement memory content; omit to preserve it.", + }, + "label": { + "anyOf": [ + {"type": "string", "maxLength": MAX_MEMORY_LABEL_CHARS}, + {"type": "null"}, + ], + "description": "Replacement category; null clears it.", + }, + "metadata": { + "type": "object", + "additionalProperties": True, + "description": "Replacement JSON metadata; an empty object clears it.", + }, + }, + example={ + "text": "The customer prefers concise release notes and changelogs.", + "metadata": {"source": "agent-session", "confirmed": True}, + }, + example_summary="Update selected fields of one private memory", + ) + schema = request["requestBody"]["content"]["application/json"]["schema"] + schema.pop("required") + schema["anyOf"] = [ + {"required": ["text"]}, + {"required": ["label"]}, + {"required": ["metadata"]}, + ] + schema["additionalProperties"] = False + return request + + +def route_success_openapi() -> Dict[str, Any]: + """Document the successful capability-routing response.""" + capability = _capability_openapi_schema() + route_schema = { + "type": "object", + "required": ["task", "decision", "confidence"], + "properties": { + "task": {"type": "string"}, + "decision": {"type": "string", "enum": ["act", "choose", "unknown"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "prompt": {"type": "string"}, + "skill": capability, + "options": {"type": "array", "items": capability}, + }, + "additionalProperties": True, + } + schema = { + "type": "object", + "required": ["ok", "tenant", "route"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "route": route_schema, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "public", + "route": { + "task": "search a large vector collection", + "decision": "act", + "confidence": 0.91, + "skill": { + "name": "Index (search)", + "does": "Search a vector index for nearest entries.", + "call": "index.nearest(query, k=5)", + }, + }, + } + return _json_success_response("Capability routing completed.", schema, example) + + +def dashboard_success_openapi() -> Dict[str, Any]: + """Document the successful service-readiness response.""" + schema = { + "type": "object", + "required": ["ok", "tenant", "dashboard"], + "properties": { + "ok": {"type": "boolean", "const": True}, + "tenant": {"type": "string"}, + "dashboard": { + "type": "object", + "required": ["name", "status", "memory", "routing", "c_kernel", "checks"], + "properties": { + "name": {"type": "string"}, + "status": {"type": "string", "enum": ["ready", "check"]}, + "memory": { + "type": "object", + "required": ["entries", "dim", "index_method", "query_mutates_store"], + "properties": { + "entries": {"type": "integer", "minimum": 0}, + "dim": {"type": "integer", "minimum": 1}, + "index_method": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "query_mutates_store": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "routing": { + "type": "object", + "required": ["capabilities", "probe_decision", "probe_skill"], + "properties": { + "capabilities": {"type": "integer", "minimum": 0}, + "probe_decision": {"type": "string"}, + "probe_skill": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + "additionalProperties": False, + }, + "c_kernel": {"type": "object", "additionalProperties": True}, + "checks": { + "type": "object", + "required": ["deterministic_encoding", "no_model_weights", "self_contained_engine"], + "properties": { + "deterministic_encoding": {"type": "boolean"}, + "no_model_weights": {"type": "boolean"}, + "self_contained_engine": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + }, + "additionalProperties": True, + }, + }, + "additionalProperties": False, + } + example = { + "ok": True, + "tenant": "public", + "dashboard": { + "name": SERVICE_NAME, + "status": "ready", + "memory": { + "entries": 3, + "dim": 512, + "index_method": "exact", + "query_mutates_store": False, + }, + "routing": { + "capabilities": 656, + "probe_decision": "act", + "probe_skill": "Index (search)", + }, + "c_kernel": {"available": False, "path": None}, + "checks": { + "deterministic_encoding": True, + "no_model_weights": True, + "self_contained_engine": True, + }, + }, + } + return _json_success_response("Readiness dashboard returned.", schema, example) + + +def public_response_headers( + path: str, + status_code: int, + public_url: str, + content_type: str = "", + network: str = DEFAULT_NETWORK, +) -> Dict[str, str]: + """Return browser and cache policy headers for one public response.""" + if path == "/docs": + content_security_policy = ( + "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: https://fastapi.tiangolo.com; connect-src 'self'; " + "base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + ) + elif path == "/redoc": + content_security_policy = ( + "default-src 'none'; script-src https://cdn.jsdelivr.net; " + "style-src 'unsafe-inline' https://fonts.googleapis.com; " + "font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + elif path == "/": + content_security_policy = ( + "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; " + "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + else: + content_security_policy = ( + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + + private_or_dynamic = ( + status_code >= 400 + or path == "/health" + or path.startswith("/v1/") + or path.startswith("/admin/") + ) + headers = { + "Cache-Control": ( + "no-store" if private_or_dynamic else "public, max-age=60, must-revalidate" + ), + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-Permitted-Cross-Domain-Policies": "none", + } + # x402's pinned browser paywall is a self-contained wallet application with + # inline script/style. Permit only its same-origin retry, the configured + # chain's public RPC, and the two optional Coinbase telemetry endpoints. + browser_paywall = ( + status_code == 402 + and path.startswith("/v1/") + and content_type.lower().startswith("text/html") + ) + if browser_paywall: + rpc_source = { + "eip155:84532": "https://sepolia.base.org", + "eip155:8453": "https://mainnet.base.org", + }.get(network, "https:") + content_security_policy = ( + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + "connect-src 'self' %s https://rpc.wallet.coinbase.com " + "https://cca-lite.coinbase.com https://as.coinbase.com; " + "img-src 'self' data:; font-src 'none'; media-src 'none'; " + "object-src 'none'; frame-src 'none'; worker-src 'none'; " + "manifest-src 'none'; base-uri 'none'; form-action 'none'; " + "frame-ancestors 'none'" % rpc_source + ) + headers["Content-Security-Policy"] = content_security_policy + if urlsplit(public_url).scheme == "https": + headers["Strict-Transport-Security"] = "max-age=31536000" + return headers + + +LANDING_PAGE_TEMPLATE = Template(""" + + + + +$service_name + + + + + + + + + + +
+
+ + +

$price_per_request$environment_label$network_label

$hero_title

A hosted HTTPS API for storing and recalling encrypted private-tenant memory, routing tasks to leCore capabilities, and reading service readiness. $payment_notice

+ +
+
Endpoint$public_url
Stage$environment_label
Protocolx402 v2
+

Four-step quickstart

Inspect the terms before signing anything.

  1. Read the free manifest

    GET /pricing returns the exact route, network, asset, receiver, and price.

  2. Make an unsigned request

    The protected route returns 402 with a base64 Payment-Required challenge.

  3. Sign with an x402 v2 client

    Use the x402 buyer guide to configure a testnet wallet and payment client.

  4. Retry and verify settlement

    Send Payment-Signature; a successful response includes Payment-Response.

First request: no wallet requiredOpen route docs
curl -i $public_url/v1/dashboard

Expected: HTTP 402 plus Payment-Required. This safely exposes the payment contract without moving testnet funds.

Inspect exact preview termsOpen live JSON
curl -sS $public_url/pricing
+

Paid API surface

A complete private-memory lifecycle, with no subscription.

POST · GET · PATCH · DELETE

Manage memory

/v1/memory

Store, page, retrieve, update, and idempotently delete encrypted private-tenant memories.

POST

Recall

/v1/recall

Query the seeded public preview memory or your authenticated private tenant.

POST

Route

/v1/route

Send a plain-language task and receive an explicit act, choose, or unknown decision with evidence.

GET

Dashboard

/v1/dashboard

Read memory, capability-routing, and deterministic-engine readiness for one tenant.

+

Storage boundaries

Encrypted before durable memory reaches disk.

Private-tenant memory and its retry journal are compressed, encrypted with per-record AES-256-GCM keys derived from a versioned service key, and authenticated against their tenant and file identity. The shared public dataset stays read-only. Private tenants still require operator-issued access credentials.

Per request
$price_per_request
Per 1,000
$price_per_thousand
Network
$network_name
API version
$api_version
+

Start testing

See the full request and response contract.

+
+ + +""") + + +@dataclass(frozen=True) +class X402Config: + """Seller configuration for the x402-paid API.""" + + pay_to: str + price: str = DEFAULT_PRICE + network: str = DEFAULT_NETWORK + facilitator_url: str = DEFAULT_FACILITATOR_URL + scheme: str = "exact" + routes: Tuple[PaidRoute, ...] = DEFAULT_PAID_ROUTES + public_url: str = DEFAULT_PUBLIC_URL + + def __post_init__(self) -> None: + if not self.pay_to: + raise ValueError("pay_to is required") + if not self.price.startswith("$"): + raise ValueError("x402 price must include a dollar prefix, e.g. '$0.001'") + _price_amount(self.price) + if not self.network: + raise ValueError("network is required") + if not self.facilitator_url: + raise ValueError("facilitator_url is required") + object.__setattr__(self, "public_url", _normalize_public_url(self.public_url)) + + @classmethod + def from_env(cls, require_pay_to: bool = True) -> "X402Config": + """Build config from LECORE_X402_* environment variables.""" + pay_to = os.environ.get("LECORE_X402_PAY_TO", "") + if require_pay_to and not pay_to: + raise ValueError("set LECORE_X402_PAY_TO to the receiving wallet address") + return cls( + pay_to=pay_to or "0xYourAddress", + price=os.environ.get("LECORE_X402_PRICE", DEFAULT_PRICE), + network=os.environ.get("LECORE_X402_NETWORK", DEFAULT_NETWORK), + facilitator_url=os.environ.get("LECORE_X402_FACILITATOR_URL", DEFAULT_FACILITATOR_URL), + public_url=os.environ.get("LECORE_X402_PUBLIC_URL", DEFAULT_PUBLIC_URL), + ) + + def to_public_dict(self) -> Dict[str, Any]: + """Public, JSON-safe view of the payment configuration.""" + return { + "pay_to": self.pay_to, + "price": self.price, + "network": self.network, + "facilitator_url": self.facilitator_url, + "scheme": self.scheme, + "public_url": self.public_url, + } + + +def optional_dependency_help() -> str: + """Install hint for the optional paid API dependencies.""" + return 'Install the optional API dependencies with: pip install ".[x402]" (includes FastAPI and EVM x402 support)' + + +def _landing_nodes() -> str: + """CSS-positioned visual nodes for the marketing page hero.""" + nodes = [] + for index in range(34): + size = 9 if index % 5 == 0 else 7 if index % 3 == 0 else 5 + nodes.append( + '' + % ((index * 29) % 100, (index * 47 + 11) % 100, (index % 9) * -0.45, size) + ) + return "".join(nodes) + + +def _network_name(network: str) -> str: + """Human label for known x402 network ids.""" + return {"eip155:84532": "Base Sepolia", "eip155:8453": "Base"}.get(network, network) + + +def normalize_tenant_id(value: Optional[Any]) -> str: + """Return a path-safe tenant id for private memory routing.""" + if value is None: + return DEFAULT_TENANT_ID + if not isinstance(value, str): + raise ValueError("tenant id must be a string") + tenant_id = value.strip().lower() + if not tenant_id: + tenant_id = DEFAULT_TENANT_ID + if not _TENANT_ID_RE.match(tenant_id): + raise ValueError("tenant id must be 1-64 chars of lowercase letters, numbers, '.', ':', '_' or '-'") + return tenant_id + + +def tenant_access_token(tenant_id: str, secret: str) -> str: + """Deterministic tenant bearer token derived from a server-side secret.""" + normalized = normalize_tenant_id(tenant_id) + return hmac.new(secret.encode("utf-8"), normalized.encode("utf-8"), hashlib.sha256).hexdigest() + + +def normalize_idempotency_key(value: Optional[Any]) -> Optional[str]: + """Validate an optional caller-provided retry key without persisting the raw value.""" + if value is None: + return None + if not isinstance(value, str) or not _IDEMPOTENCY_KEY_RE.match(value): + raise ValueError("Idempotency-Key must be 1-256 letters, numbers, '.', '_', ':', or '-'") + return value + + +def normalize_memory_id(value: Any) -> str: + """Validate a memory id before lookup or deletion.""" + if not isinstance(value, str) or not _MEMORY_ID_RE.fullmatch(value): + raise ValueError("memory_id must be 1-128 letters, numbers, '.', '_', ':', or '-'") + return value + + +@contextmanager +def _process_file_lock(path: Path) -> Any: + """Hold an exclusive process lock for one persisted tenant state file.""" + lock_path = path.with_suffix(path.suffix + ".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + if lock_path.is_symlink(): + raise MemoryStateError("durable-state lock must not be a symbolic link") + handle = open(lock_path, "a+b") + try: + if os.name == "nt": # pragma: no cover - exercised on Windows CI/users + import msvcrt + + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + yield + finally: + try: + if os.name == "nt": # pragma: no cover - exercised on Windows CI/users + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + + +class MemoryStateError(RuntimeError): + """Durable memory could not be authenticated, decoded, or migrated safely.""" + + +@dataclass(frozen=True) +class MemoryKeyring: + """A small versioned set of 256-bit application data-encryption keys.""" + + active: str + keys: Dict[str, bytes] + + @classmethod + def from_json(cls, value: str) -> "MemoryKeyring": + """Parse the Secrets Manager value used by ``LECORE_X402_MEMORY_KEYS``.""" + try: + document = json.loads(value) + except (TypeError, json.JSONDecodeError) as exc: + raise ValueError("%s must be a JSON object" % MEMORY_KEY_ENV) from exc + if not isinstance(document, dict) or set(document) != {"active", "keys"}: + raise ValueError("%s must contain exactly 'active' and 'keys'" % MEMORY_KEY_ENV) + active = document.get("active") + encoded_keys = document.get("keys") + if not isinstance(active, str) or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", active): + raise ValueError("memory active key id must be 1-64 safe characters") + if not isinstance(encoded_keys, dict) or not 1 <= len(encoded_keys) <= 8: + raise ValueError("memory keyring must contain between 1 and 8 keys") + keys: Dict[str, bytes] = {} + for key_id, encoded in encoded_keys.items(): + if not isinstance(key_id, str) or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", key_id): + raise ValueError("memory key ids must be 1-64 safe characters") + if not isinstance(encoded, str) or not encoded: + raise ValueError("memory key %s must be base64" % key_id) + try: + padded = encoded + "=" * (-len(encoded) % 4) + key = base64.b64decode(padded, altchars=b"-_", validate=True) + except (ValueError, TypeError) as exc: + raise ValueError("memory key %s must be valid base64" % key_id) from exc + if len(key) != 32: + raise ValueError("memory key %s must decode to exactly 32 bytes" % key_id) + keys[key_id] = key + if active not in keys: + raise ValueError("memory active key id is not present in the keyring") + if len(set(keys.values())) != len(keys): + raise ValueError("memory key ids must not contain duplicate key material") + return cls(active=active, keys=keys) + + +class MemoryStateCodec: + """Compress and authenticate durable JSON records before they touch disk. + + The versioned envelope deliberately leaves only format metadata and the key + id visible. Tenant/file identity is authenticated as associated data, so an + encrypted record cannot be copied into another tenant or journal location. + """ + + _MAGIC = b"LECMEM01" + _VERSION = 1 + _MAX_PLAINTEXT_BYTES = 256 * 1024 * 1024 + _MAX_HEADER_BYTES = 4096 + + def __init__(self, keyring: MemoryKeyring, allow_plaintext_migration: bool = False): + self.keyring = keyring + self.allow_plaintext_migration = bool(allow_plaintext_migration) + + def read_json(self, path: Path, context: str) -> Dict[str, Any]: + """Read one record, migrating plaintext or an old key while locked.""" + try: + if path.is_symlink() or not path.is_file(): + raise MemoryStateError("durable state %s must be a regular file" % path.name) + if path.stat().st_size > self._MAX_PLAINTEXT_BYTES + self._MAX_HEADER_BYTES + 64: + raise MemoryStateError("durable state %s exceeds the 256 MiB safety limit" % path.name) + payload = path.read_bytes() + except OSError as exc: + raise MemoryStateError("could not read durable memory state %s" % path.name) from exc + if payload.startswith(self._MAGIC): + value, key_id = self._decrypt_json(payload, context, path.name) + if key_id != self.keyring.active: + self.write_json(path, value, context) + return value + if not self.allow_plaintext_migration: + raise MemoryStateError( + "plaintext durable state %s is refused; enable one-time migration explicitly" % path.name + ) + try: + value = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise MemoryStateError("invalid plaintext durable state %s" % path.name) from exc + if not isinstance(value, dict): + raise MemoryStateError("durable state %s is not a JSON object" % path.name) + self.write_json(path, value, context) + return value + + def write_json(self, path: Path, value: Dict[str, Any], context: str) -> None: + """Serialize, compress, encrypt, and atomically replace one record.""" + if path.is_symlink(): + raise MemoryStateError("durable state %s must not be a symbolic link" % path.name) + if not isinstance(value, dict): + raise MemoryStateError("durable memory state must be a JSON object") + try: + plaintext = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise MemoryStateError("durable memory state is not JSON serializable") from exc + if len(plaintext) > self._MAX_PLAINTEXT_BYTES: + raise MemoryStateError("durable memory state exceeds the 256 MiB safety limit") + compressed = zlib.compress(plaintext, level=6) + nonce = os.urandom(12) + header = { + "algorithm": MEMORY_CIPHER, + "compression": MEMORY_COMPRESSION, + "kdf": MEMORY_KDF, + "key_id": self.keyring.active, + "nonce": base64.urlsafe_b64encode(nonce).decode("ascii").rstrip("="), + "plaintext_bytes": len(plaintext), + "version": self._VERSION, + } + header_bytes = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("ascii") + associated_data = self._associated_data(header_bytes, context) + try: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + except ImportError as exc: # pragma: no cover - covered by the x402 install + raise RuntimeError("memory encryption requires cryptography>=46,<47") from exc + ciphertext = AESGCM(self._data_key(self.keyring.keys[self.keyring.active], context)).encrypt( + nonce, + compressed, + associated_data, + ) + envelope = self._MAGIC + struct.pack(">I", len(header_bytes)) + header_bytes + ciphertext + _atomic_write_bytes(path, envelope) + + def _decrypt_json(self, payload: bytes, context: str, name: str) -> Tuple[Dict[str, Any], str]: + try: + if len(payload) < len(self._MAGIC) + 4: + raise ValueError("truncated header") + offset = len(self._MAGIC) + header_length = struct.unpack(">I", payload[offset:offset + 4])[0] + if not 1 <= header_length <= self._MAX_HEADER_BYTES: + raise ValueError("invalid header length") + header_start = offset + 4 + header_end = header_start + header_length + header_bytes = payload[header_start:header_end] + ciphertext = payload[header_end:] + if len(ciphertext) < 16: + raise ValueError("truncated ciphertext") + header = json.loads(header_bytes.decode("ascii")) + if not isinstance(header, dict) or set(header) != { + "algorithm", "compression", "kdf", "key_id", "nonce", "plaintext_bytes", "version" + }: + raise ValueError("invalid header") + if ( + header["version"] != self._VERSION + or header["algorithm"] != MEMORY_CIPHER + or header["compression"] != MEMORY_COMPRESSION + or header["kdf"] != MEMORY_KDF + ): + raise ValueError("unsupported envelope") + key_id = header["key_id"] + if key_id not in self.keyring.keys: + raise MemoryStateError("durable state %s needs unavailable memory key %s" % (name, key_id)) + encoded_nonce = header["nonce"] + if not isinstance(encoded_nonce, str): + raise ValueError("invalid nonce") + nonce = base64.b64decode( + encoded_nonce + "=" * (-len(encoded_nonce) % 4), + altchars=b"-_", + validate=True, + ) + if len(nonce) != 12: + raise ValueError("invalid nonce") + expected_size = header["plaintext_bytes"] + if not isinstance(expected_size, int) or not 0 <= expected_size <= self._MAX_PLAINTEXT_BYTES: + raise ValueError("invalid plaintext size") + except MemoryStateError: + raise + except (ValueError, TypeError, KeyError, UnicodeDecodeError, json.JSONDecodeError, struct.error) as exc: + raise MemoryStateError("invalid encrypted durable state %s" % name) from exc + try: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + compressed = AESGCM(self._data_key(self.keyring.keys[key_id], context)).decrypt( + nonce, + ciphertext, + self._associated_data(header_bytes, context), + ) + except InvalidTag as exc: + raise MemoryStateError("durable state authentication failed for %s" % name) from exc + plaintext = self._decompress(compressed, name) + if len(plaintext) != expected_size: + raise MemoryStateError("durable state size check failed for %s" % name) + try: + value = json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise MemoryStateError("invalid encrypted JSON in %s" % name) from exc + if not isinstance(value, dict): + raise MemoryStateError("durable state %s is not a JSON object" % name) + return value, key_id + + @classmethod + def _decompress(cls, compressed: bytes, name: str) -> bytes: + try: + inflater = zlib.decompressobj() + plaintext = inflater.decompress(compressed, cls._MAX_PLAINTEXT_BYTES + 1) + if len(plaintext) > cls._MAX_PLAINTEXT_BYTES or inflater.unconsumed_tail: + raise MemoryStateError("decompressed durable state %s exceeds the safety limit" % name) + plaintext += inflater.flush(cls._MAX_PLAINTEXT_BYTES + 1 - len(plaintext)) + if len(plaintext) > cls._MAX_PLAINTEXT_BYTES or not inflater.eof or inflater.unused_data: + raise MemoryStateError("invalid compressed durable state %s" % name) + return plaintext + except zlib.error as exc: + raise MemoryStateError("invalid compressed durable state %s" % name) from exc + + @classmethod + def _associated_data(cls, header: bytes, context: str) -> bytes: + if not isinstance(context, str) or not context or len(context.encode("utf-8")) > 512: + raise MemoryStateError("invalid durable-state encryption context") + return cls._MAGIC + header + b"\0" + context.encode("utf-8") + + @staticmethod + def _data_key(master_key: bytes, context: str) -> bytes: + """Derive a distinct AES key for each tenant/file context.""" + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=b"lecore-x402-memory-v1\0" + context.encode("utf-8"), + ).derive(master_key) + + +def _atomic_write_bytes(path: Path, payload: bytes) -> None: + """Durably replace one small state record with owner-only permissions.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(".%s.%s.tmp" % (path.name, os.urandom(8).hex())) + descriptor: Optional[int] = None + try: + descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as handle: + descriptor = None + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + if hasattr(os, "O_DIRECTORY"): + directory = os.open(str(path.parent), os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + except Exception: + if descriptor is not None: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + raise + + +def _atomic_write_json(path: Path, value: Dict[str, Any]) -> None: + """Durably replace a small JSON control record without exposing a partial file.""" + payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + _atomic_write_bytes(path, payload) + + +class TenantCoreStore: + """Thread-safe LocalAgentCore registry with optional per-tenant persistence.""" + + def __init__( + self, + default_core: LocalAgentCore, + state_dir: Optional[Any] = None, + codec: Optional[MemoryStateCodec] = None, + ): + self._default_dim = default_core.dim + self._default_seed = default_core.seed + self._default_route_threshold = default_core.route_threshold + self._cores: Dict[str, LocalAgentCore] = {DEFAULT_TENANT_ID: default_core} + self._versions: Dict[str, Tuple[int, int, int]] = {} + self._tenant_locks: Dict[str, threading.RLock] = {DEFAULT_TENANT_ID: threading.RLock()} + self._registry_lock = threading.RLock() + self._state_dir = Path(state_dir) if state_dir else None + self._codec = codec + if self._state_dir is not None: + self._state_dir.mkdir(parents=True, exist_ok=True) + if self._codec is not None: + self._migrate_and_rotate_all() + public_path = self._path_for(DEFAULT_TENANT_ID) + if public_path is not None and public_path.exists(): + with _process_file_lock(public_path): + self._cores[DEFAULT_TENANT_ID] = self._load_core(public_path, DEFAULT_TENANT_ID) + self._versions[DEFAULT_TENANT_ID] = self._version(public_path) + + def loaded_tenants(self) -> List[str]: + """Return tenant ids currently loaded in memory.""" + with self._registry_lock: + return sorted(self._cores) + + def summary(self, tenant_id: str) -> Dict[str, Any]: + """Return a cheap cached status summary without probing capabilities.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock_for(normalized): + core = self._get_cached(normalized) + return core.memory_summary() + + def read(self, tenant_id: str, fn: Any) -> Any: + """Run a read-style operation while holding the tenant lock.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock_for(normalized): + return fn(self._get_fresh(normalized)) + + def write(self, tenant_id: str, fn: Any) -> Any: + """Run a mutating operation, then persist that tenant if configured.""" + return self.mutate(tenant_id, lambda core: (fn(core), True)) + + def mutate(self, tenant_id: str, fn: Any) -> Any: + """Persist a callback result only when it reports that state changed. + + When the cached file version is current, mutate it in place and keep a + rollback snapshot. This avoids decrypting and rebuilding the same + tenant index before every single-process write while remaining safe if + another task changed the file or persistence fails. + """ + normalized = normalize_tenant_id(tenant_id) + with self._lock_for(normalized): + path = self._path_for(normalized) + if path is None: + core = self._get_cached(normalized) + result, _changed = fn(core) + return result + with _process_file_lock(path): + borrowed_cached = False + original_state: Optional[Dict[str, Any]] = None + current_version = self._version(path) if path.exists() else None + with self._registry_lock: + cached = self._cores.get(normalized) + cached_version = self._versions.get(normalized) + if cached is not None and current_version is not None and cached_version == current_version: + core = cached + borrowed_cached = True + original_state = core.to_state() + elif path.exists(): + core = self._load_core(path, normalized) + else: + core = LocalAgentCore.from_state(self._get_cached(normalized).to_state()) + try: + result, changed = fn(core) + if changed: + self._save_core(path, normalized, core) + except Exception: + if borrowed_cached and original_state is not None: + with self._registry_lock: + self._cores[normalized] = LocalAgentCore.from_state(original_state) + raise + with self._registry_lock: + self._cores[normalized] = core + if path.exists(): + self._versions[normalized] = self._version(path) + return result + + def _lock_for(self, tenant_id: str) -> threading.RLock: + with self._registry_lock: + lock = self._tenant_locks.get(tenant_id) + if lock is None: + lock = threading.RLock() + self._tenant_locks[tenant_id] = lock + return lock + + def _get_cached(self, tenant_id: str) -> LocalAgentCore: + with self._registry_lock: + core = self._cores.get(tenant_id) + if core is None: + core = LocalAgentCore( + dim=self._default_dim, + seed=self._default_seed, + route_threshold=self._default_route_threshold, + ) + self._cores[tenant_id] = core + return core + + def _get_fresh(self, tenant_id: str) -> LocalAgentCore: + path = self._path_for(tenant_id) + if path is not None and path.exists(): + version = self._version(path) + with self._registry_lock: + cached_version = self._versions.get(tenant_id) + if cached_version != version: + with _process_file_lock(path): + core = self._load_core(path, tenant_id) + version = self._version(path) + with self._registry_lock: + self._cores[tenant_id] = core + self._versions[tenant_id] = version + return core + return self._get_cached(tenant_id) + + @staticmethod + def _version(path: Path) -> Tuple[int, int, int]: + stat = path.stat() + return stat.st_ino, stat.st_mtime_ns, stat.st_size + + def _path_for(self, tenant_id: str) -> Optional[Path]: + if self._state_dir is None: + return None + return self._state_dir / ("%s.json" % normalize_tenant_id(tenant_id)) + + def _load_core(self, path: Path, tenant_id: str) -> LocalAgentCore: + if self._codec is None: + return LocalAgentCore.load(path) + state = self._codec.read_json(path, "core:%s" % normalize_tenant_id(tenant_id)) + return LocalAgentCore.from_state(state) + + def _save_core(self, path: Path, tenant_id: str, core: LocalAgentCore) -> None: + if self._codec is None: + core.save(path) + return + self._codec.write_json(path, core.to_state(), "core:%s" % normalize_tenant_id(tenant_id)) + + def _migrate_and_rotate_all(self) -> None: + """Rewrite every existing tenant file under the active authenticated key.""" + if self._state_dir is None or self._codec is None: + return + for path in sorted(self._state_dir.glob("*.json")): + if path.is_symlink() or not path.is_file(): + raise MemoryStateError("durable tenant state must be a regular file: %s" % path.name) + tenant_id = normalize_tenant_id(path.stem) + if tenant_id != path.stem: + raise MemoryStateError("durable tenant filename is not canonical: %s" % path.name) + with _process_file_lock(path): + self._codec.read_json(path, "core:%s" % tenant_id) + + +class NoSQLiteError(RuntimeError): + """Raised when the optional NoSQLite command process cannot serve a request.""" + + +class NoSQLiteProcess: + """Serialize JSON-line requests to one long-lived NoSQLite CLI process. + + NoSQLite's filesystem mode intentionally takes an exclusive writer lock for + the life of the process. The API therefore keeps exactly one child process + per application process and serializes its stdin/stdout protocol here. + """ + + def __init__( + self, + binary: str, + data_dir: Any, + durability: str = "sync", + timeout_seconds: float = 10.0, + ): + self._binary = str(binary) + self._data_dir = Path(data_dir) + self._durability = durability + self._timeout_seconds = float(timeout_seconds) + self._lock = threading.RLock() + self._process: Optional[Any] = None + self._stdout: Any = queue.Queue() + self._stderr: Any = queue.Queue() + self._generation = 0 + + @property + def generation(self) -> int: + """Return the number of successful NoSQLite process starts.""" + with self._lock: + return self._generation + + @property + def running(self) -> bool: + """Return whether the managed NoSQLite process is currently alive.""" + with self._lock: + return self._process is not None and self._process.poll() is None + + def ensure_started(self) -> int: + """Start the child lazily and return its generation number.""" + with self._lock: + if self._process is not None and self._process.poll() is None: + return self._generation + self._stop_process_unlocked() + command = [self._binary, "--data-dir", str(self._data_dir), "--durability", self._durability] + try: + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + except OSError as exc: + raise NoSQLiteError("could not start NoSQLite: %s" % exc) from exc + + self._process = process + self._stdout = queue.Queue() + self._stderr = queue.Queue() + self._start_reader(process.stdout, self._stdout) + self._start_reader(process.stderr, self._stderr) + try: + banner = self._read_line_unlocked("startup") + except NoSQLiteError: + self._stop_process_unlocked() + raise + if not banner.startswith("nosqlite ready;"): + self._stop_process_unlocked() + raise NoSQLiteError("unexpected NoSQLite startup response: %s" % banner.strip()) + self._generation += 1 + return self._generation + + def command(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Send one command and return the object response from NoSQLite.""" + with self._lock: + self.ensure_started() + process = self._process + if process is None or process.stdin is None: + raise NoSQLiteError("NoSQLite process has no writable stdin") + try: + process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + process.stdin.flush() + except OSError as exc: + self._stop_process_unlocked() + raise NoSQLiteError("failed to send a command to NoSQLite: %s" % exc) from exc + try: + line = self._read_line_unlocked("command") + except NoSQLiteError: + self._stop_process_unlocked() + raise + try: + response = json.loads(line) + except json.JSONDecodeError as exc: + raise NoSQLiteError("invalid NoSQLite response: %s" % line.strip()) from exc + if not isinstance(response, dict): + raise NoSQLiteError("NoSQLite response must be an object") + if response.get("ok") == "error": + raise NoSQLiteError(str(response.get("message") or "unknown NoSQLite error")) + return response + + def close(self) -> None: + """Release the child process and its filesystem writer lock.""" + with self._lock: + process = self._process + if process is None: + return + try: + if process.poll() is None and process.stdin is not None: + process.stdin.write('{"shutdown":1}\n') + process.stdin.flush() + self._read_line_unlocked("shutdown", timeout_seconds=2.0) + process.wait(timeout=2.0) + except (OSError, subprocess.TimeoutExpired, NoSQLiteError): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + process.kill() + finally: + self._clear_process_unlocked() + + def _start_reader(self, stream: Any, output: Any) -> None: + def read_lines() -> None: + if stream is None: + return + for line in stream: + output.put(line) + + threading.Thread(target=read_lines, daemon=True).start() + + def _read_line_unlocked(self, phase: str, timeout_seconds: Optional[float] = None) -> str: + timeout = self._timeout_seconds if timeout_seconds is None else timeout_seconds + deadline = time.monotonic() + timeout + while True: + process = self._process + if process is not None and process.poll() is not None: + break + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + return self._stdout.get(timeout=min(remaining, 0.1)) + except queue.Empty: + continue + process = self._process + state = "" + if process is not None and process.poll() is not None: + state = " (process exited with code %s)" % process.returncode + stderr = self._stderr_text_unlocked() + if stderr: + state += ": %s" % stderr + raise NoSQLiteError("NoSQLite %s timed out%s" % (phase, state)) + + def _stderr_text_unlocked(self) -> str: + lines = [] + while True: + try: + lines.append(self._stderr.get_nowait().strip()) + except queue.Empty: + break + return " ".join(line for line in lines if line)[:2000] + + def _clear_process_unlocked(self) -> None: + self._process = None + + def _stop_process_unlocked(self) -> None: + process = self._process + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + process.kill() + self._clear_process_unlocked() + + +class NoSQLiteMemoryStore: + """Tenant-isolated semantic memory backed by the pinned NoSQLite CLI.""" + + def __init__( + self, + binary: str, + data_dir: Any, + durability: str = "sync", + dimensions: int = NOSQLITE_DIMENSIONS, + ): + if durability not in {"sync", "buffered"}: + raise ValueError("NoSQLite durability must be 'sync' or 'buffered'") + self._dimensions = int(dimensions) + self._process = NoSQLiteProcess(binary, data_dir, durability=durability) + self._lock = threading.RLock() + self._encoder_generation: Optional[int] = None + self._ready_collections: set[str] = set() + self._synced_collections: set[Tuple[int, str]] = set() + + @property + def running(self) -> bool: + """Return whether the underlying NoSQLite process is currently alive.""" + return self._process.running + + def remember(self, tenant_id: str, memory: Dict[str, Any]) -> None: + """Persist one LocalAgentCore-compatible memory entry in its tenant collection.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock: + collection = self._ensure_collection(normalized) + self._insert_memory(collection, normalized, memory) + + def replace(self, tenant_id: str, memory: Dict[str, Any]) -> None: + """Idempotently replace one projected memory, including its embedding.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock: + collection = self._ensure_collection(normalized) + try: + self._delete_memory(collection, memory["id"]) + self._insert_memory(collection, normalized, memory) + except NoSQLiteError: + self._synced_collections.discard((self._process.generation, collection)) + raise + + def delete(self, tenant_id: str, memory_id: str) -> None: + """Idempotently delete one projected memory.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock: + collection = self._ensure_collection(normalized) + try: + self._delete_memory(collection, memory_id) + except NoSQLiteError: + self._synced_collections.discard((self._process.generation, collection)) + raise + + def sync(self, tenant_id: str, memories: Iterable[Dict[str, Any]]) -> None: + """Reconcile a tenant projection from its authoritative encrypted snapshot.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock: + collection = self._ensure_collection(normalized) + key = (self._process.generation, collection) + if key in self._synced_collections: + return + try: + self._process.command({"delete": collection}) + for memory in memories: + self._insert_memory(collection, normalized, memory) + except NoSQLiteError: + self._synced_collections.discard(key) + raise + else: + self._synced_collections.add(key) + + def recall( + self, + tenant_id: str, + query: str, + k: int, + abstain: Optional[float] = None, + ) -> List[Dict[str, Any]]: + """Return NoSQLite semantic hits in the LocalAgentCore response shape.""" + normalized = normalize_tenant_id(tenant_id) + with self._lock: + collection = self._ensure_collection(normalized) + response = self._process.command({ + "semanticSearch": collection, + "encoder": NOSQLITE_ENCODER, + "index": NOSQLITE_INDEX, + "text": query, + "k": k, + }) + documents = response.get("documents") + if not isinstance(documents, list): + raise NoSQLiteError("NoSQLite semantic search returned no documents array") + hits = [] + for document in documents: + if not isinstance(document, dict): + continue + score = document.get("_score") + if isinstance(score, bool) or not isinstance(score, (int, float)): + continue + if abstain is not None and float(score) < abstain: + continue + metadata = document.get("metadata") + label = document.get("label") + hits.append({ + "id": str(document.get("_id", "")), + "text": str(document.get("text", "")), + "label": label if isinstance(label, str) else None, + "metadata": dict(metadata) if isinstance(metadata, dict) else {}, + "score": float(score), + }) + return hits + + def close(self) -> None: + """Release the underlying NoSQLite process and writer lock.""" + self._process.close() + + def _ensure_collection(self, tenant_id: str) -> str: + generation = self._process.ensure_started() + if self._encoder_generation != generation: + self._ready_collections.clear() + self._synced_collections.clear() + self._ignore_duplicate({ + "createEncoder": NOSQLITE_ENCODER, + "provider": "holographic-hash-v1", + "kind": "text", + "dimensions": self._dimensions, + "seed": 0, + }) + self._encoder_generation = generation + collection = self._collection_name(tenant_id) + if collection not in self._ready_collections: + self._ignore_duplicate({"create": collection}) + self._ignore_duplicate({ + "createIndexes": collection, + "indexes": [{ + "neural": "embedding", + "dimensions": self._dimensions, + "name": NOSQLITE_INDEX, + }], + }) + self._ready_collections.add(collection) + return collection + + def _ignore_duplicate(self, command: Dict[str, Any]) -> None: + try: + self._process.command(command) + except NoSQLiteError as exc: + if "already exists" not in str(exc): + raise + + def _insert_memory(self, collection: str, tenant_id: str, memory: Dict[str, Any]) -> None: + document = { + "_id": str(memory["id"]), + "text": str(memory["text"]), + "label": memory.get("label"), + "metadata": dict(memory.get("metadata") or {}), + "tenant": tenant_id, + } + try: + self._process.command({ + "insert": collection, + "encode": {"encoder": NOSQLITE_ENCODER, "field": "text", "into": "embedding"}, + "documents": [document], + }) + except NoSQLiteError as exc: + if "duplicate value for `_id`" not in str(exc): + raise + + def _delete_memory(self, collection: str, memory_id: Any) -> None: + self._process.command({ + "delete": collection, + "filter": {"_id": str(memory_id)}, + }) + + @staticmethod + def _collection_name(tenant_id: str) -> str: + digest = hashlib.sha256(tenant_id.encode("utf-8")).hexdigest()[:24] + return "lecore_memory_%s" % digest + + +class MemoryTransactionError(RuntimeError): + """The durable memory write journal could not be read or completed safely.""" + + +class MemoryTransactionConflict(MemoryTransactionError): + """One idempotency key was reused for a different memory write.""" + + +class MemoryMirrorPending(NoSQLiteError): + """A durable core commit needs the same transaction projected to NoSQLite.""" + + def __init__(self, tenant_id: str, transaction_id: str, cause: NoSQLiteError): + super().__init__(str(cause)) + self.tenant_id = tenant_id + self.transaction_id = transaction_id + + +class TenantMemoryTransactions: + """Durable, idempotent memory writes spanning LocalAgentCore and NoSQLite. + + A query-layer transaction can roll in-memory tables back. This API crosses a + durable JSON core and an external NoSQLite process, so it instead records an + intent first, commits the core entry with a stable id, then projects that + entry to NoSQLite. If the process dies between steps, the journal replays the + same idempotent projection on the next request or app start. + """ + + _VERSION = 1 + _PLANNED = "planned" + _CORE_COMMITTED = "core_committed" + _COMPLETE = "complete" + _DELETED = "deleted" + + def __init__( + self, + core_store: TenantCoreStore, + state_dir: Any, + codec: Optional[MemoryStateCodec] = None, + ): + self._core_store = core_store + self._root = Path(state_dir) / ".x402-memory-transactions" + self._codec = codec + self._root.mkdir(parents=True, exist_ok=True) + + def remember( + self, + tenant_id: str, + text: str, + label: Optional[str], + metadata: Optional[Dict[str, Any]], + idempotency_key: Optional[str], + mirror: Optional[NoSQLiteMemoryStore], + ) -> Dict[str, Any]: + """Commit one memory and return its stable transaction status. + + Supplying the same `idempotency_key` with the same request returns the + original memory id. Reusing that key for a different request is refused. + """ + tenant = normalize_tenant_id(tenant_id) + key = normalize_idempotency_key(idempotency_key) + request = { + "tenant": tenant, + "text": str(text), + "label": label, + "metadata": dict(metadata or {}), + } + transaction_id = self._transaction_id(tenant, key) + path = self._path_for(tenant, transaction_id) + with _process_file_lock(path): + record = self._load_or_create(path, transaction_id, request, key, mirror is not None) + return self._apply_locked(path, record, mirror) + + def resume( + self, + tenant_id: str, + transaction_id: str, + mirror: Optional[NoSQLiteMemoryStore], + ) -> Dict[str, Any]: + """Resume a known journal record without minting a second transaction.""" + tenant = normalize_tenant_id(tenant_id) + if not re.fullmatch(r"[0-9a-f]{64}", transaction_id): + raise MemoryTransactionError("invalid memory transaction id") + path = self._path_for(tenant, transaction_id) + with _process_file_lock(path): + record = self._load(path) + self._validate_record(record, path) + if record["tenant"] != tenant or record["transaction_id"] != transaction_id: + raise MemoryTransactionError("memory transaction does not match its tenant") + return self._apply_locked(path, record, mirror) + + def recover_pending(self, mirror: Optional[NoSQLiteMemoryStore]) -> Dict[str, int]: + """Replay incomplete durable writes, leaving unavailable mirrors pending.""" + recovered = 0 + pending = 0 + invalid = 0 + for path in sorted(self._root.glob("*/*.json")): + with _process_file_lock(path): + try: + record = self._load(path) + if record.get("state") in {self._COMPLETE, self._DELETED}: + continue + result = self._apply_locked(path, record, mirror) + if result["transaction"]["state"] == self._COMPLETE: + recovered += 1 + else: + pending += 1 + except NoSQLiteError as exc: + pending += 1 + LOG.warning("NoSQLite transaction recovery remains pending: %s", exc) + except MemoryTransactionError as exc: + invalid += 1 + LOG.error("could not recover memory transaction %s: %s", path.name, exc) + return {"recovered": recovered, "pending": pending, "invalid": invalid} + + def _apply_locked( + self, + path: Path, + record: Dict[str, Any], + mirror: Optional[NoSQLiteMemoryStore], + ) -> Dict[str, Any]: + self._validate_record(record, path) + memory = dict(record["memory"]) + if record["state"] == self._DELETED: + return self._result(record, memory) + stored = self._core_store.write( + record["tenant"], + lambda core: self._ensure_core_memory(core, memory), + ) + if record["state"] == self._PLANNED: + record["state"] = self._CORE_COMMITTED + self._write(path, record) + + if record["requires_mirror"]: + if mirror is None: + return self._result(record, stored) + try: + mirror.remember(record["tenant"], stored) + except NoSQLiteError as exc: + raise MemoryMirrorPending(record["tenant"], record["transaction_id"], exc) from exc + + if record["state"] != self._COMPLETE: + record["state"] = self._COMPLETE + self._write(path, record) + return self._result(record, stored) + + def mark_deleted(self, tenant_id: str, memory_id: str) -> bool: + """Tombstone the originating idempotency record before deleting memory.""" + tenant = normalize_tenant_id(tenant_id) + wanted = normalize_memory_id(memory_id) + if not wanted.startswith("tx_"): + return False + prefix = wanted[3:] + directory = self._root / self._hash(tenant)[:24] + for path in sorted(directory.glob(prefix + "*.json")): + with _process_file_lock(path): + record = self._load(path) + self._validate_record(record, path) + if record["tenant"] != tenant or record["memory"].get("id") != wanted: + continue + if record["state"] != self._DELETED: + record["state"] = self._DELETED + self._write(path, record) + return True + return False + + @staticmethod + def _ensure_core_memory(core: LocalAgentCore, memory: Dict[str, Any]) -> Dict[str, Any]: + for entry in core.entries: + if entry.id != memory["id"]: + continue + stored = entry.to_dict() + if stored != memory: + raise MemoryTransactionConflict("memory id %s already holds different content" % memory["id"]) + return stored + return core.remember( + memory["text"], + label=memory.get("label"), + metadata=memory.get("metadata"), + id=memory["id"], + ) + + def _load_or_create( + self, + path: Path, + transaction_id: str, + request: Dict[str, Any], + key: Optional[str], + requires_mirror: bool, + ) -> Dict[str, Any]: + if path.exists(): + record = self._load(path) + self._validate_record(record, path) + if record["request_fingerprint"] != self._fingerprint(request): + raise MemoryTransactionConflict("Idempotency-Key was already used for a different memory write") + return record + record = { + "version": self._VERSION, + "transaction_id": transaction_id, + "tenant": request["tenant"], + "request_fingerprint": self._fingerprint(request), + "idempotency_key_hash": self._hash(key) if key is not None else None, + "requires_mirror": bool(requires_mirror), + "state": self._PLANNED, + "memory": { + "id": "tx_%s" % transaction_id[:32], + "text": request["text"], + "label": request["label"], + "metadata": request["metadata"], + }, + } + self._write(path, record) + return record + + def _load(self, path: Path) -> Dict[str, Any]: + if self._codec is not None: + return self._codec.read_json(path, self._encryption_context(path)) + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MemoryTransactionError("invalid transaction journal %s" % path.name) from exc + if not isinstance(value, dict): + raise MemoryTransactionError("transaction journal %s is not an object" % path.name) + return value + + def _write(self, path: Path, record: Dict[str, Any]) -> None: + if self._codec is None: + _atomic_write_json(path, record) + return + self._codec.write_json(path, record, self._encryption_context(path)) + + def _encryption_context(self, path: Path) -> str: + try: + relative = path.relative_to(self._root) + except ValueError as exc: # pragma: no cover - paths are constructed internally + raise MemoryStateError("transaction journal escaped its state directory") from exc + if len(relative.parts) != 2: + raise MemoryStateError("invalid transaction journal path") + return "journal:%s/%s" % (relative.parts[0], relative.parts[1]) + + def _validate_record(self, record: Dict[str, Any], path: Path) -> None: + required = {"version", "transaction_id", "tenant", "request_fingerprint", "requires_mirror", "state", "memory"} + if not required.issubset(record) or record.get("version") != self._VERSION: + raise MemoryTransactionError("unsupported transaction journal %s" % path.name) + if record["state"] not in {self._PLANNED, self._CORE_COMMITTED, self._COMPLETE, self._DELETED}: + raise MemoryTransactionError("unknown transaction state in %s" % path.name) + memory = record["memory"] + if not isinstance(memory, dict) or set(memory) != {"id", "text", "label", "metadata"}: + raise MemoryTransactionError("invalid memory transaction payload in %s" % path.name) + if not isinstance(memory["id"], str) or not isinstance(memory["text"], str): + raise MemoryTransactionError("invalid memory transaction value in %s" % path.name) + if memory["label"] is not None and not isinstance(memory["label"], str): + raise MemoryTransactionError("invalid memory transaction label in %s" % path.name) + if not isinstance(memory["metadata"], dict): + raise MemoryTransactionError("invalid memory transaction metadata in %s" % path.name) + if normalize_tenant_id(record["tenant"]) != record["tenant"]: + raise MemoryTransactionError("invalid transaction tenant in %s" % path.name) + + def _path_for(self, tenant_id: str, transaction_id: str) -> Path: + tenant_digest = self._hash(tenant_id)[:24] + return self._root / tenant_digest / (transaction_id + ".json") + + @staticmethod + def _hash(value: Optional[str]) -> str: + return hashlib.sha256((value or "").encode("utf-8")).hexdigest() + + def _transaction_id(self, tenant_id: str, key: Optional[str]) -> str: + material = key if key is not None else os.urandom(32).hex() + return self._hash("%s\0%s" % (tenant_id, material)) + + @classmethod + def _fingerprint(cls, value: Dict[str, Any]) -> str: + return cls._hash(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)) + + @staticmethod + def _result(record: Dict[str, Any], memory: Dict[str, Any]) -> Dict[str, Any]: + return { + "memory": memory, + "transaction": { + "id": record["transaction_id"], + "state": record["state"], + "idempotent": record.get("idempotency_key_hash") is not None, + }, + } + + +def pricing_summary(config: X402Config) -> Dict[str, Any]: + """Describe the customer-facing price and whether it is a production charge.""" + per_thousand = _price_amount(config.price) * Decimal("1000") + per_thousand_display = "$%s" % per_thousand.quantize(Decimal("0.01")) + testnet = config.network in TESTNET_NETWORKS + environment = "testnet_preview" if testnet else "production" + payment_asset = "testnet USDC" if testnet else "USDC" + payment_notice = ( + "This Base Sepolia developer preview uses testnet USDC and does not accept production payments." + if testnet + else "Payments settle in USDC through x402." + ) + return { + "environment": environment, + "environment_label": "Testnet developer preview" if testnet else "Production API", + "payment_asset": payment_asset, + "per_request": config.price, + "per_1000_requests": per_thousand_display, + "display_price": "%s per 1,000 requests" % per_thousand_display, + "payment_notice": payment_notice, + } + + +def _required_text(payload: Dict[str, Any], key: str, maximum: int) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string" % key) + if len(value) > maximum: + raise ValueError("%s must be at most %d characters" % (key, maximum)) + return value + + +def _recall_k(payload: Dict[str, Any]) -> int: + value = payload.get("k", 3) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("k must be an integer") + if not 1 <= value <= MAX_RECALL_K: + raise ValueError("k must be between 1 and %d" % MAX_RECALL_K) + return value + + +def _abstain_threshold(payload: Dict[str, Any]) -> Optional[float]: + value = payload.get("abstain") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("abstain must be a number between 0 and 1") + threshold = float(value) + if not 0.0 <= threshold <= 1.0: + raise ValueError("abstain must be between 0 and 1") + return threshold + + +def normalize_memory_backend(value: Any) -> str: + """Validate the memory backend selector without accepting silent fallbacks.""" + if not isinstance(value, str): + raise ValueError("memory backend must be a string") + backend = value.strip().lower() or MEMORY_BACKEND_CORE + if backend not in {MEMORY_BACKEND_CORE, MEMORY_BACKEND_NOSQLITE}: + raise ValueError("memory backend must be 'core' or 'nosqlite'") + return backend + + +def env_flag(value: Optional[str]) -> bool: + """Parse the small explicit boolean surface used by deployment settings.""" + return (value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def memory_state_codec( + value: Optional[Any], + *, + allow_plaintext_migration: bool = False, +) -> Optional[MemoryStateCodec]: + """Resolve optional versioned encryption material without a silent fallback.""" + if value is None or value == "": + if allow_plaintext_migration: + raise ValueError("plaintext migration requires %s" % MEMORY_KEY_ENV) + return None + if isinstance(value, MemoryKeyring): + keyring = value + elif isinstance(value, str): + keyring = MemoryKeyring.from_json(value) + else: + raise ValueError("memory keys must be a MemoryKeyring or JSON string") + return MemoryStateCodec(keyring, allow_plaintext_migration=allow_plaintext_migration) + + +def landing_page_html(config: X402Config) -> str: + """Render the buyer-facing landing page served from `/`.""" + network_name = _network_name(config.network) + summary = pricing_summary(config) + return LANDING_PAGE_TEMPLATE.substitute( + service_name=escape(SERVICE_NAME), + hero_title=escape(HERO_TITLE), + api_version=escape(LECORE_VERSION), + buyer_guide_url=escape(X402_BUYER_GUIDE_URL), + nodes=_landing_nodes(), + public_url=escape(config.public_url), + network_label=escape("%s x402" % network_name), + network_name=escape(network_name), + environment_label=escape(summary["environment_label"]), + payment_notice=escape(summary["payment_notice"]), + price_per_request=escape("%s per request" % summary["per_request"]), + price_per_thousand=escape(summary["display_price"]), + ) + + +def documentation_manifest(config: X402Config) -> Dict[str, str]: + """Return canonical public documentation URLs for discovery responses.""" + return { + "swagger_ui": config.public_url + "/docs", + "reference": config.public_url + "/redoc", + "openapi_schema": config.public_url + "/openapi.json", + } + + +def public_dashboard(data: Dict[str, Any]) -> Dict[str, Any]: + """Translate the embedded SDK dashboard into the hosted API vocabulary.""" + out = dict(data) + out["name"] = SERVICE_NAME + checks = dict(out.get("checks") or {}) + if "local_only" in checks: + checks["self_contained_engine"] = bool(checks.pop("local_only")) + out["checks"] = checks + return out + + +def payment_manifest(config: X402Config) -> List[Dict[str, Any]]: + """Plain JSON route manifest, useful for docs, `/pricing`, and tests.""" + out = [] + for route in config.routes: + price = route.price or config.price + row = { + "route": route.key, + "description": route.description, + "mime_type": route.mime_type, + "accepts": [{ + "scheme": config.scheme, + "price": price, + "network": config.network, + "pay_to": config.pay_to, + }], + } + out.append(row) + return out + + +def x402_route_configs(config: X402Config) -> Dict[str, Any]: + """Build x402 SDK RouteConfig objects for the protected routes.""" + try: + from x402.http import PaymentOption + from x402.http.types import RouteConfig + except ImportError as exc: + raise RuntimeError(optional_dependency_help()) from exc + + routes = {} + for route in config.routes: + routes[route.key] = RouteConfig( + accepts=[ + PaymentOption( + scheme=config.scheme, + pay_to=config.pay_to, + price=route.price or config.price, + network=config.network, + ) + ], + resource=config.public_url + route.path, + mime_type=route.mime_type, + description=route.description, + ) + return routes + + +def x402_resource_server(config: X402Config) -> Any: + """Create an x402 resource server wired to the configured facilitator.""" + try: + from x402.http import FacilitatorConfig, HTTPFacilitatorClient + from x402.mechanisms.evm.exact import ExactEvmServerScheme + from x402.server import x402ResourceServer + except ImportError as exc: + raise RuntimeError(optional_dependency_help()) from exc + + facilitator = HTTPFacilitatorClient(FacilitatorConfig(url=config.facilitator_url)) + server = x402ResourceServer(facilitator) + server.register(config.network, ExactEvmServerScheme()) + return server + + +def create_app( + core: Optional[LocalAgentCore] = None, + config: Optional[X402Config] = None, + paid: bool = True, + admin_token: Optional[str] = None, + tenant_secret: Optional[str] = None, + tenant_state_dir: Optional[Any] = None, + memory_keys: Optional[Any] = None, + allow_plaintext_migration: Optional[bool] = None, + memory_backend: Optional[str] = None, + nosqlite_binary: Optional[str] = None, + nosqlite_data_dir: Optional[Any] = None, + nosqlite_durability: Optional[str] = None, + nosqlite_shadow: Optional[bool] = None, +) -> Any: + """Create the FastAPI application for paid or unpaid development serving. + + With `paid=True`, the public `/v1/*` read/compute routes are protected by + x402 middleware. Set `paid=False` only for an unpaid development smoke test. + + x402 proves that a request paid. Private tenant memory is intentionally a + separate authorization layer using `X-leCore-Tenant-Token`. + """ + try: + from fastapi import FastAPI, Header, HTTPException, Query + from fastapi.responses import HTMLResponse + except ImportError as exc: + raise RuntimeError(optional_dependency_help()) from exc + + config = config or (X402Config.from_env(require_pay_to=paid) if paid else X402Config.from_env(require_pay_to=False)) + public = urlsplit(config.public_url) + if paid and public.scheme != "https" and public.hostname not in {"127.0.0.1", "::1", "localhost"}: + raise ValueError("paid mode public_url must use https outside localhost") + + allow_plaintext_migration = ( + bool(allow_plaintext_migration) + if allow_plaintext_migration is not None + else env_flag(os.environ.get(MEMORY_MIGRATION_ENV)) + ) + codec = memory_state_codec( + memory_keys if memory_keys is not None else os.environ.get(MEMORY_KEY_ENV), + allow_plaintext_migration=allow_plaintext_migration, + ) + durable_write_published = any(route.path == "/v1/memory" for route in config.routes) + if paid and durable_write_published and not tenant_state_dir: + raise ValueError("paid /v1/memory requires LECORE_X402_TENANT_STATE_DIR") + if paid and tenant_state_dir and codec is None: + raise ValueError("paid durable memory requires %s" % MEMORY_KEY_ENV) + + core = core or demo() + store = TenantCoreStore(core, state_dir=tenant_state_dir, codec=codec) + memory_backend = normalize_memory_backend( + memory_backend if memory_backend is not None else os.environ.get("LECORE_X402_MEMORY_BACKEND", MEMORY_BACKEND_CORE) + ) + nosqlite_shadow = ( + bool(nosqlite_shadow) + if nosqlite_shadow is not None + else env_flag(os.environ.get("LECORE_X402_NOSQLITE_SHADOW")) + ) + if codec is not None and (memory_backend == MEMORY_BACKEND_NOSQLITE or nosqlite_shadow): + raise ValueError("encrypted memory does not permit the plaintext NoSQLite backend or shadow") + nosqlite_store: Optional[NoSQLiteMemoryStore] = None + if memory_backend == MEMORY_BACKEND_NOSQLITE or nosqlite_shadow: + if not tenant_state_dir: + raise ValueError("LECORE_X402_TENANT_STATE_DIR is required when NoSQLite is enabled") + data_dir = nosqlite_data_dir or os.environ.get("LECORE_X402_NOSQLITE_DATA_DIR") + if not data_dir: + raise ValueError("LECORE_X402_NOSQLITE_DATA_DIR is required when NoSQLite is enabled") + nosqlite_store = NoSQLiteMemoryStore( + nosqlite_binary or os.environ.get("LECORE_X402_NOSQLITE_BIN", "nosqlite"), + data_dir, + durability=nosqlite_durability or os.environ.get("LECORE_X402_NOSQLITE_DURABILITY", "sync"), + ) + memory_transactions = TenantMemoryTransactions(store, tenant_state_dir, codec=codec) if tenant_state_dir else None + + @asynccontextmanager + async def lifespan(_: Any) -> Any: + try: + if memory_transactions is not None: + recovery = memory_transactions.recover_pending(nosqlite_store) + if recovery["recovered"] or recovery["pending"] or recovery["invalid"]: + LOG.info("memory transaction recovery: %s", recovery) + yield + finally: + if nosqlite_store is not None: + nosqlite_store.close() + + app = FastAPI( + title=SERVICE_NAME, + description=API_DESCRIPTION, + version=LECORE_VERSION, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + openapi_tags=OPENAPI_TAGS, + servers=[{"url": config.public_url, "description": "Public API"}], + openapi_external_docs={ + "description": "x402 buyer quickstart", + "url": X402_BUYER_GUIDE_URL, + }, + lifespan=lifespan, + ) + app.state.memory_backend = memory_backend + app.state.nosqlite_shadow = nosqlite_shadow + app.state.nosqlite_store = nosqlite_store + app.state.memory_transactions = memory_transactions + app.state.memory_state_codec = codec + tenant_secret = tenant_secret or os.environ.get("LECORE_X402_TENANT_SECRET") + + if paid: + try: + from x402.http.middleware.fastapi import PaymentMiddlewareASGI + except ImportError as exc: + raise RuntimeError(optional_dependency_help()) from exc + app.add_middleware( + PaymentMiddlewareASGI, + routes=x402_route_configs(config), + server=x402_resource_server(config), + ) + + @app.middleware("http") + async def apply_public_response_policy(request: Any, call_next: Any) -> Any: + response = await call_next(request) + for name, value in public_response_headers( + request.url.path, + response.status_code, + config.public_url, + response.headers.get("content-type", ""), + config.network, + ).items(): + response.headers[name] = value + return response + + def require_admin(header_value: Optional[str]) -> None: + if not admin_token: + raise HTTPException(status_code=403, detail="admin writes are disabled") + if not header_value or not hmac.compare_digest(header_value, admin_token): + raise HTTPException(status_code=401, detail="invalid admin token") + + def require_tenant_access(tenant_id: str, token: Optional[str]) -> None: + normalized = normalize_tenant_id(tenant_id) + if normalized == DEFAULT_TENANT_ID: + return + if not tenant_secret: + raise HTTPException(status_code=403, detail="private tenants require LECORE_X402_TENANT_SECRET") + expected = tenant_access_token(normalized, tenant_secret) + if not token or not hmac.compare_digest(token, expected): + raise HTTPException(status_code=401, detail="invalid tenant token") + + def tenant_from_header(header_value: Optional[str]) -> str: + try: + return normalize_tenant_id(header_value) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def tenant_from_payload(payload: Dict[str, Any], header_value: Optional[str]) -> str: + try: + payload_value = payload.get("tenant") + payload_tenant = normalize_tenant_id(payload_value) if payload_value is not None else None + header_tenant = normalize_tenant_id(header_value) if header_value is not None else None + if payload_tenant is not None and header_tenant is not None and payload_tenant != header_tenant: + raise ValueError("tenant id in payload does not match %s" % TENANT_HEADER) + return payload_tenant or header_tenant or DEFAULT_TENANT_ID + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def validated(callable_: Any, *args: Any) -> Any: + try: + return callable_(*args) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def tenancy_public_dict() -> Dict[str, Any]: + return { + "default_tenant": DEFAULT_TENANT_ID, + "tenant_header": TENANT_HEADER, + "tenant_token_header": TENANT_TOKEN_HEADER, + "private_tenants_enabled": bool(tenant_secret), + } + + def memory_public_dict() -> Dict[str, Any]: + return { + "backend": memory_backend, + "nosqlite_shadow": bool(nosqlite_shadow), + "nosqlite_configured": nosqlite_store is not None, + "durable_transactions": memory_transactions is not None, + "storage": { + "durable": bool(tenant_state_dir), + "encrypted": codec is not None, + "cipher": MEMORY_CIPHER if codec is not None else None, + "compression": MEMORY_COMPRESSION if codec is not None else None, + "plaintext_migration_enabled": bool(codec and codec.allow_plaintext_migration), + }, + } + + def nosqlite_unavailable(error: NoSQLiteError) -> HTTPException: + LOG.warning("NoSQLite memory backend is unavailable: %s", error) + return HTTPException(status_code=503, detail="NoSQLite memory backend is unavailable") + + def sync_nosqlite_tenant(tenant_id: str) -> None: + if nosqlite_store is None: + return + memories = store.read(tenant_id, lambda tenant_core: [entry.to_dict() for entry in tenant_core.entries]) + nosqlite_store.sync(tenant_id, memories) + + def shadow_recall(tenant_id: str, query: str, k: int, abstain: Optional[float], core_hits: List[Dict[str, Any]]) -> None: + if nosqlite_store is None: + return + try: + sync_nosqlite_tenant(tenant_id) + shadow_hits = nosqlite_store.recall(tenant_id, query, k=k, abstain=abstain) + except NoSQLiteError as exc: + LOG.warning("NoSQLite shadow recall failed: %s", exc) + return + if [hit.get("id") for hit in core_hits] != [hit.get("id") for hit in shadow_hits]: + LOG.info("NoSQLite shadow recall differs from LocalAgentCore") + + @app.get("/", response_class=HTMLResponse, include_in_schema=False) + def landing() -> str: + return landing_page_html(config) + + @app.get( + "/health", + tags=["Discovery"], + operation_id="getHealth", + summary="Check service health", + description="Free liveness, memory-state, backend, and tenancy summary. No x402 payment is required.", + responses={ + 200: health_success_openapi( + paid=bool(paid), + private_tenants_enabled=bool(tenant_secret), + memory_backend=memory_backend, + nosqlite_shadow=bool(nosqlite_shadow), + nosqlite_configured=nosqlite_store is not None, + durable_transactions=memory_transactions is not None, + encrypted_storage=codec is not None, + plaintext_migration_enabled=bool(codec and codec.allow_plaintext_migration), + ), + }, + ) + def health() -> Dict[str, Any]: + return { + "ok": True, + "name": SERVICE_NAME, + "paid": bool(paid), + "memory": store.summary(DEFAULT_TENANT_ID), + "memory_backend": memory_public_dict(), + "tenancy": { + "default_tenant": DEFAULT_TENANT_ID, + "loaded_tenants": len(store.loaded_tenants()), + "private_tenants_enabled": bool(tenant_secret), + }, + } + + @app.get( + "/pricing", + tags=["Discovery"], + operation_id="getPricing", + summary="Discover pricing and protected routes", + description=( + "Free discovery document for the x402 network, payment asset, price, " + "tenant headers, documentation URLs, and protected-route manifest." + ), + responses={ + 200: pricing_success_openapi( + config, + private_tenants_enabled=bool(tenant_secret), + memory_backend=memory_backend, + nosqlite_shadow=bool(nosqlite_shadow), + nosqlite_configured=nosqlite_store is not None, + durable_transactions=memory_transactions is not None, + encrypted_storage=codec is not None, + plaintext_migration_enabled=bool(codec and codec.allow_plaintext_migration), + ), + }, + ) + def pricing() -> Dict[str, Any]: + return { + "ok": True, + "documentation": documentation_manifest(config), + "x402": config.to_public_dict(), + "pricing": pricing_summary(config), + "tenancy": tenancy_public_dict(), + "memory_backend": memory_public_dict(), + "routes": payment_manifest(config), + } + + def memory_fields(payload: Dict[str, Any]) -> Tuple[str, Optional[str], Optional[Dict[str, Any]]]: + text = validated(_required_text, payload, "text", MAX_MEMORY_CHARS) + label = payload.get("label") + metadata = payload.get("metadata") + if label is not None: + if not isinstance(label, str): + raise HTTPException(status_code=400, detail="label must be a string") + if len(label) > MAX_MEMORY_LABEL_CHARS: + raise HTTPException( + status_code=400, + detail="label must be at most %d characters" % MAX_MEMORY_LABEL_CHARS, + ) + if metadata is not None: + if not isinstance(metadata, dict): + raise HTTPException(status_code=400, detail="metadata must be an object") + encoded_metadata = json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8") + if len(encoded_metadata) > MAX_MEMORY_METADATA_BYTES: + raise HTTPException( + status_code=400, + detail="metadata must be at most %d encoded bytes" % MAX_MEMORY_METADATA_BYTES, + ) + return text, label, metadata + + def memory_update_fields(payload: Dict[str, Any]) -> Dict[str, Any]: + allowed = {"text", "label", "metadata"} + unknown = sorted(set(payload) - allowed) + if unknown: + raise HTTPException(status_code=400, detail="unknown memory update field: %s" % unknown[0]) + updates = {key: payload[key] for key in allowed if key in payload} + if not updates: + raise HTTPException(status_code=400, detail="at least one of text, label, or metadata is required") + if "text" in updates: + updates["text"] = validated(_required_text, updates, "text", MAX_MEMORY_CHARS) + if "label" in updates: + label = updates["label"] + if label is not None and not isinstance(label, str): + raise HTTPException(status_code=400, detail="label must be a string or null") + if isinstance(label, str) and len(label) > MAX_MEMORY_LABEL_CHARS: + raise HTTPException( + status_code=400, + detail="label must be at most %d characters" % MAX_MEMORY_LABEL_CHARS, + ) + if "metadata" in updates: + metadata = updates["metadata"] + if not isinstance(metadata, dict): + raise HTTPException(status_code=400, detail="metadata must be an object") + encoded_metadata = json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8") + if len(encoded_metadata) > MAX_MEMORY_METADATA_BYTES: + raise HTTPException( + status_code=400, + detail="metadata must be at most %d encoded bytes" % MAX_MEMORY_METADATA_BYTES, + ) + return updates + + def commit_memory( + tenant_id: str, + text: str, + label: Optional[str], + metadata: Optional[Dict[str, Any]], + key: Optional[str], + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + transaction = None + if memory_transactions is not None: + try: + committed = memory_transactions.remember( + tenant_id, + text, + label, + metadata, + key, + nosqlite_store, + ) + except MemoryTransactionConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except MemoryTransactionError as exc: + raise HTTPException(status_code=500, detail="memory transaction could not be completed") from exc + except NoSQLiteError as exc: + if memory_backend == MEMORY_BACKEND_NOSQLITE: + raise nosqlite_unavailable(exc) from exc + LOG.warning("NoSQLite shadow write failed: %s", exc) + if not isinstance(exc, MemoryMirrorPending): # pragma: no cover - mirror errors are wrapped above + raise nosqlite_unavailable(exc) from exc + committed = memory_transactions.resume(exc.tenant_id, exc.transaction_id, None) + if committed["transaction"]["state"] == "deleted": + raise HTTPException( + status_code=409, + detail="this idempotent memory was deleted; use a new Idempotency-Key", + ) + return committed["memory"], committed["transaction"] + if key is not None: + raise HTTPException( + status_code=400, + detail="Idempotency-Key requires LECORE_X402_TENANT_STATE_DIR for durable retries", + ) + memory = store.write( + tenant_id, + lambda tenant_core: tenant_core.remember(text, label=label, metadata=metadata), + ) + if nosqlite_store is not None: # pragma: no cover - NoSQLite requires durable tenant state + try: + nosqlite_store.remember(tenant_id, memory) + except NoSQLiteError as exc: + if memory_backend == MEMORY_BACKEND_NOSQLITE: + raise nosqlite_unavailable(exc) from exc + LOG.warning("NoSQLite shadow write failed: %s", exc) + return memory, transaction + + def recall_response( + payload: Dict[str, Any], + x_lecore_tenant: Optional[str], + x_lecore_tenant_token: Optional[str], + ) -> Dict[str, Any]: + tenant_id = tenant_from_payload(payload, x_lecore_tenant) + require_tenant_access(tenant_id, x_lecore_tenant_token) + query = validated(_required_text, payload, "query", MAX_QUERY_CHARS) + k = validated(_recall_k, payload) + abstain = validated(_abstain_threshold, payload) + if memory_backend == MEMORY_BACKEND_NOSQLITE: + if nosqlite_store is None: # pragma: no cover - guarded during app setup + raise HTTPException(status_code=503, detail="NoSQLite memory backend is not configured") + try: + sync_nosqlite_tenant(tenant_id) + hits = nosqlite_store.recall(tenant_id, query, k=k, abstain=abstain) + except NoSQLiteError as exc: + raise nosqlite_unavailable(exc) from exc + else: + hits = store.read( + tenant_id, + lambda tenant_core: tenant_core.recall(query, k=k, abstain=abstain), + ) + if nosqlite_shadow: + shadow_recall(tenant_id, query, k, abstain, hits) + return { + "ok": True, + "tenant": tenant_id, + "query": query, + "hits": hits, + } + + @app.post( + "/v1/memory", + tags=["Paid API"], + operation_id="storeMemory", + summary="Store private agent memory", + description=( + "Durably store one entry in an authenticated private tenant. The write is " + "compressed, encrypted at the application boundary, and made idempotent by " + "the required Idempotency-Key. Shared public memory is read-only." + ), + responses=paid_operation_responses( + memory_write_success_openapi(), + invalid_detail="text and Idempotency-Key are required", + idempotency_conflict=True, + ), + openapi_extra=paid_request_openapi( + required=["text"], + properties={ + "text": { + "type": "string", + "minLength": 1, + "maxLength": MAX_MEMORY_CHARS, + "pattern": r"\S", + "description": "Memory content to store in the selected private tenant.", + }, + "label": { + "type": "string", + "maxLength": MAX_MEMORY_LABEL_CHARS, + "description": "Optional caller-defined category.", + }, + "metadata": { + "type": "object", + "additionalProperties": True, + "description": "Optional JSON metadata, limited to 16 KiB when encoded.", + }, + "tenant": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Private tenant id; may instead be supplied in X-leCore-Tenant.", + }, + }, + example={ + "tenant": "acme", + "text": "The customer prefers concise release notes.", + "label": "preference", + "metadata": {"source": "agent-session"}, + }, + example_summary="Store an idempotent private-tenant memory", + ), + ) + def store_memory( + payload: Dict[str, Any], + idempotency_key: str = Header( + ..., + alias=IDEMPOTENCY_HEADER, + description="Required stable retry key. Reuse it only for the identical memory write.", + ), + x_lecore_tenant: Optional[str] = Header( + default=None, + alias=TENANT_HEADER, + description="Private tenant id, if it is not supplied in the JSON body.", + ), + x_lecore_tenant_token: Optional[str] = Header( + default=None, + alias=TENANT_TOKEN_HEADER, + description="Required authorization token for the resolved private tenant.", + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + tenant_id = tenant_from_payload(payload, x_lecore_tenant) + if tenant_id == DEFAULT_TENANT_ID: + raise HTTPException(status_code=403, detail="shared public memory is read-only") + require_tenant_access(tenant_id, x_lecore_tenant_token) + if memory_transactions is None or codec is None: + raise HTTPException(status_code=503, detail="encrypted durable memory is not configured") + key = validated(normalize_idempotency_key, idempotency_key) + if key is None: # pragma: no cover - FastAPI marks the header required + raise HTTPException(status_code=400, detail="Idempotency-Key is required") + text, label, metadata = memory_fields(payload) + memory, transaction = commit_memory(tenant_id, text, label, metadata, key) + return { + "ok": True, + "tenant": tenant_id, + "memory": memory, + "transaction": transaction, + } + + @app.get( + "/v1/memory", + tags=["Paid API"], + operation_id="getMemory", + summary="List or retrieve private agent memory", + description=( + "Return a bounded insertion-ordered page for an authenticated private tenant. " + "Pass memory_id for one exact record, or cursor and limit for pagination." + ), + responses={ + **paid_operation_responses( + memory_list_success_openapi(), + invalid_detail="memory_id, cursor, or limit is invalid", + ), + 404: _error_response("The requested memory does not exist in this tenant.", "memory not found"), + }, + ) + def get_memory( + memory_id: Optional[str] = Query( + default=None, + description="Return this exact memory id instead of a page.", + ), + limit: int = Query( + default=50, + ge=1, + le=100, + description="Maximum memories in a page.", + ), + cursor: Optional[str] = Query( + default=None, + description="Last memory id from the previous page.", + ), + x_lecore_tenant: str = Header( + ..., + alias=TENANT_HEADER, + description="Required private tenant id.", + ), + x_lecore_tenant_token: str = Header( + ..., + alias=TENANT_TOKEN_HEADER, + description="Required authorization token for the private tenant.", + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + tenant_id = tenant_from_header(x_lecore_tenant) + if tenant_id == DEFAULT_TENANT_ID: + raise HTTPException(status_code=403, detail="consumer memory access requires a private tenant") + require_tenant_access(tenant_id, x_lecore_tenant_token) + if codec is None or memory_transactions is None: + raise HTTPException(status_code=503, detail="encrypted durable memory is not configured") + if memory_id is not None: + if cursor is not None: + raise HTTPException(status_code=400, detail="memory_id and cursor cannot be combined") + wanted = validated(normalize_memory_id, memory_id) + item = store.read(tenant_id, lambda tenant_core: tenant_core.get_memory(wanted)) + if item is None: + raise HTTPException(status_code=404, detail="memory not found") + page = {"items": [item], "next_cursor": None} + else: + normalized_cursor = validated(normalize_memory_id, cursor) if cursor is not None else None + page = validated( + lambda: store.read( + tenant_id, + lambda tenant_core: tenant_core.list_memories(limit=limit, cursor=normalized_cursor), + ) + ) + return {"ok": True, "tenant": tenant_id, **page} + + @app.patch( + "/v1/memory", + tags=["Paid API"], + operation_id="updateMemory", + summary="Update private agent memory", + description=( + "Atomically replace selected fields of one authenticated private-tenant memory. " + "Omitted fields are preserved; label may be null and an empty metadata object clears metadata." + ), + responses={ + **paid_operation_responses( + memory_update_success_openapi(), + invalid_detail="memory_id or update fields are invalid", + ), + 404: _error_response("The requested memory does not exist in this tenant.", "memory not found"), + }, + openapi_extra=memory_update_request_openapi(), + ) + def update_memory( + payload: Dict[str, Any], + memory_id: str = Query(..., description="Memory id to update atomically."), + x_lecore_tenant: str = Header( + ..., + alias=TENANT_HEADER, + description="Required private tenant id.", + ), + x_lecore_tenant_token: str = Header( + ..., + alias=TENANT_TOKEN_HEADER, + description="Required authorization token for the private tenant.", + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + tenant_id = tenant_from_header(x_lecore_tenant) + if tenant_id == DEFAULT_TENANT_ID: + raise HTTPException(status_code=403, detail="consumer memory updates require a private tenant") + require_tenant_access(tenant_id, x_lecore_tenant_token) + if codec is None or memory_transactions is None: + raise HTTPException(status_code=503, detail="encrypted durable memory is not configured") + wanted = validated(normalize_memory_id, memory_id) + updates = memory_update_fields(payload) + + def replace(tenant_core: LocalAgentCore) -> Tuple[Dict[str, Any], bool]: + before = tenant_core.get_memory(wanted) + if before is None: + return {"memory": None, "changed": False}, False + updated = tenant_core.update_memory(wanted, **updates) + changed = updated != before + return {"memory": updated, "changed": changed}, changed + + mutation = store.mutate(tenant_id, replace) + memory = mutation["memory"] + if memory is None: + raise HTTPException(status_code=404, detail="memory not found") + if mutation["changed"] and nosqlite_store is not None: + try: + nosqlite_store.replace(tenant_id, memory) + except NoSQLiteError as exc: + if memory_backend == MEMORY_BACKEND_NOSQLITE: + raise nosqlite_unavailable(exc) from exc + LOG.warning("NoSQLite shadow memory update failed: %s", exc) + return {"ok": True, "tenant": tenant_id, "memory": memory} + + @app.delete( + "/v1/memory", + tags=["Paid API"], + operation_id="deleteMemory", + summary="Delete private agent memory", + description=( + "Idempotently delete one memory from an authenticated private tenant. " + "Deleting an already-absent id returns deleted=false and does not rewrite storage." + ), + responses=paid_operation_responses( + memory_delete_success_openapi(), + invalid_detail="memory_id is invalid", + ), + ) + def delete_memory( + memory_id: str = Query(..., description="Memory id to delete idempotently."), + x_lecore_tenant: str = Header( + ..., + alias=TENANT_HEADER, + description="Required private tenant id.", + ), + x_lecore_tenant_token: str = Header( + ..., + alias=TENANT_TOKEN_HEADER, + description="Required authorization token for the private tenant.", + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + tenant_id = tenant_from_header(x_lecore_tenant) + if tenant_id == DEFAULT_TENANT_ID: + raise HTTPException(status_code=403, detail="consumer memory deletion requires a private tenant") + require_tenant_access(tenant_id, x_lecore_tenant_token) + if codec is None or memory_transactions is None: + raise HTTPException(status_code=503, detail="encrypted durable memory is not configured") + wanted = validated(normalize_memory_id, memory_id) + memory_transactions.mark_deleted(tenant_id, wanted) + + def remove(tenant_core: LocalAgentCore) -> Tuple[Optional[Dict[str, Any]], bool]: + removed = tenant_core.forget(wanted) + return removed, removed is not None + + removed = store.mutate(tenant_id, remove) + if nosqlite_store is not None: + try: + nosqlite_store.delete(tenant_id, wanted) + except NoSQLiteError as exc: + if memory_backend == MEMORY_BACKEND_NOSQLITE: + raise nosqlite_unavailable(exc) from exc + LOG.warning("NoSQLite shadow memory deletion failed: %s", exc) + return { + "ok": True, + "tenant": tenant_id, + "memory_id": wanted, + "deleted": removed is not None, + } + + @app.post( + "/v1/recall", + tags=["Paid API"], + operation_id="recallMemory", + summary="Recall agent memory", + description=( + "Recall the nearest entries from tenant-scoped agent memory. An " + "unsigned request returns the x402 challenge documented in the 402 response." + ), + responses=paid_operation_responses( + recall_success_openapi(), + invalid_detail="query must be a non-empty string", + backend_unavailable=True, + ), + openapi_extra=paid_request_openapi( + required=["query"], + properties={ + "query": { + "type": "string", + "minLength": 1, + "maxLength": MAX_QUERY_CHARS, + "pattern": r"\S", + "description": "Text to match against stored agent memory.", + }, + "k": { + "type": "integer", + "minimum": 1, + "maximum": MAX_RECALL_K, + "default": 3, + "description": "Maximum number of memories to return.", + }, + "abstain": { + "anyOf": [{"type": "number", "minimum": 0, "maximum": 1}, {"type": "null"}], + "description": "Optional minimum similarity score.", + }, + "tenant": { + "type": "string", + "description": ( + "Tenant id. Leading/trailing whitespace is removed and letters are " + "lowercased; the normalized id must match X-leCore-Tenant when both are supplied." + ), + }, + }, + example={"query": "deterministic agent memory", "k": 3}, + example_summary="Recall public-tenant memory", + ), + ) + def recall( + payload: Dict[str, Any], + x_lecore_tenant: Optional[str] = Header( + default=None, + alias=TENANT_HEADER, + description="Tenant id, trimmed and lowercased by the service. Omit for the public tenant.", + ), + x_lecore_tenant_token: Optional[str] = Header( + default=None, + alias=TENANT_TOKEN_HEADER, + description=( + "Required whenever the resolved tenant is private, whether selected " + "by header or JSON body." + ), + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + return recall_response(payload, x_lecore_tenant, x_lecore_tenant_token) + + def route_response( + payload: Dict[str, Any], + x_lecore_tenant: Optional[str], + x_lecore_tenant_token: Optional[str], + ) -> Dict[str, Any]: + tenant_id = tenant_from_payload(payload, x_lecore_tenant) + require_tenant_access(tenant_id, x_lecore_tenant_token) + task = validated(_required_text, payload, "task", MAX_TASK_CHARS) + routed = store.read(tenant_id, lambda tenant_core: tenant_core.route(task)) + return {"ok": True, "tenant": tenant_id, "route": routed} + + @app.post( + "/v1/route", + tags=["Paid API"], + operation_id="routeTask", + summary="Route a task to a capability", + description=( + "Route a plain-English task to the best matching leCore capability. " + "An unsigned request returns the x402 challenge documented in the 402 response." + ), + responses=paid_operation_responses( + route_success_openapi(), + invalid_detail="task must be a non-empty string", + ), + openapi_extra=paid_request_openapi( + required=["task"], + properties={ + "task": { + "type": "string", + "minLength": 1, + "maxLength": MAX_TASK_CHARS, + "pattern": r"\S", + "description": "Plain-English task to route.", + }, + "tenant": { + "type": "string", + "description": ( + "Tenant id. Leading/trailing whitespace is removed and letters are " + "lowercased; the normalized id must match X-leCore-Tenant when both are supplied." + ), + }, + }, + example={"task": "find the best capability for semantic memory retrieval"}, + example_summary="Route a memory-related task", + ), + ) + def route( + payload: Dict[str, Any], + x_lecore_tenant: Optional[str] = Header( + default=None, + alias=TENANT_HEADER, + description="Tenant id, trimmed and lowercased by the service. Omit for the public tenant.", + ), + x_lecore_tenant_token: Optional[str] = Header( + default=None, + alias=TENANT_TOKEN_HEADER, + description=( + "Required whenever the resolved tenant is private, whether selected " + "by header or JSON body." + ), + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + return route_response(payload, x_lecore_tenant, x_lecore_tenant_token) + + def dashboard_response( + x_lecore_tenant: Optional[str], + x_lecore_tenant_token: Optional[str], + ) -> Dict[str, Any]: + tenant_id = tenant_from_header(x_lecore_tenant) + require_tenant_access(tenant_id, x_lecore_tenant_token) + data = store.read(tenant_id, lambda tenant_core: tenant_core.dashboard()) + data = public_dashboard(data) + return {"ok": True, "tenant": tenant_id, "dashboard": data} + + @app.get( + "/v1/dashboard", + tags=["Paid API"], + operation_id="getDashboard", + summary="Read the readiness dashboard", + description=( + "Read memory, routing, native-kernel, and deterministic-engine readiness " + "for one tenant. An unsigned request returns the documented x402 challenge." + ), + responses=paid_operation_responses( + dashboard_success_openapi(), + invalid_detail="tenant id is invalid", + ), + ) + def dashboard( + x_lecore_tenant: Optional[str] = Header( + default=None, + alias=TENANT_HEADER, + description="Tenant id, trimmed and lowercased by the service. Omit for the public tenant.", + ), + x_lecore_tenant_token: Optional[str] = Header( + default=None, + alias=TENANT_TOKEN_HEADER, + description="Required whenever the resolved tenant is private.", + ), + _payment_signature: Optional[str] = Header( + default=None, + alias="Payment-Signature", + description=( + "Omit to receive the x402 challenge; include the base64 x402 v2 " + "payment payload when retrying." + ), + json_schema_extra={"format": "byte"}, + ), + ) -> Dict[str, Any]: + return dashboard_response(x_lecore_tenant, x_lecore_tenant_token) + + @app.post("/admin/remember", include_in_schema=False) + def remember( + payload: Dict[str, Any], + x_admin_token: Optional[str] = Header(default=None), + x_lecore_tenant: Optional[str] = Header(default=None, alias=TENANT_HEADER), + idempotency_key: Optional[str] = Header(default=None, alias=IDEMPOTENCY_HEADER), + ) -> Dict[str, Any]: + require_admin(x_admin_token) + tenant_id = tenant_from_payload(payload, x_lecore_tenant) + text, label, metadata = memory_fields(payload) + key = validated(normalize_idempotency_key, idempotency_key) + memory, transaction = commit_memory(tenant_id, text, label, metadata, key) + return { + "ok": True, + "tenant": tenant_id, + "memory": memory, + "transaction": transaction, + } + + @app.post("/admin/tenant-token", include_in_schema=False) + def issue_tenant_token(payload: Dict[str, Any], x_admin_token: Optional[str] = Header(default=None)) -> Dict[str, Any]: + require_admin(x_admin_token) + if not tenant_secret: + raise HTTPException(status_code=403, detail="tenant tokens require LECORE_X402_TENANT_SECRET") + tenant_id = tenant_from_payload(payload, None) + return { + "ok": True, + "tenant": tenant_id, + "tenant_header": TENANT_HEADER, + "tenant_token_header": TENANT_TOKEN_HEADER, + "tenant_token": tenant_access_token(tenant_id, tenant_secret), + } + + return app + + +def load_core(path: Optional[str]) -> LocalAgentCore: + """Load a persisted core if present, otherwise return the demo core.""" + if path and Path(path).exists(): + return LocalAgentCore.load(path) + return demo() + + +def main(argv: Optional[Iterable[str]] = None) -> None: + """CLI entry point for running the x402 API service.""" + p = argparse.ArgumentParser(description="Serve the leCore Agent Memory & Routing API with x402 payments") + p.add_argument("--host", default=os.environ.get("LECORE_X402_HOST", "127.0.0.1")) + p.add_argument("--port", type=int, default=int(os.environ.get("LECORE_X402_PORT", "4021"))) + p.add_argument("--state", default=os.environ.get("LECORE_X402_STATE")) + p.add_argument("--pay-to", default=os.environ.get("LECORE_X402_PAY_TO", "")) + p.add_argument("--price", default=os.environ.get("LECORE_X402_PRICE", DEFAULT_PRICE)) + p.add_argument("--network", default=os.environ.get("LECORE_X402_NETWORK", DEFAULT_NETWORK)) + p.add_argument("--facilitator-url", default=os.environ.get("LECORE_X402_FACILITATOR_URL", DEFAULT_FACILITATOR_URL)) + p.add_argument("--public-url", default=os.environ.get("LECORE_X402_PUBLIC_URL", DEFAULT_PUBLIC_URL)) + p.add_argument("--admin-token", default=os.environ.get("LECORE_X402_ADMIN_TOKEN")) + p.add_argument("--tenant-secret", default=os.environ.get("LECORE_X402_TENANT_SECRET")) + p.add_argument("--tenant-state-dir", default=os.environ.get("LECORE_X402_TENANT_STATE_DIR")) + p.add_argument( + "--memory-backend", + choices=(MEMORY_BACKEND_CORE, MEMORY_BACKEND_NOSQLITE), + default=os.environ.get("LECORE_X402_MEMORY_BACKEND", MEMORY_BACKEND_CORE), + ) + p.add_argument("--nosqlite-bin", default=os.environ.get("LECORE_X402_NOSQLITE_BIN", "nosqlite")) + p.add_argument("--nosqlite-data-dir", default=os.environ.get("LECORE_X402_NOSQLITE_DATA_DIR")) + p.add_argument( + "--nosqlite-durability", + choices=("sync", "buffered"), + default=os.environ.get("LECORE_X402_NOSQLITE_DURABILITY", "sync"), + ) + p.add_argument("--nosqlite-shadow", action="store_true", default=None) + p.add_argument("--unpaid-dev", action="store_true", help="Disable x402 middleware for development only") + args = p.parse_args(list(argv) if argv is not None else None) + + paid = not args.unpaid_dev + config = X402Config( + pay_to=args.pay_to or ("0xYourAddress" if not paid else ""), + price=args.price, + network=args.network, + facilitator_url=args.facilitator_url, + public_url=args.public_url, + ) + app = create_app( + load_core(args.state), + config=config, + paid=paid, + admin_token=args.admin_token, + tenant_secret=args.tenant_secret, + tenant_state_dir=args.tenant_state_dir, + memory_backend=args.memory_backend, + nosqlite_binary=args.nosqlite_bin, + nosqlite_data_dir=args.nosqlite_data_dir, + nosqlite_durability=args.nosqlite_durability, + nosqlite_shadow=args.nosqlite_shadow, + ) + try: + import uvicorn + except ImportError as exc: + raise RuntimeError(optional_dependency_help()) from exc + uvicorn.run(app, host=args.host, port=args.port, server_header=False) + + +if __name__ == "__main__": + main() diff --git a/lecore.py b/lecore.py index d4f4095..9b9a216 100644 --- a/lecore.py +++ b/lecore.py @@ -2,9 +2,10 @@ # # The engine is ~436 `holographic_*.py` modules organized into family packages. A newcomer shouldn't need to know which # one holds `Scene` versus `RenderSession` versus `look_at`. This module gathers the handful of things -# most callers actually want into five plain-English areas, so that after `pip install lecore` you can: +# most callers actually want into plain-English areas, so that after `pip install lecore` you can: # # import lecore +# core = lecore.product.LocalAgentCore() # local memory + routing + dashboard # doc = lecore.scene.Scene(dim=1024, seed=0) # build a scene # img = lecore.render.path_trace(sdf, camera) # render it # M = lecore.transform.look_at(eye, target) # aim a camera @@ -29,7 +30,7 @@ # --------------------------------------------------------------------------------------------------- -# The five curated areas. +# The curated areas. # # Each area is imported here and packed into a SimpleNamespace below. We keep the imports grouped by # area (not alphabetised) so it reads as "here is everything the `scene` builder needs", etc. If any @@ -40,6 +41,9 @@ # scene -- author and store a scene document (objects, handles, transforms, undo snapshots). from holographic.scene_and_pipeline.holographic_scene_doc import Scene, SceneObject +# product -- the narrowed first-user surface: local agent memory, skill routing, and readiness evidence. +from holographic_product import LocalAgentCore + # model -- edit geometry: the modifier stack, object description, SDF primitives, key mesh verbs. from holographic.misc.holographic_modifier import ModifierStack, describe_object from holographic.mesh_and_geometry.holographic_sdf import sphere, box # SDF primitives more live in holographic_sdf @@ -80,8 +84,10 @@ def _area(**members): return types.SimpleNamespace(**members) -# The five areas. These are the ONLY place a member is listed; areas() reads its map straight off these +# The areas. These are the ONLY place a member is listed; areas() reads its map straight off these # namespaces (see below) so the docs and the objects can never drift out of sync. +product = _area(LocalAgentCore=LocalAgentCore) + scene = _area(Scene=Scene, SceneObject=SceneObject) model = _area( @@ -111,9 +117,10 @@ def _area(**members): ) -# The names of the five areas, in the order a builder meets them (author -> model -> render -> sim -> -# aim). Kept as a tuple so `areas()` and any future __all__ share one source of truth. -_AREA_NAMES = ("scene", "model", "render", "sim", "transform") +# The names of the areas, in the order a product user tends to meet them (product wedge first, then +# author -> model -> render -> sim -> aim). Kept as a tuple so `areas()` and any future __all__ share +# one source of truth. +_AREA_NAMES = ("product", "scene", "model", "render", "sim", "transform") def areas(): diff --git a/pipelines.json b/pipelines.json index ac3e1e5..111d0e7 100644 --- a/pipelines.json +++ b/pipelines.json @@ -157,7 +157,7 @@ "coverage": { "percent": 3, "tagged": 110, - "total": 2919 + "total": 2921 }, "edges": [ { diff --git a/requirements-x402.txt b/requirements-x402.txt new file mode 100644 index 0000000..0801b9f --- /dev/null +++ b/requirements-x402.txt @@ -0,0 +1,3 @@ +x402[fastapi,evm]==2.15.0 +uvicorn==0.51.0 +cryptography==46.0.3 diff --git a/setup.py b/setup.py index 6c883f4..3ee2524 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def read_version(): long_description_content_type="text/markdown", author="AnOversizedMooseWithSocks", url="https://github.com/AnOversizedMooseWithSocks/leCore", - py_modules=["lecore", "holographic_service"], # <- top-level: the import-lecore shim + the standalone HTTP service (from holographic_service import serve) + py_modules=["lecore", "holographic_service", "holographic_product", "holographic_x402_api"], packages=engine_packages + ["lecore_data"], # <- the real holographic/ package tree + the runtime data package # The runtime data (the WordNet dictionary, material property JSON) ships as the small `lecore_data` PACKAGE, so # it is carried into the wheel and resolves the same from a clone or an install (see lecore_data/__init__.py). @@ -84,6 +84,7 @@ def read_version(): # `cupy-cuda12x` instead, so it is best installed by hand (and left # out of `all`, which is why `wgsl` and `gpu` are separate extras # rather than one). + "x402": ["x402[fastapi,evm]>=2.15,<3", "uvicorn>=0.51,<1", "cryptography>=46,<47"], # paid API publishing # -- optional tooling -- "ui": ["flask", "pillow"], # the browser UI (app.py) + image load/save "images": ["pillow"], # image I/O beyond stdlib PNG (jpg/webp/... via mind.save_render) -- @@ -95,6 +96,6 @@ def read_version(): # note above); wgpu is INCLUDED, because it ships prebuilt wheels for every platform and needs no # system toolchain -- the reason to leave CuPy out simply does not apply to it. -- "all": ["numba", "pyfftw", "sympy", "flask", "pillow", "pytest", "matplotlib", "ziglang", "nltk", - "wgpu"], + "wgpu", "x402[fastapi,evm]>=2.15,<3", "uvicorn>=0.51,<1", "cryptography>=46,<47"], }, ) diff --git a/tests/test_holographic_product.py b/tests/test_holographic_product.py new file mode 100644 index 0000000..1c6f275 --- /dev/null +++ b/tests/test_holographic_product.py @@ -0,0 +1,114 @@ +"""Tests for the product-facing LocalAgentCore facade.""" + +import pytest + +from holographic_product import LocalAgentCore, demo + + +def test_local_agent_core_remembers_and_recalls(): + core = LocalAgentCore(dim=256, seed=0) + core.remember("render scenes with global illumination and light caches", label="render") + core.remember("local agents need deterministic durable memory", label="memory") + + hits = core.recall("deterministic local memory", k=2) + assert hits[0]["label"] == "memory" + assert hits[0]["score"] >= hits[1]["score"] + + +def test_recall_is_query_safe_and_deterministic(): + core = demo() + before = core.to_state() + a = core.recall("deterministic local memory") + b = core.recall("deterministic local memory") + after = core.to_state() + + assert a == b + assert before == after + + +def test_recall_rejects_invalid_k_and_abstains_on_empty_queries(): + core = demo() + + with pytest.raises(ValueError, match="positive integer"): + core.recall("memory", k=0) + with pytest.raises(ValueError, match="between 0 and 1"): + core.recall("memory", abstain=2) + assert core.recall("") == [] + + +def test_memory_crud_is_ordered_paginated_and_idempotent(): + core = LocalAgentCore(dim=128, seed=0) + first = core.remember("first memory", label="first") + second = core.remember("second memory", label="second") + third = core.remember("third memory", label="third") + + page_one = core.list_memories(limit=2) + page_two = core.list_memories(limit=2, cursor=page_one["next_cursor"]) + + assert [row["id"] for row in page_one["items"]] == [first["id"], second["id"]] + assert page_one["next_cursor"] == second["id"] + assert [row["id"] for row in page_two["items"]] == [third["id"]] + assert page_two["next_cursor"] is None + assert core.get_memory(second["id"]) == second + assert core.get_memory("missing") is None + updated = core.update_memory( + second["id"], + text="updated second memory", + label=None, + metadata={"revision": 2}, + ) + assert updated == { + "id": second["id"], + "text": "updated second memory", + "label": None, + "metadata": {"revision": 2}, + } + assert core.update_memory("missing", text="ignored") is None + assert core.recall("updated second", k=1)[0]["id"] == second["id"] + assert core.forget(second["id"]) == updated + assert core.forget(second["id"]) is None + assert [entry.id for entry in core.entries] == [first["id"], third["id"]] + with pytest.raises(ValueError, match="cursor"): + core.list_memories(cursor="missing") + + +def test_route_uses_existing_skill_catalog(): + core = LocalAgentCore(dim=128, seed=0) + routed = core.route("start pause resume cancel a job") + + assert routed["task"] == "start pause resume cancel a job" + assert routed["decision"] == "act" + assert "call" in routed["skill"] + + +def test_dashboard_reports_product_evidence(): + core = demo() + data = core.dashboard() + page = core.dashboard(html=True) + + assert data["name"] == "leCore LocalAgentCore" + assert data["memory"]["entries"] == 3 + assert data["checks"]["deterministic_encoding"] is True + assert "c_kernel" in data + assert "leCore LocalAgentCore" in page + assert "No Model Weights" in page + + +def test_save_load_roundtrip(tmp_path): + path = tmp_path / "agent-core.json" + core = demo() + core.save(path) + + loaded = LocalAgentCore.load(path) + + assert loaded.to_state() == core.to_state() + assert loaded.recall("audited c kernel hot path")[0]["label"] == "c-kernel" + + +def test_lecore_exports_product_area(): + import lecore + + assert "product" in lecore.areas() + core = lecore.product.LocalAgentCore(dim=128, seed=1) + core.remember("agent memory product facade", label="product") + assert core.recall("agent memory")[0]["label"] == "product" diff --git a/tests/test_holographic_x402_api.py b/tests/test_holographic_x402_api.py new file mode 100644 index 0000000..f9b6c82 --- /dev/null +++ b/tests/test_holographic_x402_api.py @@ -0,0 +1,1397 @@ +"""Tests for the optional x402-paid API publisher.""" + +import json +import base64 +from html import escape +import os +from pathlib import Path +import threading + +import pytest + +from holographic_x402_api import ( + DEFAULT_NETWORK, + DEFAULT_PRICE, + DEFAULT_PUBLIC_URL, + DEFAULT_TENANT_ID, + HERO_TITLE, + IDEMPOTENCY_HEADER, + MEMORY_BACKEND_NOSQLITE, + MEMORY_CIPHER, + MEMORY_COMPRESSION, + MEMORY_KEY_ENV, + MemoryKeyring, + MemoryStateCodec, + MemoryStateError, + MemoryTransactionConflict, + MemoryMirrorPending, + NoSQLiteError, + NoSQLiteMemoryStore, + SERVICE_NAME, + TENANT_HEADER, + TENANT_TOKEN_HEADER, + TenantCoreStore, + TenantMemoryTransactions, + X402_BUYER_GUIDE_URL, + X402Config, + create_app, + landing_page_html, + optional_dependency_help, + payment_manifest, + pricing_summary, + tenant_access_token, + normalize_memory_backend, + x402_route_configs, +) +from holographic_product import LocalAgentCore, demo +from lecore import __version__ as LECORE_VERSION + + +def _memory_keys(active="v1", include_old=False): + def encoded(key_id): + return base64.urlsafe_b64encode((key_id * 32).encode("ascii")[:32]).decode("ascii") + + keys = {active: encoded(active)} + if include_old and active != "v1": + keys["v1"] = encoded("v1") + return json.dumps({"active": active, "keys": keys}) + + +def test_default_x402_config_uses_testnet_price_shape(): + cfg = X402Config(pay_to="0xabc") + + assert cfg.network == DEFAULT_NETWORK + assert cfg.price == DEFAULT_PRICE and cfg.price.startswith("$") + assert cfg.facilitator_url == "https://x402.org/facilitator" + assert cfg.public_url == DEFAULT_PUBLIC_URL + + +def test_payment_manifest_protects_specific_memory_and_compute_routes_only(): + manifest = payment_manifest(X402Config(pay_to="0xabc")) + routes = {row["route"] for row in manifest} + + assert routes == { + "DELETE /v1/memory", + "GET /v1/memory", + "PATCH /v1/memory", + "POST /v1/memory", + "POST /v1/recall", + "POST /v1/route", + "GET /v1/dashboard", + } + assert all("*" not in route for route in routes) + assert "POST /admin/remember" not in routes + assert "POST /admin/tenant-token" not in routes + assert "GET /health" not in routes + assert all(row["accepts"][0]["pay_to"] == "0xabc" for row in manifest) + descriptions = " ".join(row["description"] for row in manifest).lower() + assert "tenant-scoped agent memory" in descriptions + assert "localagentcore" not in descriptions + assert "local agent" not in descriptions + + +def test_price_validation_keeps_x402_format_honest(): + with pytest.raises(ValueError, match="dollar prefix"): + X402Config(pay_to="0xabc", price="0.001") + with pytest.raises(ValueError, match="positive dollar amount"): + X402Config(pay_to="0xabc", price="$0") + + +def test_x402_route_configs_build_against_optional_sdk(): + pytest.importorskip("x402") + + routes = x402_route_configs( + X402Config(pay_to="0xabc", public_url="https://api.example.test/") + ) + + assert sorted(routes) == [ + "DELETE /v1/memory", + "GET /v1/dashboard", + "GET /v1/memory", + "PATCH /v1/memory", + "POST /v1/memory", + "POST /v1/recall", + "POST /v1/route", + ] + assert routes["GET /v1/dashboard"].resource == "https://api.example.test/v1/dashboard" + assert routes["GET /v1/memory"].resource == "https://api.example.test/v1/memory" + assert routes["PATCH /v1/memory"].resource == "https://api.example.test/v1/memory" + assert routes["DELETE /v1/memory"].resource == "https://api.example.test/v1/memory" + assert routes["POST /v1/memory"].resource == "https://api.example.test/v1/memory" + assert routes["POST /v1/recall"].resource == "https://api.example.test/v1/recall" + assert routes["POST /v1/route"].resource == "https://api.example.test/v1/route" + + +def test_env_config_requires_pay_to_for_paid_mode(monkeypatch): + monkeypatch.delenv("LECORE_X402_PAY_TO", raising=False) + + with pytest.raises(ValueError, match="LECORE_X402_PAY_TO"): + X402Config.from_env(require_pay_to=True) + + monkeypatch.setenv("LECORE_X402_PUBLIC_URL", "https://api.example.test/") + config = X402Config.from_env(require_pay_to=False) + + assert config.pay_to == "0xYourAddress" + assert config.public_url == "https://api.example.test" + + +@pytest.mark.parametrize( + "public_url, message", + [ + ("api.example.test", "absolute http"), + ("ftp://api.example.test", "absolute http"), + ("https://", "absolute http"), + ("https://user:secret@api.example.test", "credentials"), + ("https://api.example.test?tenant=public", "query or fragment"), + ("https://api.example.test#pricing", "query or fragment"), + ("https://api.example.test:bad", "absolute http"), + ("https://api.example.test:", "absolute http"), + ("https://.", "absolute http"), + ("https://api.example.test /base", "absolute http"), + ("https://api.example.test\\base", "absolute http"), + ], +) +def test_public_url_rejects_unsafe_or_ambiguous_values(public_url, message): + with pytest.raises(ValueError, match=message): + X402Config(pay_to="0xabc", public_url=public_url) + + +def test_paid_mode_requires_https_outside_localhost(tmp_path): + pytest.importorskip("fastapi") + pytest.importorskip("x402") + + with pytest.raises(ValueError, match="must use https"): + create_app( + config=X402Config(pay_to="0xabc", public_url="http://api.example.test"), + paid=True, + ) + + local = create_app( + config=X402Config(pay_to="0xabc", public_url="http://127.0.0.1:4021"), + paid=True, + tenant_state_dir=tmp_path, + memory_keys=_memory_keys(), + ) + assert local is not None + + +def test_optional_dependency_help_points_to_extra(): + assert 'pip install ".[x402]"' in optional_dependency_help() + + +def test_memory_backend_selection_is_explicit(): + assert normalize_memory_backend("core") == "core" + assert normalize_memory_backend("NoSQLite") == MEMORY_BACKEND_NOSQLITE + with pytest.raises(ValueError, match="'core' or 'nosqlite'"): + normalize_memory_backend("sqlite") + + +def test_memory_keyring_requires_versioned_256_bit_keys(): + keyring = MemoryKeyring.from_json(_memory_keys()) + assert keyring.active == "v1" + assert len(keyring.keys["v1"]) == 32 + + with pytest.raises(ValueError, match="exactly 32 bytes"): + MemoryKeyring.from_json(json.dumps({"active": "v1", "keys": {"v1": "c2hvcnQ="}})) + missing_active = json.loads(_memory_keys()) + missing_active["active"] = "v2" + with pytest.raises(ValueError, match="not present"): + MemoryKeyring.from_json(json.dumps(missing_active)) + + +def test_memory_state_is_compressed_authenticated_and_context_bound(tmp_path): + codec = MemoryStateCodec(MemoryKeyring.from_json(_memory_keys())) + path = tmp_path / "acme.json" + value = {"entries": [{"text": "private-memory-phrase-" * 5000}], "next_id": 2} + + codec.write_json(path, value, "core:acme") + envelope = path.read_bytes() + + assert envelope.startswith(b"LECMEM01") + assert b"private-memory-phrase" not in envelope + assert len(envelope) < len(json.dumps(value).encode("utf-8")) + assert path.stat().st_mode & 0o777 == 0o600 + assert codec.read_json(path, "core:acme") == value + + with pytest.raises(MemoryStateError, match="authentication failed"): + codec.read_json(path, "core:other-tenant") + + tampered = bytearray(envelope) + tampered[-1] ^= 1 + path.write_bytes(tampered) + with pytest.raises(MemoryStateError, match="authentication failed"): + codec.read_json(path, "core:acme") + + +def test_memory_key_rotation_rewraps_state_under_the_active_key(tmp_path): + path = tmp_path / "public.json" + old = MemoryStateCodec(MemoryKeyring.from_json(_memory_keys("v1"))) + old.write_json(path, {"entries": [{"text": "rotate me"}]}, "core:public") + old_envelope = path.read_bytes() + + rotating = MemoryStateCodec(MemoryKeyring.from_json(_memory_keys("v2", include_old=True))) + assert rotating.read_json(path, "core:public")["entries"][0]["text"] == "rotate me" + assert path.read_bytes() != old_envelope + + new_only = MemoryStateCodec(MemoryKeyring.from_json(_memory_keys("v2"))) + assert new_only.read_json(path, "core:public")["entries"][0]["text"] == "rotate me" + with pytest.raises(MemoryStateError, match="unavailable memory key"): + old.read_json(path, "core:public") + + +def test_plaintext_memory_requires_explicit_one_time_migration(tmp_path): + path = tmp_path / "public.json" + path.write_text('{"entries": [{"text": "legacy plaintext"}]}', encoding="utf-8") + keyring = MemoryKeyring.from_json(_memory_keys()) + + with pytest.raises(MemoryStateError, match="plaintext durable state"): + MemoryStateCodec(keyring).read_json(path, "core:public") + + migrating = MemoryStateCodec(keyring, allow_plaintext_migration=True) + assert migrating.read_json(path, "core:public")["entries"][0]["text"] == "legacy plaintext" + assert path.read_bytes().startswith(b"LECMEM01") + assert b"legacy plaintext" not in path.read_bytes() + assert MemoryStateCodec(keyring).read_json(path, "core:public")["entries"][0]["text"] == "legacy plaintext" + + +def test_paid_durable_memory_fails_closed_without_encryption_key(tmp_path): + pytest.importorskip("fastapi") + with pytest.raises(ValueError, match=MEMORY_KEY_ENV): + create_app( + config=X402Config(pay_to="0xabc"), + paid=True, + tenant_state_dir=tmp_path, + ) + + +def test_nosqlite_backend_requires_durable_state_dirs(tmp_path): + pytest.importorskip("fastapi") + + with pytest.raises(ValueError, match="TENANT_STATE_DIR"): + create_app( + config=X402Config(pay_to="0xabc"), + paid=False, + memory_backend=MEMORY_BACKEND_NOSQLITE, + ) + + with pytest.raises(ValueError, match="NOSQLITE_DATA_DIR"): + create_app( + config=X402Config(pay_to="0xabc"), + paid=False, + memory_backend=MEMORY_BACKEND_NOSQLITE, + tenant_state_dir=tmp_path / "core", + ) + + with pytest.raises(ValueError, match="does not permit the plaintext NoSQLite"): + create_app( + config=X402Config(pay_to="0xabc"), + paid=False, + memory_backend=MEMORY_BACKEND_NOSQLITE, + tenant_state_dir=tmp_path / "encrypted-core", + memory_keys=_memory_keys(), + ) + + +def _nosqlite_binary() -> str: + binary = os.environ.get("LECORE_X402_NOSQLITE_BIN") + if not binary or not Path(binary).is_file(): + pytest.skip("set LECORE_X402_NOSQLITE_BIN to run the optional NoSQLite integration test") + return binary + + +def test_landing_page_marks_the_testnet_api_as_a_preview(): + html = landing_page_html(X402Config(pay_to="0x96e1604E92A8A1edD0701be3E67Bd4366e87BB84")) + + assert f"{escape(SERVICE_NAME)}" in html + assert "Testnet developer preview" in html + assert f"

{escape(HERO_TITLE)}

" in html + assert "$0.0011 per request" in html + assert "$1.10 per 1,000 requests" in html + assert "does not accept production payments" in html + assert "Base Sepolia x402" in html + assert "/pricing" in html + assert "/v1/dashboard" in html + assert 'href="/docs"' in html + assert 'href="/redoc"' in html + assert 'href="/openapi.json"' in html + assert "A hosted HTTPS API" in html + assert "storing and recalling encrypted private-tenant memory" in html + assert "/v1/memory" in html + assert "shared public dataset stays read-only" in html + assert "operator-issued" in html + assert "Encrypted before durable memory reaches disk" in html + assert "ready to integrate" not in html + assert "memory.entries" not in html + assert "curl -i %s/v1/dashboard" % DEFAULT_PUBLIC_URL in html + assert "Payment-Required" in html + assert "Payment-Signature" in html + assert "Payment-Response" in html + assert X402_BUYER_GUIDE_URL in html + assert ":focus-visible" in html + assert "prefers-reduced-motion" in html + assert "min-height:44px" in html + assert "outline:3px solid currentColor" in html + assert 'href="/docs#/Paid%20API/getDashboard"' in html + assert "dashboard_v1_dashboard_get" not in html + assert 'id="quickstart" class="section quickstart" tabindex="-1"' in html + assert "--coral-text:#b6402f" in html + assert DEFAULT_PUBLIC_URL in html + assert "leOS" not in html + assert "local agent" not in html.lower() + assert "local-memory" not in html.lower() + + +def test_landing_page_uses_the_configured_public_url(): + html = landing_page_html( + X402Config(pay_to="0xabc", public_url="https://api.example.test/base/") + ) + + assert "https://api.example.test/base" in html + + +def test_paid_challenge_uses_canonical_resource_not_request_headers(monkeypatch, tmp_path): + pytest.importorskip("x402") + fastapi_testclient = pytest.importorskip("fastapi.testclient") + from x402 import SupportedKind, SupportedResponse + from x402.http import HTTPFacilitatorClient, decode_payment_required_header + + monkeypatch.setattr( + HTTPFacilitatorClient, + "get_supported", + lambda _client: SupportedResponse( + kinds=[ + SupportedKind( + x402_version=2, + scheme="exact", + network=DEFAULT_NETWORK, + ) + ] + ), + ) + client = fastapi_testclient.TestClient( + create_app( + config=X402Config( + pay_to="0x96e1604E92A8A1edD0701be3E67Bd4366e87BB84", + public_url=DEFAULT_PUBLIC_URL, + ), + paid=True, + tenant_state_dir=tmp_path, + memory_keys=_memory_keys(), + ) + ) + + response = client.get( + "/v1/dashboard", + headers={"host": "attacker.invalid", "x-forwarded-proto": "http"}, + ) + assert response.status_code == 402 + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + assert "unsafe-inline" not in response.headers["content-security-policy"] + assert "sepolia.base.org" not in response.headers["content-security-policy"] + challenge = decode_payment_required_header(response.headers["payment-required"]) + assert challenge.resource.url == DEFAULT_PUBLIC_URL + "/v1/dashboard" + assert challenge.resource.description == "Read the service readiness dashboard" + assert "LocalAgentCore" not in challenge.resource.description + memory_challenge_response = client.post( + "/v1/memory", + headers={IDEMPOTENCY_HEADER: "challenge-only"}, + json={"tenant": "acme", "text": "not written before payment"}, + ) + assert memory_challenge_response.status_code == 402 + memory_challenge = decode_payment_required_header( + memory_challenge_response.headers["payment-required"] + ) + assert memory_challenge.resource.url == DEFAULT_PUBLIC_URL + "/v1/memory" + assert not (tmp_path / "acme.json").exists() + + browser_response = client.get( + "/v1/dashboard", + headers={"accept": "text/html", "user-agent": "Mozilla/5.0"}, + ) + assert browser_response.status_code == 402 + assert browser_response.headers["content-type"].startswith("text/html") + assert browser_response.headers["cache-control"] == "no-store" + assert browser_response.headers["x-content-type-options"] == "nosniff" + paywall_csp = browser_response.headers["content-security-policy"] + assert "script-src 'unsafe-inline'" in paywall_csp + assert "style-src 'unsafe-inline'" in paywall_csp + assert "connect-src 'self' https://sepolia.base.org" in paywall_csp + assert "https://rpc.wallet.coinbase.com" in paywall_csp + assert "object-src 'none'" in paywall_csp + assert "frame-src 'none'" in paywall_csp + assert "frame-ancestors 'none'" in paywall_csp + assert '