@decentnetwork/lan 0.1.86 → 0.1.87
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/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/carrier/peer-manager.d.ts +6 -0
- package/dist/carrier/peer-manager.js +14 -0
- package/dist/cli/commands.d.ts +12 -0
- package/dist/cli/commands.js +19 -0
- package/dist/cli/index.js +12 -1
- package/dist/daemon/friend-meta.d.ts +36 -0
- package/dist/daemon/friend-meta.js +97 -0
- package/dist/daemon/ipc.d.ts +17 -3
- package/dist/daemon/ipc.js +21 -1
- package/dist/daemon/message-store.d.ts +47 -0
- package/dist/daemon/message-store.js +133 -0
- package/dist/daemon/server.d.ts +5 -2
- package/dist/daemon/server.js +58 -15
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -33,6 +33,12 @@ export declare class PeerManager extends EventEmitter {
|
|
|
33
33
|
* pubkey is a no-op at the SDK level.
|
|
34
34
|
*/
|
|
35
35
|
acceptFriendRequest(pubkey: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Remove a friend (by userid/pubkey). Drops them from the Carrier friend
|
|
38
|
+
* store (and persists the shrunk list). Returns true if a friend was
|
|
39
|
+
* removed, false if no such friend existed.
|
|
40
|
+
*/
|
|
41
|
+
removeFriend(userid: string): boolean;
|
|
36
42
|
/**
|
|
37
43
|
* Send an outbound friend request to a Carrier address (NOT a bare
|
|
38
44
|
* userid — sendFriendRequest needs the address form because it
|
|
@@ -78,6 +78,20 @@ export class PeerManager extends EventEmitter {
|
|
|
78
78
|
await this.peer.acceptFriendRequest(pubkey);
|
|
79
79
|
this.logger.info(`Accepted friend request from ${pubkey}`);
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Remove a friend (by userid/pubkey). Drops them from the Carrier friend
|
|
83
|
+
* store (and persists the shrunk list). Returns true if a friend was
|
|
84
|
+
* removed, false if no such friend existed.
|
|
85
|
+
*/
|
|
86
|
+
removeFriend(userid) {
|
|
87
|
+
if (!this.peer) {
|
|
88
|
+
throw new Error("Peer not created. Call create() first.");
|
|
89
|
+
}
|
|
90
|
+
const removed = this.peer.removeFriend(userid);
|
|
91
|
+
if (removed)
|
|
92
|
+
this.logger.info(`Removed friend ${userid}`);
|
|
93
|
+
return removed;
|
|
94
|
+
}
|
|
81
95
|
/**
|
|
82
96
|
* Send an outbound friend request to a Carrier address (NOT a bare
|
|
83
97
|
* userid — sendFriendRequest needs the address form because it
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -232,6 +232,18 @@ export declare function cmdProxyUse(args: {
|
|
|
232
232
|
* `friends.autoAccept: false` (otherwise requests are accepted instantly and
|
|
233
233
|
* never queue).
|
|
234
234
|
*/
|
|
235
|
+
/** Remove (unfriend) a peer by userid — drops the Carrier friend, local alias,
|
|
236
|
+
* and stored messages. Requires the daemon running. */
|
|
237
|
+
export declare function cmdFriendRemove(args: {
|
|
238
|
+
userid: string;
|
|
239
|
+
configDir?: string;
|
|
240
|
+
}): Promise<void>;
|
|
241
|
+
/** Set (or clear, with empty string) a local display alias for a friend. */
|
|
242
|
+
export declare function cmdFriendAlias(args: {
|
|
243
|
+
userid: string;
|
|
244
|
+
alias: string;
|
|
245
|
+
configDir?: string;
|
|
246
|
+
}): Promise<void>;
|
|
235
247
|
/**
|
|
236
248
|
* Send a text message (native Carrier chat, packet 64) to a friend by userid.
|
|
237
249
|
* Routed through the running daemon's Carrier identity over IPC — the daemon
|
package/dist/cli/commands.js
CHANGED
|
@@ -1049,6 +1049,25 @@ export async function cmdProxyUse(args) {
|
|
|
1049
1049
|
* `friends.autoAccept: false` (otherwise requests are accepted instantly and
|
|
1050
1050
|
* never queue).
|
|
1051
1051
|
*/
|
|
1052
|
+
/** Remove (unfriend) a peer by userid — drops the Carrier friend, local alias,
|
|
1053
|
+
* and stored messages. Requires the daemon running. */
|
|
1054
|
+
export async function cmdFriendRemove(args) {
|
|
1055
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
1056
|
+
const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
|
|
1057
|
+
const res = await ipcCall(config, { op: "friend-remove", userid: args.userid });
|
|
1058
|
+
if (!res.ok)
|
|
1059
|
+
throw new Error(`Daemon refused: ${res.error}`);
|
|
1060
|
+
console.log(`Removed friend ${args.userid.slice(0, 16)}…`);
|
|
1061
|
+
}
|
|
1062
|
+
/** Set (or clear, with empty string) a local display alias for a friend. */
|
|
1063
|
+
export async function cmdFriendAlias(args) {
|
|
1064
|
+
const dir = args.configDir || ConfigLoader.defaultConfigDir();
|
|
1065
|
+
const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
|
|
1066
|
+
const res = await ipcCall(config, { op: "friend-set-alias", userid: args.userid, alias: args.alias });
|
|
1067
|
+
if (!res.ok)
|
|
1068
|
+
throw new Error(`Daemon refused: ${res.error}`);
|
|
1069
|
+
console.log(args.alias ? `Alias set: ${args.userid.slice(0, 12)}… → ${args.alias}` : `Alias cleared for ${args.userid.slice(0, 12)}…`);
|
|
1070
|
+
}
|
|
1052
1071
|
/**
|
|
1053
1072
|
* Send a text message (native Carrier chat, packet 64) to a friend by userid.
|
|
1054
1073
|
* Routed through the running daemon's Carrier identity over IPC — the daemon
|
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
|
|
|
10
10
|
// Belt-and-braces — also raise it here in case the CLI is run directly
|
|
11
11
|
// (e.g. `node dist/cli/index.js` rather than via dist/index.js).
|
|
12
12
|
EventEmitter.defaultMaxListeners = 100;
|
|
13
|
-
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdChatSend, cmdChatHistory, } from "./commands.js";
|
|
13
|
+
import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdProxyRouter, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
|
|
14
14
|
async function main() {
|
|
15
15
|
await yargs(hideBin(process.argv))
|
|
16
16
|
.scriptName("agentnet")
|
|
@@ -239,6 +239,17 @@ async function main() {
|
|
|
239
239
|
if (!argv.userid)
|
|
240
240
|
throw new Error("userid is required (positional or --userid)");
|
|
241
241
|
await cmdFriendsReject({ userid: argv.userid, configDir: argv["config-dir"] });
|
|
242
|
+
})
|
|
243
|
+
.command("remove <userid>", "Remove (unfriend) a peer — drops the friend, alias, and stored messages", (yy) => yy
|
|
244
|
+
.positional("userid", { type: "string", demandOption: true, describe: "Friend's Carrier userid" })
|
|
245
|
+
.option("config-dir", { type: "string" }), async (argv) => {
|
|
246
|
+
await cmdFriendRemove({ userid: argv.userid, configDir: argv["config-dir"] });
|
|
247
|
+
})
|
|
248
|
+
.command("alias <userid> <alias>", "Set a local display name for a friend (empty alias clears it)", (yy) => yy
|
|
249
|
+
.positional("userid", { type: "string", demandOption: true, describe: "Friend's Carrier userid" })
|
|
250
|
+
.positional("alias", { type: "string", demandOption: true, describe: "Local display name" })
|
|
251
|
+
.option("config-dir", { type: "string" }), async (argv) => {
|
|
252
|
+
await cmdFriendAlias({ userid: argv.userid, alias: argv.alias, configDir: argv["config-dir"] });
|
|
242
253
|
})
|
|
243
254
|
.demandCommand(1, "Specify a friends subcommand (run 'agentnet friends --help')"), () => {
|
|
244
255
|
// parent handler — never invoked because demandCommand above
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk friend metadata — local-only UI state that the Carrier friend store
|
|
3
|
+
* doesn't hold (PRD-DESKTOP-UI §2.2): a local alias, a read cursor for unread
|
|
4
|
+
* badges, pin/order, and when we added them.
|
|
5
|
+
*
|
|
6
|
+
* File: <configDir>/friends-meta.json -> { userid: FriendMeta }
|
|
7
|
+
* Keyed by userid. Identity/accept-state still lives in the Carrier friend
|
|
8
|
+
* store; this only augments it for display.
|
|
9
|
+
*/
|
|
10
|
+
export interface FriendMeta {
|
|
11
|
+
userid: string;
|
|
12
|
+
/** Local display name; falls back to the userid in the UI when absent. */
|
|
13
|
+
alias?: string;
|
|
14
|
+
pinned?: boolean;
|
|
15
|
+
/** ts of the newest message the user has seen — drives unread counts. */
|
|
16
|
+
lastReadTs?: number;
|
|
17
|
+
addedAt: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class FriendMetaStore {
|
|
20
|
+
private path;
|
|
21
|
+
private byUserid;
|
|
22
|
+
private logger;
|
|
23
|
+
private saveTimer?;
|
|
24
|
+
private dirty;
|
|
25
|
+
constructor(path: string);
|
|
26
|
+
private load;
|
|
27
|
+
get(userid: string): FriendMeta;
|
|
28
|
+
/** Ensure an entry exists (first time we see a friend). */
|
|
29
|
+
ensure(userid: string): FriendMeta;
|
|
30
|
+
setAlias(userid: string, alias: string | undefined): void;
|
|
31
|
+
markRead(userid: string, ts?: number): void;
|
|
32
|
+
setPinned(userid: string, pinned: boolean): void;
|
|
33
|
+
remove(userid: string): void;
|
|
34
|
+
private scheduleSave;
|
|
35
|
+
flush(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk friend metadata — local-only UI state that the Carrier friend store
|
|
3
|
+
* doesn't hold (PRD-DESKTOP-UI §2.2): a local alias, a read cursor for unread
|
|
4
|
+
* badges, pin/order, and when we added them.
|
|
5
|
+
*
|
|
6
|
+
* File: <configDir>/friends-meta.json -> { userid: FriendMeta }
|
|
7
|
+
* Keyed by userid. Identity/accept-state still lives in the Carrier friend
|
|
8
|
+
* store; this only augments it for display.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
11
|
+
import { Logger } from "../utils/logger.js";
|
|
12
|
+
export class FriendMetaStore {
|
|
13
|
+
path;
|
|
14
|
+
byUserid = new Map();
|
|
15
|
+
logger = new Logger({ prefix: "FriendMeta" });
|
|
16
|
+
saveTimer;
|
|
17
|
+
dirty = false;
|
|
18
|
+
constructor(path) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.load();
|
|
21
|
+
}
|
|
22
|
+
load() {
|
|
23
|
+
if (!existsSync(this.path))
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
const raw = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
27
|
+
for (const [userid, meta] of Object.entries(raw)) {
|
|
28
|
+
if (meta && typeof meta === "object")
|
|
29
|
+
this.byUserid.set(userid, { ...meta, userid });
|
|
30
|
+
}
|
|
31
|
+
this.logger.info(`Loaded metadata for ${this.byUserid.size} friends`);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
this.logger.warn(`Could not load ${this.path}: ${err}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
get(userid) {
|
|
38
|
+
return this.byUserid.get(userid) ?? { userid, addedAt: Date.now() };
|
|
39
|
+
}
|
|
40
|
+
/** Ensure an entry exists (first time we see a friend). */
|
|
41
|
+
ensure(userid) {
|
|
42
|
+
let m = this.byUserid.get(userid);
|
|
43
|
+
if (!m) {
|
|
44
|
+
m = { userid, addedAt: Date.now() };
|
|
45
|
+
this.byUserid.set(userid, m);
|
|
46
|
+
this.scheduleSave();
|
|
47
|
+
}
|
|
48
|
+
return m;
|
|
49
|
+
}
|
|
50
|
+
setAlias(userid, alias) {
|
|
51
|
+
const m = this.ensure(userid);
|
|
52
|
+
m.alias = alias && alias.trim() ? alias.trim() : undefined;
|
|
53
|
+
this.scheduleSave();
|
|
54
|
+
}
|
|
55
|
+
markRead(userid, ts = Date.now()) {
|
|
56
|
+
const m = this.ensure(userid);
|
|
57
|
+
if (!m.lastReadTs || ts > m.lastReadTs) {
|
|
58
|
+
m.lastReadTs = ts;
|
|
59
|
+
this.scheduleSave();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
setPinned(userid, pinned) {
|
|
63
|
+
const m = this.ensure(userid);
|
|
64
|
+
m.pinned = pinned || undefined;
|
|
65
|
+
this.scheduleSave();
|
|
66
|
+
}
|
|
67
|
+
remove(userid) {
|
|
68
|
+
if (this.byUserid.delete(userid))
|
|
69
|
+
this.scheduleSave();
|
|
70
|
+
}
|
|
71
|
+
scheduleSave() {
|
|
72
|
+
this.dirty = true;
|
|
73
|
+
if (this.saveTimer)
|
|
74
|
+
return;
|
|
75
|
+
this.saveTimer = setTimeout(() => {
|
|
76
|
+
this.saveTimer = undefined;
|
|
77
|
+
this.flush();
|
|
78
|
+
}, 1000);
|
|
79
|
+
this.saveTimer.unref?.();
|
|
80
|
+
}
|
|
81
|
+
flush() {
|
|
82
|
+
if (!this.dirty)
|
|
83
|
+
return;
|
|
84
|
+
this.dirty = false;
|
|
85
|
+
try {
|
|
86
|
+
const obj = {};
|
|
87
|
+
for (const [u, m] of this.byUserid)
|
|
88
|
+
obj[u] = m;
|
|
89
|
+
const tmp = `${this.path}.tmp`;
|
|
90
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
91
|
+
renameSync(tmp, this.path);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
this.logger.warn(`Could not save ${this.path}: ${err}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/daemon/ipc.d.ts
CHANGED
|
@@ -41,8 +41,18 @@ export interface IpcHandlers {
|
|
|
41
41
|
friendsReject: (userid: string) => Promise<void>;
|
|
42
42
|
/** Send a chat message (Carrier text, packet 64) to a friend and log it. */
|
|
43
43
|
chatSend: (userid: string, text: string) => Promise<void>;
|
|
44
|
-
/** Return
|
|
45
|
-
|
|
44
|
+
/** Return persisted chat history. With `peer`, returns just that thread and
|
|
45
|
+
* honours `before`/`limit` pagination; without, every thread (legacy shape). */
|
|
46
|
+
chatHistory: (peer?: string, before?: number, limit?: number) => Promise<Record<string, unknown>>;
|
|
47
|
+
/** Friend list for the UI: userid, alias, status, lastSeen, last message
|
|
48
|
+
* preview, and unread count (from friends-meta + the message store). */
|
|
49
|
+
friendsList: () => Promise<Record<string, unknown>>;
|
|
50
|
+
/** Remove a friend (Carrier friend store + local meta + their messages). */
|
|
51
|
+
friendRemove: (userid: string) => Promise<void>;
|
|
52
|
+
/** Set/clear a local display alias for a friend. */
|
|
53
|
+
friendSetAlias: (userid: string, alias?: string) => Promise<void>;
|
|
54
|
+
/** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
|
|
55
|
+
chatMarkRead: (userid: string, ts?: number) => Promise<void>;
|
|
46
56
|
/** Re-read proxy allowlist from config and apply it to the running
|
|
47
57
|
* proxy WITHOUT restarting the daemon. Lets `agentnet proxy
|
|
48
58
|
* allow-host` take effect instantly instead of forcing a daemon
|
|
@@ -59,11 +69,15 @@ export interface IpcHandlers {
|
|
|
59
69
|
selfRestart: () => Promise<Record<string, unknown>>;
|
|
60
70
|
}
|
|
61
71
|
export interface IpcRequest {
|
|
62
|
-
op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "proxy-reload" | "self-restart";
|
|
72
|
+
op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "chat-mark-read" | "proxy-reload" | "self-restart";
|
|
63
73
|
address?: string;
|
|
64
74
|
hello?: string;
|
|
65
75
|
userid?: string;
|
|
66
76
|
text?: string;
|
|
77
|
+
alias?: string;
|
|
78
|
+
before?: number;
|
|
79
|
+
limit?: number;
|
|
80
|
+
ts?: number;
|
|
67
81
|
}
|
|
68
82
|
export interface IpcResponseOk {
|
|
69
83
|
ok: true;
|
package/dist/daemon/ipc.js
CHANGED
|
@@ -151,7 +151,27 @@ export class IpcServer {
|
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
153
|
case "chat-history":
|
|
154
|
-
return await this.handlers.chatHistory();
|
|
154
|
+
return await this.handlers.chatHistory(req.userid, req.before, req.limit);
|
|
155
|
+
case "friends-list":
|
|
156
|
+
return await this.handlers.friendsList();
|
|
157
|
+
case "friend-remove": {
|
|
158
|
+
if (!req.userid)
|
|
159
|
+
throw new Error("userid is required");
|
|
160
|
+
await this.handlers.friendRemove(req.userid);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
case "friend-set-alias": {
|
|
164
|
+
if (!req.userid)
|
|
165
|
+
throw new Error("userid is required");
|
|
166
|
+
await this.handlers.friendSetAlias(req.userid, req.alias);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
case "chat-mark-read": {
|
|
170
|
+
if (!req.userid)
|
|
171
|
+
throw new Error("userid is required");
|
|
172
|
+
await this.handlers.chatMarkRead(req.userid, req.ts);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
155
175
|
case "proxy-reload":
|
|
156
176
|
return await this.handlers.proxyReload();
|
|
157
177
|
case "self-restart":
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk chat message store. Replaces the previous in-memory-only chatLog so
|
|
3
|
+
* conversations survive a daemon restart (PRD-DESKTOP-UI §2.1).
|
|
4
|
+
*
|
|
5
|
+
* Format: a single JSON file at <configDir>/messages.json mapping
|
|
6
|
+
* userid -> ChatMessage[] (oldest first)
|
|
7
|
+
* Loaded fully into memory on start; writes are debounced and atomic
|
|
8
|
+
* (write tmp + rename). Text chat is low-volume, so a JSON file is plenty;
|
|
9
|
+
* swap for SQLite later if threads get large (PRD §9).
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatMessage {
|
|
12
|
+
dir: "in" | "out";
|
|
13
|
+
text: string;
|
|
14
|
+
ts: number;
|
|
15
|
+
/** Stable per-message id (ts + per-process sequence) for UI keys / dedup. */
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class MessageStore {
|
|
19
|
+
private path;
|
|
20
|
+
private byPeer;
|
|
21
|
+
private logger;
|
|
22
|
+
private saveTimer?;
|
|
23
|
+
private dirty;
|
|
24
|
+
private seq;
|
|
25
|
+
constructor(path: string);
|
|
26
|
+
private load;
|
|
27
|
+
/** Append a message and schedule a flush. Returns the stored message. */
|
|
28
|
+
append(peer: string, dir: "in" | "out", text: string, ts?: number): ChatMessage;
|
|
29
|
+
/**
|
|
30
|
+
* Return history. With no peer, returns every peer's full thread (the legacy
|
|
31
|
+
* chat-history shape). With a peer, supports pagination: `limit` newest
|
|
32
|
+
* messages, optionally those strictly older than `before` (ms) for "load
|
|
33
|
+
* earlier" scrolling.
|
|
34
|
+
*/
|
|
35
|
+
history(peer?: string, opts?: {
|
|
36
|
+
before?: number;
|
|
37
|
+
limit?: number;
|
|
38
|
+
}): Record<string, ChatMessage[]>;
|
|
39
|
+
/** Most recent message per peer — for the friend-list preview/sort. */
|
|
40
|
+
lastMessages(): Map<string, ChatMessage>;
|
|
41
|
+
/** Count messages newer than `sinceTs` for a peer (unread badge). */
|
|
42
|
+
unreadCount(peer: string, sinceTs: number): number;
|
|
43
|
+
removePeer(peer: string): void;
|
|
44
|
+
private scheduleSave;
|
|
45
|
+
/** Force a synchronous flush (called on debounce + on daemon shutdown). */
|
|
46
|
+
flush(): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk chat message store. Replaces the previous in-memory-only chatLog so
|
|
3
|
+
* conversations survive a daemon restart (PRD-DESKTOP-UI §2.1).
|
|
4
|
+
*
|
|
5
|
+
* Format: a single JSON file at <configDir>/messages.json mapping
|
|
6
|
+
* userid -> ChatMessage[] (oldest first)
|
|
7
|
+
* Loaded fully into memory on start; writes are debounced and atomic
|
|
8
|
+
* (write tmp + rename). Text chat is low-volume, so a JSON file is plenty;
|
|
9
|
+
* swap for SQLite later if threads get large (PRD §9).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
12
|
+
import { Logger } from "../utils/logger.js";
|
|
13
|
+
/** Keep at most this many messages per peer on disk; older ones roll off. */
|
|
14
|
+
const MAX_PER_PEER = 2000;
|
|
15
|
+
/** Debounce window for flushing to disk after a change. */
|
|
16
|
+
const SAVE_DEBOUNCE_MS = 1000;
|
|
17
|
+
export class MessageStore {
|
|
18
|
+
path;
|
|
19
|
+
byPeer = new Map();
|
|
20
|
+
logger = new Logger({ prefix: "MessageStore" });
|
|
21
|
+
saveTimer;
|
|
22
|
+
dirty = false;
|
|
23
|
+
seq = 0;
|
|
24
|
+
constructor(path) {
|
|
25
|
+
this.path = path;
|
|
26
|
+
this.load();
|
|
27
|
+
}
|
|
28
|
+
load() {
|
|
29
|
+
if (!existsSync(this.path))
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
33
|
+
let total = 0;
|
|
34
|
+
for (const [peer, msgs] of Object.entries(raw)) {
|
|
35
|
+
if (Array.isArray(msgs)) {
|
|
36
|
+
this.byPeer.set(peer, msgs);
|
|
37
|
+
total += msgs.length;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
this.logger.info(`Loaded ${total} messages across ${this.byPeer.size} peers`);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
this.logger.warn(`Could not load ${this.path}: ${err}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Append a message and schedule a flush. Returns the stored message. */
|
|
47
|
+
append(peer, dir, text, ts = Date.now()) {
|
|
48
|
+
const msg = { dir, text, ts, id: `${ts}-${this.seq++}` };
|
|
49
|
+
let arr = this.byPeer.get(peer);
|
|
50
|
+
if (!arr) {
|
|
51
|
+
arr = [];
|
|
52
|
+
this.byPeer.set(peer, arr);
|
|
53
|
+
}
|
|
54
|
+
arr.push(msg);
|
|
55
|
+
if (arr.length > MAX_PER_PEER)
|
|
56
|
+
arr.splice(0, arr.length - MAX_PER_PEER);
|
|
57
|
+
this.scheduleSave();
|
|
58
|
+
return msg;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Return history. With no peer, returns every peer's full thread (the legacy
|
|
62
|
+
* chat-history shape). With a peer, supports pagination: `limit` newest
|
|
63
|
+
* messages, optionally those strictly older than `before` (ms) for "load
|
|
64
|
+
* earlier" scrolling.
|
|
65
|
+
*/
|
|
66
|
+
history(peer, opts = {}) {
|
|
67
|
+
const out = {};
|
|
68
|
+
const peers = peer ? [peer] : [...this.byPeer.keys()];
|
|
69
|
+
for (const p of peers) {
|
|
70
|
+
let arr = this.byPeer.get(p) ?? [];
|
|
71
|
+
if (opts.before !== undefined)
|
|
72
|
+
arr = arr.filter((m) => m.ts < opts.before);
|
|
73
|
+
if (opts.limit !== undefined && arr.length > opts.limit)
|
|
74
|
+
arr = arr.slice(arr.length - opts.limit);
|
|
75
|
+
out[p] = arr;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Most recent message per peer — for the friend-list preview/sort. */
|
|
80
|
+
lastMessages() {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
for (const [p, arr] of this.byPeer) {
|
|
83
|
+
if (arr.length)
|
|
84
|
+
out.set(p, arr[arr.length - 1]);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Count messages newer than `sinceTs` for a peer (unread badge). */
|
|
89
|
+
unreadCount(peer, sinceTs) {
|
|
90
|
+
const arr = this.byPeer.get(peer);
|
|
91
|
+
if (!arr)
|
|
92
|
+
return 0;
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
95
|
+
if (arr[i].ts <= sinceTs)
|
|
96
|
+
break;
|
|
97
|
+
if (arr[i].dir === "in")
|
|
98
|
+
n++;
|
|
99
|
+
}
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
102
|
+
removePeer(peer) {
|
|
103
|
+
if (this.byPeer.delete(peer))
|
|
104
|
+
this.scheduleSave();
|
|
105
|
+
}
|
|
106
|
+
scheduleSave() {
|
|
107
|
+
this.dirty = true;
|
|
108
|
+
if (this.saveTimer)
|
|
109
|
+
return;
|
|
110
|
+
this.saveTimer = setTimeout(() => {
|
|
111
|
+
this.saveTimer = undefined;
|
|
112
|
+
this.flush();
|
|
113
|
+
}, SAVE_DEBOUNCE_MS);
|
|
114
|
+
this.saveTimer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
/** Force a synchronous flush (called on debounce + on daemon shutdown). */
|
|
117
|
+
flush() {
|
|
118
|
+
if (!this.dirty)
|
|
119
|
+
return;
|
|
120
|
+
this.dirty = false;
|
|
121
|
+
try {
|
|
122
|
+
const obj = {};
|
|
123
|
+
for (const [p, m] of this.byPeer)
|
|
124
|
+
obj[p] = m;
|
|
125
|
+
const tmp = `${this.path}.tmp`;
|
|
126
|
+
writeFileSync(tmp, JSON.stringify(obj), "utf-8");
|
|
127
|
+
renameSync(tmp, this.path);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
this.logger.warn(`Could not save ${this.path}: ${err}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -35,7 +35,8 @@ export declare class DaemonServer {
|
|
|
35
35
|
private pendingFriends?;
|
|
36
36
|
/** In-memory chat history, keyed by friend userid (last 200 each). Drives
|
|
37
37
|
* the `agentnet ui` chat window; not persisted across restarts. */
|
|
38
|
-
private
|
|
38
|
+
private messageStore?;
|
|
39
|
+
private friendMeta?;
|
|
39
40
|
private startedAt;
|
|
40
41
|
private isRunning;
|
|
41
42
|
private statusTimer?;
|
|
@@ -63,7 +64,9 @@ export declare class DaemonServer {
|
|
|
63
64
|
*/
|
|
64
65
|
private startPeerStatusSummary;
|
|
65
66
|
getStatus(): DaemonStatus;
|
|
66
|
-
/** Append a chat message to the
|
|
67
|
+
/** Append a chat message to the on-disk message store (persists across
|
|
68
|
+
* restarts). Also ensures a friend-meta entry exists so the friend shows up
|
|
69
|
+
* in the UI list even before it's been renamed. */
|
|
67
70
|
private logChat;
|
|
68
71
|
/** Per-peer timestamp of the last self-heal friend-request re-send, so
|
|
69
72
|
* the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
|
package/dist/daemon/server.js
CHANGED
|
@@ -17,6 +17,8 @@ import { DoraIntegration } from "../dora/dora-integration.js";
|
|
|
17
17
|
import { DnsServer } from "../dns/server.js";
|
|
18
18
|
import { IpcServer, ipcSocketPath } from "./ipc.js";
|
|
19
19
|
import { PendingFriendsStore } from "./pending-friends.js";
|
|
20
|
+
import { MessageStore } from "./message-store.js";
|
|
21
|
+
import { FriendMetaStore } from "./friend-meta.js";
|
|
20
22
|
import { Logger } from "../utils/logger.js";
|
|
21
23
|
/**
|
|
22
24
|
* Quote argv safely for `sh -c`. Wraps single-quotes by closing,
|
|
@@ -52,7 +54,8 @@ export class DaemonServer {
|
|
|
52
54
|
pendingFriends;
|
|
53
55
|
/** In-memory chat history, keyed by friend userid (last 200 each). Drives
|
|
54
56
|
* the `agentnet ui` chat window; not persisted across restarts. */
|
|
55
|
-
|
|
57
|
+
messageStore;
|
|
58
|
+
friendMeta;
|
|
56
59
|
startedAt = 0;
|
|
57
60
|
isRunning = false;
|
|
58
61
|
statusTimer;
|
|
@@ -181,6 +184,9 @@ export class DaemonServer {
|
|
|
181
184
|
});
|
|
182
185
|
await this.peerManager.start();
|
|
183
186
|
this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
|
|
187
|
+
// On-disk chat + friend-metadata stores (survive restarts; back the UI).
|
|
188
|
+
this.messageStore = new MessageStore(resolve(this.configDir, "messages.json"));
|
|
189
|
+
this.friendMeta = new FriendMetaStore(resolve(this.configDir, "friends-meta.json"));
|
|
184
190
|
// Start IPC as soon as the peer is up. The CLI uses it to drive
|
|
185
191
|
// operations that need the daemon's Carrier identity (e.g.
|
|
186
192
|
// friend-request) without spawning a competing Peer instance.
|
|
@@ -219,11 +225,49 @@ export class DaemonServer {
|
|
|
219
225
|
await this.peerManager.sendText(userid, text);
|
|
220
226
|
this.logChat(userid, "out", text);
|
|
221
227
|
},
|
|
222
|
-
chatHistory: async () => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
chatHistory: async (peer, before, limit) => {
|
|
229
|
+
return { chats: this.messageStore?.history(peer, { before, limit }) ?? {} };
|
|
230
|
+
},
|
|
231
|
+
friendsList: async () => {
|
|
232
|
+
const friends = this.peerManager?.getFriends() ?? [];
|
|
233
|
+
const last = this.messageStore?.lastMessages() ?? new Map();
|
|
234
|
+
const list = friends.map((f) => {
|
|
235
|
+
const uid = f.carrierId ?? f.pubkey ?? "";
|
|
236
|
+
const meta = this.friendMeta?.get(uid);
|
|
237
|
+
const lastMsg = last.get(uid);
|
|
238
|
+
return {
|
|
239
|
+
userid: uid,
|
|
240
|
+
alias: meta?.alias,
|
|
241
|
+
name: meta?.alias || f.name || uid,
|
|
242
|
+
status: f.status,
|
|
243
|
+
lastSeen: f.lastSeen,
|
|
244
|
+
pinned: meta?.pinned ?? false,
|
|
245
|
+
lastMessage: lastMsg ? { dir: lastMsg.dir, text: lastMsg.text, ts: lastMsg.ts } : undefined,
|
|
246
|
+
unread: this.messageStore?.unreadCount(uid, meta?.lastReadTs ?? 0) ?? 0,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
// Pinned first, then most-recent-message, then by name.
|
|
250
|
+
list.sort((a, b) => {
|
|
251
|
+
if (!!a.pinned !== !!b.pinned)
|
|
252
|
+
return a.pinned ? -1 : 1;
|
|
253
|
+
const at = a.lastMessage?.ts ?? 0;
|
|
254
|
+
const bt = b.lastMessage?.ts ?? 0;
|
|
255
|
+
if (at !== bt)
|
|
256
|
+
return bt - at;
|
|
257
|
+
return String(a.name).localeCompare(String(b.name));
|
|
258
|
+
});
|
|
259
|
+
return { friends: list };
|
|
260
|
+
},
|
|
261
|
+
friendRemove: async (userid) => {
|
|
262
|
+
this.peerManager?.removeFriend(userid);
|
|
263
|
+
this.messageStore?.removePeer(userid);
|
|
264
|
+
this.friendMeta?.remove(userid);
|
|
265
|
+
},
|
|
266
|
+
friendSetAlias: async (userid, alias) => {
|
|
267
|
+
this.friendMeta?.setAlias(userid, alias);
|
|
268
|
+
},
|
|
269
|
+
chatMarkRead: async (userid, ts) => {
|
|
270
|
+
this.friendMeta?.markRead(userid, ts);
|
|
227
271
|
},
|
|
228
272
|
proxyReload: async () => {
|
|
229
273
|
// Re-read the proxy allowlist from disk and push it into the
|
|
@@ -690,16 +734,12 @@ export class DaemonServer {
|
|
|
690
734
|
activeSessions: this.packetRouter?.getStats().activeSessions || 0,
|
|
691
735
|
};
|
|
692
736
|
}
|
|
693
|
-
/** Append a chat message to the
|
|
737
|
+
/** Append a chat message to the on-disk message store (persists across
|
|
738
|
+
* restarts). Also ensures a friend-meta entry exists so the friend shows up
|
|
739
|
+
* in the UI list even before it's been renamed. */
|
|
694
740
|
logChat(userid, dir, text) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
msgs = [];
|
|
698
|
-
this.chatLog.set(userid, msgs);
|
|
699
|
-
}
|
|
700
|
-
msgs.push({ dir, text, ts: Date.now() });
|
|
701
|
-
if (msgs.length > 200)
|
|
702
|
-
msgs.splice(0, msgs.length - 200);
|
|
741
|
+
this.messageStore?.append(userid, dir, text);
|
|
742
|
+
this.friendMeta?.ensure(userid);
|
|
703
743
|
}
|
|
704
744
|
/** Per-peer timestamp of the last self-heal friend-request re-send, so
|
|
705
745
|
* the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
|
|
@@ -815,6 +855,9 @@ export class DaemonServer {
|
|
|
815
855
|
clearInterval(this.statusTimer);
|
|
816
856
|
this.statusTimer = undefined;
|
|
817
857
|
}
|
|
858
|
+
// Persist any pending chat/meta writes before we exit.
|
|
859
|
+
this.messageStore?.flush();
|
|
860
|
+
this.friendMeta?.flush();
|
|
818
861
|
try {
|
|
819
862
|
if (this.dnsServer) {
|
|
820
863
|
await this.dnsServer.stop();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.87",
|
|
4
4
|
"description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|