@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.
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
@@ -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
@@ -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
+ }
@@ -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 recent chat history grouped by friend userid. */
45
- chatHistory: () => Promise<Record<string, unknown>>;
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;
@@ -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
+ }
@@ -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 chatLog;
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 in-memory log (capped at 200 per friend). */
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. */
@@ -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
- chatLog = new Map();
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
- const chats = {};
224
- for (const [userid, msgs] of this.chatLog)
225
- chats[userid] = msgs;
226
- return { chats };
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 in-memory log (capped at 200 per friend). */
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
- let msgs = this.chatLog.get(userid);
696
- if (!msgs) {
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.86",
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",