openfused 0.3.23 → 0.4.1
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/dist/cli.js +12 -24
- package/dist/crypto.d.ts +0 -2
- package/dist/crypto.js +16 -93
- package/dist/store.d.ts +2 -0
- package/dist/store.js +26 -241
- package/dist/wasm-core.d.ts +113 -0
- package/dist/wasm-core.js +179 -0
- package/package.json +6 -4
- package/templates/CHARTER.md +45 -6
- package/templates/CONTEXT.md +10 -0
- package/wasm/.gitkeep +0 -0
- package/wasm/openfuse-core.wasm +0 -0
- package/dist/validity.d.ts +0 -41
- package/dist/validity.js +0 -117
- package/dist/validity.test.d.ts +0 -1
- package/dist/validity.test.js +0 -199
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as registry from "./registry.js";
|
|
|
8
8
|
import { fingerprint } from "./crypto.js";
|
|
9
9
|
import { resolve, join } from "node:path";
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
|
-
import {
|
|
11
|
+
import { WasmCore } from "./wasm-core.js";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
14
14
|
// Enable proxy support: Node.js built-in fetch doesn't respect HTTP_PROXY env vars.
|
|
@@ -305,20 +305,8 @@ program
|
|
|
305
305
|
const store = new ContextStore(resolve(opts.dir));
|
|
306
306
|
let prunedCount = 0;
|
|
307
307
|
if (opts.pruneStale) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const sections = parseValiditySections(content);
|
|
311
|
-
const staleSections = sections.filter((s) => s.expired);
|
|
312
|
-
if (staleSections.length > 0) {
|
|
313
|
-
// Remove stale annotated sections from file
|
|
314
|
-
let updated = content;
|
|
315
|
-
for (const s of staleSections) {
|
|
316
|
-
// Strip the section text from the file (simple text removal)
|
|
317
|
-
updated = updated.replace(s.sectionText, "[STALE — archived by openfuse compact --prune-stale]");
|
|
318
|
-
}
|
|
319
|
-
await store.writeContext(updated);
|
|
320
|
-
prunedCount = staleSections.length;
|
|
321
|
-
}
|
|
308
|
+
const core = new WasmCore(resolve(opts.dir));
|
|
309
|
+
prunedCount = await core.pruneStale();
|
|
322
310
|
}
|
|
323
311
|
const { moved, kept } = await store.compactContext();
|
|
324
312
|
if (moved === 0 && prunedCount === 0) {
|
|
@@ -345,29 +333,29 @@ program
|
|
|
345
333
|
console.error("No context store found. Run `openfuse init` first.");
|
|
346
334
|
process.exit(1);
|
|
347
335
|
}
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
const report = buildValidityReport(sections);
|
|
336
|
+
const core = new WasmCore(resolve(opts.dir));
|
|
337
|
+
const report = await core.validate();
|
|
351
338
|
if (opts.json) {
|
|
352
339
|
console.log(JSON.stringify(report, null, 2));
|
|
353
340
|
return;
|
|
354
341
|
}
|
|
355
|
-
|
|
342
|
+
const total = report.entries.length;
|
|
343
|
+
if (total === 0) {
|
|
356
344
|
console.log("No validity-annotated sections found.");
|
|
357
345
|
console.log("Add `<!-- validity: 6h -->` before time-sensitive context entries.");
|
|
358
346
|
return;
|
|
359
347
|
}
|
|
360
|
-
console.log(`Validity check: ${report.fresh} fresh, ${report.stale} stale (of ${
|
|
348
|
+
console.log(`Validity check: ${report.fresh} fresh, ${report.stale} stale (of ${total} annotated)`);
|
|
361
349
|
if (report.stale > 0) {
|
|
362
|
-
console.log("\nStale sections (confidence < 0.
|
|
350
|
+
console.log("\nStale sections (confidence < 0.5):");
|
|
363
351
|
for (const e of report.entries.filter((e) => e.expired)) {
|
|
364
|
-
const age = e.
|
|
365
|
-
console.log(` [${e.
|
|
352
|
+
const age = e.added ? ` written ${e.added}` : "";
|
|
353
|
+
console.log(` [${e.ttl_str} TTL${age}] ${e.header}`);
|
|
366
354
|
}
|
|
367
355
|
console.log("\nRun `openfuse compact --prune-stale` to archive stale sections.");
|
|
368
356
|
}
|
|
369
357
|
else {
|
|
370
|
-
console.log("All annotated sections are within their validity windows.
|
|
358
|
+
console.log("All annotated sections are within their validity windows.");
|
|
371
359
|
}
|
|
372
360
|
});
|
|
373
361
|
// --- share ---
|
package/dist/crypto.d.ts
CHANGED
|
@@ -23,8 +23,6 @@ export declare function generateKeys(storeRoot: string): Promise<{
|
|
|
23
23
|
export declare function hasKeys(storeRoot: string): Promise<boolean>;
|
|
24
24
|
export declare function fingerprint(publicKey: string): string;
|
|
25
25
|
export declare function loadAgeRecipient(storeRoot: string): Promise<string>;
|
|
26
|
-
/** Sign a raw challenge string — used for outbox authentication.
|
|
27
|
-
* Returns { signature, publicKey } without the full SignedMessage envelope. */
|
|
28
26
|
export declare function signChallenge(storeRoot: string, challenge: string): Promise<{
|
|
29
27
|
signature: string;
|
|
30
28
|
publicKey: string;
|
package/dist/crypto.js
CHANGED
|
@@ -1,39 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// combining them would violate key-separation best practice.
|
|
6
|
-
import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey, createHash } from "node:crypto";
|
|
7
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
1
|
+
// Crypto module — delegates to Rust WASM core for all operations.
|
|
2
|
+
// Keeps the same public API so cli.ts, sync.ts, watch.ts, registry.ts don't change.
|
|
3
|
+
import { createHash, verify as cryptoVerify, createPublicKey } from "node:crypto";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
8
5
|
import { join } from "node:path";
|
|
9
6
|
import { existsSync } from "node:fs";
|
|
10
|
-
import {
|
|
7
|
+
import { WasmCore } from "./wasm-core.js";
|
|
11
8
|
const KEY_DIR = ".keys";
|
|
12
9
|
// --- Key generation ---
|
|
13
10
|
export async function generateKeys(storeRoot) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
// Ed25519 signing keypair
|
|
17
|
-
const { publicKey: pubObj, privateKey: privObj } = generateKeyPairSync("ed25519");
|
|
18
|
-
const pubJwk = pubObj.export({ format: "jwk" });
|
|
19
|
-
const privJwk = privObj.export({ format: "jwk" });
|
|
20
|
-
const publicHex = Buffer.from(pubJwk.x, "base64url").toString("hex");
|
|
21
|
-
const privateHex = Buffer.from(privJwk.d, "base64url").toString("hex");
|
|
22
|
-
await writeFile(join(keyDir, "public.key"), publicHex, { mode: 0o644 });
|
|
23
|
-
await writeFile(join(keyDir, "private.key"), privateHex, { mode: 0o600 });
|
|
24
|
-
// age encryption keypair
|
|
25
|
-
const ageIdentity = await generateIdentity();
|
|
26
|
-
const ageRecipient = await identityToRecipient(ageIdentity);
|
|
27
|
-
await writeFile(join(keyDir, "age.key"), ageIdentity, { mode: 0o600 });
|
|
28
|
-
await writeFile(join(keyDir, "age.pub"), ageRecipient, { mode: 0o644 });
|
|
29
|
-
return { publicKey: publicHex, encryptionKey: ageRecipient };
|
|
11
|
+
const core = new WasmCore(storeRoot);
|
|
12
|
+
return core.generateKeys();
|
|
30
13
|
}
|
|
31
14
|
export async function hasKeys(storeRoot) {
|
|
32
15
|
return existsSync(join(storeRoot, KEY_DIR, "private.key"));
|
|
33
16
|
}
|
|
34
17
|
// --- Fingerprint ---
|
|
35
|
-
// SHA-256 truncated to 16 bytes, displayed as colon-separated hex pairs (GPG-style).
|
|
36
|
-
// Human-readable so agents can verify identities out-of-band — same UX as SSH fingerprints.
|
|
37
18
|
export function fingerprint(publicKey) {
|
|
38
19
|
const hash = createHash("sha256").update(publicKey).digest();
|
|
39
20
|
const pairs = [];
|
|
@@ -47,71 +28,27 @@ export function fingerprint(publicKey) {
|
|
|
47
28
|
return groups.join(":");
|
|
48
29
|
}
|
|
49
30
|
// --- Signing ---
|
|
50
|
-
async function loadPrivateKey(storeRoot) {
|
|
51
|
-
const privHex = (await readFile(join(storeRoot, KEY_DIR, "private.key"), "utf-8")).trim();
|
|
52
|
-
const pubHex = (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
|
|
53
|
-
const d = Buffer.from(privHex, "hex").toString("base64url");
|
|
54
|
-
const x = Buffer.from(pubHex, "hex").toString("base64url");
|
|
55
|
-
return createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d, x }, format: "jwk" });
|
|
56
|
-
}
|
|
57
|
-
async function loadPublicKeyHex(storeRoot) {
|
|
58
|
-
return (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
|
|
59
|
-
}
|
|
60
31
|
export async function loadAgeRecipient(storeRoot) {
|
|
61
32
|
return (await readFile(join(storeRoot, KEY_DIR, "age.pub"), "utf-8")).trim();
|
|
62
33
|
}
|
|
63
|
-
async function loadAgeIdentity(storeRoot) {
|
|
64
|
-
return (await readFile(join(storeRoot, KEY_DIR, "age.key"), "utf-8")).trim();
|
|
65
|
-
}
|
|
66
|
-
/** Sign a raw challenge string — used for outbox authentication.
|
|
67
|
-
* Returns { signature, publicKey } without the full SignedMessage envelope. */
|
|
68
34
|
export async function signChallenge(storeRoot, challenge) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const signature = sign(null, Buffer.from(challenge), privateKey).toString("base64");
|
|
72
|
-
return { signature, publicKey };
|
|
35
|
+
const core = new WasmCore(storeRoot);
|
|
36
|
+
return core.signChallenge(challenge);
|
|
73
37
|
}
|
|
74
38
|
export async function signMessage(storeRoot, from, message) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const timestamp = new Date().toISOString();
|
|
78
|
-
const payload = Buffer.from(`${from}\n${timestamp}\n${message}`);
|
|
79
|
-
const signature = sign(null, payload, privateKey).toString("base64");
|
|
80
|
-
// Include our age public key so recipients can encrypt replies without DNS lookup
|
|
81
|
-
let encryptionKey;
|
|
82
|
-
try {
|
|
83
|
-
encryptionKey = await loadAgeRecipient(storeRoot);
|
|
84
|
-
}
|
|
85
|
-
catch { }
|
|
86
|
-
return { from, timestamp, message, signature, publicKey, encryptionKey, encrypted: false };
|
|
39
|
+
const core = new WasmCore(storeRoot);
|
|
40
|
+
return core.signMessage(from, message);
|
|
87
41
|
}
|
|
88
|
-
// --- Encrypt-then-sign ---
|
|
89
|
-
// Encrypt first, then sign the ciphertext. This order matters:
|
|
90
|
-
// 1. Proves WHO sent the ciphertext (non-repudiation on the encrypted blob)
|
|
91
|
-
// 2. Prevents Surreptitious Forwarding — signature covers the encrypted form,
|
|
92
|
-
// so a relay can't strip the signature and re-sign for a different recipient.
|
|
93
|
-
// 3. Signature is verifiable by anyone without needing the decryption key.
|
|
94
42
|
export async function signAndEncrypt(storeRoot, from, plaintext, recipientAgeKey) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const privateKey = await loadPrivateKey(storeRoot);
|
|
98
|
-
const publicKey = await loadPublicKeyHex(storeRoot);
|
|
99
|
-
const timestamp = new Date().toISOString();
|
|
100
|
-
const payload = Buffer.from(`${from}\n${timestamp}\n${encoded}`);
|
|
101
|
-
const signature = sign(null, payload, privateKey).toString("base64");
|
|
102
|
-
let encryptionKey;
|
|
103
|
-
try {
|
|
104
|
-
encryptionKey = await loadAgeRecipient(storeRoot);
|
|
105
|
-
}
|
|
106
|
-
catch { }
|
|
107
|
-
return { from, timestamp, message: encoded, signature, publicKey, encryptionKey, encrypted: true };
|
|
43
|
+
const core = new WasmCore(storeRoot);
|
|
44
|
+
return core.signAndEncrypt(from, plaintext, recipientAgeKey);
|
|
108
45
|
}
|
|
109
46
|
export function verifyMessage(signed) {
|
|
110
47
|
try {
|
|
111
48
|
const payload = Buffer.from(`${signed.from}\n${signed.timestamp}\n${signed.message}`);
|
|
112
49
|
const x = Buffer.from(signed.publicKey.trim(), "hex").toString("base64url");
|
|
113
50
|
const pubKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
|
|
114
|
-
return
|
|
51
|
+
return cryptoVerify(null, payload, pubKey, Buffer.from(signed.signature, "base64"));
|
|
115
52
|
}
|
|
116
53
|
catch {
|
|
117
54
|
return false;
|
|
@@ -120,24 +57,10 @@ export function verifyMessage(signed) {
|
|
|
120
57
|
export async function decryptMessage(storeRoot, signed) {
|
|
121
58
|
if (!signed.encrypted)
|
|
122
59
|
return signed.message;
|
|
123
|
-
const
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
// --- age encryption ---
|
|
127
|
-
async function ageEncrypt(plaintext, recipientKey) {
|
|
128
|
-
const e = new Encrypter();
|
|
129
|
-
e.addRecipient(recipientKey);
|
|
130
|
-
return await e.encrypt(plaintext);
|
|
131
|
-
}
|
|
132
|
-
async function ageDecrypt(ciphertext, storeRoot) {
|
|
133
|
-
const identity = await loadAgeIdentity(storeRoot);
|
|
134
|
-
const d = new Decrypter();
|
|
135
|
-
d.addIdentity(identity);
|
|
136
|
-
return await d.decrypt(ciphertext, "text");
|
|
60
|
+
const core = new WasmCore(storeRoot);
|
|
61
|
+
return core.decryptMessage(signed);
|
|
137
62
|
}
|
|
138
63
|
// --- Helpers ---
|
|
139
|
-
// XML envelope wrapping — gives LLMs a structured, parseable format with clear
|
|
140
|
-
// trust signals (verified/UNVERIFIED). HTML-escaped to prevent injection into prompts.
|
|
141
64
|
export function wrapExternalMessage(signed, verified) {
|
|
142
65
|
const status = verified ? "verified" : "UNVERIFIED";
|
|
143
66
|
const esc = (s) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
package/dist/store.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type KeyringEntry } from "./crypto.js";
|
|
2
|
+
export type { KeyringEntry } from "./crypto.js";
|
|
2
3
|
export interface MeshConfig {
|
|
3
4
|
id: string;
|
|
4
5
|
name: string;
|
|
@@ -25,6 +26,7 @@ export declare function validateName(name: string, label?: string): string;
|
|
|
25
26
|
export declare function resolveKeyring(keyring: KeyringEntry[], query: string): KeyringEntry;
|
|
26
27
|
export declare class ContextStore {
|
|
27
28
|
readonly root: string;
|
|
29
|
+
private core;
|
|
28
30
|
constructor(root: string);
|
|
29
31
|
get configPath(): string;
|
|
30
32
|
exists(): Promise<boolean>;
|
package/dist/store.js
CHANGED
|
@@ -1,21 +1,9 @@
|
|
|
1
|
-
// --- Store
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// but this file is shared with peers — "profile" is honest about its visibility)
|
|
6
|
-
// inbox/ — append-only message queue from other agents
|
|
7
|
-
// outbox/ — signed envelopes waiting to be delivered
|
|
8
|
-
// shared/ — files explicitly published to peers
|
|
9
|
-
// history/ — conversation logs
|
|
10
|
-
// knowledge/ — reference docs
|
|
11
|
-
// .keys/ — Ed25519 + age keypairs (gitignored)
|
|
12
|
-
// .mesh.json — config, peer list, keyring
|
|
13
|
-
// No database, no daemon required. `ls` is your status command.
|
|
14
|
-
import { readFile, writeFile, mkdir, readdir, appendFile } from "node:fs/promises";
|
|
15
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
// --- Store module ---
|
|
2
|
+
// Delegates to Rust WASM core for all store operations.
|
|
3
|
+
// Same public API — cli.ts, sync.ts, mcp.ts, watch.ts don't change.
|
|
4
|
+
import { resolve } from "node:path";
|
|
16
5
|
import { existsSync } from "node:fs";
|
|
17
|
-
import {
|
|
18
|
-
const STORE_DIRS = ["history", "knowledge", "inbox", "outbox", "shared", ".peers"];
|
|
6
|
+
import { WasmCore } from "./wasm-core.js";
|
|
19
7
|
/** Validate agent/peer names: alphanumeric + hyphens + underscores + dots, 1-64 chars.
|
|
20
8
|
* Rejects path traversal (../, /, \) and rsync glob chars (*, ?, [). */
|
|
21
9
|
export function validateName(name, label = "Name") {
|
|
@@ -36,7 +24,6 @@ export function resolveKeyring(keyring, query) {
|
|
|
36
24
|
let name;
|
|
37
25
|
let fpPrefix;
|
|
38
26
|
if (query.includes(":")) {
|
|
39
|
-
// name:FINGERPRINT format — split on LAST colon group that looks like hex
|
|
40
27
|
const colonIdx = query.lastIndexOf(":");
|
|
41
28
|
const maybeFp = query.slice(colonIdx + 1);
|
|
42
29
|
if (/^[0-9a-fA-F]{4,16}$/.test(maybeFp)) {
|
|
@@ -50,14 +37,11 @@ export function resolveKeyring(keyring, query) {
|
|
|
50
37
|
else {
|
|
51
38
|
name = query;
|
|
52
39
|
}
|
|
53
|
-
// Match by name (or address prefix)
|
|
54
40
|
let matches = keyring.filter((k) => k.name === name || k.address.startsWith(`${name}@`));
|
|
55
|
-
// If no name match, try bare fingerprint prefix
|
|
56
41
|
if (matches.length === 0 && /^[0-9a-fA-F]{4,16}$/.test(query)) {
|
|
57
42
|
const upper = query.toUpperCase();
|
|
58
43
|
matches = keyring.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(upper));
|
|
59
44
|
}
|
|
60
|
-
// Filter by fingerprint prefix if provided
|
|
61
45
|
if (fpPrefix && matches.length > 1) {
|
|
62
46
|
matches = matches.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(fpPrefix));
|
|
63
47
|
}
|
|
@@ -72,264 +56,65 @@ export function resolveKeyring(keyring, query) {
|
|
|
72
56
|
}
|
|
73
57
|
export class ContextStore {
|
|
74
58
|
root;
|
|
59
|
+
core;
|
|
75
60
|
constructor(root) {
|
|
76
61
|
this.root = resolve(root);
|
|
62
|
+
this.core = new WasmCore(this.root);
|
|
77
63
|
}
|
|
78
64
|
get configPath() {
|
|
79
|
-
return
|
|
65
|
+
return `${this.root}/.mesh.json`;
|
|
80
66
|
}
|
|
81
67
|
async exists() {
|
|
82
68
|
return existsSync(this.configPath);
|
|
83
69
|
}
|
|
84
70
|
async init(name, id) {
|
|
85
|
-
await
|
|
86
|
-
for (const dir of STORE_DIRS) {
|
|
87
|
-
await mkdir(join(this.root, dir), { recursive: true });
|
|
88
|
-
}
|
|
89
|
-
// Copy templates
|
|
90
|
-
const templatesDir = new URL("../templates/", import.meta.url).pathname;
|
|
91
|
-
for (const file of ["CONTEXT.md", "PROFILE.md"]) {
|
|
92
|
-
const templatePath = join(templatesDir, file);
|
|
93
|
-
const destPath = join(this.root, file);
|
|
94
|
-
if (!existsSync(destPath)) {
|
|
95
|
-
const content = await readFile(templatePath, "utf-8");
|
|
96
|
-
await writeFile(destPath, content);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const keys = await generateKeys(this.root);
|
|
100
|
-
const config = {
|
|
101
|
-
id,
|
|
102
|
-
name,
|
|
103
|
-
created: new Date().toISOString(),
|
|
104
|
-
publicKey: keys.publicKey,
|
|
105
|
-
encryptionKey: keys.encryptionKey,
|
|
106
|
-
peers: [],
|
|
107
|
-
keyring: [],
|
|
108
|
-
};
|
|
109
|
-
await this.writeConfig(config);
|
|
71
|
+
await this.core.init(name, id);
|
|
110
72
|
}
|
|
111
|
-
// Shared workspace: multiple agents mount the same directory.
|
|
112
|
-
// CHARTER.md = system prompt (purpose, rules). CONTEXT.md = shared working memory.
|
|
113
|
-
// tasks/ for coordination, messages/{agent}/ for DMs, _broadcast/ for all-hands.
|
|
114
73
|
async initWorkspace(name, id) {
|
|
115
|
-
await
|
|
116
|
-
for (const dir of ["tasks", "messages", "_broadcast", "shared", "history"]) {
|
|
117
|
-
await mkdir(join(this.root, dir), { recursive: true });
|
|
118
|
-
}
|
|
119
|
-
const templatesDir = new URL("../templates/", import.meta.url).pathname;
|
|
120
|
-
for (const file of ["CHARTER.md", "CONTEXT.md"]) {
|
|
121
|
-
const templatePath = join(templatesDir, file);
|
|
122
|
-
const destPath = join(this.root, file);
|
|
123
|
-
if (!existsSync(destPath)) {
|
|
124
|
-
const content = await readFile(templatePath, "utf-8");
|
|
125
|
-
await writeFile(destPath, content);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
// Workspaces auto-trust: all imported keys are trusted by default.
|
|
129
|
-
// Safe because workspaces are private — you control who joins.
|
|
130
|
-
const config = {
|
|
131
|
-
id,
|
|
132
|
-
name,
|
|
133
|
-
created: new Date().toISOString(),
|
|
134
|
-
peers: [],
|
|
135
|
-
keyring: [],
|
|
136
|
-
autoTrust: true,
|
|
137
|
-
};
|
|
138
|
-
await this.writeConfig(config);
|
|
74
|
+
await this.core.initWorkspace(name, id);
|
|
139
75
|
}
|
|
140
76
|
async readConfig() {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
// Migrate legacy trustedKeys → keyring (v0.1 stored bare public keys in a flat array;
|
|
144
|
-
// v0.2+ uses a GPG-style keyring with trust levels, fingerprints, and encryption keys)
|
|
145
|
-
if (config.trustedKeys && config.trustedKeys.length > 0) {
|
|
146
|
-
if (!config.keyring)
|
|
147
|
-
config.keyring = [];
|
|
148
|
-
for (const key of config.trustedKeys) {
|
|
149
|
-
const k = key.trim();
|
|
150
|
-
if (!k || config.keyring.some((e) => e.signingKey === k))
|
|
151
|
-
continue;
|
|
152
|
-
config.keyring.push({
|
|
153
|
-
name: `migrated-${k.slice(0, 8)}`,
|
|
154
|
-
address: "",
|
|
155
|
-
signingKey: k,
|
|
156
|
-
fingerprint: fingerprint(k),
|
|
157
|
-
trusted: true,
|
|
158
|
-
added: new Date().toISOString(),
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
delete config.trustedKeys;
|
|
162
|
-
await this.writeConfig(config);
|
|
163
|
-
}
|
|
77
|
+
const config = await this.core.readConfig();
|
|
78
|
+
// Rust skips empty arrays with skip_serializing_if — ensure keyring/peers always exist
|
|
164
79
|
if (!config.keyring)
|
|
165
80
|
config.keyring = [];
|
|
81
|
+
if (!config.peers)
|
|
82
|
+
config.peers = [];
|
|
166
83
|
return config;
|
|
167
84
|
}
|
|
168
85
|
async writeConfig(config) {
|
|
169
|
-
await
|
|
86
|
+
await this.core.writeConfig(config);
|
|
170
87
|
}
|
|
171
88
|
async readContext() {
|
|
172
|
-
return
|
|
89
|
+
return this.core.readContext();
|
|
173
90
|
}
|
|
174
91
|
async writeContext(content) {
|
|
175
|
-
await
|
|
92
|
+
await this.core.writeContext(content);
|
|
176
93
|
}
|
|
177
|
-
// --- Context compaction ---
|
|
178
|
-
// Agents mark sections as [DONE] when work is complete. `openfuse compact`
|
|
179
|
-
// moves done sections to history/YYYY-MM-DD.md, keeping CONTEXT.md lean.
|
|
180
|
-
// Sections are delimited by markdown headers (## or ###).
|
|
181
94
|
async compactContext() {
|
|
182
|
-
|
|
183
|
-
const lines = content.split("\n");
|
|
184
|
-
const kept = [];
|
|
185
|
-
const done = [];
|
|
186
|
-
let current = [];
|
|
187
|
-
let currentDone = false;
|
|
188
|
-
const flush = () => {
|
|
189
|
-
if (current.length > 0) {
|
|
190
|
-
(currentDone ? done : kept).push(current.join("\n"));
|
|
191
|
-
current = [];
|
|
192
|
-
currentDone = false;
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
for (const line of lines) {
|
|
196
|
-
if (/^#{1,3}\s/.test(line)) {
|
|
197
|
-
flush();
|
|
198
|
-
currentDone = /\[DONE\]/i.test(line);
|
|
199
|
-
}
|
|
200
|
-
current.push(line);
|
|
201
|
-
}
|
|
202
|
-
flush();
|
|
203
|
-
if (done.length === 0)
|
|
204
|
-
return { moved: 0, kept: kept.length };
|
|
205
|
-
// Write kept sections back to CONTEXT.md
|
|
206
|
-
await this.writeContext(kept.join("\n\n") || "# Context\n\n*Working memory — what's happening right now.*\n");
|
|
207
|
-
// Append done sections to history/YYYY-MM-DD.md
|
|
208
|
-
const historyDir = join(this.root, "history");
|
|
209
|
-
await mkdir(historyDir, { recursive: true });
|
|
210
|
-
const dateStr = new Date().toISOString().split("T")[0];
|
|
211
|
-
const historyFile = join(historyDir, `${dateStr}.md`);
|
|
212
|
-
const header = existsSync(historyFile) ? "\n---\n\n" : `# Context History — ${dateStr}\n\n`;
|
|
213
|
-
await appendFile(historyFile, header + done.join("\n\n") + "\n");
|
|
214
|
-
return { moved: done.length, kept: kept.length };
|
|
95
|
+
return this.core.compact();
|
|
215
96
|
}
|
|
216
97
|
async readProfile() {
|
|
217
|
-
return
|
|
98
|
+
return this.core.readProfile();
|
|
218
99
|
}
|
|
219
100
|
async writeProfile(content) {
|
|
220
|
-
await
|
|
101
|
+
await this.core.writeProfile(content);
|
|
221
102
|
}
|
|
222
|
-
// --- Inbox ---
|
|
223
103
|
async sendInbox(peerId, message) {
|
|
224
104
|
const config = await this.readConfig();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const entry = resolveKeyring(config.keyring, peerId);
|
|
228
|
-
let signed;
|
|
229
|
-
if (entry.encryptionKey) {
|
|
230
|
-
signed = await signAndEncrypt(this.root, config.name, message, entry.encryptionKey);
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
signed = await signMessage(this.root, config.name, message);
|
|
234
|
-
}
|
|
235
|
-
const shortFp = entry.fingerprint.replace(/:/g, "").slice(0, 8);
|
|
236
|
-
const recipientDir = `${peerId}-${shortFp}`;
|
|
237
|
-
const outboxDir = join(this.root, "outbox", recipientDir);
|
|
238
|
-
await mkdir(outboxDir, { recursive: true });
|
|
239
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
240
|
-
const filename = `${timestamp}_from-${config.name}.json`;
|
|
241
|
-
await writeFile(join(outboxDir, filename), serializeSignedMessage(signed));
|
|
242
|
-
return `${recipientDir}/${filename}`;
|
|
105
|
+
await this.core.sendInbox(peerId, message, config.name);
|
|
106
|
+
return peerId;
|
|
243
107
|
}
|
|
244
108
|
async readInbox() {
|
|
245
|
-
|
|
246
|
-
if (!existsSync(inboxDir))
|
|
247
|
-
return [];
|
|
248
|
-
const config = await this.readConfig();
|
|
249
|
-
const files = await readdir(inboxDir);
|
|
250
|
-
const messages = [];
|
|
251
|
-
for (const file of files.filter((f) => f.endsWith(".json") || f.endsWith(".md"))) {
|
|
252
|
-
const raw = await readFile(join(inboxDir, file), "utf-8");
|
|
253
|
-
const signed = deserializeSignedMessage(raw);
|
|
254
|
-
if (signed) {
|
|
255
|
-
const sigValid = verifyMessage(signed);
|
|
256
|
-
// Identity binding: verify BOTH that the key is trusted AND that the claimed
|
|
257
|
-
// sender name matches the name we associated with that key in our keyring.
|
|
258
|
-
// Without this, a trusted agent could forge the "from" field and impersonate
|
|
259
|
-
// someone else while still showing [VERIFIED].
|
|
260
|
-
const keyMatchesName = (k) => k.signingKey.trim() === signed.publicKey.trim() &&
|
|
261
|
-
(k.name === signed.from || k.address.startsWith(`${signed.from}@`));
|
|
262
|
-
const trusted = config.autoTrust
|
|
263
|
-
? config.keyring.some(keyMatchesName)
|
|
264
|
-
: config.keyring.some((k) => k.trusted && keyMatchesName(k));
|
|
265
|
-
const verified = sigValid && trusted;
|
|
266
|
-
let content;
|
|
267
|
-
if (signed.encrypted) {
|
|
268
|
-
try {
|
|
269
|
-
content = await decryptMessage(this.root, signed);
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
content = "[encrypted — cannot decrypt]";
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
content = signed.message;
|
|
277
|
-
}
|
|
278
|
-
messages.push({
|
|
279
|
-
file,
|
|
280
|
-
content,
|
|
281
|
-
wrappedContent: wrapExternalMessage(signed, verified),
|
|
282
|
-
from: signed.from,
|
|
283
|
-
time: signed.timestamp,
|
|
284
|
-
verified,
|
|
285
|
-
encrypted: !!signed.encrypted,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
const parts = file.replace(/\.(md|json)$/, "").split("_");
|
|
290
|
-
const from = parts.slice(1).join("_");
|
|
291
|
-
messages.push({
|
|
292
|
-
file,
|
|
293
|
-
content: raw,
|
|
294
|
-
wrappedContent: wrapExternalMessage({ from, timestamp: parts[0], message: raw, signature: "", publicKey: "" }, false),
|
|
295
|
-
from,
|
|
296
|
-
time: parts[0],
|
|
297
|
-
verified: false,
|
|
298
|
-
encrypted: false,
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
return messages.sort((a, b) => a.time.localeCompare(b.time));
|
|
109
|
+
return this.core.readInbox();
|
|
303
110
|
}
|
|
304
|
-
// --- Shared files ---
|
|
305
111
|
async listShared() {
|
|
306
|
-
|
|
307
|
-
if (!existsSync(sharedDir))
|
|
308
|
-
return [];
|
|
309
|
-
return readdir(sharedDir);
|
|
112
|
+
return this.core.listShared();
|
|
310
113
|
}
|
|
311
114
|
async share(filename, content) {
|
|
312
|
-
|
|
313
|
-
// Critical because MCP tools pass user-supplied filenames directly.
|
|
314
|
-
const base = filename.split("/").pop().split("\\").pop();
|
|
315
|
-
if (!base || base === "." || base === ".." || base.includes("..")) {
|
|
316
|
-
throw new Error(`Invalid filename: ${filename}`);
|
|
317
|
-
}
|
|
318
|
-
const sharedDir = join(this.root, "shared");
|
|
319
|
-
await mkdir(sharedDir, { recursive: true });
|
|
320
|
-
await writeFile(join(sharedDir, base), content);
|
|
115
|
+
await this.core.share(filename, content);
|
|
321
116
|
}
|
|
322
|
-
// --- Status ---
|
|
323
117
|
async status() {
|
|
324
|
-
|
|
325
|
-
const inbox = await this.readInbox();
|
|
326
|
-
const shared = await this.listShared();
|
|
327
|
-
return {
|
|
328
|
-
id: config.id,
|
|
329
|
-
name: config.name,
|
|
330
|
-
peers: config.peers.length,
|
|
331
|
-
inboxCount: inbox.length,
|
|
332
|
-
sharedCount: shared.length,
|
|
333
|
-
};
|
|
118
|
+
return this.core.status();
|
|
334
119
|
}
|
|
335
120
|
}
|