@hermespilot/link 0.3.6 → 0.3.7

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Node.js 22.14.0 or newer
7
+ - Node.js 20.0.0 or newer
8
8
  - A local hermes-agent setup on the same machine. HermesPilot Link uses the Hermes Agent API Server on `127.0.0.1:8642`.
9
9
 
10
10
  ## Install
@@ -9,8 +9,21 @@ import { randomUUID as randomUUID7 } from "crypto";
9
9
  // src/database/link-database.ts
10
10
  import { mkdir } from "fs/promises";
11
11
  import { randomUUID } from "crypto";
12
- import { createRequire } from "module";
13
12
  import path from "path";
13
+
14
+ // src/database/sqlite.ts
15
+ import Database from "better-sqlite3";
16
+ function openSqliteDatabase(filePath, options = {}) {
17
+ return new Database(filePath, {
18
+ ...options.readonly === void 0 ? {} : {
19
+ readonly: options.readonly,
20
+ fileMustExist: options.readonly
21
+ },
22
+ ...options.timeout === void 0 ? {} : { timeout: options.timeout }
23
+ });
24
+ }
25
+
26
+ // src/database/link-database.ts
14
27
  var MIGRATIONS = [
15
28
  {
16
29
  version: 1,
@@ -136,7 +149,6 @@ var MIGRATIONS = [
136
149
  `
137
150
  }
138
151
  ];
139
- var nodeRequire = createRequire(import.meta.url);
140
152
  async function migrateLinkDatabase(paths) {
141
153
  await mkdir(path.dirname(paths.databaseFile), { recursive: true, mode: 448 });
142
154
  const db = openDatabase(paths);
@@ -210,6 +222,92 @@ async function upsertConversationStats(paths, record) {
210
222
  db.close();
211
223
  }
212
224
  }
225
+ async function listConversationStatsPage(paths, input) {
226
+ await migrateLinkDatabase(paths);
227
+ const rawLimit = Number.isFinite(input.limit) ? Math.trunc(input.limit) : 25;
228
+ const limit = Math.max(1, Math.min(100, rawLimit));
229
+ const db = openDatabase(paths);
230
+ try {
231
+ const conditions = ["status = ?"];
232
+ const params = ["active"];
233
+ if (input.cursor) {
234
+ conditions.push(`(
235
+ updated_at < ?
236
+ OR (updated_at = ? AND conversation_id < ?)
237
+ )`);
238
+ params.push(
239
+ input.cursor.updatedAt,
240
+ input.cursor.updatedAt,
241
+ input.cursor.conversationId
242
+ );
243
+ }
244
+ const rows = db.prepare(`
245
+ SELECT conversation_id, updated_at
246
+ FROM conversation_stats
247
+ WHERE ${conditions.join(" AND ")}
248
+ ORDER BY updated_at DESC, conversation_id DESC
249
+ LIMIT ?
250
+ `).all(...params, limit + 1);
251
+ const records = rows.slice(0, limit).map((row) => ({
252
+ conversationId: readString(row, "conversation_id") ?? "",
253
+ updatedAt: readString(row, "updated_at") ?? ""
254
+ })).filter((row) => row.conversationId && row.updatedAt);
255
+ return {
256
+ records,
257
+ hasMore: rows.length > limit
258
+ };
259
+ } finally {
260
+ db.close();
261
+ }
262
+ }
263
+ async function searchConversationStatsPage(paths, input) {
264
+ await migrateLinkDatabase(paths);
265
+ const rawLimit = Number.isFinite(input.limit) ? Math.trunc(input.limit) : 25;
266
+ const limit = Math.max(1, Math.min(100, rawLimit));
267
+ const query = input.query.trim();
268
+ if (!query) {
269
+ return listConversationStatsPage(paths, {
270
+ limit,
271
+ cursor: input.cursor
272
+ });
273
+ }
274
+ const db = openDatabase(paths);
275
+ try {
276
+ const conditions = ["status = ?", "LOWER(title) LIKE ? ESCAPE '\\'"];
277
+ const params = [
278
+ "active",
279
+ `%${escapeSqlLike(query.toLowerCase())}%`
280
+ ];
281
+ if (input.cursor) {
282
+ conditions.push(`(
283
+ updated_at < ?
284
+ OR (updated_at = ? AND conversation_id < ?)
285
+ )`);
286
+ params.push(
287
+ input.cursor.updatedAt,
288
+ input.cursor.updatedAt,
289
+ input.cursor.conversationId
290
+ );
291
+ }
292
+ const rows = db.prepare(`
293
+ SELECT conversation_id, updated_at
294
+ FROM conversation_stats
295
+ WHERE ${conditions.join(" AND ")}
296
+ ORDER BY updated_at DESC, conversation_id DESC
297
+ LIMIT ?
298
+ `).all(...params, limit + 1);
299
+ const records = rows.slice(0, limit).map((row) => ({
300
+ conversationId: readString(row, "conversation_id") ?? "",
301
+ updatedAt: readString(row, "updated_at") ?? ""
302
+ })).filter((row) => row.conversationId && row.updatedAt);
303
+ return {
304
+ records,
305
+ hasMore: rows.length > limit
306
+ };
307
+ } finally {
308
+ db.close();
309
+ }
310
+ }
213
311
  async function replaceRunUsageFactsForConversation(paths, conversationId, records) {
214
312
  await migrateLinkDatabase(paths);
215
313
  const db = openDatabase(paths);
@@ -618,8 +716,7 @@ async function deleteConversationStatsForProfile(paths, input) {
618
716
  }
619
717
  }
620
718
  function openDatabase(paths) {
621
- const { DatabaseSync } = nodeRequire("node:sqlite");
622
- const db = new DatabaseSync(paths.databaseFile, {
719
+ const db = openSqliteDatabase(paths.databaseFile, {
623
720
  timeout: 5e3
624
721
  });
625
722
  db.exec(`
@@ -826,6 +923,9 @@ function readString(row, key) {
826
923
  const value = row?.[key];
827
924
  return typeof value === "string" && value.trim() ? value : void 0;
828
925
  }
926
+ function escapeSqlLike(value) {
927
+ return value.replace(/[\\%_]/gu, (match) => `\\${match}`);
928
+ }
829
929
  function statisticsWhereClause(filter) {
830
930
  const profileUid = filter.profileUid?.trim();
831
931
  const profileName = filter.profileName?.trim();
@@ -3724,7 +3824,7 @@ import os2 from "os";
3724
3824
  import path5 from "path";
3725
3825
 
3726
3826
  // src/constants.ts
3727
- var LINK_VERSION = "0.3.6";
3827
+ var LINK_VERSION = "0.3.7";
3728
3828
  var LINK_COMMAND = "hermeslink";
3729
3829
  var LINK_DEFAULT_PORT = 52379;
3730
3830
  var LINK_RUNTIME_DIR_NAME = ".hermeslink";
@@ -6753,9 +6853,7 @@ function isUsableLanIpv4(value) {
6753
6853
 
6754
6854
  // src/hermes/session-title.ts
6755
6855
  import { stat as stat6 } from "fs/promises";
6756
- import { createRequire as createRequire2 } from "module";
6757
6856
  import path11 from "path";
6758
- var nodeRequire2 = createRequire2(import.meta.url);
6759
6857
  async function readHermesSessionTitle(sessionId, paths, profileName) {
6760
6858
  const trimmedSessionId = sessionId.trim();
6761
6859
  if (!trimmedSessionId) {
@@ -6801,11 +6899,8 @@ async function readHermesCompressionTip(sessionId, paths, profileName) {
6801
6899
  function readSessionTitleFromStateDb(dbPath, sessionId) {
6802
6900
  let db = null;
6803
6901
  try {
6804
- const { DatabaseSync } = nodeRequire2(
6805
- "node:sqlite"
6806
- );
6807
- db = new DatabaseSync(dbPath, {
6808
- readOnly: true,
6902
+ db = openSqliteDatabase(dbPath, {
6903
+ readonly: true,
6809
6904
  timeout: 1e3
6810
6905
  });
6811
6906
  const row = db.prepare("SELECT title FROM sessions WHERE id = ? LIMIT 1").get(sessionId);
@@ -6820,11 +6915,8 @@ function readSessionTitleFromStateDb(dbPath, sessionId) {
6820
6915
  function readCompressionTipFromStateDb(dbPath, sessionId) {
6821
6916
  let db = null;
6822
6917
  try {
6823
- const { DatabaseSync } = nodeRequire2(
6824
- "node:sqlite"
6825
- );
6826
- db = new DatabaseSync(dbPath, {
6827
- readOnly: true,
6918
+ db = openSqliteDatabase(dbPath, {
6919
+ readonly: true,
6828
6920
  timeout: 1e3
6829
6921
  });
6830
6922
  let current = sessionId;
@@ -8284,12 +8376,63 @@ function toRecord6(value) {
8284
8376
  }
8285
8377
 
8286
8378
  // src/conversations/conversation-queries.ts
8379
+ var DEFAULT_CONVERSATION_LIST_PAGE_SIZE = 25;
8380
+ var MAX_CONVERSATION_LIST_PAGE_SIZE = 100;
8287
8381
  var ConversationQueryCoordinator = class {
8288
8382
  constructor(deps) {
8289
8383
  this.deps = deps;
8290
8384
  }
8291
8385
  deps;
8292
8386
  async listConversations() {
8387
+ return this.listConversationsFromStore();
8388
+ }
8389
+ async listConversationPage(options = {}) {
8390
+ const limit = normalizeConversationListPageLimit(options.limit);
8391
+ const cursor = decodeConversationListCursor(options.cursor);
8392
+ const indexedPage = await listConversationStatsPage(this.deps.paths, {
8393
+ limit,
8394
+ cursor
8395
+ });
8396
+ if (indexedPage.records.length === 0) {
8397
+ return this.listConversationPageFromStore({ limit, cursor });
8398
+ }
8399
+ const summaries = await this.summarizeIndexedConversations(
8400
+ indexedPage.records
8401
+ );
8402
+ return {
8403
+ conversations: summaries,
8404
+ page: {
8405
+ limit,
8406
+ has_more: indexedPage.hasMore,
8407
+ next_cursor: indexedPage.hasMore && indexedPage.records.length > 0 ? encodeConversationListCursor(indexedPage.records.at(-1)) : null
8408
+ }
8409
+ };
8410
+ }
8411
+ async searchConversationPage(options = {}) {
8412
+ const query = normalizeConversationSearchQuery(options.query);
8413
+ if (!query) {
8414
+ return this.listConversationPage(options);
8415
+ }
8416
+ const limit = normalizeConversationListPageLimit(options.limit);
8417
+ const cursor = decodeConversationListCursor(options.cursor);
8418
+ const indexedPage = await searchConversationStatsPage(this.deps.paths, {
8419
+ limit,
8420
+ cursor,
8421
+ query
8422
+ });
8423
+ const summaries = await this.summarizeIndexedConversations(
8424
+ indexedPage.records
8425
+ );
8426
+ return {
8427
+ conversations: summaries,
8428
+ page: {
8429
+ limit,
8430
+ has_more: indexedPage.hasMore,
8431
+ next_cursor: indexedPage.hasMore && indexedPage.records.length > 0 ? encodeConversationListCursor(indexedPage.records.at(-1)) : null
8432
+ }
8433
+ };
8434
+ }
8435
+ async listConversationsFromStore() {
8293
8436
  const summaries = [];
8294
8437
  for (const conversationId of await this.deps.store.listConversationIds()) {
8295
8438
  const manifest = await this.deps.store.readManifest(conversationId).catch(
@@ -8305,9 +8448,41 @@ var ConversationQueryCoordinator = class {
8305
8448
  summaries.push(await this.summarizeConversation(refreshed, snapshot));
8306
8449
  }
8307
8450
  return summaries.sort(
8308
- (left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at)
8451
+ (left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at) || right.id.localeCompare(left.id)
8309
8452
  );
8310
8453
  }
8454
+ async listConversationPageFromStore(input) {
8455
+ const all = await this.listConversationsFromStore();
8456
+ const startIndex = input.cursor ? all.findIndex((summary) => isAfterConversationListCursor(summary, input.cursor)) : 0;
8457
+ const safeStartIndex = startIndex < 0 ? all.length : startIndex;
8458
+ const conversations = all.slice(safeStartIndex, safeStartIndex + input.limit);
8459
+ const hasMore = safeStartIndex + input.limit < all.length;
8460
+ return {
8461
+ conversations,
8462
+ page: {
8463
+ limit: input.limit,
8464
+ has_more: hasMore,
8465
+ next_cursor: hasMore && conversations.length > 0 ? encodeConversationListCursorFromSummary(conversations.at(-1)) : null
8466
+ }
8467
+ };
8468
+ }
8469
+ async summarizeIndexedConversations(records) {
8470
+ const summaries = [];
8471
+ for (const record of records) {
8472
+ const manifest = await this.deps.store.readManifest(record.conversationId).catch(
8473
+ () => null
8474
+ );
8475
+ if (!manifest || manifest.status !== "active") {
8476
+ continue;
8477
+ }
8478
+ const snapshot = await this.deps.store.readSnapshot(record.conversationId).catch(
8479
+ () => emptySnapshot2()
8480
+ );
8481
+ const refreshed = await this.deps.metadata.refreshTitleFromHermes(manifest, { snapshot }).catch(() => manifest);
8482
+ summaries.push(await this.summarizeConversation(refreshed, snapshot));
8483
+ }
8484
+ return summaries;
8485
+ }
8311
8486
  async getMessages(conversationId, options = {}) {
8312
8487
  const manifest = await this.deps.store.readActiveManifest(conversationId);
8313
8488
  const snapshot = await this.deps.store.readSnapshot(conversationId);
@@ -8394,6 +8569,61 @@ var ConversationQueryCoordinator = class {
8394
8569
  });
8395
8570
  }
8396
8571
  };
8572
+ function normalizeConversationListPageLimit(value) {
8573
+ if (value === void 0 || !Number.isFinite(value)) {
8574
+ return DEFAULT_CONVERSATION_LIST_PAGE_SIZE;
8575
+ }
8576
+ return Math.max(
8577
+ 1,
8578
+ Math.min(MAX_CONVERSATION_LIST_PAGE_SIZE, Math.floor(value))
8579
+ );
8580
+ }
8581
+ function encodeConversationListCursor(record) {
8582
+ return Buffer.from(
8583
+ JSON.stringify({
8584
+ updated_at: record.updatedAt,
8585
+ conversation_id: record.conversationId
8586
+ }),
8587
+ "utf8"
8588
+ ).toString("base64url");
8589
+ }
8590
+ function encodeConversationListCursorFromSummary(summary) {
8591
+ return encodeConversationListCursor({
8592
+ conversationId: summary.id,
8593
+ updatedAt: summary.updated_at
8594
+ });
8595
+ }
8596
+ function decodeConversationListCursor(value) {
8597
+ if (!value) {
8598
+ return null;
8599
+ }
8600
+ try {
8601
+ const decoded = JSON.parse(
8602
+ Buffer.from(value, "base64url").toString("utf8")
8603
+ );
8604
+ const updatedAt = readNonEmptyString(decoded.updated_at);
8605
+ const conversationId = readNonEmptyString(decoded.conversation_id);
8606
+ if (!updatedAt || !conversationId) {
8607
+ throw new Error("invalid cursor");
8608
+ }
8609
+ return { updatedAt, conversationId };
8610
+ } catch {
8611
+ throw new LinkHttpError(
8612
+ 400,
8613
+ "conversation_cursor_invalid",
8614
+ "Conversation list cursor is invalid"
8615
+ );
8616
+ }
8617
+ }
8618
+ function readNonEmptyString(value) {
8619
+ return typeof value === "string" && value.trim() ? value.trim() : null;
8620
+ }
8621
+ function normalizeConversationSearchQuery(value) {
8622
+ return typeof value === "string" ? value.trim() : "";
8623
+ }
8624
+ function isAfterConversationListCursor(summary, cursor) {
8625
+ return summary.updated_at < cursor.updatedAt || summary.updated_at === cursor.updatedAt && summary.id < cursor.conversationId;
8626
+ }
8397
8627
  function hydrateAgentEventBlocks(blocks, agentEvents) {
8398
8628
  if (!blocks?.length || agentEvents.length === 0) {
8399
8629
  return blocks;
@@ -8569,7 +8799,6 @@ function isNodeError8(error, code) {
8569
8799
  // src/conversations/hermes-session-sync.ts
8570
8800
  import { randomUUID as randomUUID6 } from "crypto";
8571
8801
  import { readdir as readdir6, readFile as readFile9, stat as stat8 } from "fs/promises";
8572
- import { createRequire as createRequire3 } from "module";
8573
8802
  import os4 from "os";
8574
8803
  import path14 from "path";
8575
8804
 
@@ -8904,7 +9133,6 @@ function isNodeError9(error, code) {
8904
9133
  }
8905
9134
 
8906
9135
  // src/conversations/hermes-session-sync.ts
8907
- var nodeRequire3 = createRequire3(import.meta.url);
8908
9136
  var PROFILE_NAME_PATTERN3 = /^[a-zA-Z0-9._-]{1,64}$/u;
8909
9137
  var DEFAULT_PROFILE_NAME = "default";
8910
9138
  var MAX_IMPORTABLE_SESSIONS = 100;
@@ -9696,11 +9924,8 @@ async function listProfileSessions(dbPath) {
9696
9924
  }
9697
9925
  let db = null;
9698
9926
  try {
9699
- const { DatabaseSync } = nodeRequire3(
9700
- "node:sqlite"
9701
- );
9702
- db = new DatabaseSync(dbPath, {
9703
- readOnly: true,
9927
+ db = openSqliteDatabase(dbPath, {
9928
+ readonly: true,
9704
9929
  timeout: 1e3
9705
9930
  });
9706
9931
  const sessionColumns = readTableColumns(db, "sessions");
@@ -9840,11 +10065,8 @@ async function readStateDbMessages(dbPath, sessionId) {
9840
10065
  }
9841
10066
  let db = null;
9842
10067
  try {
9843
- const { DatabaseSync } = nodeRequire3(
9844
- "node:sqlite"
9845
- );
9846
- db = new DatabaseSync(dbPath, {
9847
- readOnly: true,
10068
+ db = openSqliteDatabase(dbPath, {
10069
+ readonly: true,
9848
10070
  timeout: 1e3
9849
10071
  });
9850
10072
  const columns = readTableColumns(db, "messages");
@@ -10585,9 +10807,7 @@ function readString10(payload, key) {
10585
10807
 
10586
10808
  // src/conversations/history-builder.ts
10587
10809
  import { readFile as readFile10, stat as stat9 } from "fs/promises";
10588
- import { createRequire as createRequire4 } from "module";
10589
10810
  import path15 from "path";
10590
- var nodeRequire4 = createRequire4(import.meta.url);
10591
10811
  var HISTORY_ROLES = /* @__PURE__ */ new Set(["user", "assistant"]);
10592
10812
  var HERMES_HISTORY_COLUMNS = [
10593
10813
  "role",
@@ -10749,11 +10969,8 @@ async function readHermesJsonlHistory(sessionsDir, sessionId) {
10749
10969
  function readHistoryRows(dbPath, sessionId) {
10750
10970
  let db = null;
10751
10971
  try {
10752
- const { DatabaseSync } = nodeRequire4(
10753
- "node:sqlite"
10754
- );
10755
- db = new DatabaseSync(dbPath, {
10756
- readOnly: true,
10972
+ db = openSqliteDatabase(dbPath, {
10973
+ readonly: true,
10757
10974
  timeout: 1e3
10758
10975
  });
10759
10976
  const columns = readTableColumns2(db, "messages");
@@ -12744,6 +12961,12 @@ var ConversationService = class {
12744
12961
  async listConversations() {
12745
12962
  return this.queries.listConversations();
12746
12963
  }
12964
+ listConversationPage(input = {}) {
12965
+ return this.queries.listConversationPage(input);
12966
+ }
12967
+ searchConversationPage(input = {}) {
12968
+ return this.queries.searchConversationPage(input);
12969
+ }
12747
12970
  async getStatistics(filter = {}) {
12748
12971
  return readLinkStatistics(this.paths, filter);
12749
12972
  }
@@ -14291,9 +14514,28 @@ function registerConversationRoutes(router, options) {
14291
14514
  router.get("/api/v1/conversations", async (ctx) => {
14292
14515
  await authenticateRequest(ctx, paths);
14293
14516
  ctx.set("cache-control", "no-store");
14517
+ const result = await conversations.listConversationPage({
14518
+ limit: readLimit(ctx.query.limit),
14519
+ cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after) ?? readQueryString(ctx.query.page_cursor)
14520
+ });
14521
+ ctx.body = {
14522
+ ok: true,
14523
+ conversations: result.conversations,
14524
+ page: result.page
14525
+ };
14526
+ });
14527
+ router.get("/api/v1/conversations/search", async (ctx) => {
14528
+ await authenticateRequest(ctx, paths);
14529
+ ctx.set("cache-control", "no-store");
14530
+ const result = await conversations.searchConversationPage({
14531
+ limit: readLimit(ctx.query.limit),
14532
+ cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after) ?? readQueryString(ctx.query.page_cursor),
14533
+ query: readQueryString(ctx.query.query) ?? readQueryString(ctx.query.q) ?? readQueryString(ctx.query.keyword) ?? ""
14534
+ });
14294
14535
  ctx.body = {
14295
14536
  ok: true,
14296
- conversations: await conversations.listConversations()
14537
+ conversations: result.conversations,
14538
+ page: result.page
14297
14539
  };
14298
14540
  });
14299
14541
  router.post("/api/v1/conversations", async (ctx) => {
@@ -20093,6 +20335,7 @@ var DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS = 15 * 6e4;
20093
20335
  function startLanIpMonitor(options) {
20094
20336
  let running = false;
20095
20337
  let closed = false;
20338
+ let current = Promise.resolve();
20096
20339
  const check = async (context = {}) => {
20097
20340
  if (running || closed) {
20098
20341
  return;
@@ -20108,15 +20351,16 @@ function startLanIpMonitor(options) {
20108
20351
  running = false;
20109
20352
  }
20110
20353
  };
20111
- void check({ forceReport: true, publishToRelay: true });
20354
+ current = check({ forceReport: true, publishToRelay: true });
20112
20355
  const timer = setInterval(() => {
20113
- void check();
20356
+ current = check();
20114
20357
  }, options.intervalMs ?? DEFAULT_INTERVAL_MS);
20115
20358
  timer.unref?.();
20116
20359
  return {
20117
- close() {
20360
+ async close() {
20118
20361
  closed = true;
20119
20362
  clearInterval(timer);
20363
+ await current.catch(() => void 0);
20120
20364
  }
20121
20365
  };
20122
20366
  }
@@ -20186,6 +20430,7 @@ async function checkLanIpChange(options, context = {}) {
20186
20430
  // src/daemon/scheduler.ts
20187
20431
  function startCronDeliveryScheduler(options) {
20188
20432
  let running = false;
20433
+ let current = Promise.resolve();
20189
20434
  const syncCronDeliveries = async () => {
20190
20435
  if (running) {
20191
20436
  return;
@@ -20206,17 +20451,19 @@ function startCronDeliveryScheduler(options) {
20206
20451
  }
20207
20452
  };
20208
20453
  const timer = setInterval(() => {
20209
- void syncCronDeliveries();
20454
+ current = syncCronDeliveries();
20210
20455
  }, options.intervalMs ?? 3e4);
20211
20456
  timer.unref?.();
20212
20457
  return {
20213
- close() {
20458
+ async close() {
20214
20459
  clearInterval(timer);
20460
+ await current.catch(() => void 0);
20215
20461
  }
20216
20462
  };
20217
20463
  }
20218
20464
  function startHermesSessionSyncScheduler(options) {
20219
20465
  let running = false;
20466
+ let current = Promise.resolve();
20220
20467
  const syncSessions = async () => {
20221
20468
  if (running) {
20222
20469
  return;
@@ -20233,12 +20480,13 @@ function startHermesSessionSyncScheduler(options) {
20233
20480
  }
20234
20481
  };
20235
20482
  const timer = setInterval(() => {
20236
- void syncSessions();
20483
+ current = syncSessions();
20237
20484
  }, options.intervalMs ?? 10 * 60 * 1e3);
20238
20485
  timer.unref?.();
20239
20486
  return {
20240
- close() {
20487
+ async close() {
20241
20488
  clearInterval(timer);
20489
+ await current.catch(() => void 0);
20242
20490
  }
20243
20491
  };
20244
20492
  }
@@ -20262,8 +20510,9 @@ async function startLinkService(options = {}) {
20262
20510
  }
20263
20511
  const conversations = new ConversationService(paths, logger);
20264
20512
  await conversations.rebuildStatisticsIndex();
20513
+ let hermesSessionSync = Promise.resolve();
20265
20514
  const triggerHermesSessionSync = () => {
20266
- void conversations.syncHermesSessions().catch((error) => {
20515
+ hermesSessionSync = Promise.resolve().then(() => conversations.syncHermesSessions()).then(() => void 0).catch((error) => {
20267
20516
  void logger.warn("hermes_session_sync_failed", {
20268
20517
  error: error instanceof Error ? error.message : String(error)
20269
20518
  });
@@ -20337,11 +20586,14 @@ async function startLinkService(options = {}) {
20337
20586
  }
20338
20587
  return {
20339
20588
  async close() {
20340
- scheduler.close();
20341
- hermesSessionSyncScheduler.close();
20342
- lanIpMonitor.close();
20343
20589
  relay?.close();
20344
20590
  await closeServer(server);
20591
+ await Promise.all([
20592
+ scheduler.close(),
20593
+ hermesSessionSyncScheduler.close(),
20594
+ lanIpMonitor.close(),
20595
+ hermesSessionSync.catch(() => void 0)
20596
+ ]);
20345
20597
  await logger.info("service_stopped");
20346
20598
  await logger.flush();
20347
20599
  if (options.writePidFile) {
package/dist/cli/index.js CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  startDaemonProcess,
33
33
  startLinkService,
34
34
  stopDaemonProcess
35
- } from "../chunk-773L37RF.js";
35
+ } from "../chunk-TZVQZFWU.js";
36
36
 
37
37
  // src/cli/index.ts
38
38
  import { Command } from "commander";
@@ -1,82 +1,5 @@
1
1
  import Koa from 'koa';
2
2
 
3
- interface RuntimePaths {
4
- homeDir: string;
5
- identityFile: string;
6
- configFile: string;
7
- stateFile: string;
8
- credentialsFile: string;
9
- databaseFile: string;
10
- conversationsDir: string;
11
- blobsDir: string;
12
- indexesDir: string;
13
- logsDir: string;
14
- runDir: string;
15
- pairingDir: string;
16
- }
17
-
18
- interface LinkStatistics {
19
- conversations: {
20
- total: number;
21
- active: number;
22
- deleted: number;
23
- };
24
- tokens: {
25
- input_tokens: number;
26
- output_tokens: number;
27
- total_tokens: number;
28
- };
29
- messages: {
30
- total: number;
31
- };
32
- runs: {
33
- total: number;
34
- };
35
- models: {
36
- total: number;
37
- };
38
- profiles: {
39
- total: number;
40
- };
41
- skills: {
42
- total: number;
43
- };
44
- tools: {
45
- total: number;
46
- };
47
- updated_at?: string;
48
- }
49
- interface LinkStatisticsFilter {
50
- profileUid?: string | null;
51
- profileName?: string | null;
52
- }
53
-
54
- type LogLevel = 'debug' | 'info' | 'warn' | 'error';
55
- interface FileLoggerOptions {
56
- paths?: RuntimePaths;
57
- fileName?: string;
58
- maxFileBytes?: number;
59
- maxFiles?: number;
60
- now?: () => Date;
61
- }
62
- declare class FileLogger {
63
- readonly filePath: string;
64
- private readonly paths;
65
- private readonly maxFileBytes;
66
- private readonly maxFiles;
67
- private readonly now;
68
- private queue;
69
- constructor(options?: FileLoggerOptions);
70
- debug(message: string, fields?: Record<string, unknown>): Promise<void>;
71
- info(message: string, fields?: Record<string, unknown>): Promise<void>;
72
- warn(message: string, fields?: Record<string, unknown>): Promise<void>;
73
- error(message: string, fields?: Record<string, unknown>): Promise<void>;
74
- write(level: LogLevel, message: string, fields?: Record<string, unknown>): Promise<void>;
75
- flush(): Promise<void>;
76
- private appendEntry;
77
- private rotateIfNeeded;
78
- }
79
-
80
3
  interface ConversationProfileSummary {
81
4
  uid?: string;
82
5
  name: string;
@@ -102,6 +25,15 @@ interface ConversationSummary {
102
25
  content_preview: string;
103
26
  } | null;
104
27
  }
28
+ interface ConversationListPageInfo {
29
+ limit: number;
30
+ has_more: boolean;
31
+ next_cursor: string | null;
32
+ }
33
+ interface ConversationListPage {
34
+ conversations: ConversationSummary[];
35
+ page: ConversationListPageInfo;
36
+ }
105
37
  interface ConversationRuntimeMetadata {
106
38
  profile: ConversationProfileSummary;
107
39
  model: {
@@ -322,6 +254,83 @@ interface CancelRunResult {
322
254
  }
323
255
  type ConversationEventListener = (event: ConversationEvent) => void;
324
256
 
257
+ interface RuntimePaths {
258
+ homeDir: string;
259
+ identityFile: string;
260
+ configFile: string;
261
+ stateFile: string;
262
+ credentialsFile: string;
263
+ databaseFile: string;
264
+ conversationsDir: string;
265
+ blobsDir: string;
266
+ indexesDir: string;
267
+ logsDir: string;
268
+ runDir: string;
269
+ pairingDir: string;
270
+ }
271
+
272
+ interface LinkStatistics {
273
+ conversations: {
274
+ total: number;
275
+ active: number;
276
+ deleted: number;
277
+ };
278
+ tokens: {
279
+ input_tokens: number;
280
+ output_tokens: number;
281
+ total_tokens: number;
282
+ };
283
+ messages: {
284
+ total: number;
285
+ };
286
+ runs: {
287
+ total: number;
288
+ };
289
+ models: {
290
+ total: number;
291
+ };
292
+ profiles: {
293
+ total: number;
294
+ };
295
+ skills: {
296
+ total: number;
297
+ };
298
+ tools: {
299
+ total: number;
300
+ };
301
+ updated_at?: string;
302
+ }
303
+ interface LinkStatisticsFilter {
304
+ profileUid?: string | null;
305
+ profileName?: string | null;
306
+ }
307
+
308
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
309
+ interface FileLoggerOptions {
310
+ paths?: RuntimePaths;
311
+ fileName?: string;
312
+ maxFileBytes?: number;
313
+ maxFiles?: number;
314
+ now?: () => Date;
315
+ }
316
+ declare class FileLogger {
317
+ readonly filePath: string;
318
+ private readonly paths;
319
+ private readonly maxFileBytes;
320
+ private readonly maxFiles;
321
+ private readonly now;
322
+ private queue;
323
+ constructor(options?: FileLoggerOptions);
324
+ debug(message: string, fields?: Record<string, unknown>): Promise<void>;
325
+ info(message: string, fields?: Record<string, unknown>): Promise<void>;
326
+ warn(message: string, fields?: Record<string, unknown>): Promise<void>;
327
+ error(message: string, fields?: Record<string, unknown>): Promise<void>;
328
+ write(level: LogLevel, message: string, fields?: Record<string, unknown>): Promise<void>;
329
+ flush(): Promise<void>;
330
+ private appendEntry;
331
+ private rotateIfNeeded;
332
+ }
333
+
325
334
  interface HermesSessionSyncResult {
326
335
  scanned_profiles: number;
327
336
  scanned_sessions: number;
@@ -367,6 +376,15 @@ declare class ConversationService {
367
376
  constructor(paths: RuntimePaths, logger: FileLogger);
368
377
  private withConversationLock;
369
378
  listConversations(): Promise<ConversationSummary[]>;
379
+ listConversationPage(input?: {
380
+ limit?: number;
381
+ cursor?: string;
382
+ }): Promise<ConversationListPage>;
383
+ searchConversationPage(input?: {
384
+ limit?: number;
385
+ cursor?: string;
386
+ query?: string;
387
+ }): Promise<ConversationListPage>;
370
388
  getStatistics(filter?: LinkStatisticsFilter): Promise<LinkStatistics>;
371
389
  rebuildStatisticsIndex(): Promise<void>;
372
390
  createConversation(input?: {
package/dist/http/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-773L37RF.js";
3
+ } from "../chunk-TZVQZFWU.js";
4
4
  export {
5
5
  createApp
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hermespilot/link",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "description": "Hermes Link companion service and CLI for connecting hermes-agent through HermesPilot",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "scripts": {
30
- "build": "tsup src/cli/index.ts src/http/app.ts --format esm --target node22 --dts --clean",
30
+ "build": "tsup src/cli/index.ts src/http/app.ts --format esm --target node20 --dts --clean",
31
31
  "check": "tsc --noEmit",
32
32
  "dev": "tsx src/cli/index.ts",
33
33
  "preinstall": "node ./scripts/check-node-version.mjs",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@koa/router": "^15.4.0",
44
+ "better-sqlite3": "^12.9.0",
44
45
  "commander": "^12.1.0",
45
46
  "koa": "^2.15.3",
46
47
  "qrcode": "^1.5.4",
@@ -50,8 +51,9 @@
50
51
  "zod": "^3.24.1"
51
52
  },
52
53
  "devDependencies": {
54
+ "@types/better-sqlite3": "^7.6.13",
53
55
  "@types/koa": "^2.15.0",
54
- "@types/node": "^22.10.2",
56
+ "@types/node": "^20.19.39",
55
57
  "@types/qrcode": "^1.5.6",
56
58
  "@types/qrcode-terminal": "^0.12.2",
57
59
  "@types/ws": "^8.5.13",
@@ -61,6 +63,6 @@
61
63
  "vitest": "^2.1.8"
62
64
  },
63
65
  "engines": {
64
- "node": ">=22.14.0"
66
+ "node": ">=20.0.0"
65
67
  }
66
68
  }
@@ -1,6 +1,6 @@
1
1
  import process from "node:process";
2
2
 
3
- const MINIMUM_NODE_VERSION = "22.14.0";
3
+ const MINIMUM_NODE_VERSION = "20.0.0";
4
4
 
5
5
  function parseVersion(input) {
6
6
  const normalized = String(input ?? "")
@@ -55,21 +55,21 @@ if (compareVersions(current, minimum) < 0) {
55
55
  const language = detectLanguage();
56
56
  console.error("");
57
57
  if (language === "zh-CN") {
58
- console.error("Hermes Link 需要 Node.js 22.14.0 或更新版本。");
58
+ console.error("Hermes Link 需要 Node.js 20.0.0 或更新版本。");
59
59
  console.error(`当前使用的是 Node.js ${process.versions.node}。`);
60
60
  console.error("");
61
61
  console.error("为什么需要这样做:");
62
- console.error("- Hermes Link 使用和 Link daemon / CLI 一致的现代 Node.js runtime。");
63
- console.error("- 如果继续在旧版 Node.js 上安装,后续配对或后台服务可能会失败。");
62
+ console.error("- Hermes Link hermes-agent Node.js 20+ 要求保持一致。");
63
+ console.error("- 如果继续在旧版 Node.js 上安装,后续配对、后台服务或本地数据库可能会失败。");
64
64
  console.error("");
65
65
  console.error("请先升级 Node.js,然后重新运行安装命令。");
66
66
  } else {
67
- console.error("Hermes Link needs Node.js 22.14.0 or newer.");
67
+ console.error("Hermes Link needs Node.js 20.0.0 or newer.");
68
68
  console.error(`You are using Node.js ${process.versions.node}.`);
69
69
  console.error("");
70
70
  console.error("Why this is required:");
71
- console.error("- Hermes Link targets the same modern Node.js runtime used by the Link daemon and CLI.");
72
- console.error("- If installation continued on an older Node.js version, pairing or the background service could fail later.");
71
+ console.error("- Hermes Link now matches hermes-agent's Node.js 20+ requirement.");
72
+ console.error("- If installation continued on an older Node.js version, pairing, the background service, or the local database could fail later.");
73
73
  console.error("");
74
74
  console.error("Please update Node.js first, then run the install command again.");
75
75
  }