This commit is contained in:
Raven Scott
2026-05-20 21:02:45 -04:00
parent 1f3f4b24a2
commit 14d0980b4f
734 changed files with 682 additions and 746 deletions
@@ -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
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()
})