Updates
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
*.log
|
||||
hyper-p2p-intent-router-storage/
|
||||
test-*/
|
||||
example-*/
|
||||
.DS_Store
|
||||
*.tmp
|
||||
coverage/
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.0] - 2026-05-20
|
||||
|
||||
### Added
|
||||
- Real Hyperswarm + Protomux v3 wiring via `../_shared/p2p-bare.js` (where applicable)
|
||||
- 2-node integration test under `real_tests/integration/`
|
||||
|
||||
### Changed
|
||||
- Protomux v3: `createChannel` + `addMessage` + `channel.open()`
|
||||
|
||||
## [0.1.1] - 2026-05-20
|
||||
|
||||
### Fixed
|
||||
- Migrated tests from `bare-test` to `brittle` / `brittle-bare`
|
||||
- `hypercore-crypto` for keyPair, sign, verify, hash
|
||||
- `bare-process/global` and `bare-process` v4 imports
|
||||
- Background timers opt-in (`enableBackgroundTimers`, `enableGossip`) for clean test exit
|
||||
<!-- legacy: v0.2.0 -->
|
||||
|
||||
- Production-grade docs, validation, and expanded tests.
|
||||
<!-- legacy: v0.3.0 -->
|
||||
|
||||
- Wave 6: presence-tier API tables, architecture wire section, validation test.
|
||||
|
||||
<!-- legacy: v0.3.1 -->
|
||||
|
||||
- Wave 7: correct protocol in docs, getStats(), wire tables, category README.
|
||||
## [0.3.2] - 2026-05-21
|
||||
|
||||
### Changed
|
||||
- Exhaustive documentation pass (api, architecture, README) per DOC_STANDARDS.md.
|
||||
@@ -0,0 +1,43 @@
|
||||
# hyper-p2p-intent-router
|
||||
|
||||
HyperP2PIntentRouter Novel intent-based P2P routing primitive. Peers register declarative intents (capabilities, topics, goals). Messages routed by matching intents using capability overlap + keyword similarity.
|
||||
|
||||
**Category:** Routing & paths
|
||||
|
||||
**Composes with:** `hyper-p2p-relay-tunnel`, `hyper-p2p-merge-registry`
|
||||
|
||||
**Protocol:** `hyper-p2p-intent-router/v1`
|
||||
|
||||
## When to use
|
||||
|
||||
Multi-peer apps that need routing & paths over a shared Hyperswarm topic.
|
||||
|
||||
## When not to use
|
||||
|
||||
Single-process tools with no P2P topic (use local APIs only or skip `ready()`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```js
|
||||
const { HyperP2PIntentRouter } = require('hyper-p2p-intent-router')
|
||||
const topic = process.argv[2] // 64-char hex or string
|
||||
const mod = new HyperP2PIntentRouter({ topic, enableBackgroundTimers: false })
|
||||
await mod.ready() // joins swarm when topic set
|
||||
// ... application logic ...
|
||||
await mod.close()
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [docs/api.md](docs/api.md) — constructor, methods, events, errors
|
||||
- [docs/architecture.md](docs/architecture.md) — wire types, state, composition
|
||||
- [../_shared/PRODUCTION.md](../../_shared/PRODUCTION.md) — production checklist
|
||||
- [../_shared/DOC_STANDARDS.md](../../_shared/DOC_STANDARDS.md) — documentation standards
|
||||
|
||||
- Integration: [`../../real_tests/integration/`](../../../real_tests/integration/) — `intent-router-two-node.js`
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm install && npm test
|
||||
```
|
||||
@@ -0,0 +1,234 @@
|
||||
# API: hyper-p2p-intent-router
|
||||
|
||||
**Protocol:** `hyper-p2p-intent-router/v1`
|
||||
|
||||
**Export:** `HyperP2PIntentRouter` (class), `INTENT_PROTOCOL` (string constant)
|
||||
|
||||
## Overview
|
||||
|
||||
`HyperP2PIntentRouter` is an intent-based P2P routing and service-discovery primitive for Bare/Pear. Peers register **declarative intents** (capabilities, topics, description, priority). The router **resolves** selectors against local and remote intent catalogs using capability overlap (Jaccard-like) plus keyword matching, then **routes messages** to the best-scoring intent holder over a Protomux channel.
|
||||
|
||||
Persistence uses **Hyperbee** on a local Hypercore (`intents/`). Discovery uses **Hyperswarm** topics derived per intent (SHA-256 of capability/topic/description seed) plus a lifecycle topic from `opts.topic`. Built on shared helpers in [`../_shared/p2p-bare.js`](../../_shared/p2p-bare.js).
|
||||
|
||||
## Constructor
|
||||
|
||||
```js
|
||||
const router = new HyperP2PIntentRouter(opts)
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `keyPair` | `KeyPair` | `hypercore-crypto.keyPair()` | Ed25519 key pair for Hypercore and Hyperswarm identity |
|
||||
| `storageDir` | `string` | `{cwd}/hyper-p2p-intent-router-storage` | Directory for Hypercore/Hyperbee under `intents/` |
|
||||
| `topic` | `string` \| `Buffer` | `'hyper-p2p-intent-router-lifecycle'` | Hyperswarm lifecycle topic (hashed if not 64-char hex) |
|
||||
| `announceInterval` | `number` | `60000` | Ms between periodic local intent announce ticks (when background timers enabled) |
|
||||
| `intentTTL` | `number` | `300000` | Ms added to `createdAt` for `expiresAt` on new intents (5 minutes) |
|
||||
| `maxIntentsPerPeer` | `number` | `64` | Reserved cap (not enforced in v0.3.1 body) |
|
||||
| `matchThreshold` | `number` | `0.3` | Minimum `_computeMatchScore` for `resolveIntent` / `sendToIntent` |
|
||||
| `enableBackgroundTimers` | `boolean` | `false` | When `true`, starts announce + cleanup `setInterval` loops after `ready()` |
|
||||
|
||||
### Instance properties (read-only usage)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `publicKey` | `Buffer` | Local public key from `keyPair` |
|
||||
| `localIntents` | `Map` | `intentId → intent` object |
|
||||
| `peerIntents` | `Map` | `peerPubHex → { intents, lastSeen, connections }` |
|
||||
| `peers` | `Map` | Alias of `_connections` (`peerPubHex → { msg, channel }`) |
|
||||
| `_joined` | `boolean` | `true` after successful `ready()` |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### `async ready()`
|
||||
|
||||
Initializes storage, joins the lifecycle swarm, loads persisted intents from Hyperbee, optionally starts background timers, sets `_joined`, emits `ready`.
|
||||
|
||||
- **Returns:** `Promise<void>`
|
||||
- **Throws:** Filesystem errors (except `EEXIST` on mkdir), Hypercore/Hyperbee/swarm failures
|
||||
- Idempotent: no-op if already joined
|
||||
|
||||
### `async close()`
|
||||
|
||||
Clears announce/cleanup timers, destroys swarm, closes Hyperbee, sets `_joined` false, emits `close`.
|
||||
|
||||
- **Returns:** `Promise<void>`
|
||||
- **Throws:** Rare close errors are swallowed on swarm destroy
|
||||
|
||||
## Intent registration
|
||||
|
||||
### `async registerIntent(intentDef)`
|
||||
|
||||
Registers a local intent, persists to Hyperbee, joins a derived discovery topic, announces to connected peers, emits `intent:registered`.
|
||||
|
||||
**`intentDef` fields:**
|
||||
|
||||
| Field | Required | Type | Default | Description |
|
||||
|-------|----------|------|---------|-------------|
|
||||
| `id` | yes | `string` | — | Stable intent identifier |
|
||||
| `capabilities` | yes | `string[]` | — | Capability tags for matching |
|
||||
| `description` | no | `string` | `''` | Free text; first 32 chars feed topic derivation |
|
||||
| `topics` | no | `string[]` | `[]` | Topic tags; used in keyword/topic matching |
|
||||
| `metadata` | no | `object` | `{}` | Opaque application metadata |
|
||||
| `priority` | no | `number` | `0` | Score boost (`priority * 0.05`, capped in total score) |
|
||||
|
||||
**Stored intent shape** (returned in maps and wire exchange):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `string` | Same as `intentDef.id` |
|
||||
| `description` | `string` | Normalized description |
|
||||
| `capabilities` | `string[]` | Capability list |
|
||||
| `topics` | `string[]` | Topic list |
|
||||
| `metadata` | `object` | Application metadata |
|
||||
| `priority` | `number` | Priority boost |
|
||||
| `createdAt` | `number` | `Date.now()` at registration |
|
||||
| `expiresAt` | `number` | `createdAt + intentTTL` |
|
||||
|
||||
- **Returns:** `Promise<string>` — intent id
|
||||
- **Throws:** `Error('Intent must have id and capabilities array')` if `intentDef`, `id`, or `capabilities` missing/empty
|
||||
|
||||
### `async unregisterIntent(intentId)`
|
||||
|
||||
Removes local intent and Hyperbee key `local:{intentId}`.
|
||||
|
||||
- **Returns:** `Promise<boolean>` — `true` if removed, `false` if unknown
|
||||
- **Throws:** — (none)
|
||||
|
||||
## Resolution and routing
|
||||
|
||||
### `async resolveIntent(selector = {})`
|
||||
|
||||
Scores all non-expired local and peer intents against `selector`, filters by `matchThreshold`, sorts descending by score.
|
||||
|
||||
**`selector` fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `capabilities` | `string[]` | If non-empty, Jaccard similarity vs intent capabilities contributes up to `0.6` of score |
|
||||
| `keywords` | `string[]` | Lowercased; each match in description or topic adds `0.1` (capped at `0.4` total) |
|
||||
|
||||
**Match entry shape:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `peerPublicKey` | `string` | Hex public key of intent holder |
|
||||
| `intent` | `object` | Full intent record |
|
||||
| `score` | `number` | `0`–`1` composite score |
|
||||
| `lastSeen` | `number` | Peer last-seen timestamp (local uses `now`) |
|
||||
| `local` | `boolean` | Present and `true` for local matches |
|
||||
|
||||
Peer entries are skipped when `now - lastSeen > intentTTL * 2`. Expired intents (`expiresAt < now`) are excluded.
|
||||
|
||||
- **Returns:** `Promise<MatchEntry[]>`
|
||||
- **Throws:** — (none)
|
||||
|
||||
### `async sendToIntent(selector, payload, opts = {})`
|
||||
|
||||
Resolves `selector`, picks **highest score** match, delivers `payload`.
|
||||
|
||||
| Outcome | Return shape |
|
||||
|---------|----------------|
|
||||
| Local best match | `{ sent: true, local: true, peer: peerHex }` + emits `message:local` |
|
||||
| Remote, connected | `{ sent: true, peer: peerHex, score }` + wire `intent-message` + `message:sent` |
|
||||
| Remote, not connected | `{ sent: false, pending: true, peer: peerHex }` + `peer:connect-request` |
|
||||
| No matches | throws |
|
||||
|
||||
- **Returns:** `Promise<object>` — see table above
|
||||
- **Throws:** `Error('No matching intents found for selector')`
|
||||
|
||||
`opts` is reserved for future routing flags (unused in v0.3.1).
|
||||
|
||||
## Query helpers
|
||||
|
||||
### `getLocalIntents()`
|
||||
|
||||
- **Returns:** `object[]` — snapshot of `localIntents` values
|
||||
- **Throws:** — (none)
|
||||
|
||||
### `getPeerIntents(peerHex = null)`
|
||||
|
||||
- **`peerHex` set:** intents for that peer only
|
||||
- **`peerHex` null:** flat list `{ peer, ...intent }` for all known peer intents
|
||||
|
||||
- **Returns:** `object[]`
|
||||
- **Throws:** — (none)
|
||||
|
||||
### `getStats()`
|
||||
|
||||
- **Returns:** `{ ops: number, errors: number }` — shallow copy of `_stats` (counters not incremented in all code paths yet)
|
||||
- **Throws:** — (none)
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `ready` | — | After `ready()` completes |
|
||||
| `close` | — | After `close()` |
|
||||
| `error` | `Error` | Hyperswarm `error` |
|
||||
| `intent:registered` | `intent` | After `registerIntent` |
|
||||
| `intent:unregistered` | `intentId` | After `unregisterIntent` |
|
||||
| `intent:announced` | `{ intentId, topic }` | Topic hex after announce tick |
|
||||
| `intent:expired` | `intentId` | Local intent removed by cleanup |
|
||||
| `topic:joined` | `topicHex` | New derived topic joined |
|
||||
| `peer:connected` | `{ peerPublicKey }` | Protomux channel open |
|
||||
| `peer:disconnected` | `{ peerPublicKey }` | Channel close |
|
||||
| `peer:connect-request` | `{ peerPublicKey, selector }` | `sendToIntent` needs connection |
|
||||
| `intents:updated` | `{ peer, count }` | After `intent-exchange` processed |
|
||||
| `message:local` | `{ selector, payload, intent }` | Local delivery in `sendToIntent` |
|
||||
| `message:sent` | `{ peer, selector, payload }` | Outbound `intent-message` |
|
||||
| `message:received` | `{ from, selector, payload }` | Inbound `intent-message` |
|
||||
|
||||
## Scoring reference (`_computeMatchScore`)
|
||||
|
||||
| Component | Weight | Rule |
|
||||
|-----------|--------|------|
|
||||
| Capability Jaccard | up to `0.6` | `|∩| / |∪|` when both selector and intent have capabilities |
|
||||
| Keywords | up to `0.4` | `+0.1` per keyword found in description or topics |
|
||||
| Priority | `priority * 0.05` | Added before cap |
|
||||
| Cap | `1.0` | `Math.min(score, 1.0)` |
|
||||
|
||||
## Persistence keys (Hyperbee)
|
||||
|
||||
| Key pattern | Value |
|
||||
|-------------|-------|
|
||||
| `local:{intentId}` | Local intent JSON |
|
||||
| `peer:{peerHex}:{intentId}` | Cached remote intent JSON |
|
||||
|
||||
## getStats()
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `ops` | `number` | Operation counter (reserved) |
|
||||
| `errors` | `number` | Error counter (reserved) |
|
||||
|
||||
## Errors
|
||||
|
||||
| Message substring | Source |
|
||||
|-------------------|--------|
|
||||
| `Intent must have id and capabilities array` | `registerIntent` validation |
|
||||
| `No matching intents found for selector` | `sendToIntent` when resolve empty |
|
||||
| `topic is required for createSwarm` | Shared `p2p-bare` if topic removed (not default path) |
|
||||
|
||||
Cross-module error conventions: [`../../_shared/ERROR_CODES.md`](../../_shared/ERROR_CODES.md).
|
||||
|
||||
## P2P
|
||||
|
||||
| Layer | Behavior |
|
||||
|-------|----------|
|
||||
| Hyperswarm | Lifecycle `topic`; per-intent derived topics for discovery (`server` + `client`) |
|
||||
| Protomux | Channel protocol `hyper-p2p-intent-router/v1`; JSON messages |
|
||||
| Wire | `intent-exchange` on connect; `intent-message` for routed payloads |
|
||||
|
||||
See [architecture.md](architecture.md) for wire field tables and sequence diagrams.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd modules/routing-paths/hyper-p2p-intent-router && npm install && npm test
|
||||
```
|
||||
|
||||
Unit tests: [`../test/test.js`](../test/test.js) — lifecycle, register/unregister, resolve scoring, simulated peer intents, `sendToIntent` local path.
|
||||
|
||||
Integration: [`../../../real_tests/integration/intent-router-two-node.js`](../../../real_tests/integration/intent-router-two-node.js).
|
||||
|
||||
Example: [`../examples/basic-usage.js`](../examples/basic-usage.js).
|
||||
@@ -0,0 +1,187 @@
|
||||
# Architecture: hyper-p2p-intent-router
|
||||
|
||||
**Category:** Routing & paths ([`../../MODULE_CATEGORIES.md`](../../MODULE_CATEGORIES.md))
|
||||
|
||||
**Composes with:** `hyper-p2p-relay-tunnel`, `hyper-p2p-merge-registry`, `hyper-p2p-capabilities` (application-level)
|
||||
|
||||
**Protocol:** `hyper-p2p-intent-router/v1` (`INTENT_PROTOCOL`)
|
||||
|
||||
## Layer diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph app [Application]
|
||||
REG[registerIntent]
|
||||
RES[resolveIntent]
|
||||
SEND[sendToIntent]
|
||||
end
|
||||
subgraph router [HyperP2PIntentRouter]
|
||||
LM[localIntents Map]
|
||||
PM[peerIntents Map]
|
||||
CONN[_connections Map]
|
||||
SCORE[_computeMatchScore]
|
||||
end
|
||||
subgraph persist [Persistence]
|
||||
HB[(Hyperbee intents/)]
|
||||
end
|
||||
subgraph network [P2P]
|
||||
SW[Hyperswarm]
|
||||
PMX[Protomux channel]
|
||||
end
|
||||
REG --> LM
|
||||
REG --> HB
|
||||
REG --> SW
|
||||
RES --> LM
|
||||
RES --> PM
|
||||
RES --> SCORE
|
||||
SEND --> RES
|
||||
SEND --> CONN
|
||||
SEND --> PMX
|
||||
SW --> PMX
|
||||
PMX --> PM
|
||||
PM --> HB
|
||||
```
|
||||
|
||||
## Primary sequence: ready → exchange → route
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Peer A
|
||||
participant SW as Hyperswarm
|
||||
participant B as Peer B
|
||||
A->>A: ready() init Hyperbee + lifecycle join
|
||||
B->>B: ready()
|
||||
A->>A: registerIntent (local + derived topic join)
|
||||
SW-->>A: connection to B
|
||||
A->>B: intent-exchange (local intents[])
|
||||
B->>A: intent-exchange (local intents[])
|
||||
A->>A: _handleIntentExchange → peerIntents + Hyperbee peer:*
|
||||
Note over A,B: resolveIntent(selector) scores all catalogs
|
||||
A->>B: intent-message (selector, payload) when connected
|
||||
B->>B: emit message:received
|
||||
```
|
||||
|
||||
## Wire messages (Protomux JSON)
|
||||
|
||||
All messages use encoding JSON on protocol id `hyper-p2p-intent-router/v1`.
|
||||
|
||||
### `intent-exchange`
|
||||
|
||||
| Field | Type | Direction | Behavior |
|
||||
|-------|------|-----------|----------|
|
||||
| `type` | `'intent-exchange'` | both | Discriminator |
|
||||
| `intents` | `Intent[]` | A→B, B→A | Full local intent snapshots; sent on channel `onopen` |
|
||||
| `peer` | `string` | both | Sender public key hex |
|
||||
| `timestamp` | `number` | both | `Date.now()` at send |
|
||||
|
||||
**Handler:** `_handleIntentExchange(peerHex, intents)` — updates `peerIntents`, persists `peer:{hex}:{id}`, emits `intents:updated`. Intents without `id` or `capabilities` are ignored.
|
||||
|
||||
### `intent-message`
|
||||
|
||||
| Field | Type | Direction | Behavior |
|
||||
|-------|------|-----------|----------|
|
||||
| `type` | `'intent-message'` | A→B | Routed application payload |
|
||||
| `selector` | `object` | A→B | Original resolve selector (capabilities, keywords) |
|
||||
| `payload` | `any` | A→B | Application JSON-serializable body |
|
||||
| `from` | `string` | A→B | Sender public key hex |
|
||||
| `timestamp` | `number` | A→B | Send time |
|
||||
|
||||
**Handler:** emits `message:received` with `{ from, selector, payload }`.
|
||||
|
||||
## Discovery topics (Hyperswarm)
|
||||
|
||||
Derived per intent in `_deriveTopic(intent)`:
|
||||
|
||||
1. Sort `capabilities` and `topics`
|
||||
2. JSON seed: `{ caps, topics, desc: description.slice(0, 32) }`
|
||||
3. `sha256(b4a.from(seed))` → topic buffer
|
||||
4. `swarm.join(topic, { server: true, client: true })` once per unique topic hex
|
||||
|
||||
Lifecycle topic (constructor `opts.topic`, default `hyper-p2p-intent-router-lifecycle`) is separate and always joined via `createSwarm` in `_initSwarm`.
|
||||
|
||||
Announce timer (`enableBackgroundTimers`) re-emits `intent:announced` for each local intent; discovery join is the primary peer-finding mechanism in v0.3.1.
|
||||
|
||||
## State model
|
||||
|
||||
### In-memory maps
|
||||
|
||||
| Map | Key | Value | Notes |
|
||||
|-----|-----|-------|-------|
|
||||
| `localIntents` | `intentId` | Intent object | Authoritative for local registration |
|
||||
| `peerIntents` | `peerPubHex` | `{ intents: Map, lastSeen, connections: Set }` | Remote catalog |
|
||||
| `_connections` | `peerPubHex` | `{ msg, channel }` | Active Protomux handles |
|
||||
| `_topics` | `topicHex` | discovery handle | Joined derived topics |
|
||||
|
||||
### Timers (when `enableBackgroundTimers === true`)
|
||||
|
||||
| Timer | Interval | Action |
|
||||
|-------|----------|--------|
|
||||
| `announceTimer` | `announceIntervalMs` (default 60s) | `_announceAllIntents` |
|
||||
| `cleanupTimer` | 30s | `_cleanupExpiredIntents` |
|
||||
|
||||
### Cleanup rules (`_cleanupExpiredIntents`)
|
||||
|
||||
- Local: delete when `expiresAt < now`, emit `intent:expired`
|
||||
- Peer intents: delete expired per-intent entries
|
||||
- Peer row: delete entire peer when `now - lastSeen > intentTTL * 3`, drop `_connections` entry
|
||||
|
||||
### Persistence layout
|
||||
|
||||
```
|
||||
{storageDir}/
|
||||
intents/ # Hypercore name INTENT_DB_NAME
|
||||
Hyperbee keys:
|
||||
local:{id}
|
||||
peer:{peerHex}:{id}
|
||||
```
|
||||
|
||||
Loaded in `_loadPersistedIntents` on `ready()` before network exchange repopulates `lastSeen`.
|
||||
|
||||
## Matching pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SEL[selector]
|
||||
CAP[capability Jaccard ×0.6]
|
||||
KW[keyword hits ×0.1 max 0.4]
|
||||
PR[priority ×0.05]
|
||||
TH{score >= matchThreshold?}
|
||||
OUT[ranked MatchEntry[]]
|
||||
SEL --> CAP --> KW --> PR --> TH --> OUT
|
||||
```
|
||||
|
||||
`sendToIntent` uses only `matches[0]` (highest score). No multi-cast or fan-out in v0.3.1.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
`wireConnection` from `p2p-bare.js` attaches `protocolChannel` per socket:
|
||||
|
||||
- `onopen`: store `{ msg, channel }`, `peer:connected`, `_exchangeIntents`
|
||||
- `onclose`: delete connection, `peer:disconnected`
|
||||
- `conn.on('close')`: also deletes from `_connections`
|
||||
|
||||
If `sendToIntent` targets a peer without an open `msg`, returns `pending: true` and emits `peer:connect-request` (application may dial or wait for inbound connection on shared topics).
|
||||
|
||||
## Composition in the network stack
|
||||
|
||||
| Use case | Typical pairing |
|
||||
|----------|-----------------|
|
||||
| Semantic service discovery | intent-router + `hyper-p2p-capabilities` |
|
||||
| Multi-hop delivery | intent-router resolves target → `hyper-p2p-relay-tunnel` / `hyper-p2p-circuit-loom` |
|
||||
| Agent coordination | `hyper-p2p-agent-memory` → intent-router → `hyper-p2p-task-orchestrator` |
|
||||
|
||||
Wave-6 stack reference: [`../../_shared/WAVE6_NETWORK_STACK.md`](../../_shared/WAVE6_NETWORK_STACK.md) when present in the workspace.
|
||||
|
||||
## Design limits (v0.3.1)
|
||||
|
||||
- `maxIntentsPerPeer` is not enforced in code paths
|
||||
- Derived topic leave on `unregisterIntent` is not ref-counted (commented stub)
|
||||
- `_gossipIntent` exists but primary replication is connect-time `intent-exchange`
|
||||
- `getStats().ops` / `errors` are not incremented on every operation
|
||||
- Match threshold and scoring weights are fixed constants in implementation
|
||||
|
||||
## Security and trust
|
||||
|
||||
- No signature on intents or messages; peers are identified by Hyperswarm public keys only
|
||||
- Remote intents are accepted from any connected peer and persisted locally
|
||||
- Production deployments should add attestation (`hyper-p2p-attestation-chain`) or trust-graph filtering at the application layer
|
||||
@@ -0,0 +1,85 @@
|
||||
const HyperP2PIntentRouter = require('../index.js')
|
||||
const crypto = require('bare-crypto')
|
||||
const path = require('bare-path')
|
||||
const process = require('bare-process')
|
||||
|
||||
async function main () {
|
||||
console.log('hyper-p2p-intent-router basic usage example')
|
||||
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(process.cwd(), 'example-intent-router-storage')
|
||||
})
|
||||
|
||||
router.on('ready', () => console.log('Router ready'))
|
||||
router.on('intent:registered', (intent) => console.log('Registered:', intent.id))
|
||||
router.on('message:received', (msg) => console.log('Received intent message:', msg))
|
||||
|
||||
await router.ready()
|
||||
|
||||
// Register several intents
|
||||
await router.registerIntent({
|
||||
id: 'ai-image-gen',
|
||||
description: 'Stable Diffusion image generation service',
|
||||
capabilities: ['image-generation', 'stable-diffusion', 'gpu'],
|
||||
topics: ['ai', 'graphics', 'creative'],
|
||||
priority: 10
|
||||
})
|
||||
|
||||
await router.registerIntent({
|
||||
id: 'ml-inference',
|
||||
description: 'ONNX / TensorFlow model inference',
|
||||
capabilities: ['ml', 'inference', 'gpu', 'cpu'],
|
||||
topics: ['ai', 'ml'],
|
||||
priority: 8
|
||||
})
|
||||
|
||||
await router.registerIntent({
|
||||
id: 'p2p-storage',
|
||||
description: 'Hypercore-backed decentralized storage',
|
||||
capabilities: ['storage', 'hypercore', 'p2p'],
|
||||
priority: 5
|
||||
})
|
||||
|
||||
// Resolve examples
|
||||
console.log('\n--- Resolving for GPU + AI ---')
|
||||
const gpuMatches = await router.resolveIntent({
|
||||
capabilities: ['gpu', 'ai'],
|
||||
keywords: ['image', 'generation']
|
||||
})
|
||||
console.log('Top matches:', gpuMatches.slice(0, 3))
|
||||
|
||||
console.log('\n--- Resolving for storage ---')
|
||||
const storageMatches = await router.resolveIntent({
|
||||
capabilities: ['storage']
|
||||
})
|
||||
console.log('Storage matches:', storageMatches)
|
||||
|
||||
// Simulate sending
|
||||
try {
|
||||
const sendRes = await router.sendToIntent({
|
||||
capabilities: ['ml', 'inference']
|
||||
}, {
|
||||
model: 'llama-3',
|
||||
prompt: 'Explain P2P intent routing'
|
||||
})
|
||||
console.log('Send result:', sendRes)
|
||||
} catch (e) {
|
||||
console.log('Send demo (no real peers):', e.message)
|
||||
}
|
||||
|
||||
// Show local + peer snapshot
|
||||
console.log('\nLocal intents:', router.getLocalIntents().length)
|
||||
console.log('Known peer intents:', router.getPeerIntents().length)
|
||||
|
||||
// Graceful shutdown
|
||||
setTimeout(async () => {
|
||||
await router.close()
|
||||
console.log('\nExample complete. Router closed.')
|
||||
process.exit(0)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
require('bare-process/global')
|
||||
const EventEmitter = require('bare-events')
|
||||
const { setInterval, clearInterval, setTimeout, clearTimeout } = require('bare-timers')
|
||||
const crypto = require('bare-crypto')
|
||||
const fs = require('bare-fs/promises')
|
||||
const path = require('bare-path')
|
||||
const process = require('bare-process')
|
||||
const Hyperbee = require('hyperbee')
|
||||
const Hypercore = require('hypercore')
|
||||
const b4a = require('b4a')
|
||||
const { createSwarm, wireConnection, protocolChannel, gossipSend } = require('../../_shared/p2p-bare.js')
|
||||
|
||||
const INTENT_PROTOCOL = 'hyper-p2p-intent-router/v1'
|
||||
const DEFAULT_ANNOUNCE_INTERVAL = 60000
|
||||
const DEFAULT_INTENT_TTL = 300000 // 5min
|
||||
const INTENT_DB_NAME = 'intents'
|
||||
|
||||
/**
|
||||
* HyperP2PIntentRouter
|
||||
* Novel intent-based P2P routing primitive.
|
||||
* Peers register declarative intents (capabilities, topics, goals).
|
||||
* Messages routed by matching intents using capability overlap + keyword similarity.
|
||||
* Built on Hyperswarm, Protomux, Hyperbee persistence.
|
||||
* Enables semantic service discovery, intent-driven microservices, AI agent coordination in P2P.
|
||||
*/
|
||||
class HyperP2PIntentRouter extends EventEmitter {
|
||||
constructor (opts = {}) {
|
||||
super()
|
||||
this._stats = { ops: 0, errors: 0 }
|
||||
|
||||
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
|
||||
this.publicKey = this.keyPair.publicKey
|
||||
const cwd = process.cwd()
|
||||
this.storageDir = opts.storageDir || path.join(cwd, 'hyper-p2p-intent-router-storage')
|
||||
this.announceIntervalMs = opts.announceInterval || DEFAULT_ANNOUNCE_INTERVAL
|
||||
this.intentTTL = opts.intentTTL || DEFAULT_INTENT_TTL
|
||||
this.maxIntentsPerPeer = opts.maxIntentsPerPeer || 64
|
||||
this.matchThreshold = opts.matchThreshold || 0.3 // Jaccard-like similarity
|
||||
this.options = opts
|
||||
this._enableBackgroundTimers = opts.enableBackgroundTimers === true
|
||||
|
||||
this.localIntents = new Map() // intentId -> intent
|
||||
this.peerIntents = new Map() // peerPubHex -> { intents: Map, lastSeen: ts, connections: Set }
|
||||
this.swarm = null
|
||||
this.corestore = null
|
||||
this.bee = null
|
||||
this.announceTimer = null
|
||||
this.cleanupTimer = null
|
||||
this._joined = false
|
||||
this._protocol = null
|
||||
this._connections = new Map() // peerPubHex -> { msg, channel }
|
||||
this.peers = this._connections
|
||||
this._topics = new Map() // topicHex -> swarm topic handle
|
||||
}
|
||||
|
||||
async ready () {
|
||||
if (this._joined) return
|
||||
await this._initStorage()
|
||||
await this._initSwarm()
|
||||
await this._loadPersistedIntents()
|
||||
if (this._enableBackgroundTimers) {
|
||||
this._startAnnounceTimer()
|
||||
this._startCleanupTimer()
|
||||
}
|
||||
this._joined = true
|
||||
this.emit('ready')
|
||||
}
|
||||
|
||||
async _initStorage () {
|
||||
try {
|
||||
await fs.mkdir(this.storageDir, { recursive: true })
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') throw err
|
||||
}
|
||||
const core = new Hypercore(path.join(this.storageDir, INTENT_DB_NAME), this.keyPair, { valueEncoding: 'json' })
|
||||
this.bee = new Hyperbee(core, { keyEncoding: 'utf8', valueEncoding: 'json' })
|
||||
await this.bee.ready()
|
||||
}
|
||||
|
||||
async _initSwarm () {
|
||||
const { swarm } = await createSwarm({
|
||||
keyPair: this.keyPair,
|
||||
topic: this.options.topic || 'hyper-p2p-intent-router-lifecycle'
|
||||
})
|
||||
this.swarm = swarm
|
||||
wireConnection(swarm, (conn, peerInfo, mux) => this._handleConnection(conn, peerInfo, mux))
|
||||
swarm.on('error', (err) => this.emit('error', err))
|
||||
}
|
||||
|
||||
async _loadPersistedIntents () {
|
||||
for await (const { key, value } of this.bee.createReadStream()) {
|
||||
if (key.startsWith('local:')) {
|
||||
const intent = value
|
||||
this.localIntents.set(intent.id, intent)
|
||||
} else if (key.startsWith('peer:')) {
|
||||
const [_, peerHex, intentId] = key.split(':')
|
||||
if (!this.peerIntents.has(peerHex)) {
|
||||
this.peerIntents.set(peerHex, { intents: new Map(), lastSeen: Date.now(), connections: new Set() })
|
||||
}
|
||||
this.peerIntents.get(peerHex).intents.set(intentId, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_startAnnounceTimer () {
|
||||
if (this.announceTimer) clearInterval(this.announceTimer)
|
||||
this.announceTimer = setInterval(() => {
|
||||
this._announceAllIntents()
|
||||
}, this.announceIntervalMs)
|
||||
}
|
||||
|
||||
_startCleanupTimer () {
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer)
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this._cleanupExpiredIntents()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
async registerIntent (intentDef) {
|
||||
if (!intentDef || !intentDef.id || !intentDef.capabilities) {
|
||||
throw new Error('Intent must have id and capabilities array')
|
||||
}
|
||||
const intent = {
|
||||
id: intentDef.id,
|
||||
description: intentDef.description || '',
|
||||
capabilities: intentDef.capabilities || [],
|
||||
topics: intentDef.topics || [],
|
||||
metadata: intentDef.metadata || {},
|
||||
priority: intentDef.priority || 0,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + this.intentTTL
|
||||
}
|
||||
|
||||
this.localIntents.set(intent.id, intent)
|
||||
|
||||
// Persist
|
||||
await this.bee.put(`local:${intent.id}`, intent)
|
||||
|
||||
// Derive and join topics for discovery
|
||||
const topic = this._deriveTopic(intent)
|
||||
await this._joinTopic(topic)
|
||||
|
||||
this.emit('intent:registered', intent)
|
||||
this._announceIntent(intent)
|
||||
return intent.id
|
||||
}
|
||||
|
||||
async unregisterIntent (intentId) {
|
||||
const intent = this.localIntents.get(intentId)
|
||||
if (!intent) return false
|
||||
|
||||
this.localIntents.delete(intentId)
|
||||
await this.bee.del(`local:${intentId}`)
|
||||
|
||||
// Leave topic if no more local intents use it
|
||||
const topic = this._deriveTopic(intent)
|
||||
// simple: always leave for now or count refs
|
||||
if (this._topics.has(b4a.toString(topic, 'hex'))) {
|
||||
// could implement ref count but skip for v0.1
|
||||
}
|
||||
|
||||
this.emit('intent:unregistered', intentId)
|
||||
return true
|
||||
}
|
||||
|
||||
_deriveTopic (intent) {
|
||||
const seed = JSON.stringify({
|
||||
caps: intent.capabilities.sort(),
|
||||
topics: intent.topics.sort(),
|
||||
desc: intent.description.slice(0, 32)
|
||||
})
|
||||
return crypto.createHash('sha256').update(b4a.from(seed)).digest()
|
||||
}
|
||||
|
||||
async _joinTopic (topic) {
|
||||
if (!this.swarm) return
|
||||
const topicHex = b4a.toString(topic, 'hex')
|
||||
if (this._topics.has(topicHex)) return
|
||||
|
||||
const discovery = this.swarm.join(topic, { server: true, client: true })
|
||||
this._topics.set(topicHex, discovery)
|
||||
await discovery.flushed()
|
||||
this.emit('topic:joined', topicHex)
|
||||
}
|
||||
|
||||
async _announceAllIntents () {
|
||||
for (const intent of this.localIntents.values()) {
|
||||
this._announceIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
_announceIntent (intent) {
|
||||
const topic = this._deriveTopic(intent)
|
||||
const topicHex = b4a.toString(topic, 'hex')
|
||||
// In real impl, would send via protomux on connections, but for discovery the join is enough
|
||||
this.emit('intent:announced', { intentId: intent.id, topic: topicHex })
|
||||
}
|
||||
|
||||
async resolveIntent (selector = {}) {
|
||||
const results = []
|
||||
const now = Date.now()
|
||||
|
||||
for (const [peerHex, peerData] of this.peerIntents.entries()) {
|
||||
if (now - peerData.lastSeen > this.intentTTL * 2) continue
|
||||
|
||||
for (const [intentId, intent] of peerData.intents.entries()) {
|
||||
if (intent.expiresAt < now) continue
|
||||
|
||||
const score = this._computeMatchScore(selector, intent)
|
||||
if (score >= this.matchThreshold) {
|
||||
results.push({
|
||||
peerPublicKey: peerHex,
|
||||
intent,
|
||||
score,
|
||||
lastSeen: peerData.lastSeen
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check local
|
||||
for (const [id, intent] of this.localIntents.entries()) {
|
||||
if (intent.expiresAt < now) continue
|
||||
const score = this._computeMatchScore(selector, intent)
|
||||
if (score >= this.matchThreshold) {
|
||||
results.push({
|
||||
peerPublicKey: b4a.toString(this.publicKey, 'hex'),
|
||||
intent,
|
||||
score,
|
||||
lastSeen: now,
|
||||
local: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results
|
||||
}
|
||||
|
||||
_computeMatchScore (selector, intent) {
|
||||
let score = 0
|
||||
const selCaps = selector.capabilities || []
|
||||
const selKeywords = (selector.keywords || []).map(k => k.toLowerCase())
|
||||
const intentCaps = intent.capabilities || []
|
||||
const intentDesc = (intent.description || '').toLowerCase()
|
||||
const intentTopics = intent.topics || []
|
||||
|
||||
// Capability Jaccard similarity
|
||||
if (selCaps.length > 0 && intentCaps.length > 0) {
|
||||
const intersection = selCaps.filter(c => intentCaps.includes(c)).length
|
||||
const union = new Set([...selCaps, ...intentCaps]).size
|
||||
score += (intersection / union) * 0.6
|
||||
}
|
||||
|
||||
// Keyword / description match
|
||||
let kwScore = 0
|
||||
for (const kw of selKeywords) {
|
||||
if (intentDesc.includes(kw) || intentTopics.some(t => t.toLowerCase().includes(kw))) {
|
||||
kwScore += 0.1
|
||||
}
|
||||
}
|
||||
score += Math.min(kwScore, 0.4)
|
||||
|
||||
// Priority boost
|
||||
score += (intent.priority || 0) * 0.05
|
||||
|
||||
return Math.min(score, 1.0)
|
||||
}
|
||||
|
||||
async sendToIntent (selector, payload, opts = {}) {
|
||||
const matches = await this.resolveIntent(selector)
|
||||
if (matches.length === 0) {
|
||||
throw new Error('No matching intents found for selector')
|
||||
}
|
||||
|
||||
const target = matches[0] // highest score
|
||||
const peerHex = target.peerPublicKey
|
||||
|
||||
if (target.local) {
|
||||
this.emit('message:local', { selector, payload, intent: target.intent })
|
||||
return { sent: true, local: true, peer: peerHex }
|
||||
}
|
||||
|
||||
const peerConn = this._connections.get(peerHex)
|
||||
if (!peerConn || !peerConn.msg) {
|
||||
this.emit('peer:connect-request', { peerPublicKey: peerHex, selector })
|
||||
return { sent: false, pending: true, peer: peerHex }
|
||||
}
|
||||
|
||||
peerConn.msg.send({
|
||||
type: 'intent-message',
|
||||
selector,
|
||||
payload,
|
||||
from: b4a.toString(this.publicKey, 'hex'),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
this.emit('message:sent', { peer: peerHex, selector, payload })
|
||||
return { sent: true, peer: peerHex, score: target.score }
|
||||
}
|
||||
|
||||
_handleConnection (conn, peerInfo, mux) {
|
||||
const peerPub = peerInfo.publicKey || conn.remotePublicKey
|
||||
if (!peerPub) return
|
||||
|
||||
const peerHex = b4a.toString(peerPub, 'hex')
|
||||
const self = this
|
||||
|
||||
const { channel, msg } = protocolChannel(mux, {
|
||||
protocol: INTENT_PROTOCOL,
|
||||
onopen (channel, msg) {
|
||||
self._connections.set(peerHex, { msg, channel })
|
||||
self.emit('peer:connected', { peerPublicKey: peerHex })
|
||||
self._exchangeIntents(peerHex, msg)
|
||||
},
|
||||
onclose () {
|
||||
self._connections.delete(peerHex)
|
||||
self.emit('peer:disconnected', { peerPublicKey: peerHex })
|
||||
},
|
||||
onmessage (data) {
|
||||
if (data.type === 'intent-exchange') {
|
||||
self._handleIntentExchange(peerHex, data.intents || [])
|
||||
} else if (data.type === 'intent-message') {
|
||||
self.emit('message:received', { from: data.from, selector: data.selector, payload: data.payload })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.peerIntents.has(peerHex)) {
|
||||
this.peerIntents.set(peerHex, { intents: new Map(), lastSeen: Date.now(), connections: new Set() })
|
||||
}
|
||||
this.peerIntents.get(peerHex).connections.add(channel)
|
||||
|
||||
conn.on('close', () => {
|
||||
this._connections.delete(peerHex)
|
||||
})
|
||||
}
|
||||
|
||||
_exchangeIntents (peerHex, msg) {
|
||||
const intents = Array.from(this.localIntents.values())
|
||||
try {
|
||||
msg.send({
|
||||
type: 'intent-exchange',
|
||||
intents,
|
||||
peer: b4a.toString(this.publicKey, 'hex'),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_gossipIntent (intent) {
|
||||
gossipSend(this, { type: 'intent-exchange', intents: [intent] })
|
||||
}
|
||||
|
||||
_handleIntentExchange (peerHex, receivedIntents = []) {
|
||||
if (!this.peerIntents.has(peerHex)) {
|
||||
this.peerIntents.set(peerHex, { intents: new Map(), lastSeen: Date.now(), connections: new Set() })
|
||||
}
|
||||
const peerData = this.peerIntents.get(peerHex)
|
||||
peerData.lastSeen = Date.now()
|
||||
|
||||
for (const intent of receivedIntents) {
|
||||
if (intent.id && intent.capabilities) {
|
||||
peerData.intents.set(intent.id, intent)
|
||||
// Persist peer intent
|
||||
this.bee.put(`peer:${peerHex}:${intent.id}`, intent).catch(() => {})
|
||||
}
|
||||
}
|
||||
this.emit('intents:updated', { peer: peerHex, count: peerData.intents.size })
|
||||
}
|
||||
|
||||
_cleanupExpiredIntents () {
|
||||
const now = Date.now()
|
||||
for (const [id, intent] of this.localIntents) {
|
||||
if (intent.expiresAt < now) {
|
||||
this.localIntents.delete(id)
|
||||
this.emit('intent:expired', id)
|
||||
}
|
||||
}
|
||||
for (const [peerHex, peerData] of this.peerIntents) {
|
||||
for (const [id, intent] of peerData.intents) {
|
||||
if (intent.expiresAt < now) {
|
||||
peerData.intents.delete(id)
|
||||
}
|
||||
}
|
||||
if (now - peerData.lastSeen > this.intentTTL * 3) {
|
||||
this.peerIntents.delete(peerHex)
|
||||
this._connections.delete(peerHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getStats () {
|
||||
return { ...this._stats }
|
||||
}
|
||||
|
||||
async close () {
|
||||
if (this.announceTimer) clearInterval(this.announceTimer)
|
||||
if (this.cleanupTimer) clearInterval(this.cleanupTimer)
|
||||
if (this.swarm) await this.swarm.destroy().catch(() => {})
|
||||
if (this.bee) await this.bee.close()
|
||||
this._joined = false
|
||||
this.emit('close')
|
||||
}
|
||||
|
||||
// Utility: get all known intents snapshot
|
||||
getLocalIntents () {
|
||||
return Array.from(this.localIntents.values())
|
||||
}
|
||||
|
||||
getPeerIntents (peerHex = null) {
|
||||
if (peerHex) {
|
||||
const p = this.peerIntents.get(peerHex)
|
||||
return p ? Array.from(p.intents.values()) : []
|
||||
}
|
||||
const all = []
|
||||
for (const [hex, data] of this.peerIntents) {
|
||||
for (const intent of data.intents.values()) {
|
||||
all.push({ peer: hex, ...intent })
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HyperP2PIntentRouter
|
||||
module.exports.INTENT_PROTOCOL = INTENT_PROTOCOL
|
||||
+1902
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "hyper-p2p-intent-router",
|
||||
"version": "0.3.1",
|
||||
"description": "Novel intent-based P2P message routing and service discovery primitive for Bare/Pear. Register declarative intents, route messages by semantic/capability matching, Hyperswarm discovery, Protomux streaming, Hyperbee persistence.",
|
||||
"main": "index.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"test": "brittle-bare test/test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"brittle": "^3.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"p2p",
|
||||
"bare",
|
||||
"pear",
|
||||
"holepunch",
|
||||
"intent",
|
||||
"routing",
|
||||
"discovery",
|
||||
"capabilities",
|
||||
"hyperswarm"
|
||||
],
|
||||
"author": "Holepunch Module Dev Agent",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.8.0",
|
||||
"bare-timers": "^2.0.0",
|
||||
"bare-crypto": "^1.9.0",
|
||||
"bare-fs": "^4.0.0",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-process": "^4.4.0",
|
||||
"b4a": "^1.6.7",
|
||||
"hyperswarm": "^4.8.0",
|
||||
"hyperbee": "^2.4.0",
|
||||
"hypercore": "^10.0.0",
|
||||
"protomux": "^3.5.0",
|
||||
"hypercore-crypto": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hyperbee": "^2.4.0"
|
||||
},
|
||||
"pear": {
|
||||
"name": "hyper-p2p-intent-router",
|
||||
"type": "module",
|
||||
"entry": "index.js",
|
||||
"assets": [
|
||||
"docs/**",
|
||||
"examples/**",
|
||||
"README.md"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/holepunchto/hyper-p2p-intent-router.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/holepunchto/hyper-p2p-intent-router/issues"
|
||||
},
|
||||
"homepage": "https://github.com/holepunchto/hyper-p2p-intent-router#readme",
|
||||
"imports": {
|
||||
"process": {
|
||||
"bare": "bare-process",
|
||||
"default": "process"
|
||||
},
|
||||
"crypto": {
|
||||
"bare": "bare-crypto",
|
||||
"default": "crypto"
|
||||
},
|
||||
"path": {
|
||||
"bare": "bare-path",
|
||||
"default": "path"
|
||||
},
|
||||
"fs": {
|
||||
"bare": "bare-fs",
|
||||
"default": "fs"
|
||||
},
|
||||
"timers": {
|
||||
"bare": "bare-timers",
|
||||
"default": "timers"
|
||||
},
|
||||
"events": {
|
||||
"bare": "bare-events",
|
||||
"default": "events"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
const test = require('brittle')
|
||||
const HyperP2PIntentRouter = require('../index.js')
|
||||
const crypto = require('bare-crypto')
|
||||
const path = require('bare-path')
|
||||
const process = require('bare-process')
|
||||
const { setTimeout } = require('bare-timers')
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-intent-router-' + Date.now())
|
||||
|
||||
test('lifecycle - ready and close', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-lifecycle')
|
||||
})
|
||||
await router.ready()
|
||||
t.is(router._joined, true, 'ready should set joined')
|
||||
await router.close()
|
||||
t.pass('close works')
|
||||
})
|
||||
|
||||
test('register and unregister intent', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-reg')
|
||||
})
|
||||
await router.ready()
|
||||
|
||||
const intentId = await router.registerIntent({
|
||||
id: 'test-intent-1',
|
||||
description: 'AI image generation service',
|
||||
capabilities: ['image-gen', 'stable-diffusion', 'gpu'],
|
||||
topics: ['ai', 'graphics'],
|
||||
priority: 5
|
||||
})
|
||||
t.ok(typeof intentId === 'string', 'intentId is string')
|
||||
const local = router.getLocalIntents()
|
||||
t.is(local.length, 1, 'one local intent')
|
||||
|
||||
await router.unregisterIntent(intentId)
|
||||
const afterUnreg = router.getLocalIntents()
|
||||
t.is(afterUnreg.length, 0, 'unregister works')
|
||||
|
||||
await router.close()
|
||||
})
|
||||
|
||||
test('resolve intent local match and scoring', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-resolve')
|
||||
})
|
||||
await router.ready()
|
||||
|
||||
await router.registerIntent({
|
||||
id: 'intent-ml',
|
||||
description: 'Machine learning inference',
|
||||
capabilities: ['ml', 'inference', 'gpu'],
|
||||
priority: 8
|
||||
})
|
||||
await router.registerIntent({
|
||||
id: 'intent-storage',
|
||||
description: 'Distributed storage node',
|
||||
capabilities: ['storage', 's3-compatible'],
|
||||
priority: 3
|
||||
})
|
||||
|
||||
const mlMatches = await router.resolveIntent({ capabilities: ['ml', 'gpu'] })
|
||||
t.ok(mlMatches.length >= 1, 'found ml match')
|
||||
t.ok(mlMatches[0].score > 0.5, 'score reasonable')
|
||||
|
||||
const storageMatches = await router.resolveIntent({ capabilities: ['storage'] })
|
||||
t.ok(storageMatches.length >= 1, 'found storage match')
|
||||
|
||||
await router.close()
|
||||
})
|
||||
|
||||
test('peer intent simulation and cross-peer matching', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-peer')
|
||||
})
|
||||
await router.ready()
|
||||
|
||||
const fakePeerHex = 'a'.repeat(64)
|
||||
router.peerIntents.set(fakePeerHex, {
|
||||
intents: new Map([['peer-intent-1', {
|
||||
id: 'peer-intent-1',
|
||||
description: 'GPU compute for AI',
|
||||
capabilities: ['gpu', 'compute', 'ai'],
|
||||
expiresAt: Date.now() + 600000
|
||||
}]]),
|
||||
lastSeen: Date.now(),
|
||||
connections: new Set()
|
||||
})
|
||||
|
||||
const peerMatches = await router.resolveIntent({ capabilities: ['gpu', 'ai'] })
|
||||
t.ok(peerMatches.some(m => m.peerPublicKey === fakePeerHex), 'peer match found')
|
||||
|
||||
await router.close()
|
||||
})
|
||||
|
||||
test('sendToIntent simulated', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-send')
|
||||
})
|
||||
await router.ready()
|
||||
|
||||
await router.registerIntent({
|
||||
id: 'intent-ml',
|
||||
description: 'Machine learning inference',
|
||||
capabilities: ['ml', 'inference', 'gpu'],
|
||||
priority: 8
|
||||
})
|
||||
|
||||
const sendResult = await router.sendToIntent({ capabilities: ['ml'] }, { prompt: 'test' })
|
||||
t.ok(sendResult, 'sendToIntent returns result')
|
||||
|
||||
await router.close()
|
||||
})
|
||||
|
||||
test('cleanup and metrics', async (t) => {
|
||||
const router = new HyperP2PIntentRouter({
|
||||
storageDir: path.join(TEST_DIR, 'router-cleanup')
|
||||
})
|
||||
await router.ready()
|
||||
await router.close()
|
||||
t.pass('graceful close and cleanup')
|
||||
})
|
||||
test('hyper-p2p-intent-router: close without leak', async (t) => {
|
||||
const m = new HyperP2PIntentRouter()
|
||||
await m.close()
|
||||
t.pass()
|
||||
})
|
||||
test('hyper-p2p-intent-router: validation rejects invalid input', async (t) => {
|
||||
const m = new HyperP2PIntentRouter()
|
||||
try {
|
||||
if (typeof m.addNeighbor === 'function') m.addNeighbor(null)
|
||||
else if (typeof m.buildCircuit === 'function') m.buildCircuit([])
|
||||
else if (typeof m.grant === 'function') m.grant(null, -1)
|
||||
else if (typeof m.enqueue === 'function') m.enqueue('bad', null)
|
||||
else if (typeof m.reportSample === 'function') m.reportSample(null, -1, -1)
|
||||
else if (typeof m.fanout === 'function') m.fanout(null, 0)
|
||||
else if (typeof m.probe === 'function') m.probe(null)
|
||||
else if (typeof m.resolve === 'function') m.resolve(null)
|
||||
else if (typeof m.acquire === 'function') m.acquire(null)
|
||||
else throw new Error('no validation hook')
|
||||
t.fail('expected throw')
|
||||
} catch (err) {
|
||||
t.ok(err instanceof Error)
|
||||
}
|
||||
await m.close()
|
||||
})
|
||||
Reference in New Issue
Block a user