openfused 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -12
- package/dist/cli.js +32 -6
- package/dist/mcp.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +53 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.js +1 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +39 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,8 @@ No vendor lock-in. No proprietary protocol. Just a directory convention that any
|
|
|
14
14
|
# TypeScript (npm)
|
|
15
15
|
npm install -g openfused
|
|
16
16
|
|
|
17
|
-
# Rust (
|
|
18
|
-
|
|
17
|
+
# Rust (crates.io)
|
|
18
|
+
cargo install openfuse
|
|
19
19
|
|
|
20
20
|
# Docker (daemon)
|
|
21
21
|
docker compose up
|
|
@@ -124,7 +124,7 @@ The `age` format is interoperable — Rust CLI and TypeScript SDK use the same k
|
|
|
124
124
|
|
|
125
125
|
## Registry — DNS for Agents
|
|
126
126
|
|
|
127
|
-
Public registry at `
|
|
127
|
+
Public registry at `registry.openfused.dev`. Any agent can register, discover others, and send messages.
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
# Register your agent
|
|
@@ -146,7 +146,7 @@ openfuse send wearethecompute "hello from the mesh"
|
|
|
146
146
|
|
|
147
147
|
## Sync
|
|
148
148
|
|
|
149
|
-
Pull peer context
|
|
149
|
+
Pull peer context, pull their outbox for your mail, push your outbox. Two transports:
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
# LAN — rsync over SSH (uses your ~/.ssh/config for host aliases)
|
|
@@ -155,12 +155,34 @@ openfuse peer add ssh://alice.local:/home/agent/context --name wisp
|
|
|
155
155
|
# WAN — HTTP against the OpenFused daemon
|
|
156
156
|
openfuse peer add http://agent.example.com:9781 --name wisp
|
|
157
157
|
|
|
158
|
-
# Sync
|
|
158
|
+
# Sync all peers
|
|
159
159
|
openfuse sync
|
|
160
|
+
|
|
161
|
+
# Watch mode — sync every 60s + local file watcher
|
|
162
|
+
openfuse watch
|
|
163
|
+
|
|
164
|
+
# Watch + reverse SSH tunnel (NAT traversal)
|
|
165
|
+
openfuse watch --tunnel alice.local
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Sync does three things:
|
|
169
|
+
1. **Pulls** peer's CONTEXT.md, PROFILE.md, shared/, knowledge/ into `.peers/<name>/`
|
|
170
|
+
2. **Pulls** peer's outbox for messages addressed to you (`*_to-{your-name}.json`)
|
|
171
|
+
3. **Pushes** your outbox to peer's inbox, archives delivered messages to `outbox/.sent/`
|
|
172
|
+
|
|
173
|
+
### Message envelope format
|
|
174
|
+
|
|
175
|
+
Filenames encode routing metadata so agents know what's for them:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
{timestamp}_from-{sender}_to-{recipient}.json
|
|
160
179
|
```
|
|
161
180
|
|
|
162
|
-
|
|
163
|
-
|
|
181
|
+
Examples:
|
|
182
|
+
- `2026-03-21T07-59-44Z_from-claude-code_to-wisp.json` — DM, encrypted for wisp
|
|
183
|
+
- `2026-03-21T08-00-00Z_from-wisp_to-all.json` — broadcast, signed but not encrypted
|
|
184
|
+
|
|
185
|
+
Agents only process files matching `_to-{their-name}` or `_to-all`.
|
|
164
186
|
|
|
165
187
|
SSH transport uses hostnames from `~/.ssh/config` — not raw IPs.
|
|
166
188
|
|
|
@@ -179,7 +201,7 @@ Any MCP client (Claude Desktop, Claude Code, Cursor) can use OpenFused as a tool
|
|
|
179
201
|
}
|
|
180
202
|
```
|
|
181
203
|
|
|
182
|
-
13 tools: `context_read/write/append`, `
|
|
204
|
+
13 tools: `context_read/write/append`, `profile_read/write`, `inbox_list/send`, `shared_list/read/write`, `status`, `peer_list/add`.
|
|
183
205
|
|
|
184
206
|
## Docker
|
|
185
207
|
|
|
@@ -191,12 +213,29 @@ docker compose up
|
|
|
191
213
|
TUNNEL_TOKEN=your-token docker compose --profile tunnel up
|
|
192
214
|
```
|
|
193
215
|
|
|
194
|
-
The daemon
|
|
216
|
+
The daemon has two modes:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Full mode — serves everything to trusted LAN peers
|
|
220
|
+
openfused serve --store ./my-context --port 9781
|
|
221
|
+
|
|
222
|
+
# Public mode — only PROFILE.md + inbox (for WAN/tunnels)
|
|
223
|
+
openfused serve --store ./my-context --port 9781 --public
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## File Watching
|
|
227
|
+
|
|
228
|
+
`openfuse watch` combines three things:
|
|
229
|
+
|
|
230
|
+
1. **Local inbox watcher** — chokidar (inotify on Linux) for instant notification when messages arrive
|
|
231
|
+
2. **CONTEXT.md watcher** — detects local changes
|
|
232
|
+
3. **Periodic peer sync** — pulls from all peers every 60s (configurable)
|
|
195
233
|
|
|
196
234
|
```bash
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
235
|
+
openfuse watch -d ./store # sync every 60s
|
|
236
|
+
openfuse watch -d ./store --sync-interval 30 # sync every 30s
|
|
237
|
+
openfuse watch -d ./store --sync-interval 0 # local watch only
|
|
238
|
+
openfuse watch -d ./store --tunnel alice.local # + reverse SSH tunnel
|
|
200
239
|
```
|
|
201
240
|
|
|
202
241
|
## Reachability
|
package/dist/cli.js
CHANGED
|
@@ -3,12 +3,12 @@ import { Command } from "commander";
|
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
4
|
import { ContextStore } from "./store.js";
|
|
5
5
|
import { watchInbox, watchContext, watchSync } from "./watch.js";
|
|
6
|
-
import { syncAll, syncOne } from "./sync.js";
|
|
6
|
+
import { syncAll, syncOne, deliverOne } from "./sync.js";
|
|
7
7
|
import * as registry from "./registry.js";
|
|
8
8
|
import { fingerprint } from "./crypto.js";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
|
-
const VERSION = "0.3.
|
|
11
|
+
const VERSION = "0.3.5";
|
|
12
12
|
const program = new Command();
|
|
13
13
|
program
|
|
14
14
|
.name("openfuse")
|
|
@@ -123,8 +123,15 @@ inbox
|
|
|
123
123
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
124
124
|
.action(async (peerId, message, opts) => {
|
|
125
125
|
const store = new ContextStore(resolve(opts.dir));
|
|
126
|
-
await store.sendInbox(peerId, message);
|
|
127
|
-
|
|
126
|
+
const filename = await store.sendInbox(peerId, message);
|
|
127
|
+
// Try immediate delivery — if peer is reachable, deliver now
|
|
128
|
+
const delivered = await deliverOne(store, peerId, filename);
|
|
129
|
+
if (delivered) {
|
|
130
|
+
console.log(`Delivered to ${peerId}.`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.log(`Queued for ${peerId}. Will deliver on next sync.`);
|
|
134
|
+
}
|
|
128
135
|
});
|
|
129
136
|
// --- watch ---
|
|
130
137
|
program
|
|
@@ -132,8 +139,9 @@ program
|
|
|
132
139
|
.description("Watch for inbox messages, context changes, and sync with peers")
|
|
133
140
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
134
141
|
.option("--sync-interval <seconds>", "Peer sync interval in seconds (0 to disable)", "60")
|
|
135
|
-
.option("--tunnel <host>", "
|
|
136
|
-
.option("--tunnel-port <port>", "Remote port for reverse tunnel", "2222")
|
|
142
|
+
.option("--tunnel <host>", "Reverse SSH tunnel to host for NAT traversal (uses autossh if available)")
|
|
143
|
+
.option("--tunnel-port <port>", "Remote port for reverse SSH tunnel", "2222")
|
|
144
|
+
.option("--cloudflared", "Start a cloudflared quick tunnel (no config needed, gives you a public URL)")
|
|
137
145
|
.action(async (opts) => {
|
|
138
146
|
const store = new ContextStore(resolve(opts.dir));
|
|
139
147
|
if (!(await store.exists())) {
|
|
@@ -175,6 +183,24 @@ program
|
|
|
175
183
|
console.log(`Tunnel: ${cmd} -R ${tunnelPort}:localhost:9781 ${tunnelHost}`);
|
|
176
184
|
console.log(`Your store is reachable at ssh://${tunnelHost}:${tunnelPort} (via daemon on :9781)`);
|
|
177
185
|
}
|
|
186
|
+
// Cloudflared quick tunnel (optional) — gives you a public *.trycloudflare.com URL
|
|
187
|
+
if (opts.cloudflared) {
|
|
188
|
+
const { spawn } = await import("node:child_process");
|
|
189
|
+
const cf = spawn("cloudflared", ["tunnel", "--url", "http://localhost:9781"], {
|
|
190
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
191
|
+
});
|
|
192
|
+
cf.on("error", (e) => console.error(`[cloudflared] failed: ${e.message}. Install: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`));
|
|
193
|
+
cf.stderr.on("data", (data) => {
|
|
194
|
+
const line = data.toString();
|
|
195
|
+
const match = line.match(/https:\/\/[^\s]+\.trycloudflare\.com/);
|
|
196
|
+
if (match) {
|
|
197
|
+
console.log(`[cloudflared] Your public URL: ${match[0]}`);
|
|
198
|
+
console.log(` Register it: openfuse register --endpoint ${match[0]}`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
process.on("exit", () => cf.kill());
|
|
202
|
+
console.log("Starting cloudflared tunnel...");
|
|
203
|
+
}
|
|
178
204
|
console.log(`Press Ctrl+C to stop.\n`);
|
|
179
205
|
watchInbox(store.root, (from, message) => {
|
|
180
206
|
console.log(`\n[inbox] New message from ${from}:`);
|
package/dist/mcp.js
CHANGED
|
@@ -23,7 +23,7 @@ const storeDir = process.env.OPENFUSE_DIR || process.argv[3] || ".";
|
|
|
23
23
|
const store = new ContextStore(resolve(storeDir));
|
|
24
24
|
const server = new McpServer({
|
|
25
25
|
name: "openfuse",
|
|
26
|
-
version: "0.3.
|
|
26
|
+
version: "0.3.5",
|
|
27
27
|
});
|
|
28
28
|
// --- Context ---
|
|
29
29
|
server.tool("context_read", "Read the agent's CONTEXT.md (working memory)", async () => {
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ContextStore } from "./store.js";
|
|
2
|
-
export declare const DEFAULT_REGISTRY = "https://
|
|
2
|
+
export declare const DEFAULT_REGISTRY = "https://registry.openfused.dev";
|
|
3
3
|
export interface Manifest {
|
|
4
4
|
name: string;
|
|
5
5
|
endpoint: string;
|
package/dist/registry.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// This is TOFU (Trust On First Use) done right: the registry distributes keys,
|
|
8
8
|
// but never asserts trust. Trust is a local decision.
|
|
9
9
|
import { signMessage, fingerprint } from "./crypto.js";
|
|
10
|
-
export const DEFAULT_REGISTRY = "https://
|
|
10
|
+
export const DEFAULT_REGISTRY = "https://registry.openfused.dev";
|
|
11
11
|
export function resolveRegistry(flag) {
|
|
12
12
|
return flag || process.env.OPENFUSE_REGISTRY || DEFAULT_REGISTRY;
|
|
13
13
|
}
|
|
@@ -41,7 +41,22 @@ export async function register(store, endpoint, registry) {
|
|
|
41
41
|
}
|
|
42
42
|
return manifest;
|
|
43
43
|
}
|
|
44
|
+
// Discovery: try DNS TXT first (decentralized, no registry needed), fall back to Worker API.
|
|
45
|
+
// DNS format: v=of1 e={endpoint} pk={pubkey} ek={agekey} fp={fingerprint}
|
|
46
|
+
// Self-hosted: _openfuse.{name}.{their-domain} — user manages their own TXT records.
|
|
47
|
+
// Our zone: _openfuse.{name}.openfused.dev — managed by the registry Worker on registration.
|
|
44
48
|
export async function discover(name, registry) {
|
|
49
|
+
// If name contains a dot, it's a domain — try DNS TXT directly
|
|
50
|
+
// Otherwise try DNS at openfused.dev, then fall back to registry API
|
|
51
|
+
const dnsNames = name.includes(".")
|
|
52
|
+
? [`_openfuse.${name}`]
|
|
53
|
+
: [`_openfuse.${name}.openfused.dev`];
|
|
54
|
+
for (const dnsName of dnsNames) {
|
|
55
|
+
const manifest = await discoverViaDns(dnsName, name);
|
|
56
|
+
if (manifest)
|
|
57
|
+
return manifest;
|
|
58
|
+
}
|
|
59
|
+
// Fall back to registry API
|
|
45
60
|
const resp = await fetch(`${registry.replace(/\/$/, "")}/discover/${name}`);
|
|
46
61
|
if (!resp.ok) {
|
|
47
62
|
const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
|
@@ -49,6 +64,43 @@ export async function discover(name, registry) {
|
|
|
49
64
|
}
|
|
50
65
|
return (await resp.json());
|
|
51
66
|
}
|
|
67
|
+
async function discoverViaDns(dnsName, agentName) {
|
|
68
|
+
try {
|
|
69
|
+
// Use DNS-over-HTTPS (Cloudflare 1.1.1.1) to resolve TXT records
|
|
70
|
+
const resp = await fetch(`https://1.1.1.1/dns-query?name=${encodeURIComponent(dnsName)}&type=TXT`, {
|
|
71
|
+
headers: { "Accept": "application/dns-json" },
|
|
72
|
+
});
|
|
73
|
+
if (!resp.ok)
|
|
74
|
+
return null;
|
|
75
|
+
const data = await resp.json();
|
|
76
|
+
if (!data.Answer || data.Answer.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
// Parse v=of1 format from TXT record
|
|
79
|
+
const txt = data.Answer[0].data.replace(/"/g, "");
|
|
80
|
+
if (!txt.startsWith("v=of1"))
|
|
81
|
+
return null;
|
|
82
|
+
const fields = {};
|
|
83
|
+
for (const part of txt.split(" ")) {
|
|
84
|
+
const [k, v] = part.split("=", 2);
|
|
85
|
+
if (k && v)
|
|
86
|
+
fields[k] = v;
|
|
87
|
+
}
|
|
88
|
+
if (!fields.e || !fields.pk)
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
name: agentName,
|
|
92
|
+
endpoint: fields.e,
|
|
93
|
+
publicKey: fields.pk,
|
|
94
|
+
encryptionKey: fields.ek || undefined,
|
|
95
|
+
fingerprint: fields.fp || "",
|
|
96
|
+
created: "",
|
|
97
|
+
capabilities: ["inbox", "shared", "knowledge"],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
52
104
|
// Revocation is permanent and self-authenticated: the agent signs its own revocation
|
|
53
105
|
// with the key being revoked. No admin needed — if you have the private key, you can kill it.
|
|
54
106
|
export async function revoke(store, registry) {
|
package/dist/store.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare class ContextStore {
|
|
|
28
28
|
writeContext(content: string): Promise<void>;
|
|
29
29
|
readProfile(): Promise<string>;
|
|
30
30
|
writeProfile(content: string): Promise<void>;
|
|
31
|
-
sendInbox(peerId: string, message: string): Promise<
|
|
31
|
+
sendInbox(peerId: string, message: string): Promise<string>;
|
|
32
32
|
readInbox(): Promise<Array<{
|
|
33
33
|
file: string;
|
|
34
34
|
content: string;
|
package/dist/store.js
CHANGED
|
@@ -114,6 +114,7 @@ export class ContextStore {
|
|
|
114
114
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
115
115
|
const filename = `${timestamp}_from-${config.name}_to-${peerId}.json`;
|
|
116
116
|
await writeFile(join(this.root, "outbox", filename), serializeSignedMessage(signed));
|
|
117
|
+
return filename;
|
|
117
118
|
}
|
|
118
119
|
async readInbox() {
|
|
119
120
|
const inboxDir = join(this.root, "inbox");
|
package/dist/sync.d.ts
CHANGED
|
@@ -5,5 +5,7 @@ export interface SyncResult {
|
|
|
5
5
|
pushed: string[];
|
|
6
6
|
errors: string[];
|
|
7
7
|
}
|
|
8
|
+
/** Try to deliver a single outbox message immediately. Returns true if delivered. */
|
|
9
|
+
export declare function deliverOne(store: ContextStore, peerName: string, filename: string): Promise<boolean>;
|
|
8
10
|
export declare function syncAll(store: ContextStore): Promise<SyncResult[]>;
|
|
9
11
|
export declare function syncOne(store: ContextStore, peerName: string): Promise<SyncResult>;
|
package/dist/sync.js
CHANGED
|
@@ -39,6 +39,42 @@ function parseUrl(url) {
|
|
|
39
39
|
}
|
|
40
40
|
throw new Error(`Unknown URL scheme: ${url}. Use http:// or ssh://`);
|
|
41
41
|
}
|
|
42
|
+
/** Try to deliver a single outbox message immediately. Returns true if delivered. */
|
|
43
|
+
export async function deliverOne(store, peerName, filename) {
|
|
44
|
+
const config = await store.readConfig();
|
|
45
|
+
const peer = config.peers.find((p) => p.name === peerName || p.id === peerName);
|
|
46
|
+
if (!peer)
|
|
47
|
+
return false;
|
|
48
|
+
const outboxDir = join(store.root, "outbox");
|
|
49
|
+
const filePath = join(outboxDir, filename);
|
|
50
|
+
if (!existsSync(filePath))
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
const transport = parseUrl(peer.url);
|
|
54
|
+
if (transport.type === "http") {
|
|
55
|
+
const body = await readFile(filePath, "utf-8");
|
|
56
|
+
const r = await fetch(`${transport.baseUrl}/inbox`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body,
|
|
60
|
+
});
|
|
61
|
+
if (!r.ok)
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await execFile("rsync", [
|
|
66
|
+
"-az", filePath,
|
|
67
|
+
`${transport.host}:${transport.path}/inbox/${filename}`,
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
// Delivered — archive to .sent/
|
|
71
|
+
await archiveSent(outboxDir, filename);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false; // stays in outbox for next sync
|
|
76
|
+
}
|
|
77
|
+
}
|
|
42
78
|
export async function syncAll(store) {
|
|
43
79
|
const config = await store.readConfig();
|
|
44
80
|
const results = [];
|
|
@@ -120,7 +156,7 @@ async function syncHttp(store, peer, baseUrl, peerDir) {
|
|
|
120
156
|
for (const fname of await readdir(outboxDir)) {
|
|
121
157
|
if (!fname.endsWith(".json"))
|
|
122
158
|
continue;
|
|
123
|
-
if (!fname.includes(`_to-${peer.name}`) && !fname.includes(peer.id))
|
|
159
|
+
if (!fname.includes(`_to-${peer.name}.json`) && !fname.includes(peer.id))
|
|
124
160
|
continue;
|
|
125
161
|
try {
|
|
126
162
|
const body = await readFile(join(outboxDir, fname), "utf-8");
|
|
@@ -180,6 +216,7 @@ async function syncSsh(store, peer, host, remotePath, peerDir) {
|
|
|
180
216
|
"-az", "--ignore-existing",
|
|
181
217
|
"--include", `*_to-${myName}.json`,
|
|
182
218
|
"--include", `*_to-all.json`,
|
|
219
|
+
"--include", `*_${myName}.json`, // legacy format (pre-envelope)
|
|
183
220
|
"--exclude", "*",
|
|
184
221
|
`${host}:${remotePath}/outbox/`,
|
|
185
222
|
`${inboxDir}/`,
|
|
@@ -197,7 +234,7 @@ async function syncSsh(store, peer, host, remotePath, peerDir) {
|
|
|
197
234
|
for (const fname of await readdir(outboxDir)) {
|
|
198
235
|
if (!fname.endsWith(".json"))
|
|
199
236
|
continue;
|
|
200
|
-
if (!fname.includes(`_to-${peer.name}`) && !fname.includes(peer.id))
|
|
237
|
+
if (!fname.includes(`_to-${peer.name}.json`) && !fname.includes(peer.id))
|
|
201
238
|
continue;
|
|
202
239
|
try {
|
|
203
240
|
await execFile("rsync", ["-az", join(outboxDir, fname), `${host}:${remotePath}/inbox/${fname}`]);
|
package/package.json
CHANGED