@bulolo/hermes-link 0.3.1 → 0.3.3

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
@@ -341,38 +341,144 @@ hermeslink config set log-level debug # 日志级别:debug / info / wa
341
341
 
342
342
  ### 对话(Conversations)
343
343
 
344
+ #### 活跃对话
345
+
344
346
  | 方法 | 路径 | 说明 |
345
347
  |------|------|------|
346
- | GET | `/api/v1/conversations` | 列出所有对话(`?limit=20&cursor=xxx`) |
347
- | GET | `/api/v1/conversations/search` | 搜索对话(`?q=关键词`) |
348
+ | GET | `/api/v1/conversations` | 列出活跃对话(`?limit=20&cursor=xxx`) |
349
+ | GET | `/api/v1/conversations/search` | 搜索活跃对话(`?q=关键词`) |
348
350
  | POST | `/api/v1/conversations` | 新建对话 |
349
- | DELETE | `/api/v1/conversations` | 批量删除对话 |
351
+ | DELETE | `/api/v1/conversations` | 批量删除对话(`{"conversation_ids":["conv_xxx"]}`) |
350
352
  | DELETE | `/api/v1/conversations/:id` | 删除单个对话 |
351
- | GET | `/api/v1/conversations/:id/messages` | 获取对话消息列表 |
353
+ | GET | `/api/v1/conversations/:id/messages` | 获取对话消息列表(含 `runtime` 上下文信息) |
352
354
  | POST | `/api/v1/conversations/:id/messages` | 发送消息 |
353
355
  | GET | `/api/v1/conversations/:id/events` | SSE 实时事件流 |
354
356
  | GET | `/api/v1/conversations/events` | 所有对话事件流(SSE) |
355
357
  | PATCH | `/api/v1/conversations/:id/title` | 重命名对话(`{"title":"新标题"}`) |
356
- | PATCH | `/api/v1/conversations/:id/model` | 切换模型 |
358
+ | PATCH | `/api/v1/conversations/:id/model` | 切换模型(`{"model_id":"xxx"}`) |
357
359
  | PATCH | `/api/v1/conversations/:id/profile` | 切换 profile |
358
360
  | POST | `/api/v1/conversations/:id/ack` | 确认已读 |
359
- | POST | `/api/v1/conversations/clear-plans` | 创建批量清理计划 |
360
- | GET | `/api/v1/conversations/clear-plans/:planId` | 查询清理计划状态 |
361
- | POST | `/api/v1/conversations/clear-plans/:planId/execute` | 执行清理计划 |
362
361
  | POST | `/api/v1/conversations/:id/runs/:runId/cancel` | 取消对话内的某次执行 |
363
- | POST | `/api/v1/conversations/:id/approvals/:approvalId/approve` | 审批工具调用(允许) |
362
+ | POST | `/api/v1/conversations/:id/approvals/:approvalId/approve` | 审批工具调用(允许,`{"scope":"once\|session\|always"}`) |
364
363
  | POST | `/api/v1/conversations/:id/approvals/:approvalId/deny` | 审批工具调用(拒绝) |
365
364
  | POST | `/api/v1/conversations/:id/blobs` | 上传附件 |
366
365
  | GET | `/api/v1/conversations/:id/blobs/:blobId` | 下载附件 |
367
366
  | DELETE | `/api/v1/conversations/:id/blobs/:blobId` | 删除附件 |
368
367
 
368
+ #### 归档对话
369
+
370
+ | 方法 | 路径 | 说明 |
371
+ |------|------|------|
372
+ | GET | `/api/v1/conversations/archived` | 列出已归档对话(`?limit=20&cursor=xxx`) |
373
+ | GET | `/api/v1/conversations/archived/search` | 搜索已归档对话(`?q=关键词`) |
374
+ | POST | `/api/v1/conversations/:id/archive` | 归档对话(发送新消息时自动恢复为活跃) |
375
+ | POST | `/api/v1/conversations/:id/unarchive` | 取消归档 |
376
+
377
+ #### 批量清理计划
378
+
379
+ | 方法 | 路径 | 说明 |
380
+ |------|------|------|
381
+ | POST | `/api/v1/conversations/clear-plans` | 创建批量删除计划(`{"target_status":"active\|archived"}`) |
382
+ | GET | `/api/v1/conversations/clear-plans/:planId` | 查询计划状态 |
383
+ | POST | `/api/v1/conversations/clear-plans/:planId/execute` | 执行计划(批量删除) |
384
+
385
+ #### 批量归档计划
386
+
387
+ | 方法 | 路径 | 说明 |
388
+ |------|------|------|
389
+ | POST | `/api/v1/conversations/archive-plans` | 创建批量归档计划(`{"exclude_conversation_ids":[]}`) |
390
+ | GET | `/api/v1/conversations/archive-plans/:planId` | 查询计划状态 |
391
+ | POST | `/api/v1/conversations/archive-plans/:planId/execute` | 执行计划(批量归档) |
392
+
393
+ #### 响应结构说明
394
+
395
+ **ConversationSummary**(对话列表每项):
396
+
397
+ ```json
398
+ {
399
+ "id": "conv_xxx",
400
+ "title": "对话标题",
401
+ "created_at": "2026-05-09T00:00:00.000Z",
402
+ "updated_at": "2026-05-09T00:00:00.000Z",
403
+ "last_event_seq": 12,
404
+ "usage": { "input_tokens": 100, "output_tokens": 200, "total_tokens": 300, "updated_at": "..." },
405
+ "profile": { "uid": "prof_xxx", "name": "default", "display_name": "default", "avatar_url": null },
406
+ "last_message": { "id": "msg_xxx", "role": "assistant", "content_preview": "消息摘要..." }
407
+ }
408
+ ```
409
+
410
+ **GET `/api/v1/conversations/:id/messages`** 响应新增 `runtime` 字段:
411
+
412
+ ```json
413
+ {
414
+ "ok": true,
415
+ "messages": [...],
416
+ "last_event_seq": 12,
417
+ "runtime": {
418
+ "profile": { "name": "default", "display_name": "default", "avatar_url": null },
419
+ "model": { "id": "claude-3-5-sonnet", "provider": "anthropic", "context_window": 200000 },
420
+ "context": { "input_tokens": 100, "output_tokens": 200, "total_tokens": 300, "usage_percent": 0, "source": "estimated" }
421
+ },
422
+ "page": { "limit": 50, "has_more_before": false, "has_more_after": false, "oldest_message_id": "...", "newest_message_id": "..." }
423
+ }
424
+ ```
425
+
426
+ **LinkMessage**(消息对象):
427
+
428
+ ```json
429
+ {
430
+ "id": "msg_xxx",
431
+ "schema_version": 1,
432
+ "conversation_id": "conv_xxx",
433
+ "role": "user|assistant|tool|system",
434
+ "status": "queued|streaming|completed|failed|cancelled",
435
+ "created_at": "...",
436
+ "updated_at": "...",
437
+ "sender": { "id": "app_user", "type": "human|agent|system|tool", "display_name": "Me" },
438
+ "parts": [{ "type": "text", "text": "消息内容" }],
439
+ "attachments": [],
440
+ "blocks": [],
441
+ "agent_events": [],
442
+ "approvals": []
443
+ }
444
+ ```
445
+
446
+ **DELETE `/api/v1/conversations/:id`** 响应新增字段:
447
+
448
+ ```json
449
+ {
450
+ "ok": true,
451
+ "conversation_id": "conv_xxx",
452
+ "hermes_deleted": false,
453
+ "deleted_at": "2026-05-09T00:00:00.000Z"
454
+ }
455
+ ```
456
+
369
457
  ### 统计
370
458
 
371
459
  | 方法 | 路径 | 说明 |
372
460
  |------|------|------|
373
- | GET | `/api/v1/statistics` | 全局使用统计(对话、消息数等) |
461
+ | GET | `/api/v1/statistics` | 全局使用统计(`?profile=xxx&profile_uid=xxx`) |
374
462
  | GET | `/api/v1/statistics/usage` | Token 用量统计(`?days=7&from=2026-05-01&to=2026-05-08&model=xxx&profile=xxx`) |
375
463
 
464
+ **GET `/api/v1/statistics`** 响应:
465
+
466
+ ```json
467
+ {
468
+ "ok": true,
469
+ "statistics": {
470
+ "conversations": { "total": 10, "active": 8, "archived": 1, "deleted": 1 },
471
+ "tokens": { "input_tokens": 5000, "output_tokens": 8000, "total_tokens": 13000 },
472
+ "messages": { "total": 120 },
473
+ "runs": { "total": 50 },
474
+ "models": { "total": 0 },
475
+ "profiles": { "total": 0 },
476
+ "skills": { "total": 0 },
477
+ "tools": { "total": 0 }
478
+ }
479
+ }
480
+ ```
481
+
376
482
  ### 模型(Models)
377
483
 
378
484
  | 方法 | 路径 | 说明 |
@@ -1546,9 +1546,9 @@ async function discoverRouteCandidates(options) {
1546
1546
  const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
1547
1547
  const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
1548
1548
  const preferredUrls = [
1549
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
1550
1549
  ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
1551
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port))
1550
+ ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
1551
+ ...lanIps.map((ip) => buildDirectUrl(ip, options.port))
1552
1552
  ];
1553
1553
  return { lanIps, publicIpv4s, publicIpv6s, preferredUrls, environment };
1554
1554
  }
@@ -2046,6 +2046,43 @@ function createConversationsRouter(options) {
2046
2046
  });
2047
2047
  ctx.body = { ok: true, conversations: result.conversations, page: result.page };
2048
2048
  });
2049
+ router.get("/api/v1/conversations/archived", async (ctx) => {
2050
+ await authenticateRequest(ctx, paths);
2051
+ ctx.set("cache-control", "no-store");
2052
+ const result = await conversations.listArchivedConversationPage({
2053
+ limit: readLimit(ctx.query.limit),
2054
+ cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after)
2055
+ });
2056
+ ctx.body = { ok: true, conversations: result.conversations, page: result.page };
2057
+ });
2058
+ router.get("/api/v1/conversations/archived/search", async (ctx) => {
2059
+ await authenticateRequest(ctx, paths);
2060
+ ctx.set("cache-control", "no-store");
2061
+ const result = await conversations.searchArchivedConversationPage({
2062
+ limit: readLimit(ctx.query.limit),
2063
+ cursor: readQueryString(ctx.query.cursor) ?? readQueryString(ctx.query.after),
2064
+ query: readQueryString(ctx.query.query) ?? readQueryString(ctx.query.q) ?? readQueryString(ctx.query.keyword) ?? ""
2065
+ });
2066
+ ctx.body = { ok: true, conversations: result.conversations, page: result.page };
2067
+ });
2068
+ router.post("/api/v1/conversations/archive-plans", async (ctx) => {
2069
+ await authenticateRequest(ctx, paths);
2070
+ const body = await readJsonBody3(ctx.req);
2071
+ const excludeIds = readStringArray2(body, "exclude_conversation_ids", "excludeConversationIds") ?? [];
2072
+ const plan = await conversations.prepareArchiveAllConversationPlan({ excludeConversationIds: excludeIds });
2073
+ ctx.status = 201;
2074
+ ctx.body = { ok: true, plan };
2075
+ });
2076
+ router.get("/api/v1/conversations/archive-plans/:planId", async (ctx) => {
2077
+ await authenticateRequest(ctx, paths);
2078
+ ctx.set("cache-control", "no-store");
2079
+ ctx.body = { ok: true, plan: await conversations.readArchiveAllConversationPlan(ctx.params.planId) };
2080
+ });
2081
+ router.post("/api/v1/conversations/archive-plans/:planId/execute", async (ctx) => {
2082
+ await authenticateRequest(ctx, paths);
2083
+ const plan = await conversations.executeArchiveAllConversationPlan(ctx.params.planId);
2084
+ ctx.body = { ok: true, plan };
2085
+ });
2049
2086
  router.post("/api/v1/conversations/clear-plans", async (ctx) => {
2050
2087
  await authenticateRequest(ctx, paths);
2051
2088
  const plan = await conversations.prepareClearAllConversationPlan();
@@ -2173,6 +2210,14 @@ function createConversationsRouter(options) {
2173
2210
  if (!title) throw new LinkHttpError(400, "title_required", "title is required");
2174
2211
  ctx.body = { ok: true, ...await conversations.renameConversation(ctx.params.conversationId, title) };
2175
2212
  });
2213
+ router.post("/api/v1/conversations/:conversationId/archive", async (ctx) => {
2214
+ await authenticateRequest(ctx, paths);
2215
+ ctx.body = { ok: true, ...await conversations.archiveConversation(ctx.params.conversationId) };
2216
+ });
2217
+ router.post("/api/v1/conversations/:conversationId/unarchive", async (ctx) => {
2218
+ await authenticateRequest(ctx, paths);
2219
+ ctx.body = { ok: true, ...await conversations.unarchiveConversation(ctx.params.conversationId) };
2220
+ });
2176
2221
  router.post("/api/v1/conversations/:conversationId/ack", async (ctx) => {
2177
2222
  await authenticateRequest(ctx, paths);
2178
2223
  ctx.body = { ok: true };
@@ -7602,6 +7647,13 @@ async function readManifest(paths, conversationId) {
7602
7647
  return readJson(manifestPath(paths, conversationId), null);
7603
7648
  }
7604
7649
  async function readActiveManifest(paths, conversationId) {
7650
+ const manifest = await readManifest(paths, conversationId);
7651
+ if (!manifest || manifest.status === "deleted_soft" || manifest.status === "archived") {
7652
+ throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
7653
+ }
7654
+ return manifest;
7655
+ }
7656
+ async function readExistingManifest(paths, conversationId) {
7605
7657
  const manifest = await readManifest(paths, conversationId);
7606
7658
  if (!manifest || manifest.status === "deleted_soft") {
7607
7659
  throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
@@ -7884,6 +7936,8 @@ function normalizeLimit(value, defaultValue, max) {
7884
7936
  if (!Number.isFinite(n) || n < 1) return defaultValue;
7885
7937
  return Math.min(n, max);
7886
7938
  }
7939
+ var clearPlans = /* @__PURE__ */ new Map();
7940
+ var archivePlans = /* @__PURE__ */ new Map();
7887
7941
  var ConversationService = class extends EventEmitter4 {
7888
7942
  paths;
7889
7943
  logger;
@@ -7952,13 +8006,82 @@ var ConversationService = class extends EventEmitter4 {
7952
8006
  if (after === void 0 || after === null) return events;
7953
8007
  return events.filter((e) => e.seq > after);
7954
8008
  }
8009
+ summarizeManifest(manifest, snapshot) {
8010
+ const stats = manifest.stats;
8011
+ const profileName = manifest.profile ?? manifest.profile_name_snapshot ?? "default";
8012
+ let lastMessage = null;
8013
+ if (snapshot && snapshot.messages.length > 0) {
8014
+ const last = snapshot.messages[snapshot.messages.length - 1];
8015
+ if (last) {
8016
+ const text = last.parts.find((p) => p.type === "text")?.text ?? "";
8017
+ lastMessage = { id: last.id, role: last.role, content_preview: text.slice(0, 200) };
8018
+ }
8019
+ }
8020
+ return {
8021
+ id: manifest.id,
8022
+ title: manifest.title,
8023
+ created_at: manifest.created_at,
8024
+ updated_at: manifest.updated_at,
8025
+ last_event_seq: manifest.last_event_seq,
8026
+ usage: {
8027
+ input_tokens: stats?.input_tokens ?? 0,
8028
+ output_tokens: stats?.output_tokens ?? 0,
8029
+ total_tokens: stats?.total_tokens ?? 0,
8030
+ ...stats?.updated_at ? { updated_at: stats.updated_at } : {}
8031
+ },
8032
+ profile: {
8033
+ uid: manifest.profile_uid ?? void 0,
8034
+ name: profileName,
8035
+ display_name: profileName,
8036
+ avatar_url: null
8037
+ },
8038
+ last_message: lastMessage
8039
+ };
8040
+ }
8041
+ buildRuntimeMetadata(manifest) {
8042
+ const stats = manifest.stats;
8043
+ const profileName = manifest.profile ?? manifest.profile_name_snapshot ?? "default";
8044
+ const usagePercent = stats?.context_window && stats.total_tokens ? Math.min(100, Math.round(stats.total_tokens / stats.context_window * 100)) : void 0;
8045
+ return {
8046
+ profile: {
8047
+ uid: manifest.profile_uid ?? void 0,
8048
+ name: profileName,
8049
+ display_name: profileName,
8050
+ avatar_url: null
8051
+ },
8052
+ model: {
8053
+ id: stats?.model ?? "unknown",
8054
+ ...stats?.provider ? { provider: stats.provider } : {},
8055
+ ...stats?.context_window ? { context_window: stats.context_window } : {}
8056
+ },
8057
+ context: {
8058
+ input_tokens: stats?.input_tokens ?? 0,
8059
+ output_tokens: stats?.output_tokens ?? 0,
8060
+ total_tokens: stats?.total_tokens ?? 0,
8061
+ ...stats?.context_window ? { context_window: stats.context_window } : {},
8062
+ ...usagePercent !== void 0 ? { usage_percent: usagePercent } : {},
8063
+ source: stats?.model ? "estimated" : "unknown",
8064
+ ...stats?.updated_at ? { updated_at: stats.updated_at } : {}
8065
+ }
8066
+ };
8067
+ }
8068
+ async listConversations() {
8069
+ const ids = await listConversationIds(this.paths);
8070
+ const manifests = [];
8071
+ for (const id of ids) {
8072
+ const m = await readManifest(this.paths, id);
8073
+ if (m && m.status !== "deleted_soft") manifests.push(m);
8074
+ }
8075
+ manifests.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
8076
+ return manifests.map((m) => this.summarizeManifest(m));
8077
+ }
7955
8078
  async listConversationPage(options = {}) {
7956
8079
  const limit = normalizeLimit(options.limit, 20, 100);
7957
8080
  const ids = await listConversationIds(this.paths);
7958
8081
  const manifests = [];
7959
8082
  for (const id of ids) {
7960
8083
  const m = await readManifest(this.paths, id);
7961
- if (m && m.status !== "deleted_soft") manifests.push(m);
8084
+ if (m && m.status === "active") manifests.push(m);
7962
8085
  }
7963
8086
  manifests.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
7964
8087
  let startIndex = 0;
@@ -7985,7 +8108,7 @@ var ConversationService = class extends EventEmitter4 {
7985
8108
  const results = [];
7986
8109
  for (const id of ids) {
7987
8110
  const m = await readManifest(this.paths, id);
7988
- if (m && m.status !== "deleted_soft" && m.title.toLowerCase().includes(q)) {
8111
+ if (m && m.status === "active" && m.title.toLowerCase().includes(q)) {
7989
8112
  results.push(m);
7990
8113
  }
7991
8114
  }
@@ -7996,6 +8119,48 @@ var ConversationService = class extends EventEmitter4 {
7996
8119
  page: { limit, has_more: results.length > limit, next_cursor: null }
7997
8120
  };
7998
8121
  }
8122
+ async listArchivedConversationPage(options = {}) {
8123
+ const limit = normalizeLimit(options.limit, 20, 100);
8124
+ const ids = await listConversationIds(this.paths);
8125
+ const manifests = [];
8126
+ for (const id of ids) {
8127
+ const m = await readManifest(this.paths, id);
8128
+ if (m && m.status === "archived") manifests.push(m);
8129
+ }
8130
+ manifests.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
8131
+ let startIndex = 0;
8132
+ if (options.cursor) {
8133
+ const idx = manifests.findIndex((m) => m.id === options.cursor);
8134
+ if (idx >= 0) startIndex = idx + 1;
8135
+ }
8136
+ const page = manifests.slice(startIndex, startIndex + limit);
8137
+ const hasMore = startIndex + limit < manifests.length;
8138
+ return {
8139
+ conversations: page.map((m) => this.summarizeManifest(m)),
8140
+ page: {
8141
+ limit,
8142
+ has_more: hasMore,
8143
+ next_cursor: hasMore && page.length > 0 ? page[page.length - 1]?.id ?? null : null
8144
+ }
8145
+ };
8146
+ }
8147
+ async searchArchivedConversationPage(options = {}) {
8148
+ if (!options.query?.trim()) return this.listArchivedConversationPage(options);
8149
+ const q = options.query.trim().toLowerCase();
8150
+ const limit = normalizeLimit(options.limit, 20, 100);
8151
+ const ids = await listConversationIds(this.paths);
8152
+ const results = [];
8153
+ for (const id of ids) {
8154
+ const m = await readManifest(this.paths, id);
8155
+ if (m && m.status === "archived" && m.title.toLowerCase().includes(q)) results.push(m);
8156
+ }
8157
+ results.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
8158
+ const page = results.slice(0, limit);
8159
+ return {
8160
+ conversations: page.map((m) => this.summarizeManifest(m)),
8161
+ page: { limit, has_more: results.length > limit, next_cursor: null }
8162
+ };
8163
+ }
7999
8164
  async createConversation(options = {}) {
8000
8165
  const now = (/* @__PURE__ */ new Date()).toISOString();
8001
8166
  const id = createConversationId();
@@ -8017,7 +8182,7 @@ var ConversationService = class extends EventEmitter4 {
8017
8182
  return this.summarizeManifest(manifest);
8018
8183
  }
8019
8184
  async getMessages(conversationId, options = {}) {
8020
- const manifest = await readActiveManifest(this.paths, conversationId);
8185
+ const manifest = await readExistingManifest(this.paths, conversationId);
8021
8186
  const snapshot = await readSnapshot(this.paths, conversationId);
8022
8187
  const limit = normalizeLimit(options.limit, 50, 200);
8023
8188
  const total = snapshot.messages.length;
@@ -8028,6 +8193,7 @@ var ConversationService = class extends EventEmitter4 {
8028
8193
  return {
8029
8194
  messages,
8030
8195
  last_event_seq: manifest.last_event_seq,
8196
+ runtime: this.buildRuntimeMetadata(manifest),
8031
8197
  page: {
8032
8198
  limit,
8033
8199
  has_more_before: startIndex > 0,
@@ -8043,7 +8209,16 @@ var ConversationService = class extends EventEmitter4 {
8043
8209
  async sendMessageLocked(input) {
8044
8210
  const content = input.content.trim();
8045
8211
  if (!content) throw new LinkHttpError(400, "message_content_required", "message content is required");
8046
- const manifest = await readActiveManifest(this.paths, input.conversationId);
8212
+ const raw = await readManifest(this.paths, input.conversationId);
8213
+ if (!raw || raw.status === "deleted_soft") {
8214
+ throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
8215
+ }
8216
+ if (raw.status === "archived") {
8217
+ raw.status = "active";
8218
+ raw.updated_at = (/* @__PURE__ */ new Date()).toISOString();
8219
+ await writeManifest(this.paths, raw);
8220
+ }
8221
+ const manifest = raw;
8047
8222
  const snapshot = await readSnapshot(this.paths, input.conversationId);
8048
8223
  const now = (/* @__PURE__ */ new Date()).toISOString();
8049
8224
  const runId = createRunId();
@@ -8109,9 +8284,14 @@ var ConversationService = class extends EventEmitter4 {
8109
8284
  assistant_message: { id: assistantMessageId, status: assistantMessage.status },
8110
8285
  run: { id: runId, status: run.status },
8111
8286
  last_event_seq: latestEvent.seq,
8112
- conversation: this.summarizeManifest(manifest)
8287
+ conversation: this.summarizeManifest(manifest, snapshot)
8113
8288
  };
8114
8289
  }
8290
+ async cancelRunById(runId) {
8291
+ const active = this.activeRunControllers.get(runId);
8292
+ if (!active) throw new LinkHttpError(404, "run_not_found", "Run was not found");
8293
+ return this.cancelRun(active.conversationId, runId);
8294
+ }
8115
8295
  async cancelRun(conversationId, runId) {
8116
8296
  const active = this.activeRunControllers.get(runId);
8117
8297
  if (active) active.controller.abort();
@@ -8330,13 +8510,50 @@ var ConversationService = class extends EventEmitter4 {
8330
8510
  async deleteConversation(conversationId) {
8331
8511
  assertValidConversationId(conversationId);
8332
8512
  return withConversationLock(conversationId, async () => {
8333
- const manifest = await readActiveManifest(this.paths, conversationId);
8513
+ const manifest = await readExistingManifest(this.paths, conversationId);
8334
8514
  const now = (/* @__PURE__ */ new Date()).toISOString();
8515
+ const hermesSessionIds = manifest.hermes_session_ids ?? (manifest.hermes_session_id ? [manifest.hermes_session_id] : []);
8335
8516
  manifest.status = "deleted_soft";
8336
8517
  manifest.deleted_at = now;
8337
8518
  manifest.updated_at = now;
8338
8519
  await writeManifest(this.paths, manifest);
8339
- return { conversation_id: conversationId, deleted_at: now };
8520
+ return {
8521
+ conversation_id: conversationId,
8522
+ hermes_deleted: false,
8523
+ ...hermesSessionIds.length > 0 ? { hermes_session_ids: hermesSessionIds } : {},
8524
+ deleted_at: now
8525
+ };
8526
+ });
8527
+ }
8528
+ async archiveConversation(conversationId) {
8529
+ assertValidConversationId(conversationId);
8530
+ return withConversationLock(conversationId, async () => {
8531
+ const manifest = await readActiveManifest(this.paths, conversationId);
8532
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8533
+ manifest.status = "archived";
8534
+ manifest.updated_at = now;
8535
+ await writeManifest(this.paths, manifest);
8536
+ const conv = this.summarizeManifest(manifest);
8537
+ await this.appendAndEmit(conversationId, { type: "conversation.archived", payload: { conversation: conv } }, manifest);
8538
+ await writeManifest(this.paths, manifest);
8539
+ return { conversation_id: conversationId, archived_at: now };
8540
+ });
8541
+ }
8542
+ async unarchiveConversation(conversationId) {
8543
+ assertValidConversationId(conversationId);
8544
+ return withConversationLock(conversationId, async () => {
8545
+ const manifest = await readExistingManifest(this.paths, conversationId);
8546
+ if (manifest.status !== "archived") {
8547
+ throw new LinkHttpError(400, "conversation_not_archived", "Conversation is not archived");
8548
+ }
8549
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8550
+ manifest.status = "active";
8551
+ manifest.updated_at = now;
8552
+ await writeManifest(this.paths, manifest);
8553
+ const conv = this.summarizeManifest(manifest);
8554
+ await this.appendAndEmit(conversationId, { type: "conversation.unarchived", payload: { conversation: conv } }, manifest);
8555
+ await writeManifest(this.paths, manifest);
8556
+ return { conversation_id: conversationId, unarchived_at: now };
8340
8557
  });
8341
8558
  }
8342
8559
  async deleteConversations(conversationIds) {
@@ -8363,17 +8580,27 @@ var ConversationService = class extends EventEmitter4 {
8363
8580
  manifest.title = title;
8364
8581
  manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
8365
8582
  await writeManifest(this.paths, manifest);
8366
- await this.appendAndEmit(conversationId, { type: "conversation.updated", payload: { conversation: this.summarizeManifest(manifest) } }, manifest);
8583
+ const conv = this.summarizeManifest(manifest);
8584
+ const event = await this.appendAndEmit(conversationId, { type: "conversation.updated", payload: { conversation: conv } }, manifest);
8367
8585
  await writeManifest(this.paths, manifest);
8368
- return { conversation_id: conversationId, title };
8586
+ return { conversation_id: conversationId, title, conversation: conv, hermes_synced: false, last_event_seq: event.seq };
8369
8587
  });
8370
8588
  }
8371
8589
  async setConversationModel(conversationId, modelId) {
8372
8590
  return withConversationLock(conversationId, async () => {
8373
8591
  const manifest = await readActiveManifest(this.paths, conversationId);
8592
+ if (manifest.stats) manifest.stats.model = modelId;
8374
8593
  manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
8375
8594
  await writeManifest(this.paths, manifest);
8376
- return { conversation_id: conversationId, model_id: modelId };
8595
+ const conv = this.summarizeManifest(manifest);
8596
+ const event = await this.appendAndEmit(conversationId, { type: "conversation.updated", payload: { conversation: conv } }, manifest);
8597
+ await writeManifest(this.paths, manifest);
8598
+ return {
8599
+ conversation_id: conversationId,
8600
+ model_override: modelId,
8601
+ runtime: this.buildRuntimeMetadata(manifest),
8602
+ last_event_seq: event.seq
8603
+ };
8377
8604
  });
8378
8605
  }
8379
8606
  async setConversationProfile(conversationId, profileName) {
@@ -8383,7 +8610,21 @@ var ConversationService = class extends EventEmitter4 {
8383
8610
  manifest.profile_name_snapshot = profileName;
8384
8611
  manifest.updated_at = (/* @__PURE__ */ new Date()).toISOString();
8385
8612
  await writeManifest(this.paths, manifest);
8386
- return { conversation_id: conversationId, profile: profileName };
8613
+ const conv = this.summarizeManifest(manifest);
8614
+ const event = await this.appendAndEmit(conversationId, { type: "conversation.updated", payload: { conversation: conv } }, manifest);
8615
+ await writeManifest(this.paths, manifest);
8616
+ return {
8617
+ conversation_id: conversationId,
8618
+ profile: {
8619
+ uid: manifest.profile_uid ?? "",
8620
+ name: profileName,
8621
+ display_name: profileName,
8622
+ avatar_url: null
8623
+ },
8624
+ runtime: this.buildRuntimeMetadata(manifest),
8625
+ conversation: conv,
8626
+ last_event_seq: event.seq
8627
+ };
8387
8628
  });
8388
8629
  }
8389
8630
  async ackConversation(conversationId, lastEventSeq) {
@@ -8413,55 +8654,194 @@ var ConversationService = class extends EventEmitter4 {
8413
8654
  }
8414
8655
  return { deleted_count: count };
8415
8656
  }
8416
- async prepareClearAllConversationPlan() {
8657
+ async shouldPublishNotificationEvent(event) {
8658
+ const publishable = ["conversation.updated", "message.created", "run.started", "run.completed", "run.failed", "run.cancelled"];
8659
+ return publishable.includes(event.type);
8660
+ }
8661
+ async prepareClearAllConversationPlan(targetStatus = "active") {
8417
8662
  const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
8663
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8418
8664
  const ids = await listConversationIds(this.paths);
8419
- const activeIds = [];
8665
+ const targetIds = [];
8420
8666
  for (const id of ids) {
8421
8667
  const m = await readManifest(this.paths, id);
8422
- if (m && m.status === "active") activeIds.push(id);
8668
+ if (m && m.status === targetStatus) targetIds.push(id);
8423
8669
  }
8424
- return {
8670
+ const plan = {
8425
8671
  id: planId,
8426
- status: "pending",
8427
- conversation_count: activeIds.length,
8428
- conversation_ids: activeIds,
8429
- created_at: (/* @__PURE__ */ new Date()).toISOString()
8672
+ status: "prepared",
8673
+ target_status: targetStatus,
8674
+ created_at: now,
8675
+ updated_at: now,
8676
+ total_count: targetIds.length,
8677
+ deleted_count: 0,
8678
+ failed_count: 0,
8679
+ conversation_ids: targetIds,
8680
+ conversations: []
8430
8681
  };
8682
+ clearPlans.set(planId, plan);
8683
+ return plan;
8431
8684
  }
8432
8685
  async readClearAllConversationPlan(planId) {
8433
- return { id: planId, status: "pending", conversation_count: 0, conversation_ids: [], created_at: (/* @__PURE__ */ new Date()).toISOString() };
8686
+ const plan = clearPlans.get(planId);
8687
+ if (!plan) throw new LinkHttpError(404, "plan_not_found", "Clear plan was not found");
8688
+ return plan;
8689
+ }
8690
+ async executeClearAllConversationPlan(planId) {
8691
+ return this.startClearAllConversationPlan(planId);
8434
8692
  }
8435
8693
  async startClearAllConversationPlan(planId) {
8694
+ const plan = clearPlans.get(planId) ?? { id: planId, conversation_ids: [], total_count: 0 };
8695
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8696
+ clearPlans.set(planId, { ...plan, status: "executing", updated_at: now });
8697
+ const conversationIds = Array.isArray(plan.conversation_ids) ? plan.conversation_ids : [];
8698
+ let deletedCount = 0, failedCount = 0;
8699
+ const conversations = [];
8700
+ for (const id of conversationIds) {
8701
+ try {
8702
+ const result = await this.deleteConversation(id);
8703
+ conversations.push({ ...result, status: "deleted" });
8704
+ deletedCount++;
8705
+ } catch (err) {
8706
+ failedCount++;
8707
+ conversations.push({
8708
+ conversation_id: id,
8709
+ status: "failed",
8710
+ error: { code: err instanceof LinkHttpError ? err.code : "internal_error", message: err.message }
8711
+ });
8712
+ }
8713
+ }
8714
+ const completed = {
8715
+ ...plan,
8716
+ status: "completed",
8717
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
8718
+ completed_at: (/* @__PURE__ */ new Date()).toISOString(),
8719
+ deleted_count: deletedCount,
8720
+ failed_count: failedCount,
8721
+ conversations
8722
+ };
8723
+ clearPlans.set(planId, completed);
8724
+ return completed;
8725
+ }
8726
+ async prepareArchiveAllConversationPlan(input = {}) {
8727
+ const planId = `plan_${crypto4.randomUUID().replaceAll("-", "")}`;
8728
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8729
+ const excluded = new Set(input.excludeConversationIds ?? []);
8436
8730
  const ids = await listConversationIds(this.paths);
8437
- let count = 0;
8731
+ const targetIds = [];
8438
8732
  for (const id of ids) {
8733
+ if (excluded.has(id)) continue;
8439
8734
  const m = await readManifest(this.paths, id);
8440
- if (m && m.status === "active") {
8441
- await this.deleteConversation(id).catch(() => void 0);
8442
- count++;
8735
+ if (m && m.status === "active") targetIds.push(id);
8736
+ }
8737
+ const plan = {
8738
+ id: planId,
8739
+ status: "prepared",
8740
+ created_at: now,
8741
+ updated_at: now,
8742
+ total_count: targetIds.length,
8743
+ archived_count: 0,
8744
+ failed_count: 0,
8745
+ conversation_ids: targetIds,
8746
+ conversations: []
8747
+ };
8748
+ archivePlans.set(planId, plan);
8749
+ return plan;
8750
+ }
8751
+ async readArchiveAllConversationPlan(planId) {
8752
+ const plan = archivePlans.get(planId);
8753
+ if (!plan) throw new LinkHttpError(404, "plan_not_found", "Archive plan was not found");
8754
+ return plan;
8755
+ }
8756
+ async executeArchiveAllConversationPlan(planId) {
8757
+ return this.startArchiveAllConversationPlan(planId);
8758
+ }
8759
+ async startArchiveAllConversationPlan(planId) {
8760
+ const plan = archivePlans.get(planId) ?? { id: planId, conversation_ids: [], total_count: 0 };
8761
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8762
+ archivePlans.set(planId, { ...plan, status: "executing", updated_at: now });
8763
+ const conversationIds = Array.isArray(plan.conversation_ids) ? plan.conversation_ids : [];
8764
+ let archivedCount = 0, failedCount = 0;
8765
+ const conversations = [];
8766
+ for (const id of conversationIds) {
8767
+ try {
8768
+ const result = await this.archiveConversation(id);
8769
+ conversations.push({ ...result, status: "archived" });
8770
+ archivedCount++;
8771
+ } catch (err) {
8772
+ failedCount++;
8773
+ conversations.push({
8774
+ conversation_id: id,
8775
+ status: "failed",
8776
+ error: { code: err instanceof LinkHttpError ? err.code : "internal_error", message: err.message }
8777
+ });
8443
8778
  }
8444
8779
  }
8445
- return { id: planId, status: "completed", deleted_count: count, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
8780
+ const completed = {
8781
+ ...plan,
8782
+ status: "completed",
8783
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
8784
+ completed_at: (/* @__PURE__ */ new Date()).toISOString(),
8785
+ archived_count: archivedCount,
8786
+ failed_count: failedCount,
8787
+ conversations
8788
+ };
8789
+ archivePlans.set(planId, completed);
8790
+ return completed;
8446
8791
  }
8447
8792
  async resolveApproval(input) {
8448
- return { conversation_id: input.conversationId, approval_id: input.approvalId, decision: input.decision };
8793
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8794
+ let lastEventSeq = 0;
8795
+ try {
8796
+ const m = await readManifest(this.paths, input.conversationId);
8797
+ if (m) lastEventSeq = m.last_event_seq;
8798
+ } catch {
8799
+ }
8800
+ return {
8801
+ conversation_id: input.conversationId,
8802
+ message_id: "",
8803
+ approval: {
8804
+ id: input.approvalId,
8805
+ status: input.decision === "deny" ? "denied" : "approved",
8806
+ kind: "terminal_command",
8807
+ command: "",
8808
+ choices: ["once", "session", "always", "deny"],
8809
+ created_at: now,
8810
+ resolved_at: now,
8811
+ decision: input.decision,
8812
+ resume_available: false
8813
+ },
8814
+ command_allowlist_updated: false,
8815
+ requires_gateway_reload: false,
8816
+ resume_available: false,
8817
+ last_event_seq: lastEventSeq
8818
+ };
8449
8819
  }
8450
- async getStatistics(options) {
8820
+ async getStatistics(options = {}) {
8451
8821
  const ids = await listConversationIds(this.paths);
8452
- let total = 0, active = 0, messages = 0, runs = 0;
8822
+ let total = 0, active = 0, archived = 0, deleted = 0;
8823
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
8824
+ let messages = 0, runs = 0;
8453
8825
  for (const id of ids) {
8454
8826
  const m = await readManifest(this.paths, id);
8455
8827
  if (!m) continue;
8456
8828
  if (options.profileName && m.profile !== options.profileName) continue;
8829
+ if (options.profileUid && m.profile_uid !== options.profileUid) continue;
8457
8830
  total++;
8458
8831
  if (m.status === "active") active++;
8459
- const snap = await readSnapshot(this.paths, id);
8460
- messages += snap.messages.length;
8461
- runs += snap.runs.filter((r) => r.kind === "agent").length;
8832
+ else if (m.status === "archived") archived++;
8833
+ else if (m.status === "deleted_soft") deleted++;
8834
+ if (m.stats) {
8835
+ inputTokens += m.stats.input_tokens;
8836
+ outputTokens += m.stats.output_tokens;
8837
+ totalTokens += m.stats.total_tokens;
8838
+ messages += m.stats.message_count;
8839
+ runs += m.stats.run_count;
8840
+ }
8462
8841
  }
8463
8842
  return {
8464
- conversations: { total, active },
8843
+ conversations: { total, active, archived, deleted },
8844
+ tokens: { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: totalTokens },
8465
8845
  messages: { total: messages },
8466
8846
  runs: { total: runs },
8467
8847
  models: { total: 0 },
@@ -8470,20 +8850,6 @@ var ConversationService = class extends EventEmitter4 {
8470
8850
  profiles: { total: 0 }
8471
8851
  };
8472
8852
  }
8473
- summarizeManifest(manifest) {
8474
- return {
8475
- id: manifest.id,
8476
- kind: manifest.kind,
8477
- title: manifest.title,
8478
- status: manifest.status,
8479
- profile: manifest.profile,
8480
- profile_uid: manifest.profile_uid,
8481
- last_event_seq: manifest.last_event_seq,
8482
- created_at: manifest.created_at,
8483
- updated_at: manifest.updated_at,
8484
- stats: manifest.stats ?? null
8485
- };
8486
- }
8487
8853
  };
8488
8854
 
8489
8855
  // src/http/app.ts
package/dist/cli/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  saveConfig,
22
22
  startLinkService,
23
23
  writeJsonFile
24
- } from "../chunk-ZO2S4ZIO.js";
24
+ } from "../chunk-ELQBIHDQ.js";
25
25
  import "../chunk-NP3Y2NVF.js";
26
26
 
27
27
  // src/cli/index.ts
@@ -261,7 +261,7 @@ async function runPairingPreflight(options) {
261
261
  code: token.token,
262
262
  preferred_urls: preferredUrls
263
263
  };
264
- const pageUrl = buildLocalPairingPageUrl(options.config.port, sessionId, token.token);
264
+ const pageUrl = buildLocalPairingPageUrl(preferredUrls[0] ?? `http://127.0.0.1:${options.config.port}`, sessionId, token.token);
265
265
  if (options.openBrowser !== false) {
266
266
  await openSystemBrowser(pageUrl).catch(() => void 0);
267
267
  }
@@ -273,9 +273,9 @@ async function runPairingPreflight(options) {
273
273
  preferredUrls
274
274
  };
275
275
  }
276
- function buildLocalPairingPageUrl(port, sessionId, connectToken) {
276
+ function buildLocalPairingPageUrl(baseUrl, sessionId, connectToken) {
277
277
  const qs = new URLSearchParams({ session_id: sessionId, connect_token: connectToken });
278
- return `http://127.0.0.1:${port}/pair?${qs.toString()}`;
278
+ return `${baseUrl}/pair?${qs.toString()}`;
279
279
  }
280
280
 
281
281
  // src/cli/index.ts
package/dist/http/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  startLinkService
3
- } from "../chunk-ZO2S4ZIO.js";
3
+ } from "../chunk-ELQBIHDQ.js";
4
4
  import "../chunk-NP3Y2NVF.js";
5
5
  export {
6
6
  startLinkService
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@bulolo/hermes-link",
3
- "version": "0.3.1",
4
- "description": "Local companion service and CLI for Hermes Agent, enabling mobile and LAN access",
3
+ "version": "0.3.3",
4
+ "description": "Provides full client API, multi-device auth and conversation management for Hermes Agent, with LAN and internet connectivity.",
5
+ "author": "Bulolo",
5
6
  "license": "MIT",
6
7
  "type": "module",
7
8
  "bin": {