From 287b2c1678047c23cdf3e67742f71d00b73d391b Mon Sep 17 00:00:00 2001 From: Erickson Hyppolite Poel <20659380+EricksonAtHome@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:05:19 +0200 Subject: [PATCH 1/2] README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b6f9d42..ee7021a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🚀 FRC (Fast Response Connection) Ecosystem +# 🚀 FRC (Fast Response Connection) Ecosystem (BETA Open Source) [![Netlify Status](https://api.netlify.com/api/v1/badges/d6402a4e-7305-4f49-bd1c-c41798ee15da/deploy-status)](https://app.netlify.com/projects/frc7/deploys) From dd1f9b6640d25a1ad8260d04913cdb50448ac540 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 22:00:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20FRC7.1=20upgrade=20=E2=80=94=20Ayit?= =?UTF-8?q?i=20OS=20GoV,=20batch/lint/metrics,=20new=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a real monorepo with FRCL parser, engine, queue, SDK, gateway, control panel, and Ayiti OS government models wired to HaitiDocs and .gouv.ht portals. Adds batch runs, FRCL lint, webhooks, worker tick, translate/alert models, tests, CI, and a redesigned README. Co-authored-by: Erickson Hyppolite Poel --- .env.example | 10 + .github/workflows/ci.yml | 23 + .gitignore | 7 + AYITI_OS.md | 9 + LICENSE | 21 + README.md | 197 ++-- apps/cli/package.json | 17 + apps/cli/src/index.js | 98 ++ apps/cli/test/cli.test.js | 21 + apps/control-panel/package.json | 8 + apps/control-panel/public/app.js | 44 + apps/control-panel/public/index.html | 59 ++ apps/control-panel/public/styles.css | 39 + apps/control-panel/server.js | 35 + apps/control-panel/test/panel.test.js | 14 + apps/gateway/package.json | 19 + apps/gateway/src/app.js | 189 ++++ apps/gateway/src/server.js | 8 + apps/gateway/test/gateway.test.js | 69 ++ demo.frcl | 23 +- examples/ayiti/batch-alert.frcl | 12 + examples/ayiti/citizen.frcl | 10 + examples/ayiti/search.frcl | 11 + examples/basic/hello.frcl | 12 + frc-cli/frc | 46 +- frc-v2/api/server.js | 40 +- frc-v2/workers/worker.js | 39 +- github_readme.md | 87 +- install.sh | 17 +- package-lock.json | 1031 +++++++++++++++++++ package.json | 34 + packages/ayiti-gov/package.json | 9 + packages/ayiti-gov/src/adapters/gov.js | 85 ++ packages/ayiti-gov/src/clients/haitidocs.js | 66 ++ packages/ayiti-gov/src/clients/portals.js | 18 + packages/ayiti-gov/src/executor.js | 129 +++ packages/ayiti-gov/src/index.js | 7 + packages/ayiti-gov/src/models/registry.js | 35 + packages/ayiti-gov/test/ayiti.test.js | 45 + packages/core/package.json | 10 + packages/core/src/index.js | 202 ++++ packages/core/test/core.test.js | 25 + packages/engine/package.json | 10 + packages/engine/src/index.js | 75 ++ packages/engine/test/engine.test.js | 19 + packages/frcl/package.json | 9 + packages/frcl/src/index.js | 19 + packages/frcl/src/parser.js | 128 +++ packages/frcl/src/tokenize.js | 67 ++ packages/frcl/test/parser.test.js | 34 + packages/sdk/package.json | 9 + packages/sdk/src/index.js | 51 + packages/sdk/test/sdk.test.js | 20 + scripts/dev.mjs | 7 + scripts/lint.mjs | 28 + tests/integration/e2e.test.js | 43 + 56 files changed, 3122 insertions(+), 277 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 AYITI_OS.md create mode 100644 LICENSE create mode 100644 apps/cli/package.json create mode 100755 apps/cli/src/index.js create mode 100644 apps/cli/test/cli.test.js create mode 100644 apps/control-panel/package.json create mode 100644 apps/control-panel/public/app.js create mode 100644 apps/control-panel/public/index.html create mode 100644 apps/control-panel/public/styles.css create mode 100644 apps/control-panel/server.js create mode 100644 apps/control-panel/test/panel.test.js create mode 100644 apps/gateway/package.json create mode 100644 apps/gateway/src/app.js create mode 100644 apps/gateway/src/server.js create mode 100644 apps/gateway/test/gateway.test.js create mode 100644 examples/ayiti/batch-alert.frcl create mode 100644 examples/ayiti/citizen.frcl create mode 100644 examples/ayiti/search.frcl create mode 100644 examples/basic/hello.frcl create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/ayiti-gov/package.json create mode 100644 packages/ayiti-gov/src/adapters/gov.js create mode 100644 packages/ayiti-gov/src/clients/haitidocs.js create mode 100644 packages/ayiti-gov/src/clients/portals.js create mode 100644 packages/ayiti-gov/src/executor.js create mode 100644 packages/ayiti-gov/src/index.js create mode 100644 packages/ayiti-gov/src/models/registry.js create mode 100644 packages/ayiti-gov/test/ayiti.test.js create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.js create mode 100644 packages/core/test/core.test.js create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/index.js create mode 100644 packages/engine/test/engine.test.js create mode 100644 packages/frcl/package.json create mode 100644 packages/frcl/src/index.js create mode 100644 packages/frcl/src/parser.js create mode 100644 packages/frcl/src/tokenize.js create mode 100644 packages/frcl/test/parser.test.js create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/index.js create mode 100644 packages/sdk/test/sdk.test.js create mode 100755 scripts/dev.mjs create mode 100755 scripts/lint.mjs create mode 100644 tests/integration/e2e.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3400848 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORT=3000 +NODE_ENV=development +REDIS_URL=redis://127.0.0.1:6379 +FRC_API_KEYS=frc_test_key,ayiti_gov_test_key +AYITI_API_KEY=ayiti_gov_test_key +FRC_DEFAULT_REGION=ht +AYITI_HTTP_TIMEOUT_MS=20000 +# FRC_ALLOW_BUILTIN=1 +# AYITI_HAITIDOCS_MCP=https://mcp.haitidocs.org +# AYITI_MEF_URL=https://www.mef.gouv.ht diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c353add --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI +on: + push: + branches: [main, "cursor/**"] + pull_request: +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm install + - run: npm run lint + - run: npm test + env: + AYITI_HTTP_TIMEOUT_MS: "30000" + FRC_DEFAULT_REGION: ht diff --git a/.gitignore b/.gitignore index b37c0d4..afbc3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,12 @@ node_modules/ dist/ build/ .env +.env.local .netlify/ .gemini/ +coverage/ +*.vsix +.turbo/ +.cache/ +tmp/ +*.tgz diff --git a/AYITI_OS.md b/AYITI_OS.md new file mode 100644 index 0000000..0b65762 --- /dev/null +++ b/AYITI_OS.md @@ -0,0 +1,9 @@ +# Ayiti OS (GoV) + +Government-only model layer for FRC7. See root [README.md](./README.md) for full docs. + +Live APIs: +- HaitiDocs MCP `https://mcp.haitidocs.org` +- HaitiDocs catalog `https://www.haitidocs.org/data/api/catalog.json` +- AyitiStats `https://ayitistats.org` +- Portals: MEF, DGI, BRH, OMRH, CNMP diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dfafc66 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EricksonAtHome + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ee7021a..f8f6ff5 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,139 @@ -# 🚀 FRC (Fast Response Connection) Ecosystem (BETA Open Source) +# FRC7 — Fast Response Connection +[![CI](https://github.com/EricksonAtHome/FRC7/actions/workflows/ci.yml/badge.svg)](https://github.com/EricksonAtHome/FRC7/actions/workflows/ci.yml) [![Netlify Status](https://api.netlify.com/api/v1/badges/d6402a4e-7305-4f49-bd1c-c41798ee15da/deploy-status)](https://app.netlify.com/projects/frc7/deploys) -Welcome to the **Fast Response Connection (FRC)** ecosystem. FRC is a next-generation distributed AI execution platform and scripting language. +**FRC7** is a distributed AI execution platform with a declarative language (**FRCL**), a production gateway, and **Ayiti OS (GoV)** — Haitian government models wired to real public APIs. -What started as a simple declarative language (`.frcl`) has evolved through 9 levels of complexity into a full-scale, cloud-native, distributed AI network capable of executing models securely across global regional nodes. +![FRC](https://raw.githubusercontent.com/EricksonAtHome/FRC7/refs/heads/main/img/1n8jky1n8jky1n8j.png) -This repository serves as the **master monorepo** for the entire FRC project. It contains every layer of the architecture: from the lightweight language parser to the production Kubernetes cluster setup. +## What's new in 7.1 -![FRC Image](https://raw.githubusercontent.com/EricksonAtHome/FRC7/refs/heads/main/img/1n8jky1n8jky1n8j.png) +- **Ayiti OS GoV models only by default** (`ayiti.search`, `ayiti.stats`, `ayiti.dgi`, …) +- **Live HaitiDocs MCP + JSON APIs** (search, indicators, documents) +- **Ministry portals** — MEF, DGI, BRH, OMRH, CNMP (+ AyitiStats) +- **New APIs**: `/v1/batch`, `/v1/lint`, `/v1/metrics`, `/v1/worker/tick`, webhooks +- **New models**: `ayiti.translate`, `ayiti.alert`, citizen triage, UXP envelopes +- **FRCL upgrades**: `lang`, `webhook`, `retry`, `batch`, comments, URL idents +- **Control panel** UI for Ayiti OS +- **Tests + CI** on Node 20/22 -## 🧠 What is FRC? +## Quick start -At its core, **FRC** solves the problem of decentralized AI execution. Instead of building monolithic AI apps, FRC allows developers to write simple, declarative scripts (using the **FRCL** language) that instruct a network to: - -1. Parse the intent. -2. Route the task to the nearest global node (EU, US, ASIA). -3. Queue the task asynchronously using Redis. -4. Execute the AI model and stream the result back to the client. - -## 📂 Directory Breakdown & Capabilities - -Here is a thorough explanation of every component folder in this repository, why it exists, and how to use it. - -### 1. `frcl-extension/` (Developer Tooling) - -- **What it is:** A Visual Studio Code extension. -- **Why it exists:** FRC has its own language syntax (`.frcl`). To make the developer experience seamless, this extension provides native syntax highlighting, intelligent auto-completion, and colorization for `.frcl` files inside VS Code. -- **What you can do:** Compile it using `vsce package` and install the `.vsix` file into your IDE to get proper FRC code styling. - -### 2. `dev-installer/` (The AI Project Brain) - -- **What it is:** A Python-based orchestration and scaffolding tool. -- **Why it exists:** This represents "Level 4" of the system—an autonomous project generator. Instead of manually setting up Next.js or Docker projects, you describe what you want in FRCL, and this Python engine scaffolds the entire project. -- **What you can do:** Run `python installer.py` to auto-generate full-stack applications. - -### 3. `frc-node/` (The Autonomous Meta-System) - -- **What it is:** A theoretical Python implementation of the advanced concepts (Levels 5 through 9). -- **Why it exists:** It explores how FRC functions as a "living" system without a UI. It includes logic for `Self-Adaptive Nodes` (auto-healing), `Global Routing` (zero-trust security), and `Self-Rewriting` architectures where the system writes its own deployment code based on server stress. -- **What you can do:** Explore files like `self_evolving_node.py` to study autonomous system architectures and self-scaling mathematical models. - -### 4. `frc-runtime/` & `frc-real/` (The Core Engine Basics) - -- **What it is:** The earliest, foundational JavaScript implementations of the FRC execution engine. -- **Why it exists:** To bridge the gap between the `.frcl` script and the actual machine execution. It contains the raw string parsers that extract instructions like `use model models5` and turn them into JSON payloads. -- **What you can do:** Run the CLI (`node cli/frc.js`) to parse raw text files locally. - -### 5. `frc-v1/` & `frc-v2/` (The Production Backends) - -- **What it is:** Grounded, production-ready backend architectures using Node.js, Express, and Redis. -- **Why it exists:** This translates the theoretical routing into real software engineering. - - **`v1`** is a simple monolithic API and worker. - - **`v2`** is the **Production System**. It introduces `x-api-key` authentication, an Express API Gateway, and a Redis Job Queue. The gateway pushes tasks to Redis, and multi-node Docker workers pull jobs off the queue asynchronously. -- **What you can do:** `cd frc-v2` and run `docker-compose up` to launch a fully distributed job queue and worker cluster on your local machine. +```bash +npm install +npm test +npm run demo # ayiti.translate local demo +npm run demo:ayiti # live HaitiDocs search +npm run start:gateway # http://127.0.0.1:3000 +npm start -w @frc/control-panel # http://127.0.0.1:8787 +``` -### 6. `frc-cluster/` (The Multi-Region Blueprint) +```bash +# CLI +npx frc models +npx frc exec ayiti.citizen "Mwen bezwen NIF nan DGI" +npx frc lint demo.frcl +npx frc health +npx frc run examples/ayiti/search.frcl +``` -- **What it is:** A simulated global compute network using Docker Compose. -- **Why it exists:** It proves that FRC can scale globally. It launches an API Gateway alongside three independent regional nodes (`node-eu`, `node-us`, `node-asia`). -- **What you can do:** Send a request to the Gateway with a header `x-country: US`, and watch the Gateway intelligently proxy the request specifically to the US Node container. +## Architecture -### 7. `frc-k8s/` (The Enterprise Cloud Architecture) +``` +Client / CLI / Control Panel / Arduino + │ + ▼ + FRC7 Gateway — auth · lint · batch · geo-route (HT default) + │ + Redis queue + job results + webhooks + │ + ▼ + @frc/engine → Ayiti OS GoV models + │ + ▼ + HaitiDocs MCP/JSON · AyitiStats · .gouv.ht portals +``` -- **What it is:** A comprehensive suite of Kubernetes configuration manifests (`.yaml`). -- **Why it exists:** To transition FRC from "local Docker" to a real Cloud-Native platform (like AWS EKS or Google GKE). It includes Deployments, Services, an Ingress router, and Horizontal Pod Autoscalers (HPA). -- **What you can do:** Apply this directly to a Kubernetes cluster (`kubectl apply -f k8s/`) to spin up auto-scaling regional nodes that react to real-time CPU utilization. +## Ayiti OS (GoV) models + +| Model | Purpose | +|---|---| +| `ayiti.search` | HaitiDocs knowledge search | +| `ayiti.stats` | Indicator catalog / SDMX series | +| `ayiti.docs` | Official document lookup | +| `ayiti.mef` / `ayiti.dgi` / `ayiti.brh` / `ayiti.omrh` / `ayiti.cnmp` | Ministry desks | +| `ayiti.citizen` | Intent triage → ministry model | +| `ayiti.translate` | Kreyòl / FR / EN civic glossary assist | +| `ayiti.alert` | Public alert brief from open sources | +| `ayiti.uxp` | Inter-agency exchange envelope | + +Generic models like `models5` are **rejected** unless `FRC_ALLOW_BUILTIN=1` (demo only: `echo`, `summarizer`, `coder`). + +## HTTP API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/health` | Liveness + Ayiti probe summary | +| `GET` | `/v1/models` | Allowed GoV models | +| `GET` | `/v1/metrics` | Queue counters | +| `POST` | `/v1/run/:model` | Sync/async model run | +| `POST` | `/v1/execute` | Full FRCL script | +| `POST` | `/v1/batch` | Up to 20 jobs | +| `POST` | `/v1/lint` | FRCL validation | +| `GET` | `/v1/jobs/:id` | Job status/result | +| `POST` | `/v1/worker/tick` | Process one queued job | + +Auth: `x-api-key: ayiti_gov_test_key` (dev). -### 8. `frc-netlify-live/` & `frc-control-panel/` (The Serverless Edge) +```bash +curl -s localhost:3000/v1/run/ayiti.search \ + -H 'content-type: application/json' \ + -H 'x-api-key: ayiti_gov_test_key' \ + -d '{"input":"BRH inflation","sync":true,"region":"ht"}' +``` -- **What it is:** The Global Control Panel built on Netlify Serverless Edge Functions. -- **Why it exists:** Because running the heavy AI compute directly on the frontend is inefficient. The Netlify app serves purely as a **Smart Router and UI**. It uses Netlify's native IP/Geo-headers (`x-nf-country`) to instantly detect where the user is located, and forwards the payload to the nearest external `frc.systems` cluster. -- **What you can do:** Push this to Netlify to instantly deploy a globally distributed Edge Router with zero server maintenance. +## FRCL example -### 9. `arduino_example/` (The Hardware IoT Client) +```frcl +set env "prod" +region ht +lang ht -- **What it is:** C++ hardware logic for microcontrollers like the ESP32 and Arduino boards. -- **Why FRC makes IoT better:** Traditional hardware requires complex backend logic, heavy MQTT brokers, or tight coupling to a specific cloud provider to process AI logic. FRC abstracts all of this. An Arduino board simply makes a lightweight HTTP `POST` request containing FRCL instructions, and the FRC cloud network instantly parses it, routes it globally, executes the AI model, and returns a clean JSON response. -- **How developers can use it for testing:** Developers can flash `arduino_example.ino` onto an ESP32, connect it to WiFi, and immediately see live responses from `frc.systems` in their Serial Monitor. This provides a zero-friction playground to test latency and AI model outputs on real physical devices. -- **Using it in your own apps:** You can directly embed this HTTP architecture into smart home sensors, robotics, or industrial monitoring tools. For example, a temperature sensor could send raw data to an FRC node, where an AI model analyzes it and returns an instruction (e.g., "turn_on_cooling") directly to the microcontroller. +use model "ayiti.citizen" -## 🛠️ Usage (Singularity CLI) +run model ayiti.citizen { + input "Mwen bezwen NIF nan DGI" + retry 1 +} -The FRC CLI interfaces with the self-modifying ecosystem. +print result +``` -```bash -# Execute model globally -frc run models5 +## Monorepo layout -# Trigger Level 9 Singularity Loop (Self-Rewriting Ecosystem) -frc singularity +| Path | Role | +|---|---| +| `packages/frcl` | Language tokenizer / parser / lint | +| `packages/engine` | Execution + model policy | +| `packages/core` | Auth, regions, Redis/memory queue, webhooks, metrics | +| `packages/ayiti-gov` | Government models + Haiti API clients | +| `packages/sdk` | JS client | +| `apps/gateway` | Production API | +| `apps/cli` | `frc` CLI | +| `apps/control-panel` | Browser UI | +| `examples/` | Sample `.frcl` scripts | +| `frc-v1` … `frc-k8s` | Legacy infra packages (reference) | -# View dynamic ecosystem map -frc nodes -``` +## Configuration -## 🛠️ The FRC Execution Flow +Copy `.env.example` → `.env`: -No matter which folder you are looking at, the systemic logic of FRC remains uniform: +- `AYITI_API_KEY` / `FRC_API_KEYS` +- `REDIS_URL` (optional — memory fallback) +- `AYITI_HAITIDOCS_MCP` / portal URL overrides +- `FRC_ALLOW_BUILTIN=1` for local non-gov demos -1. **Client Request**: A developer, dashboard, or hardware device sends FRCL script instructions. -2. **Global Router (Netlify / Ingress)**: Evaluates user location and traffic. -3. **API Gateway (Express)**: Authenticates API keys and secures the payload. -4. **Broker (Redis)**: Buffers the tasks to prevent system crashes during high traffic. -5. **Execution Nodes (Docker / K8s)**: Asynchronously grabs the job, runs the AI model, and returns the result. +## License -This repository is the complete blueprint for an enterprise-scale AI operating system. +MIT diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..0808eec --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,17 @@ +{ + "name": "@frc/cli", + "version": "7.1.0", + "type": "module", + "bin": { "frc": "src/index.js" }, + "scripts": { + "frc": "node src/index.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@frc/frcl": "7.1.0", + "@frc/engine": "7.1.0", + "@frc/core": "7.1.0", + "@ayiti/gov": "7.1.0" + }, + "license": "MIT" +} diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js new file mode 100755 index 0000000..16871c9 --- /dev/null +++ b/apps/cli/src/index.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { lint, analyze } from "@frc/frcl"; +import { executeFrcl, executeJob, listModels } from "@frc/engine"; +import { describeRoute, generateApiKey, getMetrics, DEMO_KEY } from "@frc/core"; +import { healthcheck } from "@ayiti/gov"; + +const VERSION = "7.1.0"; + +const HELP = ` +FRC7 CLI v${VERSION} — Fast Response Connection + Ayiti OS (GoV) + +Commands: + run Execute FRCL locally + exec Run one model + lint Validate FRCL + parse Show AST/plan + models List Ayiti OS models + route [--country XX] Geo route preview + health Probe Ayiti government APIs + metrics Local process metrics snapshot + keygen [--ayiti] Generate API key + call Execute against FRC_URL gateway + version | help +`; + +function read(file) { + const p = resolve(process.cwd(), file); + if (!existsSync(p)) throw new Error(`File not found: ${p}`); + return readFileSync(p, "utf8"); +} + +async function main(argv) { + const [cmd, ...rest] = argv; + switch (cmd) { + case "run": { + const out = await executeFrcl(read(rest[0])); + console.log(out.plan.print ? out.output : JSON.stringify(out.results, null, 2)); + break; + } + case "exec": { + const result = await executeJob({ model: rest[0], input: rest.slice(1).join(" ") }); + console.log(result.output); + break; + } + case "lint": { + const result = lint(read(rest[0])); + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) process.exitCode = 1; + break; + } + case "parse": + console.log(JSON.stringify(analyze(read(rest[0])), null, 2)); + break; + case "models": + console.log(JSON.stringify(listModels(), null, 2)); + break; + case "route": { + const i = rest.indexOf("--country"); + console.log(JSON.stringify(describeRoute({ country: i >= 0 ? rest[i + 1] : "HT", region: "ht" }), null, 2)); + break; + } + case "health": + console.log(JSON.stringify(await healthcheck(), null, 2)); + break; + case "metrics": + console.log(JSON.stringify(getMetrics(), null, 2)); + break; + case "keygen": + console.log(generateApiKey(rest.includes("--ayiti") ? "ayiti_live_" : "frc_live_")); + break; + case "call": { + const base = (process.env.FRC_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + const key = process.env.FRC_API_KEY || process.env.AYITI_API_KEY || DEMO_KEY; + const res = await fetch(`${base}/v1/execute`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": key }, + body: JSON.stringify({ source: read(rest[0]), sync: true }), + }); + const body = await res.json(); + if (!res.ok) { console.error(JSON.stringify(body, null, 2)); process.exitCode = 1; } + else console.log(body.output || JSON.stringify(body, null, 2)); + break; + } + case "version": case "-v": case "--version": + console.log(VERSION); break; + case "help": case "-h": case "--help": case undefined: + console.log(HELP.trim()); break; + default: + throw new Error(`Unknown command '${cmd}'`); + } +} + +main(process.argv.slice(2)).catch((err) => { + console.error(`error: ${err.message}`); + process.exit(1); +}); diff --git a/apps/cli/test/cli.test.js b/apps/cli/test/cli.test.js new file mode 100644 index 0000000..3000d3b --- /dev/null +++ b/apps/cli/test/cli.test.js @@ -0,0 +1,21 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cli = join(dirname(fileURLToPath(import.meta.url)), "../src/index.js"); +const run = (...args) => spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); + +describe("cli", () => { + it("version + models", () => { + assert.match(run("version").stdout, /7\.1\.0/); + assert.match(run("models").stdout, /ayiti\.search/); + }); + + it("exec translate", () => { + const out = run("exec", "ayiti.translate", "citizen", "tax"); + assert.equal(out.status, 0, out.stderr); + assert.match(out.stdout, /glossary|term|citizen/i); + }); +}); diff --git a/apps/control-panel/package.json b/apps/control-panel/package.json new file mode 100644 index 0000000..caad6ee --- /dev/null +++ b/apps/control-panel/package.json @@ -0,0 +1,8 @@ +{ + "name": "@frc/control-panel", + "version": "7.1.0", + "type": "module", + "scripts": { "start": "node server.js", "test": "node --test test/*.test.js" }, + "dependencies": { "@frc/gateway": "7.1.0" }, + "license": "MIT" +} diff --git a/apps/control-panel/public/app.js b/apps/control-panel/public/app.js new file mode 100644 index 0000000..3734e48 --- /dev/null +++ b/apps/control-panel/public/app.js @@ -0,0 +1,44 @@ +const sourceEl = document.getElementById("source"); +const outputEl = document.getElementById("output"); +const apiKeyEl = document.getElementById("apiKey"); +const modelEl = document.getElementById("model"); + +async function call(path, body, method = "POST") { + const res = await fetch(path, { + method, + headers: { "content-type": "application/json", "x-api-key": apiKeyEl.value.trim() }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function show(v) { + outputEl.textContent = typeof v === "string" ? v : JSON.stringify(v, null, 2); +} + +document.getElementById("runBtn").onclick = async () => { + show("Executing…"); + try { + if (modelEl.value) { + const data = await call(`/v1/run/${modelEl.value}`, { input: sourceEl.value, sync: true, region: "ht" }); + show(data.result?.output || data); + } else { + const data = await call("/v1/execute", { source: sourceEl.value, sync: true, region: "ht" }); + show(data.output || data); + } + } catch (e) { show(`Error: ${e.message}`); } +}; + +document.getElementById("lintBtn").onclick = async () => { + show("Linting…"); + try { show(await call("/v1/lint", { source: sourceEl.value })); } + catch (e) { show(`Error: ${e.message}`); } +}; + +document.getElementById("modelsBtn").onclick = async () => { + show("Loading models…"); + try { show(await call("/v1/models", undefined, "GET")); } + catch (e) { show(`Error: ${e.message}`); } +}; diff --git a/apps/control-panel/public/index.html b/apps/control-panel/public/index.html new file mode 100644 index 0000000..bc3731a --- /dev/null +++ b/apps/control-panel/public/index.html @@ -0,0 +1,59 @@ + + + + + + FRC7 · Ayiti OS GoV + + + + + + +
+
FRC7
+

Ayiti OS · GoV

+
+
+
+

Gouvènman AI pou Ayiti

+

Run Ayiti OS models against HaitiDocs, AyitiStats, and .gouv.ht portals.

+
+ + + +
+
+
+ + + +
Ready.
+
+
+ + + diff --git a/apps/control-panel/public/styles.css b/apps/control-panel/public/styles.css new file mode 100644 index 0000000..cdc874d --- /dev/null +++ b/apps/control-panel/public/styles.css @@ -0,0 +1,39 @@ +:root { + --ink: #101820; --muted: #3d5348; --paper: #e7f0ea; + --accent: #0b6e4f; --accent-2: #c45c26; --line: rgba(16,24,32,.12); + --font-display: "Syne", sans-serif; --font-body: "DM Sans", sans-serif; --font-mono: "IBM Plex Mono", monospace; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; color: var(--ink); font-family: var(--font-body); background: var(--paper); } +.atmosphere { + position: fixed; inset: 0; z-index: -1; + background: + radial-gradient(900px 480px at 8% -8%, rgba(11,110,79,.28), transparent 60%), + radial-gradient(700px 400px at 92% 0%, rgba(196,92,38,.16), transparent 55%), + linear-gradient(160deg, #d5e8dc, #eef4f0 50%, #f2ebe4); + animation: drift 16s ease-in-out infinite alternate; +} +@keyframes drift { to { filter: hue-rotate(10deg); transform: scale(1.03); } } +.top { display: flex; justify-content: space-between; align-items: baseline; padding: 1.25rem clamp(1.25rem,4vw,3rem); } +.brand { display: flex; gap: .65rem; align-items: center; } +.mark { width: .85rem; height: .85rem; background: linear-gradient(135deg,var(--accent),var(--accent-2)); box-shadow: 0 0 0 6px rgba(11,110,79,.16); animation: pulse 2.6s ease-in-out infinite; } +@keyframes pulse { 50% { transform: scale(.92); } } +.name { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem,5vw,3rem); letter-spacing: -.04em; } +.tag { margin: 0; color: var(--muted); } +main { width: min(960px, calc(100% - 2rem)); margin: 0 auto 3rem; } +.hero { padding: 1rem 0 1.5rem; animation: rise .7s ease both; } +@keyframes rise { from { opacity: 0; transform: translateY(10px); } } +.hero h1 { margin: 0 0 .6rem; font-family: var(--font-display); font-size: clamp(1.5rem,3.4vw,2.2rem); max-width: 16ch; } +.lede { color: var(--muted); max-width: 42ch; } +.actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1rem; } +button { font: inherit; border: 0; cursor: pointer; border-radius: .4rem; padding: .75rem 1.1rem; } +.primary { background: var(--accent); color: #f3fff8; } +.ghost { background: transparent; box-shadow: inset 0 0 0 1px var(--line); } +.workspace { display: grid; gap: .9rem; padding: 1.1rem; background: rgba(255,255,255,.72); border: 1px solid var(--line); backdrop-filter: blur(10px); animation: rise .9s ease both; } +.field { display: grid; gap: .35rem; } +.field span { font-size: .75rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); } +.field.grow { grid-column: 1 / -1; } +input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: .35rem; padding: .7rem .8rem; font: inherit; background: rgba(255,255,255,.9); } +textarea, .output { font-family: var(--font-mono); font-size: .86rem; line-height: 1.5; } +.output { margin: 0; min-height: 10rem; white-space: pre-wrap; padding: 1rem; background: #102019; color: #d7f5e8; border-radius: .35rem; } +@media (min-width: 720px) { .workspace { grid-template-columns: 1fr 1fr; } } diff --git a/apps/control-panel/server.js b/apps/control-panel/server.js new file mode 100644 index 0000000..44da8c8 --- /dev/null +++ b/apps/control-panel/server.js @@ -0,0 +1,35 @@ +import { createServer } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname, extname, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createApp } from "@frc/gateway/app"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, "public"); +const PORT = Number(process.env.PORT || 8787); +const api = createApp(); + +const mime = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +}; + +function resolvePublic(urlPath) { + const raw = decodeURIComponent((urlPath || "/").split("?")[0]); + const relative = normalize(raw).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, ""); + if (!relative || relative.includes("..")) return null; + const file = join(publicDir, relative); + if (!file.startsWith(publicDir + "/") && file !== publicDir) return null; + return file; +} + +createServer((req, res) => { + if (req.url?.startsWith("/v1/") || req.url === "/health" || req.url?.startsWith("/run/")) { + api(req, res); return; + } + const file = resolvePublic(req.url === "/" ? "index.html" : req.url); + if (!file || !existsSync(file)) { res.writeHead(404).end("Not found"); return; } + res.writeHead(200, { "content-type": mime[extname(file)] || "application/octet-stream" }); + res.end(readFileSync(file)); +}).listen(PORT, () => console.log(`FRC7 Control Panel → http://127.0.0.1:${PORT}`)); diff --git a/apps/control-panel/test/panel.test.js b/apps/control-panel/test/panel.test.js new file mode 100644 index 0000000..578de3e --- /dev/null +++ b/apps/control-panel/test/panel.test.js @@ -0,0 +1,14 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pub = join(dirname(fileURLToPath(import.meta.url)), "../public"); + +describe("control panel", () => { + it("ships UI", () => { + assert.ok(existsSync(join(pub, "index.html"))); + assert.match(readFileSync(join(pub, "index.html"), "utf8"), /FRC7/); + }); +}); diff --git a/apps/gateway/package.json b/apps/gateway/package.json new file mode 100644 index 0000000..e27ed78 --- /dev/null +++ b/apps/gateway/package.json @@ -0,0 +1,19 @@ +{ + "name": "@frc/gateway", + "version": "7.1.0", + "type": "module", + "main": "src/server.js", + "exports": { ".": "./src/server.js", "./app": "./src/app.js" }, + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@frc/core": "7.1.0", + "@frc/engine": "7.1.0", + "@frc/frcl": "7.1.0", + "@ayiti/gov": "7.1.0", + "express": "^4.21.2" + }, + "license": "MIT" +} diff --git a/apps/gateway/src/app.js b/apps/gateway/src/app.js new file mode 100644 index 0000000..74f5dc3 --- /dev/null +++ b/apps/gateway/src/app.js @@ -0,0 +1,189 @@ +import express from "express"; +import { + createAuthMiddleware, resolveRegion, describeRoute, enqueueJob, getJob, + waitForJob, queueBackend, getMetrics, deliverWebhook, completeJob, failJob, dequeueJob, +} from "@frc/core"; +import { analyze, lint } from "@frc/frcl"; +import { executeFrcl, executeJob, listModels } from "@frc/engine"; +import { healthcheck as ayitiHealth, AYITI_OS } from "@ayiti/gov"; + +export function createApp(options = {}) { + const app = express(); + const auth = options.authMiddleware || createAuthMiddleware(); + app.use(express.json({ limit: "1mb" })); + + app.get("/health", async (_req, res) => { + let ayiti = null; + try { ayiti = await ayitiHealth(); } catch (e) { ayiti = { ok: false, error: e.message }; } + res.json({ + ok: true, service: "frc7-gateway", version: "7.1.0", + queue: queueBackend(), metrics: getMetrics(), ayiti, + }); + }); + + app.get("/v1/models", (_req, res) => { + res.json({ + os: AYITI_OS, + policy: "Ayiti OS (GoV) models by default. Set FRC_ALLOW_BUILTIN=1 for echo/summarizer/coder demos.", + models: listModels(), + }); + }); + + app.get("/v1/metrics", auth, (_req, res) => { + res.json(getMetrics()); + }); + + app.get("/v1/regions", (_req, res) => { + res.json({ regions: ["ht", "eu", "us", "asia"], default: "ht" }); + }); + + app.post("/v1/route", (req, res) => { + res.json(describeRoute({ + region: req.body?.region || req.headers["x-frc-region"], + country: req.body?.country || req.headers["x-country"] || req.headers["x-nf-country"], + ip: req.body?.ip || req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim(), + })); + }); + + app.post("/v1/lint", auth, (req, res) => { + const source = req.body?.source || req.body?.frcl; + if (!source) return res.status(400).json({ error: "source is required" }); + res.json(lint(source)); + }); + + app.post("/v1/parse", auth, (req, res) => { + try { + const source = req.body?.source || req.body?.frcl; + if (!source) return res.status(400).json({ error: "source is required" }); + res.json(analyze(source)); + } catch (err) { + res.status(400).json({ error: err.message }); + } + }); + + async function handleRun(req, res) { + try { + const input = req.body?.input; + if (input == null || input === "") return res.status(400).json({ error: "input is required" }); + const region = resolveRegion({ + region: req.body?.region || req.headers["x-frc-region"] || "ht", + country: req.headers["x-country"] || req.headers["x-nf-country"], + }); + const sync = req.body?.sync !== false && req.query.sync !== "0"; + const webhook = req.body?.webhook; + + if (sync) { + const result = await executeJob({ + model: req.params.model, input, + meta: { region, env: req.body?.env || "prod", lang: req.body?.lang, webhook }, + }); + if (webhook) await deliverWebhook(webhook, { type: "frc.job.completed", result }); + return res.json({ status: "completed", region, result }); + } + + const job = await enqueueJob({ + model: req.params.model, input, region, apiKeyHash: req.apiKeyHash, + meta: { env: req.body?.env || "prod", webhook, lang: req.body?.lang }, + }); + res.status(202).json({ status: "queued", jobId: job.id, region, poll: `/v1/jobs/${job.id}` }); + } catch (err) { + const status = err.code === "FRC_MODEL_FORBIDDEN" || err.code === "AYITI_MODEL_FORBIDDEN" ? 403 : 500; + res.status(status).json({ error: err.message, code: err.code }); + } + } + + app.post("/v1/run/:model", auth, handleRun); + app.post("/run/:model", auth, handleRun); + + app.post("/v1/batch", auth, async (req, res) => { + try { + const jobs = req.body?.jobs; + if (!Array.isArray(jobs) || !jobs.length) return res.status(400).json({ error: "jobs[] required" }); + if (jobs.length > 20) return res.status(400).json({ error: "max 20 jobs per batch" }); + const sync = req.body?.sync !== false; + const region = resolveRegion({ region: req.body?.region || "ht" }); + + if (sync) { + const results = []; + for (const j of jobs) { + results.push(await executeJob({ model: j.model, input: j.input, meta: { region, ...j.meta } })); + } + return res.json({ status: "completed", count: results.length, results }); + } + + const queued = []; + for (const j of jobs) { + queued.push(await enqueueJob({ model: j.model, input: j.input, region, meta: j.meta || {} })); + } + res.status(202).json({ status: "queued", jobs: queued }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.post("/v1/execute", auth, async (req, res) => { + try { + const source = req.body?.source || req.body?.frcl; + if (!source) return res.status(400).json({ error: "source is required" }); + const analyzed = analyze(source); + if (!analyzed.validation.ok) { + return res.status(400).json({ error: "invalid_frcl", details: analyzed.validation }); + } + const region = resolveRegion({ region: req.body?.region || analyzed.plan.region || "ht" }); + if (req.body?.sync === false) { + const jobs = []; + for (const run of analyzed.plan.runs) { + jobs.push(await enqueueJob({ + model: run.model, input: run.input, region, + meta: { webhook: run.webhook, lang: run.lang || analyzed.plan.lang }, + })); + } + return res.status(202).json({ status: "queued", jobs, plan: analyzed.plan }); + } + const out = await executeFrcl(source, { region, env: analyzed.plan.env }); + // deliver webhooks from plan runs + for (let i = 0; i < analyzed.plan.runs.length; i++) { + const wh = analyzed.plan.runs[i].webhook; + if (wh) await deliverWebhook(wh, { type: "frc.run.completed", result: out.results[i] }); + } + res.json({ status: "completed", region, plan: out.plan, results: out.results, output: out.output }); + } catch (err) { + const status = err.code === "FRC_MODEL_FORBIDDEN" || err.code === "AYITI_MODEL_FORBIDDEN" ? 403 : 500; + res.status(status).json({ error: err.message, code: err.code }); + } + }); + + app.get("/v1/jobs/:id", auth, async (req, res) => { + const job = await getJob(req.params.id); + if (!job) return res.status(404).json({ error: "job_not_found" }); + res.json(job); + }); + + app.get("/v1/jobs/:id/wait", auth, async (req, res) => { + try { + const job = await waitForJob(req.params.id, { + timeoutMs: Math.min(Number(req.query.timeoutMs) || 30000, 120000), + }); + res.json(job); + } catch (err) { + res.status(err.name === "FRCTimeoutError" ? 408 : 500).json({ error: err.message }); + } + }); + + /** Embedded worker tick for demos without a separate process */ + app.post("/v1/worker/tick", auth, async (req, res) => { + const job = await dequeueJob({ timeoutSec: 0 }); + if (!job) return res.json({ processed: 0 }); + try { + const result = await executeJob({ model: job.model, input: job.input, meta: { ...job.meta, region: job.region } }); + await completeJob(job.id, result); + if (job.meta?.webhook) await deliverWebhook(job.meta.webhook, { type: "frc.job.completed", jobId: job.id, result }); + res.json({ processed: 1, jobId: job.id, status: "completed" }); + } catch (err) { + await failJob(job.id, err); + res.json({ processed: 1, jobId: job.id, status: "failed", error: err.message }); + } + }); + + return app; +} diff --git a/apps/gateway/src/server.js b/apps/gateway/src/server.js new file mode 100644 index 0000000..a1f227a --- /dev/null +++ b/apps/gateway/src/server.js @@ -0,0 +1,8 @@ +import { createApp } from "./app.js"; + +const PORT = Number(process.env.PORT || 3000); +createApp().listen(PORT, () => { + console.log(`FRC7 Gateway v7.1 on :${PORT}`); + console.log(" GET /health /v1/models /v1/metrics"); + console.log(" POST /v1/run/:model /v1/execute /v1/batch /v1/lint"); +}); diff --git a/apps/gateway/test/gateway.test.js b/apps/gateway/test/gateway.test.js new file mode 100644 index 0000000..6d14953 --- /dev/null +++ b/apps/gateway/test/gateway.test.js @@ -0,0 +1,69 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { useMemoryQueue, AYITI_DEMO_KEY } from "@frc/core"; +import { createApp } from "../src/app.js"; + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) }); + }); + }); +} + +describe("gateway", () => { + let ctx; + before(async () => { useMemoryQueue(); ctx = await listen(createApp()); }); + after(async () => ctx.close()); + + it("health + models", async () => { + const h = await (await fetch(`${ctx.url}/health`)).json(); + assert.equal(h.ok, true); + const m = await (await fetch(`${ctx.url}/v1/models`)).json(); + assert.ok(m.models.some((x) => x.id === "ayiti.search")); + }); + + it("forbids generic models", async () => { + const res = await fetch(`${ctx.url}/v1/run/models5`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": AYITI_DEMO_KEY }, + body: JSON.stringify({ input: "x", sync: true }), + }); + assert.equal(res.status, 403); + }); + + it("lints and runs translate", async () => { + const lint = await fetch(`${ctx.url}/v1/lint`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": AYITI_DEMO_KEY }, + body: JSON.stringify({ source: 'run model ayiti.translate { input "tax" }' }), + }); + assert.equal(lint.status, 200); + assert.equal((await lint.json()).ok, true); + + const run = await fetch(`${ctx.url}/v1/run/ayiti.translate`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": AYITI_DEMO_KEY }, + body: JSON.stringify({ input: "citizen tax", sync: true }), + }); + assert.equal(run.status, 200); + }); + + it("batch sync", async () => { + const res = await fetch(`${ctx.url}/v1/batch`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": AYITI_DEMO_KEY }, + body: JSON.stringify({ + sync: true, + jobs: [ + { model: "ayiti.translate", input: "budget" }, + { model: "ayiti.uxp", input: "{\"from\":\"mef\",\"to\":\"dgi\"}" }, + ], + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.count, 2); + }); +}); diff --git a/demo.frcl b/demo.frcl index 8762ce8..632ca1b 100644 --- a/demo.frcl +++ b/demo.frcl @@ -1,25 +1,16 @@ set env "prod" +region ht +lang ht -network frc.systems { +network ayiti.gouv { mode "secure" - protocol "frc" + protocol "ayiti-os-gov" } -network mobile { - type "5g" - provider "auto" - priority "low-latency" -} - -docker "frc-runtime" { - image "node:20" - run "node ai.js" -} - -use model "models5" +use model "ayiti.stats" -run model models5 { - input "Explain FRC systems orchestration with 5G and Docker" +run model ayiti.stats { + input "displacement" } print result diff --git a/examples/ayiti/batch-alert.frcl b/examples/ayiti/batch-alert.frcl new file mode 100644 index 0000000..b419771 --- /dev/null +++ b/examples/ayiti/batch-alert.frcl @@ -0,0 +1,12 @@ +set env "prod" +region ht +batch true + +use model "ayiti.alert" + +run model ayiti.alert { + input "siklon ak deplasman nan Ayiti" + retry 1 +} + +print result diff --git a/examples/ayiti/citizen.frcl b/examples/ayiti/citizen.frcl new file mode 100644 index 0000000..9360ef9 --- /dev/null +++ b/examples/ayiti/citizen.frcl @@ -0,0 +1,10 @@ +set env "prod" +region ht + +use model "ayiti.citizen" + +run model ayiti.citizen { + input "Mwen bezwen enfòmasyon sou NIF nan DGI" +} + +print result diff --git a/examples/ayiti/search.frcl b/examples/ayiti/search.frcl new file mode 100644 index 0000000..31db858 --- /dev/null +++ b/examples/ayiti/search.frcl @@ -0,0 +1,11 @@ +set env "prod" +region ht +lang ht + +use model "ayiti.search" + +run model ayiti.search { + input "BRH inflation note Haiti" +} + +print result diff --git a/examples/basic/hello.frcl b/examples/basic/hello.frcl new file mode 100644 index 0000000..dab42e1 --- /dev/null +++ b/examples/basic/hello.frcl @@ -0,0 +1,12 @@ +# Local demo — requires FRC_ALLOW_BUILTIN=1 OR use an ayiti.* model +set env "dev" +region ht + +use model "ayiti.translate" + +run model ayiti.translate { + input "citizen tax budget inflation government" + lang ht +} + +print result diff --git a/frc-cli/frc b/frc-cli/frc index 267fbcb..7007b13 100755 --- a/frc-cli/frc +++ b/frc-cli/frc @@ -1,42 +1,4 @@ -#!/bin/bash - -COMMAND=$1 -ARG1=$2 - -case $COMMAND in - run) - echo "📡 Transmitting to FRC Global Network..." - cd ../frc-node && python3 global_router.py route "$ARG1" 100 - ;; - evolve) - echo "🔁 Triggering Level 7 Evolution Loop..." - cd ../frc-node && python3 self_adaptive_node.py loop - ;; - autogenesis) - echo "🧬 Initiating Level 8 System Autogenesis..." - cd ../frc-node && python3 self_evolving_node.py autogenesis - ;; - singularity) - echo "🌌 Initiating Level 9 System Singularity (Self-Rewriting Ecosystem)..." - cd ../frc-node && python3 self_rewriting_ecosystem.py singularity - ;; - health) - echo "🧬 Initiating global diagnostics..." - cd ../frc-node && python3 -c 'from self_adaptive_node import FRCNode; import json; print("Status:", json.dumps(FRCNode().health_check()))' - ;; - nodes) - echo "🌍 FRC LEVEL 9 METAVERSE MAP:" - echo "🇳🇱 EU Edge Cloud [ONLINE] - Language Syntax Evolving" - echo "🇩🇪 EU Central Core [ONLINE] - Spawning new models_v2" - echo "🇺🇸 US East Cloud [ONLINE] - Engine kernel self-modifying" - echo "🇸🇬 Asia Edge [ONLINE] - Optimizing compute economy" - ;; - *) - echo "🌍 FRC CLI (Level 9 Self-Rewriting Ecosystem):" - echo " frc run - Execute AI globally" - echo " frc evolve - Trigger manual evolution (L7)" - echo " frc autogenesis - Trigger self-creation loop (L8)" - echo " frc singularity - Trigger self-rewriting loop (L9)" - echo " frc nodes - View dynamic ecosystem status" - ;; -esac +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec node "$ROOT/apps/cli/src/index.js" "$@" diff --git a/frc-v2/api/server.js b/frc-v2/api/server.js index bfa0881..4b14da6 100644 --- a/frc-v2/api/server.js +++ b/frc-v2/api/server.js @@ -1,29 +1,17 @@ -import express from "express"; -import { authMiddleware } from "./auth.js"; -import { addJob } from "../queue/queue.js"; +/** + * Legacy FRC v2 entry — delegates to FRC7 gateway app when available. + * Prefer: npm run start:gateway + */ +import { pathToFileURL } from "node:url"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; -const app = express(); -app.use(express.json()); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { createApp } = await import( + pathToFileURL(resolve(__dirname, "../../apps/gateway/src/app.js")).href +); -// AUTH PROTECTED -app.post("/run/:model", authMiddleware, async (req, res) => { - try { - const job = await addJob({ - model: req.params.model, - input: req.body.input, - apiKey: req.apiKey - }); - - res.json({ - status: "queued", - jobId: job.id - }); - } catch (error) { - res.status(500).json({ error: "Failed to queue job" }); - } -}); - -const PORT = process.env.PORT || 3000; -app.listen(PORT, () => { - console.log(`🌐 FRC v2 API Gateway running on port ${PORT}`); +const PORT = Number(process.env.PORT || 3000); +createApp().listen(PORT, () => { + console.log(`FRC7 gateway (via frc-v2 shim) on :${PORT}`); }); diff --git a/frc-v2/workers/worker.js b/frc-v2/workers/worker.js index 14b54e1..9cc9121 100644 --- a/frc-v2/workers/worker.js +++ b/frc-v2/workers/worker.js @@ -1,29 +1,26 @@ -import { getJob } from "../queue/queue.js"; +import { dequeueJob, completeJob, failJob, useMemoryQueue } from "../../packages/core/src/index.js"; +import { executeJob } from "../../packages/engine/src/index.js"; -function executeModel(model, input) { - return { - output: `[FRC v2] ${model} processed: ${input}`, - latency: "8ms" - }; -} +if (process.env.REDIS_URL == null) useMemoryQueue(); async function loop() { - console.log("⚙️ Worker node started. Waiting for jobs..."); + console.log("FRC7 worker — Ayiti OS GoV models"); while (true) { try { - const job = await getJob(); - - if (job) { - console.log(`\n📦 Processing job: ${job.id}`); - const result = executeModel(job.model, job.input); - console.log(`✅ Result:`, result); - } else { - // Wait before checking again if queue is empty - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } catch (error) { - console.error("Worker error:", error.message); - await new Promise(resolve => setTimeout(resolve, 2000)); + const job = await dequeueJob({ timeoutSec: 1 }); + if (!job) { await new Promise((r) => setTimeout(r, 200)); continue; } + console.log(`→ ${job.id} ${job.model}`); + try { + const result = await executeJob({ model: job.model, input: job.input, meta: job.meta }); + await completeJob(job.id, result); + console.log(`✓ ${job.id}`); + } catch (err) { + await failJob(job.id, err); + console.error(`✗ ${job.id}: ${err.message}`); + } + } catch (err) { + console.error(err.message); + await new Promise((r) => setTimeout(r, 1000)); } } } diff --git a/github_readme.md b/github_readme.md index 6e003d0..bc31cdc 100644 --- a/github_readme.md +++ b/github_readme.md @@ -1,84 +1,7 @@ -# 🚀 FRC (Fast Response Connection) Ecosystem -[![Netlify Status](https://api.netlify.com/api/v1/badges/d6402a4e-7305-4f49-bd1c-c41798ee15da/deploy-status)](https://app.netlify.com/projects/frc7/deploys) +# FRC7 — Fast Response Connection + Ayiti OS (GoV) -Welcome to the **Fast Response Connection (FRC)** ecosystem. FRC is a next-generation distributed AI execution platform and scripting language. +```bash +npm install && npm test && npm run demo:ayiti +``` -What started as a simple declarative language (`.frcl`) has evolved through 9 levels of complexity into a full-scale, cloud-native, distributed AI network capable of executing models securely across global regional nodes. - -This repository serves as the **master monorepo** for the entire FRC project. It contains every layer of the architecture: from the lightweight language parser to the production Kubernetes cluster setup. - ---- - -## 🧠 What is FRC? - -At its core, **FRC** solves the problem of decentralized AI execution. Instead of building monolithic AI apps, FRC allows developers to write simple, declarative scripts (using the **FRCL** language) that instruct a network to: -1. Parse the intent. -2. Route the task to the nearest global node (EU, US, ASIA). -3. Queue the task asynchronously using Redis. -4. Execute the AI model and stream the result back to the client. - -## 📂 Directory Breakdown & Capabilities - -Here is a thorough explanation of every component folder in this repository, why it exists, and how to use it. - -### 1. `frcl-extension/` (Developer Tooling) -* **What it is:** A Visual Studio Code extension. -* **Why it exists:** FRC has its own language syntax (`.frcl`). To make the developer experience seamless, this extension provides native syntax highlighting, intelligent auto-completion, and colorization for `.frcl` files inside VS Code. -* **What you can do:** Compile it using `vsce package` and install the `.vsix` file into your IDE to get proper FRC code styling. - -### 2. `dev-installer/` (The AI Project Brain) -* **What it is:** A Python-based orchestration and scaffolding tool. -* **Why it exists:** This represents "Level 4" of the system—an autonomous project generator. Instead of manually setting up Next.js or Docker projects, you describe what you want in FRCL, and this Python engine scaffolds the entire project. -* **What you can do:** Run `python installer.py` to auto-generate full-stack applications. - -### 3. `frc-node/` (The Autonomous Meta-System) -* **What it is:** A theoretical Python implementation of the advanced concepts (Levels 5 through 9). -* **Why it exists:** It explores how FRC functions as a "living" system without a UI. It includes logic for `Self-Adaptive Nodes` (auto-healing), `Global Routing` (zero-trust security), and `Self-Rewriting` architectures where the system writes its own deployment code based on server stress. -* **What you can do:** Explore files like `self_evolving_node.py` to study autonomous system architectures and self-scaling mathematical models. - -### 4. `frc-runtime/` & `frc-real/` (The Core Engine Basics) -* **What it is:** The earliest, foundational JavaScript implementations of the FRC execution engine. -* **Why it exists:** To bridge the gap between the `.frcl` script and the actual machine execution. It contains the raw string parsers that extract instructions like `use model models5` and turn them into JSON payloads. -* **What you can do:** Run the CLI (`node cli/frc.js`) to parse raw text files locally. - -### 5. `frc-v1/` & `frc-v2/` (The Production Backends) -* **What it is:** Grounded, production-ready backend architectures using Node.js, Express, and Redis. -* **Why it exists:** This translates the theoretical routing into real software engineering. - * **`v1`** is a simple monolithic API and worker. - * **`v2`** is the **Production System**. It introduces `x-api-key` authentication, an Express API Gateway, and a Redis Job Queue. The gateway pushes tasks to Redis, and multi-node Docker workers pull jobs off the queue asynchronously. -* **What you can do:** `cd frc-v2` and run `docker-compose up` to launch a fully distributed job queue and worker cluster on your local machine. - -### 6. `frc-cluster/` (The Multi-Region Blueprint) -* **What it is:** A simulated global compute network using Docker Compose. -* **Why it exists:** It proves that FRC can scale globally. It launches an API Gateway alongside three independent regional nodes (`node-eu`, `node-us`, `node-asia`). -* **What you can do:** Send a request to the Gateway with a header `x-country: US`, and watch the Gateway intelligently proxy the request specifically to the US Node container. - -### 7. `frc-k8s/` (The Enterprise Cloud Architecture) -* **What it is:** A comprehensive suite of Kubernetes configuration manifests (`.yaml`). -* **Why it exists:** To transition FRC from "local Docker" to a real Cloud-Native platform (like AWS EKS or Google GKE). It includes Deployments, Services, an Ingress router, and Horizontal Pod Autoscalers (HPA). -* **What you can do:** Apply this directly to a Kubernetes cluster (`kubectl apply -f k8s/`) to spin up auto-scaling regional nodes that react to real-time CPU utilization. - -### 8. `frc-netlify-live/` & `frc-control-panel/` (The Serverless Edge) -* **What it is:** The Global Control Panel built on Netlify Serverless Edge Functions. -* **Why it exists:** Because running the heavy AI compute directly on the frontend is inefficient. The Netlify app serves purely as a **Smart Router and UI**. It uses Netlify's native IP/Geo-headers (`x-nf-country`) to instantly detect where the user is located, and forwards the payload to the nearest external `frc.systems` cluster. -* **What you can do:** Push this to Netlify to instantly deploy a globally distributed Edge Router with zero server maintenance. - -### 9. `arduino_example/` (The Hardware IoT Client) -* **What it is:** C++ hardware logic for microcontrollers like the ESP32 and Arduino boards. -* **Why FRC makes IoT better:** Traditional hardware requires complex backend logic, heavy MQTT brokers, or tight coupling to a specific cloud provider to process AI logic. FRC abstracts all of this. An Arduino board simply makes a lightweight HTTP `POST` request containing FRCL instructions, and the FRC cloud network instantly parses it, routes it globally, executes the AI model, and returns a clean JSON response. -* **How developers can use it for testing:** Developers can flash `arduino_example.ino` onto an ESP32, connect it to WiFi, and immediately see live responses from `frc.systems` in their Serial Monitor. This provides a zero-friction playground to test latency and AI model outputs on real physical devices. -* **Using it in your own apps:** You can directly embed this HTTP architecture into smart home sensors, robotics, or industrial monitoring tools. For example, a temperature sensor could send raw data to an FRC node, where an AI model analyzes it and returns an instruction (e.g., "turn_on_cooling") directly to the microcontroller. - ---- - -## 🛠️ The FRC Execution Flow - -No matter which folder you are looking at, the systemic logic of FRC remains uniform: - -1. **Client Request**: A developer, dashboard, or hardware device sends FRCL script instructions. -2. **Global Router (Netlify / Ingress)**: Evaluates user location and traffic. -3. **API Gateway (Express)**: Authenticates API keys and secures the payload. -4. **Broker (Redis)**: Buffers the tasks to prevent system crashes during high traffic. -5. **Execution Nodes (Docker / K8s)**: Asynchronously grabs the job, runs the AI model, and returns the result. - -This repository is the complete blueprint for an enterprise-scale AI operating system. +Full docs: [README.md](./README.md) diff --git a/install.sh b/install.sh index 80a1ec8..79cda24 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,10 @@ -#!/bin/bash -echo "Installing FRC Level 5 Headless Node..." -mkdir -p /opt/frc -cp -r ./frc-node/* /opt/frc/ -chmod +x /opt/frc/node.py -echo "✅ FRC Node installed." -echo "🚀 Run node with: python3 /opt/frc/node.py start" +#!/usr/bin/env bash +set -euo pipefail +echo "Installing FRC7 + Ayiti OS (GoV)..." +cd "$(dirname "$0")" +command -v node >/dev/null || { echo "Node.js 20+ required" >&2; exit 1; } +npm install +mkdir -p "$HOME/.local/bin" +ln -sf "$(pwd)/apps/cli/src/index.js" "$HOME/.local/bin/frc" +chmod +x apps/cli/src/index.js frc-cli/frc +echo "✅ FRC7 ready — try: frc help | npm run demo:ayiti" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7b8ddce --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1031 @@ +{ + "name": "frc7", + "version": "7.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frc7", + "version": "7.1.0", + "license": "MIT", + "workspaces": [ + "packages/*", + "apps/*" + ], + "engines": { + "node": ">=20" + } + }, + "apps/cli": { + "name": "@frc/cli", + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@ayiti/gov": "7.1.0", + "@frc/core": "7.1.0", + "@frc/engine": "7.1.0", + "@frc/frcl": "7.1.0" + }, + "bin": { + "frc": "src/index.js" + } + }, + "apps/control-panel": { + "name": "@frc/control-panel", + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@frc/gateway": "7.1.0" + } + }, + "apps/gateway": { + "name": "@frc/gateway", + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@ayiti/gov": "7.1.0", + "@frc/core": "7.1.0", + "@frc/engine": "7.1.0", + "@frc/frcl": "7.1.0", + "express": "^4.21.2" + } + }, + "node_modules/@ayiti/gov": { + "resolved": "packages/ayiti-gov", + "link": true + }, + "node_modules/@frc/cli": { + "resolved": "apps/cli", + "link": true + }, + "node_modules/@frc/control-panel": { + "resolved": "apps/control-panel", + "link": true + }, + "node_modules/@frc/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@frc/engine": { + "resolved": "packages/engine", + "link": true + }, + "node_modules/@frc/frcl": { + "resolved": "packages/frcl", + "link": true + }, + "node_modules/@frc/gateway": { + "resolved": "apps/gateway", + "link": true + }, + "node_modules/@frc/sdk": { + "resolved": "packages/sdk", + "link": true + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "packages/ayiti-gov": { + "name": "@ayiti/gov", + "version": "7.1.0", + "license": "MIT" + }, + "packages/core": { + "name": "@frc/core", + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "redis": "^4.7.0" + } + }, + "packages/engine": { + "name": "@frc/engine", + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@ayiti/gov": "7.1.0", + "@frc/frcl": "7.1.0" + } + }, + "packages/frcl": { + "name": "@frc/frcl", + "version": "7.1.0", + "license": "MIT" + }, + "packages/sdk": { + "name": "@frc/sdk", + "version": "7.1.0", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7894761 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "frc7", + "version": "7.1.0", + "private": true, + "description": "FRC7 — Fast Response Connection ecosystem with Ayiti OS (GoV) government models", + "type": "module", + "workspaces": [ + "packages/*", + "apps/*" + ], + "scripts": { + "test": "npm run test --workspaces --if-present && node --test tests/integration/*.test.js", + "lint": "node scripts/lint.mjs", + "demo": "node apps/cli/src/index.js run examples/basic/hello.frcl", + "demo:ayiti": "node apps/cli/src/index.js run examples/ayiti/search.frcl", + "start:gateway": "npm run start -w @frc/gateway", + "frc": "node apps/cli/src/index.js", + "dev": "node scripts/dev.mjs" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "frc", + "frc7", + "frcl", + "ayiti", + "haiti", + "government", + "ai" + ], + "license": "MIT", + "author": "EricksonAtHome" +} diff --git a/packages/ayiti-gov/package.json b/packages/ayiti-gov/package.json new file mode 100644 index 0000000..389a64f --- /dev/null +++ b/packages/ayiti-gov/package.json @@ -0,0 +1,9 @@ +{ + "name": "@ayiti/gov", + "version": "7.1.0", + "type": "module", + "main": "src/index.js", + "exports": { ".": "./src/index.js" }, + "scripts": { "test": "node --test test/*.test.js" }, + "license": "MIT" +} diff --git a/packages/ayiti-gov/src/adapters/gov.js b/packages/ayiti-gov/src/adapters/gov.js new file mode 100644 index 0000000..3ac4ee6 --- /dev/null +++ b/packages/ayiti-gov/src/adapters/gov.js @@ -0,0 +1,85 @@ +import { GOV_PORTALS, portalForMinistry } from "../clients/portals.js"; +import * as haitiDocs from "../clients/haitidocs.js"; + +export async function probePortal(portalKey) { + const portal = portalForMinistry(portalKey); + if (!portal) throw new Error(`Unknown portal '${portalKey}'`); + const started = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + let status = null, ok = false, error = null; + try { + const res = await fetch(portal.baseUrl, { + method: "GET", redirect: "follow", signal: controller.signal, + headers: { "user-agent": "AyitiOS-GoV/7.1 (+frc7)" }, + }); + status = res.status; ok = res.status >= 200 && res.status < 500; + } catch (err) { error = err.message; } + finally { clearTimeout(timer); } + return { + portal: portal.id, name: portal.name, baseUrl: portal.baseUrl, + related: portal.related || [], egov: portal.egov || null, + reachable: ok, httpStatus: status, latencyMs: Date.now() - started, error, + }; +} + +export async function ministryBrief(portalKey, query) { + const probe = await probePortal(portalKey); + let knowledge = { results: [] }; + try { knowledge = await haitiDocs.search(`${portalKey} ${query}`, { limit: 5 }); } + catch (err) { knowledge = { error: err.message, results: [] }; } + const q = encodeURIComponent(String(query || "").slice(0, 100)); + const actions = [ + { type: "open_portal", label: `Open ${probe.name}`, url: probe.baseUrl }, + ...(probe.egov ? [{ type: "open_egov", label: "E-gouvernance", url: probe.egov }] : []), + ...(probe.related || []).map((url) => ({ type: "open_related", label: url, url })), + { type: "search_public_data", label: "HaitiDocs", url: `https://www.haitidocs.org/?q=${q}` }, + { type: "ayitistats", label: "AyitiStats", url: GOV_PORTALS.ayitistats.baseUrl }, + ]; + return { os: "Ayiti OS (GoV)", ministry: probe.name, query, portal: probe, knowledge, actions }; +} + +export function routeCitizenIntent(text) { + const t = String(text || "").toLowerCase(); + const rules = [ + { model: "ayiti.dgi", keys: ["tax", "impôt", "impot", "nif", "patente", "dgi", "taks"] }, + { model: "ayiti.brh", keys: ["inflation", "change", "dollar", "brh", "monnaie"] }, + { model: "ayiti.mef", keys: ["budget", "mef", "finance", "dépense", "depense", "sysdep"] }, + { model: "ayiti.cnmp", keys: ["marché", "marche", "procurement", "cnmp", "appel"] }, + { model: "ayiti.omrh", keys: ["fonction publique", "sigrh", "omrh", "recrutement"] }, + { model: "ayiti.stats", keys: ["statistique", "indicateur", "population", "ihsi", "ayitistats", "données", "donnees"] }, + { model: "ayiti.alert", keys: ["alèt", "alerte", "emergency", "urgence", "seisme", "séisme", "siklon"] }, + ]; + for (const r of rules) { + const hit = r.keys.find((k) => t.includes(k)); + if (hit) return { model: r.model, reason: `matched:${hit}` }; + } + return { model: "ayiti.search", reason: "default_search" }; +} + +/** Lightweight HT/FR/EN assist (no external MT API required) */ +export function translateAssist(text, lang = "ht") { + const t = String(text || ""); + const gloss = { + tax: { ht: "taks", fr: "impôt" }, + budget: { ht: "bidjè", fr: "budget" }, + inflation: { ht: "enflasyon", fr: "inflation" }, + citizen: { ht: "sitwayen", fr: "citoyen" }, + government: { ht: "gouvènman", fr: "gouvernement" }, + }; + const notes = []; + for (const [en, map] of Object.entries(gloss)) { + if (t.toLowerCase().includes(en) || t.toLowerCase().includes(map.ht) || t.toLowerCase().includes(map.fr)) { + notes.push({ term: en, ht: map.ht, fr: map.fr, en }); + } + } + return { + lang, + original: t, + glossaryHits: notes, + summary: notes.length + ? `Found ${notes.length} civic term(s). Target lang: ${lang}.` + : `No glossary hits. Pass through (${lang}).`, + passThrough: t, + }; +} diff --git a/packages/ayiti-gov/src/clients/haitidocs.js b/packages/ayiti-gov/src/clients/haitidocs.js new file mode 100644 index 0000000..bebcb03 --- /dev/null +++ b/packages/ayiti-gov/src/clients/haitidocs.js @@ -0,0 +1,66 @@ +import { GOV_PORTALS } from "./portals.js"; + +const TIMEOUT = Number(process.env.AYITI_HTTP_TIMEOUT_MS || 20000); + +async function httpJson(url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs || TIMEOUT); + try { + const res = await fetch(url, { + ...options, + signal: controller.signal, + headers: { accept: "application/json", ...(options.headers || {}) }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`); + return res.json(); + } finally { clearTimeout(timer); } +} + +function abs(path) { + if (!path) return null; + if (/^https?:/i.test(path)) return path; + return `${GOV_PORTALS.haitidocs.baseUrl.replace(/\/$/, "")}${path.startsWith("/") ? "" : "/"}${path}`; +} + +export async function health() { + return httpJson(`${GOV_PORTALS.haitidocs.mcp.replace(/\/$/, "")}/health`); +} + +export async function getCatalog() { + const catalog = await httpJson(`${GOV_PORTALS.haitidocs.apiBase.replace(/\/$/, "")}/catalog.json`); + return { + buildId: catalog.build_id, + generatedAt: catalog.generated_at, + seriesCount: catalog.downloads?.series?.length || 0, + series: (catalog.downloads?.series || []).map((s) => ({ ...s, csvUrl: abs(s.csv_url), sdmxUrl: abs(s.sdmx_url) })), + downloads: catalog.downloads?.all, + }; +} + +export async function mcpCall(name, args = {}) { + const url = GOV_PORTALS.haitidocs.mcp.replace(/\/$/, ""); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT); + try { + const res = await fetch(url, { + method: "POST", + signal: controller.signal, + headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name, arguments: args } }), + }); + if (!res.ok) throw new Error(`MCP HTTP ${res.status}`); + const text = await res.text(); + const dataLine = text.split("\n").map((l) => l.trim()).find((l) => l.startsWith("data:")); + const payload = JSON.parse(dataLine ? dataLine.slice(5).trim() : text); + if (payload.error) throw new Error(payload.error.message || "MCP error"); + const result = payload.result || payload; + if (result.structuredContent) return result.structuredContent; + const first = result.content?.find?.((c) => c.type === "text"); + if (first?.text) { try { return JSON.parse(first.text); } catch { return { text: first.text }; } } + return result; + } finally { clearTimeout(timer); } +} + +export const search = (query, opts = {}) => mcpCall("search", { query, limit: opts.limit ?? 8, filters: opts.filters ?? null }); +export const fetchResource = (id, extra = {}) => mcpCall("fetch", { id, ...extra }); +export const listDocuments = (filters = {}) => mcpCall("list_documents", filters); diff --git a/packages/ayiti-gov/src/clients/portals.js b/packages/ayiti-gov/src/clients/portals.js new file mode 100644 index 0000000..65006b9 --- /dev/null +++ b/packages/ayiti-gov/src/clients/portals.js @@ -0,0 +1,18 @@ +export const GOV_PORTALS = Object.freeze({ + mef: { id: "mef", name: "Ministère de l'Économie et des Finances", baseUrl: process.env.AYITI_MEF_URL || "https://www.mef.gouv.ht", related: ["https://www.budget.gouv.ht"] }, + dgi: { id: "dgi", name: "Direction Générale des Impôts", baseUrl: process.env.AYITI_DGI_URL || "https://www.dgi.gouv.ht", related: [] }, + brh: { id: "brh", name: "Banque de la République d'Haïti", baseUrl: process.env.AYITI_BRH_URL || "https://www.brh.ht", related: [] }, + omrh: { id: "omrh", name: "OMRH", baseUrl: process.env.AYITI_OMRH_URL || "https://omrh.gouv.ht", egov: "https://omrh.gouv.ht/egouvernance", related: [] }, + cnmp: { id: "cnmp", name: "CNMP", baseUrl: process.env.AYITI_CNMP_URL || "https://www.cnmp.gouv.ht", related: [] }, + ayitistats: { id: "ayitistats", name: "AyitiStats", baseUrl: process.env.AYITI_STATS_URL || "https://ayitistats.org", related: [] }, + haitidocs: { + id: "haitidocs", name: "HaitiDocs", + baseUrl: process.env.AYITI_HAITIDOCS_URL || "https://www.haitidocs.org", + apiBase: process.env.AYITI_HAITIDOCS_API || "https://www.haitidocs.org/data/api", + mcp: process.env.AYITI_HAITIDOCS_MCP || "https://mcp.haitidocs.org", + }, +}); + +export function portalForMinistry(key) { + return GOV_PORTALS[String(key || "").toLowerCase()] || null; +} diff --git a/packages/ayiti-gov/src/executor.js b/packages/ayiti-gov/src/executor.js new file mode 100644 index 0000000..bb77849 --- /dev/null +++ b/packages/ayiti-gov/src/executor.js @@ -0,0 +1,129 @@ +import { assertAyitiModel, AYITI_OS } from "./models/registry.js"; +import * as haitiDocs from "./clients/haitidocs.js"; +import { ministryBrief, routeCitizenIntent, probePortal, translateAssist } from "./adapters/gov.js"; +import { GOV_PORTALS } from "./clients/portals.js"; + +function citationsFrom(payload) { + const out = []; + for (const r of payload?.results || payload?.knowledge?.results || []) { + if (r?.url || r?.title) out.push({ id: r.id, title: r.title, url: r.url, type: r.type }); + } + if (payload?.portal?.baseUrl) { + out.push({ id: `portal:${payload.portal.portal}`, title: payload.portal.name, url: payload.portal.baseUrl, type: "portal" }); + } + return out; +} + +function formatOutput(model, payload) { + if (payload?.summary) return payload.summary; + if (Array.isArray(payload?.results)) { + return [`${model.title} — ${payload.results.length} result(s)`, + ...payload.results.slice(0, 8).map((r, i) => `${i + 1}. [${r.type || "item"}] ${r.title}${r.url ? ` — ${r.url}` : ""}`), + ].join("\n"); + } + if (payload?.portal) { + const lines = [ + model.title, `Ministry: ${payload.ministry}`, + `Portal: ${payload.portal.baseUrl} (${payload.portal.reachable ? "up" : "down"})`, + ]; + for (const a of (payload.actions || []).slice(0, 5)) lines.push(`• ${a.label}: ${a.url}`); + for (const r of (payload.knowledge?.results || []).slice(0, 4)) lines.push(`• ${r.title}`); + return lines.join("\n"); + } + if (payload?.envelope) return JSON.stringify(payload.envelope, null, 2); + if (payload?.passThrough != null) return `${payload.summary}\n\n${payload.passThrough}`; + return JSON.stringify(payload, null, 2); +} + +function wrap(model, input, payload, started) { + return { + os: AYITI_OS, model: model.id, title: model.title, ministry: model.ministry, + input: String(input ?? ""), output: formatOutput(model, payload), data: payload, + provider: "ayiti-os-gov", latencyMs: Date.now() - started, citations: citationsFrom(payload), + }; +} + +export async function executeAyitiModel(modelId, input, meta = {}) { + const model = assertAyitiModel(modelId); + const started = Date.now(); + const text = String(input ?? ""); + + switch (model.id) { + case "ayiti.search": + return wrap(model, text, await haitiDocs.search(text, { limit: 8 }), started); + case "ayiti.stats": { + const catalog = await haitiDocs.getCatalog(); + const q = text.toLowerCase(); + const matched = q + ? catalog.series.filter((s) => JSON.stringify(s).toLowerCase().includes(q)) + : catalog.series.slice(0, 12); + let knowledge = null; + if (q && matched.length < 3) { + try { knowledge = await haitiDocs.search(q, { limit: 6, filters: { types: ["indicator"] } }); } + catch { /* ignore */ } + } + return wrap(model, text, { + summary: `AyitiStats / HaitiDocs: ${catalog.seriesCount} series; matched ${matched.length}`, + results: knowledge?.results || matched.slice(0, 10).map((s) => ({ id: s.series_id, title: s.series_id, url: s.csvUrl, type: "indicator" })), + matched: matched.slice(0, 15), ayitistats: GOV_PORTALS.ayitistats.baseUrl, + }, started); + } + case "ayiti.docs": { + const found = await haitiDocs.search(text, { limit: 5, filters: { types: ["doc"] } }); + return wrap(model, text, found, started); + } + case "ayiti.mef": return wrap(model, text, await ministryBrief("mef", text), started); + case "ayiti.dgi": return wrap(model, text, await ministryBrief("dgi", text), started); + case "ayiti.brh": { + const brief = await ministryBrief("brh", text || "inflation"); + try { brief.knowledge = await haitiDocs.search(`BRH ${text || "inflation"}`, { limit: 5 }); } catch { /* keep */ } + return wrap(model, text, brief, started); + } + case "ayiti.omrh": return wrap(model, text, await ministryBrief("omrh", text), started); + case "ayiti.cnmp": return wrap(model, text, await ministryBrief("cnmp", text), started); + case "ayiti.citizen": { + const route = routeCitizenIntent(text); + const nested = await executeAyitiModel(route.model, text, meta); + return wrap(model, text, { + route, summary: `Citizen request routed to ${route.model} (${route.reason})`, + result: nested.data, results: nested.citations, + }, started); + } + case "ayiti.uxp": { + let body; try { body = JSON.parse(text); } catch { body = { message: text }; } + return wrap(model, text, { + summary: "UXP inter-agency envelope ready", + envelope: { + protocol: "ayiti-uxp", version: "1.0", os: "Ayiti OS (GoV)", + createdAt: new Date().toISOString(), + from: body.from || "ayiti-os-gov", to: body.to || "agency:*", + classification: body.classification || "OFFICIAL", + payload: body.payload || body, + }, + }, started); + } + case "ayiti.translate": + return wrap(model, text, translateAssist(text, meta.lang || "ht"), started); + case "ayiti.alert": { + const knowledge = await haitiDocs.search(`Haiti alert emergency ${text}`, { limit: 6 }); + return wrap(model, text, { + summary: `Public alert brief — ${knowledge.results?.length || 0} sources`, + results: knowledge.results || [], + guidance: "Verify with official .gouv.ht channels before acting.", + }, started); + } + default: + throw new Error(`No executor for ${model.id}`); + } +} + +export async function healthcheck() { + const checks = {}; + try { checks.haitidocs = await haitiDocs.health(); } + catch (err) { checks.haitidocs = { status: "error", error: err.message }; } + for (const key of ["mef", "dgi", "brh", "omrh", "cnmp", "ayitistats"]) { + try { checks[key] = await probePortal(key); } + catch (err) { checks[key] = { error: err.message }; } + } + return { os: AYITI_OS, ok: checks.haitidocs?.status === "ok", checks }; +} diff --git a/packages/ayiti-gov/src/index.js b/packages/ayiti-gov/src/index.js new file mode 100644 index 0000000..2138053 --- /dev/null +++ b/packages/ayiti-gov/src/index.js @@ -0,0 +1,7 @@ +export { + AYITI_OS, AYITI_MODELS, listModels, getModel, assertAyitiModel, isAyitiModel, +} from "./models/registry.js"; +export { GOV_PORTALS, portalForMinistry } from "./clients/portals.js"; +export * as haitiDocs from "./clients/haitidocs.js"; +export { executeAyitiModel, healthcheck } from "./executor.js"; +export { routeCitizenIntent, probePortal, ministryBrief, translateAssist } from "./adapters/gov.js"; diff --git a/packages/ayiti-gov/src/models/registry.js b/packages/ayiti-gov/src/models/registry.js new file mode 100644 index 0000000..225492d --- /dev/null +++ b/packages/ayiti-gov/src/models/registry.js @@ -0,0 +1,35 @@ +export const AYITI_OS = Object.freeze({ + id: "ayiti-os", name: "Ayiti OS", edition: "GoV", country: "HT", version: "7.1.0", +}); + +export const AYITI_MODELS = Object.freeze({ + "ayiti.search": { id: "ayiti.search", title: "Ayiti Knowledge Search", ministry: "HaitiDocs", liveApi: true }, + "ayiti.stats": { id: "ayiti.stats", title: "AyitiStats Indicators", ministry: "IHSI / AyitiStats", liveApi: true }, + "ayiti.docs": { id: "ayiti.docs", title: "Ayiti Documents", ministry: "Cross-government", liveApi: true }, + "ayiti.mef": { id: "ayiti.mef", title: "MEF Finance Desk", ministry: "MEF", liveApi: true }, + "ayiti.dgi": { id: "ayiti.dgi", title: "DGI Tax Services", ministry: "DGI", liveApi: true }, + "ayiti.brh": { id: "ayiti.brh", title: "BRH Monetary Desk", ministry: "BRH", liveApi: true }, + "ayiti.omrh": { id: "ayiti.omrh", title: "OMRH Public Admin", ministry: "OMRH", liveApi: true }, + "ayiti.cnmp": { id: "ayiti.cnmp", title: "CNMP Procurement", ministry: "CNMP", liveApi: true }, + "ayiti.citizen": { id: "ayiti.citizen", title: "Citizen Services Router", ministry: "Ayiti OS GoV", liveApi: true }, + "ayiti.uxp": { id: "ayiti.uxp", title: "UXP Inter-Agency Exchange", ministry: "UXP", liveApi: false }, + "ayiti.translate": { id: "ayiti.translate", title: "Kreyòl / Français / English assist", ministry: "Ayiti OS GoV", liveApi: false }, + "ayiti.alert": { id: "ayiti.alert", title: "Public alert summarizer", ministry: "Ayiti OS GoV", liveApi: true }, +}); + +export function listModels() { return Object.values(AYITI_MODELS); } +export function getModel(id) { + const key = String(id || "").trim().toLowerCase(); + const normalized = key.startsWith("ayiti.") ? key : `ayiti.${key}`; + return AYITI_MODELS[normalized] || AYITI_MODELS[key] || null; +} +export function isAyitiModel(id) { return Boolean(getModel(id)); } +export function assertAyitiModel(id) { + const m = getModel(id); + if (!m) { + const err = new Error(`Forbidden model '${id}'. Ayiti OS GoV allows: ${Object.keys(AYITI_MODELS).join(", ")}`); + err.code = "AYITI_MODEL_FORBIDDEN"; + throw err; + } + return m; +} diff --git a/packages/ayiti-gov/test/ayiti.test.js b/packages/ayiti-gov/test/ayiti.test.js new file mode 100644 index 0000000..0568750 --- /dev/null +++ b/packages/ayiti-gov/test/ayiti.test.js @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + listModels, assertAyitiModel, isAyitiModel, routeCitizenIntent, + executeAyitiModel, translateAssist, +} from "../src/index.js"; + +describe("ayiti policy", () => { + it("only ayiti models", () => { + assert.ok(listModels().every((m) => m.id.startsWith("ayiti."))); + assert.equal(isAyitiModel("models5"), false); + assert.throws(() => assertAyitiModel("echo"), /Forbidden/); + }); + + it("routes citizen intents", () => { + assert.equal(routeCitizenIntent("NIF nan DGI").model, "ayiti.dgi"); + assert.equal(routeCitizenIntent("inflation BRH").model, "ayiti.brh"); + assert.equal(routeCitizenIntent("siklon urgence").model, "ayiti.alert"); + }); + + it("translate assist hits glossary", () => { + const t = translateAssist("citizen tax budget", "ht"); + assert.ok(t.glossaryHits.length >= 2); + }); +}); + +describe("live apis", () => { + it("search", async () => { + const r = await executeAyitiModel("ayiti.search", "inflation Haiti"); + assert.equal(r.provider, "ayiti-os-gov"); + assert.ok(r.output.length > 0); + }); + + it("stats", async () => { + const r = await executeAyitiModel("ayiti.stats", "displacement"); + assert.match(r.output, /series|matched|catalog/i); + }); + + it("uxp + translate offline", async () => { + const u = await executeAyitiModel("ayiti.uxp", JSON.stringify({ from: "mef", to: "dgi" })); + assert.equal(u.data.envelope.protocol, "ayiti-uxp"); + const t = await executeAyitiModel("ayiti.translate", "government tax"); + assert.match(t.output, /glossary|term/i); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..0eb2f1e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@frc/core", + "version": "7.1.0", + "type": "module", + "main": "src/index.js", + "exports": { ".": "./src/index.js" }, + "scripts": { "test": "node --test test/*.test.js" }, + "dependencies": { "redis": "^4.7.0" }, + "license": "MIT" +} diff --git a/packages/core/src/index.js b/packages/core/src/index.js new file mode 100644 index 0000000..00f5258 --- /dev/null +++ b/packages/core/src/index.js @@ -0,0 +1,202 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createClient } from "redis"; + +export const DEMO_KEY = "frc_test_key"; +export const AYITI_DEMO_KEY = "ayiti_gov_test_key"; + +export function loadApiKeys() { + const keys = new Set([DEMO_KEY, AYITI_DEMO_KEY]); + if (process.env.FRC_API_KEY) keys.add(process.env.FRC_API_KEY.trim()); + if (process.env.AYITI_API_KEY) keys.add(process.env.AYITI_API_KEY.trim()); + if (process.env.FRC_API_KEYS) { + for (const k of process.env.FRC_API_KEYS.split(",")) if (k.trim()) keys.add(k.trim()); + } + if (process.env.NODE_ENV === "production" && process.env.FRC_STRICT_AUTH === "1") { + keys.delete(DEMO_KEY); + keys.delete(AYITI_DEMO_KEY); + } + return keys; +} + +export function generateApiKey(prefix = "frc_live_") { + return `${prefix}${randomBytes(24).toString("hex")}`; +} + +export function hashKey(key) { + return createHash("sha256").update(String(key)).digest("hex").slice(0, 16); +} + +export function validateApiKey(key, keySet = loadApiKeys()) { + if (!key) return { ok: false, reason: "missing_api_key" }; + const trimmed = key.trim(); + for (const allowed of keySet) { + const a = Buffer.from(trimmed), b = Buffer.from(allowed); + if (a.length === b.length && timingSafeEqual(a, b)) { + return { ok: true, keyHash: hashKey(trimmed) }; + } + } + return { ok: false, reason: "invalid_api_key" }; +} + +export function createAuthMiddleware() { + const keys = loadApiKeys(); + return (req, res, next) => { + const key = req.headers["x-api-key"] || + (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : undefined); + const result = validateApiKey(key, keys); + if (!result.ok) { + res.status(401).json({ error: "unauthorized", reason: result.reason }); + return; + } + req.apiKey = key; + req.apiKeyHash = result.keyHash; + next(); + }; +} + +export const REGIONS = Object.freeze({ + ht: { id: "ht", name: "Haiti", countries: new Set(["HT"]) }, + eu: { id: "eu", name: "Europe", countries: new Set(["FR", "DE", "BE", "NL", "ES", "IT", "GB", "CH"]) }, + us: { id: "us", name: "North America", countries: new Set(["US", "CA", "MX"]) }, + asia: { id: "asia", name: "Asia Pacific", countries: new Set(["CN", "JP", "KR", "IN", "SG"]) }, +}); + +export function resolveRegion({ region, country, ip } = {}) { + const explicit = String(region || "").toLowerCase(); + if (REGIONS[explicit]) return explicit; + const c = String(country || "").toUpperCase(); + for (const r of Object.values(REGIONS)) if (r.countries.has(c)) return r.id; + if (c === "HT" || process.env.FRC_DEFAULT_REGION === "ht") return "ht"; + return "eu"; +} + +export function describeRoute(input = {}) { + const region = resolveRegion(input); + return { region, name: REGIONS[region].name, reason: input.region ? "explicit" : input.country ? "country" : "default" }; +} + +// ---- Queue + metrics ---- +let redisClient = null, redisMode = null; +const memoryQueue = [], memoryJobs = new Map(); +const metrics = { enqueued: 0, completed: 0, failed: 0, startedAt: Date.now() }; + +export function useMemoryQueue() { + redisMode = "memory"; redisClient = null; memoryQueue.length = 0; memoryJobs.clear(); +} + +async function getRedis() { + if (redisMode === "memory") return null; + if (redisClient?.isOpen) return redisClient; + try { + const client = createClient({ url: process.env.REDIS_URL || "redis://127.0.0.1:6379" }); + client.on("error", () => {}); + await client.connect(); + redisClient = client; redisMode = "redis"; + return client; + } catch { + redisMode = "memory"; return null; + } +} + +export async function enqueueJob(data) { + const id = data.id || randomUUID(); + const job = { + id, model: data.model, input: data.input, region: data.region || "ht", + meta: data.meta || {}, status: "queued", createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), result: null, error: null, + }; + const client = await getRedis(); + if (client) { + await client.set(`frc:job:${id}`, JSON.stringify(job)); + await client.lPush("frc:jobs", id); + } else { + memoryJobs.set(id, job); memoryQueue.push(id); + } + metrics.enqueued++; + return { id, status: "queued" }; +} + +export async function dequeueJob({ timeoutSec = 1 } = {}) { + const client = await getRedis(); + let id = null; + if (client) id = (await client.brPop("frc:jobs", timeoutSec))?.element || null; + else { + id = memoryQueue.shift() || null; + if (!id && timeoutSec > 0) { await new Promise((r) => setTimeout(r, 40)); id = memoryQueue.shift() || null; } + } + if (!id) return null; + const job = await getJob(id); + if (!job) return null; + job.status = "running"; job.updatedAt = new Date().toISOString(); + await saveJob(job); + return job; +} + +export async function getJob(id) { + const client = await getRedis(); + if (client) { + const raw = await client.get(`frc:job:${id}`); + return raw ? JSON.parse(raw) : null; + } + return memoryJobs.get(id) || null; +} + +async function saveJob(job) { + job.updatedAt = new Date().toISOString(); + const client = await getRedis(); + if (client) await client.set(`frc:job:${job.id}`, JSON.stringify(job)); + else memoryJobs.set(job.id, job); +} + +export async function completeJob(id, result) { + const job = await getJob(id); + if (!job) throw new Error(`Unknown job ${id}`); + job.status = "completed"; job.result = result; job.error = null; + await saveJob(job); metrics.completed++; return job; +} + +export async function failJob(id, error) { + const job = await getJob(id); + if (!job) throw new Error(`Unknown job ${id}`); + job.status = "failed"; job.error = String(error?.message || error); + await saveJob(job); metrics.failed++; return job; +} + +export async function waitForJob(id, { timeoutMs = 30000, intervalMs = 80 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const job = await getJob(id); + if (!job) throw new Error(`Unknown job ${id}`); + if (job.status === "completed" || job.status === "failed") return job; + await new Promise((r) => setTimeout(r, intervalMs)); + } + const err = new Error(`Timed out waiting for job ${id}`); + err.name = "FRCTimeoutError"; + throw err; +} + +export function getMetrics() { + return { + ...metrics, + queueBackend: redisMode || "pending", + uptimeSec: Math.floor((Date.now() - metrics.startedAt) / 1000), + queueDepth: memoryQueue.length, + }; +} + +export function queueBackend() { return redisMode || "pending"; } + +/** Fire-and-forget webhook after job completion */ +export async function deliverWebhook(url, payload) { + if (!url) return { skipped: true }; + try { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": "FRC7-Webhook/7.1" }, + body: JSON.stringify(payload), + }); + return { ok: res.ok, status: res.status }; + } catch (err) { + return { ok: false, error: err.message }; + } +} diff --git a/packages/core/test/core.test.js b/packages/core/test/core.test.js new file mode 100644 index 0000000..91c3aed --- /dev/null +++ b/packages/core/test/core.test.js @@ -0,0 +1,25 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { + validateApiKey, DEMO_KEY, resolveRegion, useMemoryQueue, + enqueueJob, dequeueJob, completeJob, getMetrics, +} from "../src/index.js"; + +describe("core", () => { + before(() => useMemoryQueue()); + + it("auth + region", () => { + assert.equal(validateApiKey(DEMO_KEY).ok, true); + assert.equal(resolveRegion({ country: "HT" }), "ht"); + assert.equal(resolveRegion({ country: "US" }), "us"); + }); + + it("queue lifecycle + metrics", async () => { + const { id } = await enqueueJob({ model: "ayiti.search", input: "x", region: "ht" }); + const job = await dequeueJob(); + assert.equal(job.id, id); + await completeJob(id, { output: "ok" }); + assert.ok(getMetrics().enqueued >= 1); + assert.ok(getMetrics().completed >= 1); + }); +}); diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..1626c76 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,10 @@ +{ + "name": "@frc/engine", + "version": "7.1.0", + "type": "module", + "main": "src/index.js", + "exports": { ".": "./src/index.js" }, + "scripts": { "test": "node --test test/*.test.js" }, + "dependencies": { "@frc/frcl": "7.1.0", "@ayiti/gov": "7.1.0" }, + "license": "MIT" +} diff --git a/packages/engine/src/index.js b/packages/engine/src/index.js new file mode 100644 index 0000000..96026de --- /dev/null +++ b/packages/engine/src/index.js @@ -0,0 +1,75 @@ +import { analyze } from "@frc/frcl"; +import { executeAyitiModel, isAyitiModel, assertAyitiModel, listModels } from "@ayiti/gov"; + +/** FRC7 default policy: Ayiti OS GoV models preferred; builtin fallback for local demos */ +export function resolveMode(model) { + if (isAyitiModel(model)) return "ayiti"; + if (process.env.FRC_ALLOW_BUILTIN === "1" && ["echo", "summarizer", "coder"].includes(String(model))) { + return "builtin"; + } + return "forbidden"; +} + +async function executeBuiltin(model, input) { + const started = Date.now(); + const text = String(input ?? ""); + let output = text; + if (model === "summarizer") output = text.slice(0, 180) + (text.length > 180 ? "..." : ""); + else if (model === "coder") output = `// FRC7 scaffold\nexport const prompt = ${JSON.stringify(text.slice(0, 120))};\n`; + await new Promise((r) => setTimeout(r, 2)); + return { + output, + provider: "builtin", + model, + latencyMs: Date.now() - started, + usage: { inputChars: text.length, outputChars: output.length }, + }; +} + +export async function executeJob({ model, input, meta = {} }) { + if (!model) throw new Error("model required"); + if (input == null) throw new Error("input required"); + const mode = resolveMode(model); + if (mode === "ayiti") { + assertAyitiModel(model); + return executeAyitiModel(model, input, meta); + } + if (mode === "builtin") return executeBuiltin(model, input); + const allowed = listModels().map((m) => m.id).join(", "); + const err = new Error(`Model '${model}' not allowed. Use Ayiti OS GoV models: ${allowed}`); + err.code = "FRC_MODEL_FORBIDDEN"; + throw err; +} + +export async function executeFrcl(source, options = {}) { + const { ast, plan, validation } = analyze(source); + if (!validation.ok) { + const err = new Error(`Invalid FRCL: ${validation.errors.join("; ")}`); + err.name = "FRCLValidationError"; + throw err; + } + const results = []; + for (const run of plan.runs) { + const attempts = Math.max(1, Number(run.retry ?? 0) + 1); + let lastErr; + for (let a = 1; a <= attempts; a++) { + try { + const result = await executeJob({ + model: run.model, + input: run.input, + meta: { ...options, env: plan.env, region: options.region || plan.region, lang: run.lang || plan.lang, stream: run.stream, webhook: run.webhook }, + }); + results.push(result); + lastErr = null; + break; + } catch (e) { + lastErr = e; + if (a < attempts) await new Promise((r) => setTimeout(r, 40 * a)); + } + } + if (lastErr) throw lastErr; + } + return { ast, plan, validation, results, output: results.map((r) => r.output).join("\n\n") }; +} + +export { listModels }; diff --git a/packages/engine/test/engine.test.js b/packages/engine/test/engine.test.js new file mode 100644 index 0000000..8dfc050 --- /dev/null +++ b/packages/engine/test/engine.test.js @@ -0,0 +1,19 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { executeJob, executeFrcl, resolveMode } from "../src/index.js"; + +describe("engine policy", () => { + it("prefers ayiti models", () => { + assert.equal(resolveMode("ayiti.search"), "ayiti"); + assert.equal(resolveMode("models5"), "forbidden"); + }); + + it("rejects forbidden models", async () => { + await assert.rejects(() => executeJob({ model: "models5", input: "x" }), /not allowed|Forbidden/); + }); + + it("runs ayiti translate via frcl", async () => { + const out = await executeFrcl(`run model ayiti.translate { input "citizen tax" }\nprint result`); + assert.ok(out.output.length > 0); + }); +}); diff --git a/packages/frcl/package.json b/packages/frcl/package.json new file mode 100644 index 0000000..fcef679 --- /dev/null +++ b/packages/frcl/package.json @@ -0,0 +1,9 @@ +{ + "name": "@frc/frcl", + "version": "7.1.0", + "type": "module", + "main": "src/index.js", + "exports": { ".": "./src/index.js" }, + "scripts": { "test": "node --test test/*.test.js" }, + "license": "MIT" +} diff --git a/packages/frcl/src/index.js b/packages/frcl/src/index.js new file mode 100644 index 0000000..5458dfc --- /dev/null +++ b/packages/frcl/src/index.js @@ -0,0 +1,19 @@ +export { tokenize, TokenType } from "./tokenize.js"; +export { parse, compile, toPlan, validatePlan, analyze } from "./parser.js"; + +import { analyze as analyzeSource } from "./parser.js"; + +/** Lint helper for IDEs / CLI */ +export function lint(source) { + try { + const result = analyzeSource(source); + return { + ok: result.validation.ok, + errors: result.validation.errors, + warnings: result.validation.warnings, + plan: result.plan, + }; + } catch (err) { + return { ok: false, errors: [err.message], warnings: [], plan: null }; + } +} diff --git a/packages/frcl/src/parser.js b/packages/frcl/src/parser.js new file mode 100644 index 0000000..9ad88ee --- /dev/null +++ b/packages/frcl/src/parser.js @@ -0,0 +1,128 @@ +import { tokenize, TokenType } from "./tokenize.js"; + +export function parse(source) { + const tokens = tokenize(source); + let pos = 0; + const peek = () => tokens[pos]; + const at = (type, value) => peek().type === type && (value === undefined || peek().value === value); + const advance = () => tokens[pos++]; + const skip = () => { while (at(TokenType.NEWLINE)) advance(); }; + const expect = (type, value) => { + if (!at(type, value)) throw parseErr(peek(), `Expected ${value || type}`); + return advance(); + }; + const stringOrIdent = () => { + if (at(TokenType.STRING) || at(TokenType.IDENT) || at(TokenType.KEYWORD) || at(TokenType.NUMBER)) return advance().value; + throw parseErr(peek(), "Expected value"); + }; + const block = () => { + expect(TokenType.LBRACE); skip(); + const props = {}; + while (!at(TokenType.RBRACE) && !at(TokenType.EOF)) { + if (at(TokenType.NEWLINE)) { advance(); continue; } + const key = advance().value; + props[key] = stringOrIdent(); + skip(); + } + expect(TokenType.RBRACE); + return props; + }; + + const body = []; + skip(); + while (!at(TokenType.EOF)) { + const t = peek(); + if (at(TokenType.KEYWORD, "set")) { + advance(); expect(TokenType.KEYWORD, "env"); + body.push({ type: "SetEnv", value: stringOrIdent() }); + } else if (at(TokenType.KEYWORD, "network") || at(TokenType.KEYWORD, "docker")) { + const kind = advance().value; + const name = stringOrIdent(); skip(); + body.push({ type: kind === "network" ? "Network" : "Docker", name, properties: block() }); + } else if (at(TokenType.KEYWORD, "use")) { + advance(); expect(TokenType.KEYWORD, "model"); + body.push({ type: "UseModel", model: stringOrIdent() }); + } else if (at(TokenType.KEYWORD, "run")) { + advance(); + if (at(TokenType.KEYWORD, "model")) advance(); + const model = stringOrIdent(); skip(); + let props = {}; + if (at(TokenType.LBRACE)) props = block(); + else if (at(TokenType.KEYWORD, "input")) { advance(); props.input = stringOrIdent(); } + if (!props.input) throw parseErr(t, "run requires input"); + body.push({ + type: "RunModel", + model, + input: props.input, + stream: props.stream === "true", + timeout: props.timeout ? Number(props.timeout) : null, + retry: props.retry ? Number(props.retry) : null, + webhook: props.webhook || null, + lang: props.lang || null, + }); + } else if (at(TokenType.KEYWORD, "print")) { + advance(); if (at(TokenType.KEYWORD, "result") || at(TokenType.IDENT, "result")) advance(); + body.push({ type: "Print" }); + } else if (at(TokenType.KEYWORD, "region") || at(TokenType.KEYWORD, "connect") || at(TokenType.KEYWORD, "auth") || at(TokenType.KEYWORD, "lang")) { + const kind = advance().value; + body.push({ type: kind[0].toUpperCase() + kind.slice(1), value: stringOrIdent() }); + } else if (at(TokenType.KEYWORD, "batch")) { + advance(); skip(); + const props = at(TokenType.LBRACE) ? block() : {}; + body.push({ type: "Batch", ...props }); + } else { + throw parseErr(t, `Unexpected '${t.value}'`); + } + skip(); + } + return { type: "Program", body }; +} + +export function toPlan(ast) { + const plan = { + env: "prod", networks: {}, docker: null, model: null, runs: [], + region: null, connect: null, auth: null, lang: "ht", print: false, batch: false, + }; + for (const n of ast.body) { + if (n.type === "SetEnv") plan.env = n.value; + else if (n.type === "Network") plan.networks[n.name] = n.properties; + else if (n.type === "Docker") plan.docker = { name: n.name, ...n.properties }; + else if (n.type === "UseModel") plan.model = n.model; + else if (n.type === "RunModel") { + plan.runs.push({ model: n.model || plan.model, input: n.input, stream: n.stream, timeout: n.timeout, retry: n.retry, webhook: n.webhook, lang: n.lang }); + if (!plan.model) plan.model = n.model; + } + else if (n.type === "Print") plan.print = true; + else if (n.type === "Region") plan.region = String(n.value).toLowerCase(); + else if (n.type === "Connect") plan.connect = n.value; + else if (n.type === "Auth") plan.auth = n.value; + else if (n.type === "Lang") plan.lang = n.value; + else if (n.type === "Batch") plan.batch = true; + } + return plan; +} + +export function compile(source) { + const ast = parse(source); + return { ast, plan: toPlan(ast) }; +} + +export function validatePlan(plan) { + const errors = [], warnings = []; + if (!plan?.runs?.length) errors.push("No run model statements"); + for (const [i, run] of (plan.runs || []).entries()) { + if (!run.model) errors.push(`Run #${i + 1} missing model`); + if (!run.input?.trim?.()) errors.push(`Run #${i + 1} empty input`); + } + return { ok: errors.length === 0, errors, warnings }; +} + +export function analyze(source) { + const { ast, plan } = compile(source); + return { ast, plan, validation: validatePlan(plan) }; +} + +function parseErr(token, message) { + const e = new Error(`FRCL parse error at ${token.line}:${token.column}: ${message}`); + e.name = "FRCLParseError"; return e; +} diff --git a/packages/frcl/src/tokenize.js b/packages/frcl/src/tokenize.js new file mode 100644 index 0000000..b7a56f7 --- /dev/null +++ b/packages/frcl/src/tokenize.js @@ -0,0 +1,67 @@ +export const TokenType = Object.freeze({ + KEYWORD: "KEYWORD", + IDENT: "IDENT", + STRING: "STRING", + NUMBER: "NUMBER", + LBRACE: "LBRACE", + RBRACE: "RBRACE", + NEWLINE: "NEWLINE", + EOF: "EOF", +}); + +const KEYWORDS = new Set([ + "set", "env", "network", "docker", "use", "model", "run", "input", + "print", "result", "region", "auth", "connect", "timeout", "retry", + "stream", "batch", "webhook", "lang", +]); + +export function tokenize(source) { + const tokens = []; + let i = 0, line = 1, column = 1; + const push = (type, value, startCol = column) => tokens.push({ type, value, line, column: startCol }); + + while (i < source.length) { + const ch = source[i]; + if (ch === "\n") { push(TokenType.NEWLINE, "\n"); i++; line++; column = 1; continue; } + if (ch === " " || ch === "\t" || ch === "\r") { i++; column++; continue; } + if (ch === "#" || (ch === "/" && source[i + 1] === "/")) { + while (i < source.length && source[i] !== "\n") { i++; column++; } + continue; + } + if (ch === "{") { push(TokenType.LBRACE, "{"); i++; column++; continue; } + if (ch === "}") { push(TokenType.RBRACE, "}"); i++; column++; continue; } + if (ch === '"') { + const startCol = column; i++; column++; + let value = ""; + while (i < source.length && source[i] !== '"') { + if (source[i] === "\\" && i + 1 < source.length) { + const next = source[i + 1]; + const escapes = { n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" }; + value += escapes[next] ?? next; i += 2; column += 2; continue; + } + if (source[i] === "\n") throw err(line, column, "Unterminated string"); + value += source[i]; i++; column++; + } + if (i >= source.length) throw err(line, startCol, "Unterminated string"); + i++; column++; push(TokenType.STRING, value, startCol); continue; + } + if (/[0-9]/.test(ch)) { + const startCol = column; let value = ""; + while (i < source.length && /[0-9.]/.test(source[i])) { value += source[i]; i++; column++; } + push(TokenType.NUMBER, value, startCol); continue; + } + if (/[A-Za-z_.-]/.test(ch)) { + const startCol = column; let value = ""; + while (i < source.length && /[A-Za-z0-9_.:\/@-]/.test(source[i])) { value += source[i]; i++; column++; } + push(KEYWORDS.has(value) ? TokenType.KEYWORD : TokenType.IDENT, value, startCol); continue; + } + throw err(line, column, `Unexpected '${ch}'`); + } + push(TokenType.EOF, ""); + return tokens; +} + +function err(line, column, message) { + const e = new Error(`FRCL syntax error at ${line}:${column}: ${message}`); + e.name = "FRCLSyntaxError"; e.line = line; e.column = column; return e; +} diff --git a/packages/frcl/test/parser.test.js b/packages/frcl/test/parser.test.js new file mode 100644 index 0000000..a61d933 --- /dev/null +++ b/packages/frcl/test/parser.test.js @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { analyze, lint, compile } from "../src/index.js"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + +describe("frcl", () => { + it("parses demo.frcl", () => { + const { plan } = compile(readFileSync(join(root, "demo.frcl"), "utf8")); + assert.equal(plan.model, "ayiti.stats"); + assert.equal(plan.region, "ht"); + assert.equal(plan.runs.length, 1); + }); + + it("lints valid and invalid scripts", () => { + assert.equal(lint('run model ayiti.search { input "x" }').ok, true); + assert.equal(lint('set env "prod"').ok, false); + }); + + it("supports webhook/lang/retry props", () => { + const { plan } = analyze(`run model ayiti.search { + input "hi" + retry 2 + lang ht + webhook https://example.com/hook + }`); + assert.equal(plan.runs[0].retry, 2); + assert.equal(plan.runs[0].lang, "ht"); + assert.equal(plan.runs[0].webhook, "https://example.com/hook"); + }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000..38194ea --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,9 @@ +{ + "name": "@frc/sdk", + "version": "7.1.0", + "type": "module", + "main": "src/index.js", + "exports": { ".": "./src/index.js" }, + "scripts": { "test": "node --test test/*.test.js" }, + "license": "MIT" +} diff --git a/packages/sdk/src/index.js b/packages/sdk/src/index.js new file mode 100644 index 0000000..f4e4871 --- /dev/null +++ b/packages/sdk/src/index.js @@ -0,0 +1,51 @@ +export class FRCClient { + constructor(options = {}) { + this.baseUrl = (options.baseUrl || process.env.FRC_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + this.apiKey = options.apiKey || process.env.FRC_API_KEY || process.env.AYITI_API_KEY || "ayiti_gov_test_key"; + this.fetch = options.fetch || globalThis.fetch; + } + + async #req(path, { method = "GET", body, country, region } = {}) { + const res = await this.fetch(`${this.baseUrl}${path}`, { + method, + headers: { + "content-type": "application/json", + "x-api-key": this.apiKey, + ...(country ? { "x-country": country } : {}), + ...(region ? { "x-frc-region": region } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `HTTP ${res.status}`); + err.status = res.status; err.data = data; throw err; + } + return data; + } + + health() { return this.#req("/health"); } + models() { return this.#req("/v1/models"); } + metrics() { return this.#req("/v1/metrics"); } + route(body = {}) { return this.#req("/v1/route", { method: "POST", body }); } + run(model, input, opts = {}) { + return this.#req(`/v1/run/${encodeURIComponent(model)}`, { + method: "POST", + body: { input, sync: opts.sync !== false, ...opts }, + country: opts.country, region: opts.region, + }); + } + batch(jobs, opts = {}) { + return this.#req("/v1/batch", { method: "POST", body: { jobs, sync: opts.sync !== false } }); + } + execute(source, opts = {}) { + return this.#req("/v1/execute", { method: "POST", body: { source, sync: opts.sync !== false, ...opts } }); + } + lint(source) { return this.#req("/v1/lint", { method: "POST", body: { source } }); } + job(id) { return this.#req(`/v1/jobs/${encodeURIComponent(id)}`); } + wait(id, { timeoutMs = 30000 } = {}) { + return this.#req(`/v1/jobs/${encodeURIComponent(id)}/wait?timeoutMs=${timeoutMs}`); + } +} + +export default FRCClient; diff --git a/packages/sdk/test/sdk.test.js b/packages/sdk/test/sdk.test.js new file mode 100644 index 0000000..4c3e103 --- /dev/null +++ b/packages/sdk/test/sdk.test.js @@ -0,0 +1,20 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { FRCClient } from "../src/index.js"; + +describe("sdk", () => { + it("builds run requests", async () => { + const calls = []; + const client = new FRCClient({ + baseUrl: "http://frc.test", + apiKey: "k", + fetch: async (url, init) => { + calls.push({ url, init }); + return { ok: true, json: async () => ({ status: "completed" }) }; + }, + }); + await client.run("ayiti.search", "hi", { country: "HT" }); + assert.equal(calls[0].url, "http://frc.test/v1/run/ayiti.search"); + assert.equal(calls[0].init.headers["x-api-key"], "k"); + }); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100755 index 0000000..e39cb6a --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +process.env.FRC_DEFAULT_REGION ||= "ht"; +process.env.PORT ||= "3000"; +const child = spawn(process.execPath, ["apps/gateway/src/server.js"], { stdio: "inherit", env: process.env }); +process.on("SIGINT", () => child.kill("SIGTERM")); +process.on("SIGTERM", () => child.kill("SIGTERM")); diff --git a/scripts/lint.mjs b/scripts/lint.mjs new file mode 100755 index 0000000..d65b2a6 --- /dev/null +++ b/scripts/lint.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const root = process.cwd(); +const errors = []; +const required = [ + "package.json", "packages/frcl/src/index.js", "packages/engine/src/index.js", + "packages/core/src/index.js", "packages/ayiti-gov/src/index.js", + "apps/gateway/src/app.js", "apps/cli/src/index.js", "README.md", "demo.frcl", +]; +for (const p of required) if (!existsSync(join(root, p))) errors.push(`missing ${p}`); + +const secretRe = new RegExp(["sk-", "live-"].join("") + "|AKIA" + "[0-9A-Z]{16}"); +function walk(dir) { + for (const name of readdirSync(dir)) { + if (["node_modules", ".git", "img"].includes(name)) continue; + const p = join(dir, name); + const st = statSync(p); + if (st.isDirectory()) walk(p); + else if (/\.(js|mjs|yml|yaml|md|frcl)$/.test(name) && !p.endsWith("scripts/lint.mjs")) { + if (secretRe.test(readFileSync(p, "utf8"))) errors.push(`possible secret in ${p}`); + } + } +} +walk(root); +if (errors.length) { console.error("lint failed:"); errors.forEach((e) => console.error(" -", e)); process.exit(1); } +console.log("lint ok"); diff --git a/tests/integration/e2e.test.js b/tests/integration/e2e.test.js new file mode 100644 index 0000000..7189db2 --- /dev/null +++ b/tests/integration/e2e.test.js @@ -0,0 +1,43 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { useMemoryQueue, AYITI_DEMO_KEY } from "@frc/core"; +import { createApp } from "@frc/gateway/app"; +import { FRCClient } from "@frc/sdk"; + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) }); + }); + }); +} + +describe("e2e", () => { + let ctx, client; + before(async () => { + useMemoryQueue(); + ctx = await listen(createApp()); + client = new FRCClient({ baseUrl: ctx.url, apiKey: AYITI_DEMO_KEY }); + }); + after(async () => ctx.close()); + + it("sdk run + metrics + async tick", async () => { + const done = await client.run("ayiti.translate", "government tax", { sync: true, region: "ht" }); + assert.equal(done.status, "completed"); + + const queued = await client.run("ayiti.uxp", "{\"from\":\"a\",\"to\":\"b\"}", { sync: false }); + assert.equal(queued.status, "queued"); + + const tick = await fetch(`${ctx.url}/v1/worker/tick`, { + method: "POST", + headers: { "x-api-key": AYITI_DEMO_KEY }, + }); + assert.equal(tick.status, 200); + const job = await client.wait(queued.jobId, { timeoutMs: 5000 }); + assert.equal(job.status, "completed"); + + const metrics = await client.metrics(); + assert.ok(metrics.enqueued >= 1); + }); +});