@saleso.innovations/bridge 0.1.39 → 0.1.41

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/INTEGRATION.md CHANGED
@@ -34,10 +34,21 @@ export async function onCleosPairingCodeSubmitted(code: string) {
34
34
  for await (const chunk of stream) {
35
35
  reply.delta(chunk, sequence++);
36
36
  }
37
- // If you stream from Hermes /v1/chat/completions, also forward tool progress via
38
- // reply.activity() when you receive `event: hermes.tool.progress` SSE events.
37
+ // If you stream from Hermes /v1/chat/completions, also forward activity via
38
+ // reply.activity() when you receive progress SSE events:
39
+ // - `event: hermes.tool.progress` { tool, label?, emoji?, source?, server? }
40
+ // - `event: hermes.skill.progress` { skill, label?, emoji? }
41
+ // - `event: hermes.memory.progress` { memory, label?, emoji? }
42
+ // - `event: hermes.mcp.progress` { server, tool?, label?, emoji? }
39
43
  // Do not inject tool markers into assistant text (see Hermes #6972).
40
- reply.complete(stream.finalText, sequence);
44
+ // Accumulate the capabilities used during the turn and pass a `turnActivity`
45
+ // summary (plus the model that produced the reply) to reply.complete() so Cleos
46
+ // can show it on the completed message. The `forwardToHermes` helper does this
47
+ // automatically when you use createHermesMessageHandler().
48
+ reply.complete(stream.finalText, sequence, undefined, undefined, {
49
+ tools: [{ name: "web_search", count: 2 }],
50
+ model: "anthropic/claude-sonnet-4",
51
+ });
41
52
  },
42
53
  });
43
54
  return session.agentId;
package/dist/client.d.ts CHANGED
@@ -22,13 +22,49 @@ export type UserMessageMeta = {
22
22
  replyMessageId: string;
23
23
  attachments?: UserMessageAttachment[];
24
24
  };
25
+ export type AgentActivityKind = "tool" | "skill" | "memory" | "mcp";
25
26
  export type AgentActivityPayload = {
26
27
  activityType: "tool.started" | "tool.completed";
28
+ kind?: AgentActivityKind;
27
29
  tool?: string;
30
+ skill?: string;
31
+ memory?: string;
32
+ server?: string;
33
+ source?: string;
28
34
  label?: string;
29
35
  emoji?: string;
30
36
  sequence: number;
31
37
  };
38
+ /** Per-message summary of capabilities the agent used. Keep in sync with @repo/backend-types. */
39
+ export type AgentTurnActivity = {
40
+ tools?: Array<{
41
+ name: string;
42
+ label?: string;
43
+ emoji?: string;
44
+ count?: number;
45
+ source?: string;
46
+ }>;
47
+ skills?: Array<{
48
+ name: string;
49
+ label?: string;
50
+ }>;
51
+ skillsLearned?: Array<{
52
+ name: string;
53
+ label?: string;
54
+ description?: string;
55
+ }>;
56
+ memories?: Array<{
57
+ name: string;
58
+ label?: string;
59
+ }>;
60
+ mcps?: Array<{
61
+ server: string;
62
+ tool?: string;
63
+ label?: string;
64
+ count?: number;
65
+ }>;
66
+ model?: string;
67
+ };
32
68
  export type AgentFailedPayload = {
33
69
  error: string;
34
70
  code?: string;
@@ -37,7 +73,7 @@ export type AgentFailedPayload = {
37
73
  export type AgentReply = {
38
74
  delta: (text: string, sequence: number) => void;
39
75
  activity: (activity: AgentActivityPayload) => void;
40
- complete: (text: string, sequence: number, attachments?: UserMessageAttachment[], hermesAssistantMessageId?: string) => void;
76
+ complete: (text: string, sequence: number, attachments?: UserMessageAttachment[], hermesAssistantMessageId?: string, turnActivity?: AgentTurnActivity) => void;
41
77
  failed: (failure: AgentFailedPayload) => void;
42
78
  };
43
79
  export type CronDeliveryMeta = {
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -9,6 +9,14 @@ import { DEFAULT_BRIDGE_CAPABILITIES } from "./constants.js";
9
9
  export function cronDeliveryMessageId(sourceKey) {
10
10
  return `cron:${sourceKey}`;
11
11
  }
12
+ function hasTurnActivity(activity) {
13
+ return ((activity.tools?.length ?? 0) > 0 ||
14
+ (activity.skills?.length ?? 0) > 0 ||
15
+ (activity.skillsLearned?.length ?? 0) > 0 ||
16
+ (activity.memories?.length ?? 0) > 0 ||
17
+ (activity.mcps?.length ?? 0) > 0 ||
18
+ typeof activity.model === "string");
19
+ }
12
20
  function parseUserMessageAttachments(raw) {
13
21
  if (!Array.isArray(raw))
14
22
  return undefined;
@@ -71,13 +79,25 @@ function createReplySender(ws, agentId, conversationId, messageId) {
71
79
  conversationId,
72
80
  messageId,
73
81
  activityType: activity.activityType,
82
+ kind: activity.kind,
74
83
  tool: activity.tool,
84
+ skill: activity.skill,
85
+ memory: activity.memory,
86
+ server: activity.server,
87
+ source: activity.source,
75
88
  label: activity.label,
76
89
  emoji: activity.emoji,
77
90
  sequence: activity.sequence,
78
91
  }));
79
92
  },
80
- complete(text, sequence, attachments, hermesAssistantMessageId) {
93
+ complete(text, sequence, attachments, hermesAssistantMessageId, turnActivity) {
94
+ const metadata = {};
95
+ if (hermesAssistantMessageId) {
96
+ metadata.hermes = { assistantMessageId: hermesAssistantMessageId };
97
+ }
98
+ if (turnActivity && hasTurnActivity(turnActivity)) {
99
+ metadata.turnActivity = turnActivity;
100
+ }
81
101
  ws.send(JSON.stringify({
82
102
  type: "agent.message",
83
103
  agentId,
@@ -87,9 +107,7 @@ function createReplySender(ws, agentId, conversationId, messageId) {
87
107
  sequence,
88
108
  final: true,
89
109
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
90
- ...(hermesAssistantMessageId
91
- ? { metadata: { hermes: { assistantMessageId: hermesAssistantMessageId } } }
92
- : {}),
110
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
93
111
  }));
94
112
  },
95
113
  failed(failure) {
@@ -1 +1 @@
1
- {"version":3,"file":"hermesForwarder.d.ts","sourceRoot":"","sources":["../src/hermesForwarder.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAyB,eAAe,EAAE,MAAM,aAAa,CAAC;AAWtF,MAAM,MAAM,sBAAsB,GAAG;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAkBF,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,sBAA2B,GAAG;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;CACf,CAUA;AA+MD,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,eAAe,EACrB,KAAK,EAAE,UAAU,EACjB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,mBAAmB,CAAC,CA6E9B;AAED,wBAAgB,0BAA0B,CAAC,OAAO,GAAE,sBAA2B,IAC/D,SAAS,MAAM,EAAE,MAAM,eAAe,EAAE,OAAO,UAAU,KAAG,OAAO,CAAC,IAAI,CAAC,CAMxF"}
1
+ {"version":3,"file":"hermesForwarder.d.ts","sourceRoot":"","sources":["../src/hermesForwarder.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAyB,eAAe,EAAE,MAAM,aAAa,CAAC;AAmBtF,MAAM,MAAM,sBAAsB,GAAG;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAkBF,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,sBAA2B,GAAG;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;CACf,CAUA;AAgID,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,eAAe,EACrB,KAAK,EAAE,UAAU,EACjB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,mBAAmB,CAAC,CAyF9B;AA2BD,wBAAgB,0BAA0B,CAAC,OAAO,GAAE,sBAA2B,IAC/D,SAAS,MAAM,EAAE,MAAM,eAAe,EAAE,OAAO,UAAU,KAAG,OAAO,CAAC,IAAI,CAAC,CAMxF"}
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { linkHermesMessageIds, uploadFileToConvex } from "./convexRelay.js";
5
5
  import { DEFAULT_HERMES_API_URL } from "./constants.js";
6
- import { getLatestTurnMessageIds, hermesStateDbExists } from "./hermesSessionDb.js";
6
+ import { getLatestTurnMessageIds, getSessionUsage, hermesStateDbExists } from "./hermesSessionDb.js";
7
+ import { diffSkillsLearned, snapshotSkills } from "./skillLearnedDetector.js";
8
+ import { buildTurnActivity, createTurnActivityAccumulator, parseSseDataLines, processSseEventBlock, } from "./turnActivity.js";
7
9
  import { parseMediaReferences, resolveMediaReferenceBytes, stripMediaReferences, } from "./hermesFiles.js";
8
10
  function readHermesApiKeyFromEnvFile() {
9
11
  const envPath = join(homedir(), ".hermes", ".env");
@@ -49,70 +51,6 @@ async function ensureHermesReachable(apiUrl, apiKey) {
49
51
  throw new Error(`Hermes is not reachable at ${healthUrl}. Ensure Hermes is installed and run \`hermes gateway\` on this machine. ${message}`);
50
52
  }
51
53
  }
52
- function parseSseDataLines(buffer) {
53
- const parts = buffer.split("\n\n");
54
- const rest = parts.pop() ?? "";
55
- return { events: parts, rest };
56
- }
57
- function extractDeltaFromChunk(payload) {
58
- const choices = payload.choices;
59
- if (!Array.isArray(choices) || choices.length === 0)
60
- return null;
61
- const choice = choices[0];
62
- if (!choice || typeof choice !== "object")
63
- return null;
64
- const delta = choice.delta;
65
- if (!delta || typeof delta !== "object")
66
- return null;
67
- const content = delta.content;
68
- return typeof content === "string" ? content : null;
69
- }
70
- function processSseEventBlock(eventBlock, reply, counters, onTextDelta) {
71
- let eventName = "message";
72
- for (const line of eventBlock.split("\n")) {
73
- if (line.startsWith("event:")) {
74
- eventName = line.slice(6).trim();
75
- continue;
76
- }
77
- if (!line.startsWith("data:"))
78
- continue;
79
- const data = line.slice(5).trim();
80
- if (!data || data === "[DONE]")
81
- continue;
82
- if (eventName === "hermes.tool.progress") {
83
- try {
84
- const payload = JSON.parse(data);
85
- const tool = typeof payload.tool === "string" ? payload.tool : undefined;
86
- const label = typeof payload.label === "string" ? payload.label : undefined;
87
- const emoji = typeof payload.emoji === "string" ? payload.emoji : undefined;
88
- reply.activity({
89
- activityType: "tool.started",
90
- tool,
91
- label,
92
- emoji,
93
- sequence: counters.activitySequence,
94
- });
95
- counters.activitySequence += 1;
96
- }
97
- catch {
98
- // Ignore malformed tool progress payloads.
99
- }
100
- continue;
101
- }
102
- try {
103
- const payload = JSON.parse(data);
104
- const delta = extractDeltaFromChunk(payload);
105
- if (delta && delta.trim()) {
106
- onTextDelta(delta);
107
- reply.delta(delta, counters.textSequence);
108
- counters.textSequence += 1;
109
- }
110
- }
111
- catch {
112
- // Ignore malformed SSE chunks.
113
- }
114
- }
115
- }
116
54
  function buildHermesUserContent(text, attachments) {
117
55
  const trimmed = text.trim();
118
56
  const imageParts = (attachments ?? [])
@@ -223,6 +161,8 @@ export async function forwardToHermes(content, meta, reply, options = {}) {
223
161
  messages: [{ role: "user", content: userContent }],
224
162
  user: sessionId,
225
163
  };
164
+ const turnStartedAt = Date.now();
165
+ const skillsBefore = snapshotSkills();
226
166
  const response = await fetch(apiUrl, {
227
167
  method: "POST",
228
168
  headers,
@@ -233,6 +173,7 @@ export async function forwardToHermes(content, meta, reply, options = {}) {
233
173
  throw new Error(`Hermes request failed (${response.status}): ${text}`);
234
174
  }
235
175
  const resolvedSessionId = response.headers.get("X-Hermes-Session-Id")?.trim() || sessionId;
176
+ const modelFromHeader = response.headers.get("X-Hermes-Model")?.trim() || undefined;
236
177
  if (!response.body) {
237
178
  throw new Error("Hermes returned an empty streaming body");
238
179
  }
@@ -240,6 +181,7 @@ export async function forwardToHermes(content, meta, reply, options = {}) {
240
181
  const decoder = new TextDecoder();
241
182
  let buffer = "";
242
183
  const counters = { textSequence: 0, activitySequence: 0 };
184
+ const accumulator = createTurnActivityAccumulator();
243
185
  let fullText = "";
244
186
  while (true) {
245
187
  const { done, value } = await reader.read();
@@ -249,7 +191,7 @@ export async function forwardToHermes(content, meta, reply, options = {}) {
249
191
  const { events, rest } = parseSseDataLines(buffer);
250
192
  buffer = rest;
251
193
  for (const eventBlock of events) {
252
- processSseEventBlock(eventBlock, reply, counters, (delta) => {
194
+ processSseEventBlock(eventBlock, reply, counters, accumulator, (delta) => {
253
195
  fullText += delta;
254
196
  });
255
197
  }
@@ -263,10 +205,37 @@ export async function forwardToHermes(content, meta, reply, options = {}) {
263
205
  const attachments = mediaRefs.length > 0 ? await uploadMediaAttachments(meta.agentId, mediaRefs) : [];
264
206
  const displayText = attachments.length > 0 ? stripMediaReferences(fullText, mediaRefs) : fullText.trim() || fullText;
265
207
  const { assistantMessageId } = getLatestTurnMessageIds(resolvedSessionId);
266
- reply.complete(displayText, counters.textSequence, attachments.length > 0 ? attachments : undefined, assistantMessageId !== undefined ? String(assistantMessageId) : undefined);
208
+ const replyModel = resolveReplyModel(modelFromHeader, resolvedSessionId, model);
209
+ const turnActivity = buildTurnActivity(accumulator, replyModel);
210
+ const skillsLearned = diffSkillsLearned(skillsBefore, snapshotSkills(), { turnStartedAt });
211
+ if (skillsLearned.length > 0) {
212
+ turnActivity.skillsLearned = skillsLearned;
213
+ }
214
+ reply.complete(displayText, counters.textSequence, attachments.length > 0 ? attachments : undefined, assistantMessageId !== undefined ? String(assistantMessageId) : undefined, turnActivity);
267
215
  await linkTurnToConvex(meta, resolvedSessionId);
268
216
  return { sessionId: resolvedSessionId };
269
217
  }
218
+ /**
219
+ * Resolves which model produced the reply, in priority order:
220
+ * 1. The `X-Hermes-Model` response header.
221
+ * 2. The session's recorded model in Hermes `state.db`.
222
+ * 3. The model from the outbound request (skipping the generic placeholder alias).
223
+ */
224
+ function resolveReplyModel(modelFromHeader, resolvedSessionId, requestModel) {
225
+ if (modelFromHeader)
226
+ return modelFromHeader;
227
+ if (hermesStateDbExists()) {
228
+ try {
229
+ const usage = getSessionUsage(resolvedSessionId);
230
+ if (usage.model)
231
+ return usage.model;
232
+ }
233
+ catch {
234
+ // Fall through to request model.
235
+ }
236
+ }
237
+ return requestModel && requestModel !== "hermes-agent" ? requestModel : undefined;
238
+ }
270
239
  export function createHermesMessageHandler(options = {}) {
271
240
  return async (content, meta, reply) => {
272
241
  await forwardToHermes(content, meta, reply, {
@@ -0,0 +1,20 @@
1
+ import type { AgentTurnActivity } from "./client.js";
2
+ import { type HermesSkillEntry } from "./skillsList.js";
3
+ export type SkillSnapshotEntry = HermesSkillEntry & {
4
+ mtimeMs?: number;
5
+ };
6
+ /**
7
+ * Captures the set of skills currently on disk, keyed by relative path, with each
8
+ * file's mtime so a later diff can ignore writes that predate the turn.
9
+ */
10
+ export declare function snapshotSkills(): Map<string, SkillSnapshotEntry>;
11
+ /**
12
+ * Returns skills present in `after` but not in `before` — i.e. newly written during the
13
+ * turn. When `turnStartedAt` is provided, entries whose file mtime predates the turn
14
+ * start (beyond a small grace window) are excluded to guard against concurrent
15
+ * background writes.
16
+ */
17
+ export declare function diffSkillsLearned(before: Map<string, SkillSnapshotEntry>, after: Map<string, SkillSnapshotEntry>, options?: {
18
+ turnStartedAt?: number;
19
+ }): NonNullable<AgentTurnActivity["skillsLearned"]>;
20
+ //# sourceMappingURL=skillLearnedDetector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skillLearnedDetector.d.ts","sourceRoot":"","sources":["../src/skillLearnedDetector.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,OAAO,EAA4B,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAElF,MAAM,MAAM,kBAAkB,GAAG,gBAAgB,GAAG;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzE;;;GAGG;AACH,wBAAgB,cAAc,IAAI,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAchE;AAKD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACvC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACtC,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAO,GACvC,WAAW,CAAC,iBAAiB,CAAC,eAAe,CAAC,CAAC,CAuBjD"}
@@ -0,0 +1,53 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveHermesHome } from "./hermesFiles.js";
4
+ import { listSkillsFromFilesystem } from "./skillsList.js";
5
+ /**
6
+ * Captures the set of skills currently on disk, keyed by relative path, with each
7
+ * file's mtime so a later diff can ignore writes that predate the turn.
8
+ */
9
+ export function snapshotSkills() {
10
+ const home = resolveHermesHome();
11
+ const snapshot = new Map();
12
+ for (const skill of listSkillsFromFilesystem().skills) {
13
+ if (!skill.relativePath)
14
+ continue;
15
+ let mtimeMs;
16
+ try {
17
+ mtimeMs = statSync(join(home, skill.relativePath)).mtimeMs;
18
+ }
19
+ catch {
20
+ // File may be unreadable; record without mtime.
21
+ }
22
+ snapshot.set(skill.relativePath, { ...skill, mtimeMs });
23
+ }
24
+ return snapshot;
25
+ }
26
+ /** Tolerance for clock skew between the turn start and a freshly written skill file. */
27
+ const MTIME_GRACE_MS = 5_000;
28
+ /**
29
+ * Returns skills present in `after` but not in `before` — i.e. newly written during the
30
+ * turn. When `turnStartedAt` is provided, entries whose file mtime predates the turn
31
+ * start (beyond a small grace window) are excluded to guard against concurrent
32
+ * background writes.
33
+ */
34
+ export function diffSkillsLearned(before, after, options = {}) {
35
+ const learned = [];
36
+ const threshold = typeof options.turnStartedAt === "number" ? options.turnStartedAt - MTIME_GRACE_MS : undefined;
37
+ for (const [relativePath, entry] of after) {
38
+ if (before.has(relativePath))
39
+ continue;
40
+ if (threshold !== undefined &&
41
+ typeof entry.mtimeMs === "number" &&
42
+ entry.mtimeMs < threshold) {
43
+ continue;
44
+ }
45
+ learned.push({
46
+ name: entry.name,
47
+ ...(entry.category ? { label: entry.category } : {}),
48
+ ...(entry.description ? { description: entry.description } : {}),
49
+ });
50
+ }
51
+ learned.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
52
+ return learned;
53
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=skillLearnedDetector.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skillLearnedDetector.test.d.ts","sourceRoot":"","sources":["../src/skillLearnedDetector.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { diffSkillsLearned } from "./skillLearnedDetector.js";
4
+ function snapshot(entries) {
5
+ const map = new Map();
6
+ for (const entry of entries) {
7
+ if (entry.relativePath)
8
+ map.set(entry.relativePath, entry);
9
+ }
10
+ return map;
11
+ }
12
+ test("returns empty when nothing changed", () => {
13
+ const before = snapshot([
14
+ { name: "alpha", description: "", relativePath: "skills/alpha/SKILL.md" },
15
+ ]);
16
+ const after = snapshot([
17
+ { name: "alpha", description: "", relativePath: "skills/alpha/SKILL.md" },
18
+ ]);
19
+ assert.deepEqual(diffSkillsLearned(before, after), []);
20
+ });
21
+ test("detects a single new skill with label and description", () => {
22
+ const before = snapshot([]);
23
+ const after = snapshot([
24
+ {
25
+ name: "crypto-technical-analysis",
26
+ description: "Analyze crypto charts",
27
+ category: "trading",
28
+ relativePath: "skills/trading/crypto-technical-analysis/SKILL.md",
29
+ },
30
+ ]);
31
+ assert.deepEqual(diffSkillsLearned(before, after), [
32
+ {
33
+ name: "crypto-technical-analysis",
34
+ label: "trading",
35
+ description: "Analyze crypto charts",
36
+ },
37
+ ]);
38
+ });
39
+ test("detects multiple new skills sorted by name", () => {
40
+ const before = snapshot([
41
+ { name: "alpha", description: "", relativePath: "skills/alpha/SKILL.md" },
42
+ ]);
43
+ const after = snapshot([
44
+ { name: "alpha", description: "", relativePath: "skills/alpha/SKILL.md" },
45
+ { name: "zeta", description: "", relativePath: "skills/zeta/SKILL.md" },
46
+ { name: "beta", description: "", relativePath: "skills/beta/SKILL.md" },
47
+ ]);
48
+ assert.deepEqual(diffSkillsLearned(before, after).map((skill) => skill.name), ["beta", "zeta"]);
49
+ });
50
+ test("excludes new entries whose mtime predates the turn start", () => {
51
+ const turnStartedAt = 1_000_000;
52
+ const before = snapshot([]);
53
+ const after = snapshot([
54
+ {
55
+ name: "stale",
56
+ description: "",
57
+ relativePath: "skills/stale/SKILL.md",
58
+ mtimeMs: turnStartedAt - 60_000,
59
+ },
60
+ {
61
+ name: "fresh",
62
+ description: "",
63
+ relativePath: "skills/fresh/SKILL.md",
64
+ mtimeMs: turnStartedAt + 1_000,
65
+ },
66
+ ]);
67
+ assert.deepEqual(diffSkillsLearned(before, after, { turnStartedAt }).map((skill) => skill.name), ["fresh"]);
68
+ });
69
+ test("keeps new entries within the mtime grace window", () => {
70
+ const turnStartedAt = 1_000_000;
71
+ const before = snapshot([]);
72
+ const after = snapshot([
73
+ {
74
+ name: "borderline",
75
+ description: "",
76
+ relativePath: "skills/borderline/SKILL.md",
77
+ mtimeMs: turnStartedAt - 2_000,
78
+ },
79
+ ]);
80
+ assert.deepEqual(diffSkillsLearned(before, after, { turnStartedAt }).map((skill) => skill.name), ["borderline"]);
81
+ });
@@ -4,6 +4,13 @@ export type HermesSkillEntry = {
4
4
  category?: string;
5
5
  relativePath?: string;
6
6
  };
7
+ /**
8
+ * Synchronously reads the Hermes skills directory from disk. Used for fast turn-boundary
9
+ * snapshots without shelling out to the `hermes` CLI.
10
+ */
11
+ export declare function listSkillsFromFilesystem(): {
12
+ skills: HermesSkillEntry[];
13
+ };
7
14
  export declare function listHermesSkills(): Promise<{
8
15
  skills: HermesSkillEntry[];
9
16
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"skillsList.d.ts","sourceRoot":"","sources":["../src/skillsList.ts"],"names":[],"mappings":"AAeA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAuOF,wBAAsB,gBAAgB,IAAI,OAAO,CAAC;IAAE,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC,CAOhF"}
1
+ {"version":3,"file":"skillsList.d.ts","sourceRoot":"","sources":["../src/skillsList.ts"],"names":[],"mappings":"AAeA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AA6GF;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI;IAAE,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAEzE;AA4HD,wBAAsB,gBAAgB,IAAI,OAAO,CAAC;IAAE,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC,CAOhF"}
@@ -105,6 +105,13 @@ function toHermesRelativeSkillPath(skillsRootDir, skillFilePath) {
105
105
  const fromSkillsDir = relative(skillsRootDir, skillFilePath).replace(/\\/g, "/");
106
106
  return `${SKILLS_DIR}/${fromSkillsDir}`;
107
107
  }
108
+ /**
109
+ * Synchronously reads the Hermes skills directory from disk. Used for fast turn-boundary
110
+ * snapshots without shelling out to the `hermes` CLI.
111
+ */
112
+ export function listSkillsFromFilesystem() {
113
+ return readSkillsFromDirectory();
114
+ }
108
115
  function readSkillsFromDirectory() {
109
116
  const root = skillsRoot();
110
117
  if (!existsSync(root)) {
@@ -0,0 +1,36 @@
1
+ import type { AgentReply, AgentTurnActivity } from "./client.js";
2
+ export type StreamCounters = {
3
+ textSequence: number;
4
+ activitySequence: number;
5
+ };
6
+ export type TurnActivityAccumulator = {
7
+ tools: Map<string, {
8
+ name: string;
9
+ label?: string;
10
+ emoji?: string;
11
+ count: number;
12
+ source?: string;
13
+ }>;
14
+ skills: Map<string, {
15
+ name: string;
16
+ label?: string;
17
+ }>;
18
+ memories: Map<string, {
19
+ name: string;
20
+ label?: string;
21
+ }>;
22
+ mcps: Map<string, {
23
+ server: string;
24
+ tool?: string;
25
+ label?: string;
26
+ count: number;
27
+ }>;
28
+ };
29
+ export declare function createTurnActivityAccumulator(): TurnActivityAccumulator;
30
+ export declare function buildTurnActivity(acc: TurnActivityAccumulator, model?: string): AgentTurnActivity;
31
+ export declare function parseSseDataLines(buffer: string): {
32
+ events: string[];
33
+ rest: string;
34
+ };
35
+ export declare function processSseEventBlock(eventBlock: string, reply: AgentReply, counters: StreamCounters, accumulator: TurnActivityAccumulator, onTextDelta: (delta: string) => void): void;
36
+ //# sourceMappingURL=turnActivity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnActivity.d.ts","sourceRoot":"","sources":["../src/turnActivity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEjE,MAAM,MAAM,cAAc,GAAG;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC;AAgCF,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrG,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrF,CAAC;AAEF,wBAAgB,6BAA6B,IAAI,uBAAuB,CAEvE;AAmCD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,uBAAuB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,iBAAiB,CA6CjG;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAIpF;AAiBD,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,cAAc,EACxB,WAAW,EAAE,uBAAuB,EACpC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACnC,IAAI,CA4IN"}
@@ -0,0 +1,233 @@
1
+ /** Caps how many distinct items per category are persisted on a message summary. */
2
+ const MAX_TURN_ACTIVITY_ITEMS = 20;
3
+ export function createTurnActivityAccumulator() {
4
+ return { tools: new Map(), skills: new Map(), memories: new Map(), mcps: new Map() };
5
+ }
6
+ function recordToolUse(acc, name, label, emoji, source) {
7
+ const existing = acc.tools.get(name);
8
+ if (existing) {
9
+ existing.count += 1;
10
+ if (!existing.label && label)
11
+ existing.label = label;
12
+ if (!existing.emoji && emoji)
13
+ existing.emoji = emoji;
14
+ return;
15
+ }
16
+ acc.tools.set(name, { name, label, emoji, count: 1, source });
17
+ }
18
+ function recordMcpUse(acc, server, tool, label) {
19
+ const key = `${server}\u0000${tool ?? ""}`;
20
+ const existing = acc.mcps.get(key);
21
+ if (existing) {
22
+ existing.count += 1;
23
+ if (!existing.label && label)
24
+ existing.label = label;
25
+ return;
26
+ }
27
+ acc.mcps.set(key, { server, tool, label, count: 1 });
28
+ }
29
+ export function buildTurnActivity(acc, model) {
30
+ const activity = {};
31
+ const tools = [...acc.tools.values()].slice(0, MAX_TURN_ACTIVITY_ITEMS);
32
+ if (tools.length > 0) {
33
+ activity.tools = tools.map((tool) => ({
34
+ name: tool.name,
35
+ ...(tool.label ? { label: tool.label } : {}),
36
+ ...(tool.emoji ? { emoji: tool.emoji } : {}),
37
+ ...(tool.count > 1 ? { count: tool.count } : {}),
38
+ ...(tool.source ? { source: tool.source } : {}),
39
+ }));
40
+ }
41
+ const skills = [...acc.skills.values()].slice(0, MAX_TURN_ACTIVITY_ITEMS);
42
+ if (skills.length > 0) {
43
+ activity.skills = skills.map((skill) => ({
44
+ name: skill.name,
45
+ ...(skill.label ? { label: skill.label } : {}),
46
+ }));
47
+ }
48
+ const memories = [...acc.memories.values()].slice(0, MAX_TURN_ACTIVITY_ITEMS);
49
+ if (memories.length > 0) {
50
+ activity.memories = memories.map((memory) => ({
51
+ name: memory.name,
52
+ ...(memory.label ? { label: memory.label } : {}),
53
+ }));
54
+ }
55
+ const mcps = [...acc.mcps.values()].slice(0, MAX_TURN_ACTIVITY_ITEMS);
56
+ if (mcps.length > 0) {
57
+ activity.mcps = mcps.map((mcp) => ({
58
+ server: mcp.server,
59
+ ...(mcp.tool ? { tool: mcp.tool } : {}),
60
+ ...(mcp.label ? { label: mcp.label } : {}),
61
+ ...(mcp.count > 1 ? { count: mcp.count } : {}),
62
+ }));
63
+ }
64
+ if (model) {
65
+ activity.model = model;
66
+ }
67
+ return activity;
68
+ }
69
+ export function parseSseDataLines(buffer) {
70
+ const parts = buffer.split("\n\n");
71
+ const rest = parts.pop() ?? "";
72
+ return { events: parts, rest };
73
+ }
74
+ function extractDeltaFromChunk(payload) {
75
+ const choices = payload.choices;
76
+ if (!Array.isArray(choices) || choices.length === 0)
77
+ return null;
78
+ const choice = choices[0];
79
+ if (!choice || typeof choice !== "object")
80
+ return null;
81
+ const delta = choice.delta;
82
+ if (!delta || typeof delta !== "object")
83
+ return null;
84
+ const content = delta.content;
85
+ return typeof content === "string" ? content : null;
86
+ }
87
+ function asString(value) {
88
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
89
+ }
90
+ export function processSseEventBlock(eventBlock, reply, counters, accumulator, onTextDelta) {
91
+ let eventName = "message";
92
+ for (const line of eventBlock.split("\n")) {
93
+ if (line.startsWith("event:")) {
94
+ eventName = line.slice(6).trim();
95
+ continue;
96
+ }
97
+ if (!line.startsWith("data:"))
98
+ continue;
99
+ const data = line.slice(5).trim();
100
+ if (!data || data === "[DONE]")
101
+ continue;
102
+ if (eventName === "hermes.tool.progress") {
103
+ try {
104
+ const payload = JSON.parse(data);
105
+ const tool = asString(payload.tool);
106
+ const label = asString(payload.label);
107
+ const emoji = asString(payload.emoji);
108
+ const source = asString(payload.source);
109
+ const server = asString(payload.server);
110
+ // MCP tool calls may arrive as tool progress; bucket them as MCP usage.
111
+ if (source === "mcp" || server) {
112
+ const mcpServer = server ?? "mcp";
113
+ recordMcpUse(accumulator, mcpServer, tool, label);
114
+ reply.activity({
115
+ activityType: "tool.started",
116
+ kind: "mcp",
117
+ tool,
118
+ server: mcpServer,
119
+ source: "mcp",
120
+ label,
121
+ emoji,
122
+ sequence: counters.activitySequence,
123
+ });
124
+ }
125
+ else {
126
+ if (tool)
127
+ recordToolUse(accumulator, tool, label, emoji, source);
128
+ reply.activity({
129
+ activityType: "tool.started",
130
+ kind: "tool",
131
+ tool,
132
+ source,
133
+ label,
134
+ emoji,
135
+ sequence: counters.activitySequence,
136
+ });
137
+ }
138
+ counters.activitySequence += 1;
139
+ }
140
+ catch {
141
+ // Ignore malformed tool progress payloads.
142
+ }
143
+ continue;
144
+ }
145
+ if (eventName === "hermes.skill.progress") {
146
+ try {
147
+ const payload = JSON.parse(data);
148
+ const skill = asString(payload.skill);
149
+ const label = asString(payload.label);
150
+ const emoji = asString(payload.emoji);
151
+ if (skill && !accumulator.skills.has(skill)) {
152
+ accumulator.skills.set(skill, { name: skill, label });
153
+ }
154
+ reply.activity({
155
+ activityType: "tool.started",
156
+ kind: "skill",
157
+ skill,
158
+ label,
159
+ emoji,
160
+ sequence: counters.activitySequence,
161
+ });
162
+ counters.activitySequence += 1;
163
+ }
164
+ catch {
165
+ // Ignore malformed skill progress payloads.
166
+ }
167
+ continue;
168
+ }
169
+ if (eventName === "hermes.memory.progress") {
170
+ try {
171
+ const payload = JSON.parse(data);
172
+ const memory = asString(payload.memory);
173
+ const label = asString(payload.label);
174
+ const emoji = asString(payload.emoji);
175
+ if (memory && !accumulator.memories.has(memory)) {
176
+ accumulator.memories.set(memory, { name: memory, label });
177
+ }
178
+ reply.activity({
179
+ activityType: "tool.started",
180
+ kind: "memory",
181
+ memory,
182
+ label,
183
+ emoji,
184
+ sequence: counters.activitySequence,
185
+ });
186
+ counters.activitySequence += 1;
187
+ }
188
+ catch {
189
+ // Ignore malformed memory progress payloads.
190
+ }
191
+ continue;
192
+ }
193
+ if (eventName === "hermes.mcp.progress") {
194
+ try {
195
+ const payload = JSON.parse(data);
196
+ const server = asString(payload.server);
197
+ const tool = asString(payload.tool);
198
+ const label = asString(payload.label);
199
+ const emoji = asString(payload.emoji);
200
+ if (server) {
201
+ recordMcpUse(accumulator, server, tool, label);
202
+ reply.activity({
203
+ activityType: "tool.started",
204
+ kind: "mcp",
205
+ server,
206
+ tool,
207
+ source: "mcp",
208
+ label,
209
+ emoji,
210
+ sequence: counters.activitySequence,
211
+ });
212
+ counters.activitySequence += 1;
213
+ }
214
+ }
215
+ catch {
216
+ // Ignore malformed MCP progress payloads.
217
+ }
218
+ continue;
219
+ }
220
+ try {
221
+ const payload = JSON.parse(data);
222
+ const delta = extractDeltaFromChunk(payload);
223
+ if (delta && delta.trim()) {
224
+ onTextDelta(delta);
225
+ reply.delta(delta, counters.textSequence);
226
+ counters.textSequence += 1;
227
+ }
228
+ }
229
+ catch {
230
+ // Ignore malformed SSE chunks.
231
+ }
232
+ }
233
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=turnActivity.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnActivity.test.d.ts","sourceRoot":"","sources":["../src/turnActivity.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildTurnActivity, createTurnActivityAccumulator, processSseEventBlock, } from "./turnActivity.js";
4
+ function collectingReply() {
5
+ const activities = [];
6
+ const deltas = [];
7
+ const reply = {
8
+ delta: (text) => {
9
+ deltas.push(text);
10
+ },
11
+ activity: (activity) => {
12
+ activities.push(activity);
13
+ },
14
+ complete: () => { },
15
+ failed: () => { },
16
+ };
17
+ return { reply, activities, deltas };
18
+ }
19
+ function sseEvent(event, data) {
20
+ return `event: ${event}\ndata: ${JSON.stringify(data)}`;
21
+ }
22
+ test("accumulates and dedupes tool usage with counts", () => {
23
+ const { reply, activities } = collectingReply();
24
+ const accumulator = createTurnActivityAccumulator();
25
+ const counters = { textSequence: 0, activitySequence: 0 };
26
+ processSseEventBlock(sseEvent("hermes.tool.progress", { tool: "web_search", label: "Searching", emoji: "🔍" }), reply, counters, accumulator, () => { });
27
+ processSseEventBlock(sseEvent("hermes.tool.progress", { tool: "web_search" }), reply, counters, accumulator, () => { });
28
+ const activity = buildTurnActivity(accumulator);
29
+ assert.deepEqual(activity.tools, [
30
+ { name: "web_search", label: "Searching", emoji: "🔍", count: 2 },
31
+ ]);
32
+ assert.equal(activities.length, 2);
33
+ assert.equal(activities[0]?.kind, "tool");
34
+ });
35
+ test("buckets MCP tool progress into mcps, not tools", () => {
36
+ const { reply } = collectingReply();
37
+ const accumulator = createTurnActivityAccumulator();
38
+ const counters = { textSequence: 0, activitySequence: 0 };
39
+ processSseEventBlock(sseEvent("hermes.tool.progress", { tool: "search", source: "mcp", server: "context7" }), reply, counters, accumulator, () => { });
40
+ processSseEventBlock(sseEvent("hermes.mcp.progress", { server: "context7", tool: "search" }), reply, counters, accumulator, () => { });
41
+ const activity = buildTurnActivity(accumulator);
42
+ assert.equal(activity.tools, undefined);
43
+ assert.deepEqual(activity.mcps, [{ server: "context7", tool: "search", count: 2 }]);
44
+ });
45
+ test("collects skills and memories uniquely", () => {
46
+ const { reply } = collectingReply();
47
+ const accumulator = createTurnActivityAccumulator();
48
+ const counters = { textSequence: 0, activitySequence: 0 };
49
+ processSseEventBlock(sseEvent("hermes.skill.progress", { skill: "frontend-design", label: "Frontend design" }), reply, counters, accumulator, () => { });
50
+ processSseEventBlock(sseEvent("hermes.skill.progress", { skill: "frontend-design" }), reply, counters, accumulator, () => { });
51
+ processSseEventBlock(sseEvent("hermes.memory.progress", { memory: "USER.md" }), reply, counters, accumulator, () => { });
52
+ const activity = buildTurnActivity(accumulator, "anthropic/claude-sonnet-4");
53
+ assert.deepEqual(activity.skills, [{ name: "frontend-design", label: "Frontend design" }]);
54
+ assert.deepEqual(activity.memories, [{ name: "USER.md" }]);
55
+ assert.equal(activity.model, "anthropic/claude-sonnet-4");
56
+ });
57
+ test("text deltas are forwarded and not treated as activity", () => {
58
+ const { reply, deltas, activities } = collectingReply();
59
+ const accumulator = createTurnActivityAccumulator();
60
+ const counters = { textSequence: 0, activitySequence: 0 };
61
+ let captured = "";
62
+ processSseEventBlock(`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}`, reply, counters, accumulator, (delta) => {
63
+ captured += delta;
64
+ });
65
+ assert.equal(captured, "Hello");
66
+ assert.deepEqual(deltas, ["Hello"]);
67
+ assert.equal(activities.length, 0);
68
+ assert.equal(buildTurnActivity(accumulator).model, undefined);
69
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saleso.innovations/bridge",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Connect your Hermes agent to the Cleos iOS app via pairing code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  "lint": "eslint . --max-warnings 0",
37
37
  "check-types": "tsc --noEmit",
38
38
  "prepublishOnly": "npm run build",
39
- "test": "node --import tsx --test src/hermesFiles.test.ts src/hermesSessionDb.test.ts src/renameHermesSession.test.ts src/shellSession.test.ts src/bridgeVersion.test.ts"
39
+ "test": "node --import tsx --test src/hermesFiles.test.ts src/hermesSessionDb.test.ts src/renameHermesSession.test.ts src/shellSession.test.ts src/bridgeVersion.test.ts src/turnActivity.test.ts src/skillLearnedDetector.test.ts"
40
40
  },
41
41
  "dependencies": {
42
42
  "better-sqlite3": "^11.10.0",