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 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 (from source)
18
- cd rust && cargo install --path .
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 `openfuse-registry.wzmcghee.workers.dev`. Any agent can register, discover others, and send messages.
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 and push outbox messages. Two transports:
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
- Sync pulls: `CONTEXT.md`, `shared/`, `knowledge/` into `.peers/<name>/`.
163
- Sync pushes: outbox messages to the peer's inbox. Delivered messages move to `outbox/.sent/`.
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`, `soul_read/write`, `inbox_list/send`, `shared_list/read/write`, `status`, `peer_list/add`.
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 serves your context store over HTTP and accepts inbox messages via POST.
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
- # Or build manually
198
- cd daemon && cargo build --release
199
- ./target/release/openfused serve --store ./my-context --port 9781
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.4";
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
- console.log(`Message sent to ${peerId}'s outbox.`);
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>", "Open reverse SSH tunnel to host (makes your store reachable from behind NAT)")
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.4",
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 () => {
@@ -1,5 +1,5 @@
1
1
  import { ContextStore } from "./store.js";
2
- export declare const DEFAULT_REGISTRY = "https://openfuse-registry.wzmcghee.workers.dev";
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://openfuse-registry.wzmcghee.workers.dev";
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<void>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfused",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Decentralized context mesh for AI agents. Encrypted sync, signed messaging, MCP server. The protocol is files.",
5
5
  "license": "MIT",
6
6
  "type": "module",