@alfe.ai/openclaw-chat 0.2.4 → 0.3.0

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
@@ -36,3 +36,8 @@ pnpm --filter @alfe.ai/openclaw-chat test
36
36
 
37
37
  - **@alfe.ai/openclaw** — OpenClaw runtime plugin API
38
38
  - **openclaw** — OpenClaw runtime plugin API
39
+
40
+ ## Links
41
+
42
+ - 🌐 Website: <https://alfe.ai>
43
+ - 📚 Docs: <https://docs.alfe.ai>
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.cjs";
1
+ import { a as createAlfeChannelPlugin, c as AlfeResolvedAccount, o as AlfeChannelConfig, r as plugin, s as AlfePluginConfig } from "./plugin.cjs";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlfeChannelConfig, i as createAlfeChannelPlugin, n as plugin, o as AlfePluginConfig, s as AlfeResolvedAccount } from "./plugin.js";
1
+ import { a as createAlfeChannelPlugin, c as AlfeResolvedAccount, o as AlfeChannelConfig, r as plugin, s as AlfePluginConfig } from "./plugin.js";
2
2
 
3
3
  //#region src/session-store.d.ts
4
4
 
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { i as createAlfeChannelPlugin, n as plugin } from "./plugin2.js";
1
+ import { a as createAlfeChannelPlugin, r as plugin } from "./plugin2.js";
2
2
  export { createAlfeChannelPlugin, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -4,5 +4,6 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  const require_plugin = require("./plugin2.cjs");
6
6
  exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
7
+ exports.buildToolActivity = require_plugin.buildToolActivity;
7
8
  exports.default = require_plugin.plugin;
8
9
  exports.stripLeakedToolSummary = require_plugin.stripLeakedToolSummary;
package/dist/plugin.d.cts CHANGED
@@ -274,6 +274,40 @@ interface PluginServiceContext {
274
274
  stateDir: string;
275
275
  logger: PluginLogger;
276
276
  }
277
+ type ChatActivity = {
278
+ kind: 'thinking';
279
+ text: string;
280
+ delta?: boolean;
281
+ ts?: number;
282
+ seq?: number;
283
+ } | {
284
+ kind: 'tool';
285
+ toolCallId: string;
286
+ name: string;
287
+ phase?: string;
288
+ status: 'running' | 'done' | 'failed';
289
+ summary?: string;
290
+ argsText?: string;
291
+ progressText?: string;
292
+ resultText?: string;
293
+ isError?: boolean;
294
+ durationMs?: number;
295
+ truncated?: {
296
+ args?: boolean;
297
+ progress?: boolean;
298
+ result?: boolean;
299
+ };
300
+ ts?: number;
301
+ seq?: number;
302
+ };
303
+ /**
304
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
305
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
306
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
307
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
308
+ * event already provides one (title/summary/label/description), trimmed.
309
+ */
310
+ declare function buildToolActivity(data: Record<string, unknown>): ChatActivity | null;
277
311
  declare function stripLeakedToolSummary(text: string): string;
278
312
  /**
279
313
  * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
@@ -328,4 +362,4 @@ declare const plugin: {
328
362
  deactivate(api: PluginApi): Promise<void>;
329
363
  };
330
364
  //#endregion
331
- export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
365
+ export { createAlfeChannelPlugin as a, AlfeResolvedAccount as c, stripLeakedToolSummary as i, buildToolActivity as n, AlfeChannelConfig as o, plugin as r, AlfePluginConfig as s, buildA2ACompletePayload as t };
package/dist/plugin.d.ts CHANGED
@@ -274,6 +274,40 @@ interface PluginServiceContext {
274
274
  stateDir: string;
275
275
  logger: PluginLogger;
276
276
  }
277
+ type ChatActivity = {
278
+ kind: 'thinking';
279
+ text: string;
280
+ delta?: boolean;
281
+ ts?: number;
282
+ seq?: number;
283
+ } | {
284
+ kind: 'tool';
285
+ toolCallId: string;
286
+ name: string;
287
+ phase?: string;
288
+ status: 'running' | 'done' | 'failed';
289
+ summary?: string;
290
+ argsText?: string;
291
+ progressText?: string;
292
+ resultText?: string;
293
+ isError?: boolean;
294
+ durationMs?: number;
295
+ truncated?: {
296
+ args?: boolean;
297
+ progress?: boolean;
298
+ result?: boolean;
299
+ };
300
+ ts?: number;
301
+ seq?: number;
302
+ };
303
+ /**
304
+ * Extract structured tool status from an OpenClaw `tool`-stream event.
305
+ * We send the emoji-free raw `name` plus a lifecycle `status` and let the
306
+ * web render the `🛠️ <Label>: <summary> ✓/✗` row — never OpenClaw's raw
307
+ * `(+N steps)` string. `summary` carries OpenClaw's short detail when the
308
+ * event already provides one (title/summary/label/description), trimmed.
309
+ */
310
+ declare function buildToolActivity(data: Record<string, unknown>): ChatActivity | null;
277
311
  declare function stripLeakedToolSummary(text: string): string;
278
312
  /**
279
313
  * Build the `a2a-complete` notify payload. ALWAYS fired for an A2A turn —
@@ -328,4 +362,4 @@ declare const plugin: {
328
362
  deactivate(api: PluginApi): Promise<void>;
329
363
  };
330
364
  //#endregion
331
- export { AlfeChannelConfig as a, createAlfeChannelPlugin as i, plugin as n, AlfePluginConfig as o, stripLeakedToolSummary as r, AlfeResolvedAccount as s, buildA2ACompletePayload as t };
365
+ export { createAlfeChannelPlugin as a, AlfeResolvedAccount as c, stripLeakedToolSummary as i, buildToolActivity as n, AlfeChannelConfig as o, plugin as r, AlfePluginConfig as s, buildA2ACompletePayload as t };
package/dist/plugin.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin2.js";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { i as stripLeakedToolSummary, n as buildToolActivity, r as plugin, t as buildA2ACompletePayload } from "./plugin2.js";
2
+ export { buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.cjs CHANGED
@@ -619,6 +619,235 @@ function validateAttachmentUrl(input, opts = {}) {
619
619
  };
620
620
  }
621
621
  //#endregion
622
+ //#region src/activity-serialize.ts
623
+ /**
624
+ * Console-grade tool-activity serialization (Slice 3).
625
+ *
626
+ * The chat plugin forwards a curated activity stream to the chat service.
627
+ * For tool calls we now carry the args / streaming progress / final result
628
+ * so the client can render an OpenClaw-console-grade card. Those payloads
629
+ * arrive from OpenClaw's `onAgentEvent` bus as arbitrary `unknown`-shaped
630
+ * objects (`args` is the raw tool-call arguments; `partialResult` / `result`
631
+ * are tool-result envelopes). We must render them to BOUNDED strings BEFORE
632
+ * they cross the WS to the chat service, because:
633
+ *
634
+ * 1. Daemon WS frames are `maxPayload`-bounded — an uncapped multi-MB tool
635
+ * stdout would drop the WHOLE frame (losing the status transition too),
636
+ * so the cap has to live on the emit side, not the client.
637
+ * 2. The relay is a dumb passthrough and the reducer is pure — neither
638
+ * should have to serialize or sanitize unknown objects. The wire carries
639
+ * display text, never raw objects.
640
+ *
641
+ * OpenClaw's own `sanitizeToolResult` already strips standard image content
642
+ * blocks (`{type:'image', data}` → `{omitted:true}`) and truncates text on
643
+ * the `result`/`partialResult` path — but (a) its helpers are NOT importable
644
+ * from the plugin (openclaw is a runtime peer dep, absent from node_modules),
645
+ * and (b) `args` is NOT sanitized at all and can carry base64 (an inline
646
+ * image arg, a `data:` URI in a write). So we reimplement a defensive
647
+ * strip+cap here and apply it to every field, regardless of upstream
648
+ * sanitization.
649
+ */
650
+ /** Args render cap — args are usually small; 4KB is generous. */
651
+ const ARGS_CAP = 4 * 1024;
652
+ /** Streaming progress window — keep the newest 4KB (tail-truncated). */
653
+ const PROGRESS_CAP = 4 * 1024;
654
+ /** Final result cap — results can be large (file reads, search); 16KB. */
655
+ const RESULT_CAP = 16 * 1024;
656
+ /**
657
+ * Replace long base64 runs, `data:` URIs, and known image content-block
658
+ * shapes with a placeholder. Runs BEFORE JSON serialization on a deep-cloned
659
+ * structure so we never mutate OpenClaw's live objects.
660
+ *
661
+ * Detection heuristics (all conservative — we only replace clear payloads):
662
+ * - A string that is a `data:` URI → `[data-uri omitted]`.
663
+ * - A string that is a long (≥256 char) run of base64 chars → `[base64 omitted]`.
664
+ * 256 is well above any realistic id/token/hash and below inline media.
665
+ * - An object that looks like an image content block (`type:'image'` with a
666
+ * `data`/`source`/`url` field, OR an Anthropic `{source:{data}}` shape) →
667
+ * the payload field is replaced with `[image omitted]`.
668
+ */
669
+ const DATA_URI_RE = /^data:[^;,]*(;[^,]*)?,/i;
670
+ const BASE64_BLOB_RE = /^[A-Za-z0-9+/=\r\n]+$/;
671
+ const BASE64_MIN_LEN = 256;
672
+ function looksLikeBase64Blob(s) {
673
+ if (s.length < BASE64_MIN_LEN) return false;
674
+ return s.replace(/[\r\n]/g, "").length >= BASE64_MIN_LEN && BASE64_BLOB_RE.test(s);
675
+ }
676
+ const EMBEDDED_DATA_URI_RE = /data:[^;,\s"'`]{0,64}(?:;[^,\s"'`]{0,32})?,[A-Za-z0-9+/=]{128,}/g;
677
+ const EMBEDDED_BASE64_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
678
+ /**
679
+ * Post-match filter for EMBEDDED_BASE64_RE: real base64 media is
680
+ * high-entropy — a 512+ char run virtually always mixes lower, upper, and
681
+ * digits. This keeps legitimate long runs (a repeated fill char in test
682
+ * output, an ASCII divider) from being eaten by the strip.
683
+ */
684
+ function looksHighEntropyBase64(run) {
685
+ return /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run);
686
+ }
687
+ function stripStringMedia(s) {
688
+ if (DATA_URI_RE.test(s)) return "[data-uri omitted]";
689
+ if (looksLikeBase64Blob(s)) return "[base64 omitted]";
690
+ if (s.length >= 128) return s.replace(EMBEDDED_DATA_URI_RE, "[data-uri omitted]").replace(EMBEDDED_BASE64_RE, (m) => looksHighEntropyBase64(m) ? "[base64 omitted]" : m);
691
+ return s;
692
+ }
693
+ const MEDIA_PAYLOAD_KEYS = new Set([
694
+ "data",
695
+ "base64",
696
+ "b64_json",
697
+ "bytes"
698
+ ]);
699
+ function isImageBlock(obj) {
700
+ const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
701
+ if (type === "image" || type === "image_url" || type === "input_image") return true;
702
+ if (obj.source && typeof obj.source === "object") {
703
+ const src = obj.source;
704
+ if (typeof src.data === "string" || typeof src.url === "string") return true;
705
+ }
706
+ return false;
707
+ }
708
+ /**
709
+ * Deep-clone `value` while stripping media. Guards against cycles (returns
710
+ * `'[circular]'`) and caps recursion depth. Non-plain values (functions,
711
+ * symbols) are dropped by JSON semantics later; here we normalize them so the
712
+ * safe-JSON pass can't throw.
713
+ */
714
+ function stripMediaDeep(value, seen, depth) {
715
+ if (depth > 12) return "[max-depth]";
716
+ if (value === null) return null;
717
+ const t = typeof value;
718
+ if (t === "string") return stripStringMedia(value);
719
+ if (t === "number" || t === "boolean") return value;
720
+ if (t === "bigint") return `${value.toString()}n`;
721
+ if (t === "undefined" || t === "function" || t === "symbol") return void 0;
722
+ const obj = value;
723
+ if (seen.has(obj)) return "[circular]";
724
+ seen.add(obj);
725
+ try {
726
+ if (Array.isArray(obj)) return obj.map((item) => stripMediaDeep(item, seen, depth + 1));
727
+ const record = obj;
728
+ const imageBlock = isImageBlock(record);
729
+ const out = {};
730
+ for (const [key, v] of Object.entries(record)) {
731
+ if (imageBlock && MEDIA_PAYLOAD_KEYS.has(key)) {
732
+ out[key] = "[image omitted]";
733
+ continue;
734
+ }
735
+ if (imageBlock && key === "source" && v && typeof v === "object") {
736
+ const src = { ...v };
737
+ for (const mk of MEDIA_PAYLOAD_KEYS) if (typeof src[mk] === "string") src[mk] = "[image omitted]";
738
+ out[key] = src;
739
+ continue;
740
+ }
741
+ out[key] = stripMediaDeep(v, seen, depth + 1);
742
+ }
743
+ return out;
744
+ } finally {
745
+ seen.delete(obj);
746
+ }
747
+ }
748
+ /**
749
+ * Cycle- and BigInt-safe JSON stringify. Returns `undefined` when there's
750
+ * nothing meaningful to render (null/undefined). A plain string value is
751
+ * returned as-is (unquoted) — args like `"ls -la"` read better raw than
752
+ * `"\"ls -la\""`.
753
+ */
754
+ function safeJson(value) {
755
+ if (value === null || value === void 0) return void 0;
756
+ const stripped = stripMediaDeep(value, /* @__PURE__ */ new WeakSet(), 0);
757
+ if (stripped === void 0) return void 0;
758
+ if (typeof stripped === "string") return stripped;
759
+ try {
760
+ return JSON.stringify(stripped, null, 2);
761
+ } catch {
762
+ return "[unserializable]";
763
+ }
764
+ }
765
+ /** Head-truncate: keep the START of the text (for args/results). */
766
+ function capHead(text, cap) {
767
+ if (text.length <= cap) return {
768
+ text,
769
+ truncated: false
770
+ };
771
+ return {
772
+ text: `${text.slice(0, cap)}\n… [truncated ${String(text.length - cap)} chars]`,
773
+ truncated: true
774
+ };
775
+ }
776
+ /** Tail-truncate: keep the END of the text (for streaming progress). */
777
+ function capTail(text, cap) {
778
+ if (text.length <= cap) return {
779
+ text,
780
+ truncated: false
781
+ };
782
+ return {
783
+ text: `… [truncated ${String(text.length - cap)} chars]\n${text.slice(text.length - cap)}`,
784
+ truncated: true
785
+ };
786
+ }
787
+ /**
788
+ * Serialize + strip + head-cap the tool-call `args` (from `phase:start`).
789
+ * Returns `undefined` when there's nothing to show.
790
+ */
791
+ function serializeArgs(args) {
792
+ const rendered = safeJson(args);
793
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
794
+ return capHead(rendered, ARGS_CAP);
795
+ }
796
+ /**
797
+ * Serialize + strip + head-cap the final tool `result` (from `phase:result`).
798
+ * Prefers rendering the result's text content when it's a standard
799
+ * `{content:[{type:'text',text}]}` envelope (reads far better than the raw
800
+ * JSON), falling back to the full JSON otherwise.
801
+ */
802
+ function serializeResult(result) {
803
+ if (isEmptyEnvelope(result)) return void 0;
804
+ const rendered = extractResultText(result) ?? safeJson(result);
805
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
806
+ return capHead(stripStringMedia(rendered), RESULT_CAP);
807
+ }
808
+ /**
809
+ * Serialize + strip + TAIL-cap a streaming `partialResult` (from
810
+ * `phase:update`). Tail-truncation keeps the NEWEST output — for a bash/exec
811
+ * stream the tail is what the user cares about. Whether OpenClaw's
812
+ * `partialResult` is cumulative or incremental is version-dependent; treating
813
+ * it as "the latest window" (overwrite, not append — see the reducer) is
814
+ * correct for both: a cumulative stream's newest frame already contains the
815
+ * full window (tail-capped), and an incremental stream's newest frame is the
816
+ * latest chunk.
817
+ */
818
+ function serializeProgress(partial) {
819
+ if (isEmptyEnvelope(partial)) return void 0;
820
+ const rendered = extractResultText(partial) ?? safeJson(partial);
821
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
822
+ return capTail(stripStringMedia(rendered), PROGRESS_CAP);
823
+ }
824
+ /**
825
+ * Pull the concatenated `text` blocks out of a standard tool-result envelope
826
+ * (`{content:[{type:'text',text}, {type:'image',omitted:true}]}`). Returns
827
+ * `undefined` when the shape isn't a text-bearing envelope, so callers fall
828
+ * back to full-JSON rendering.
829
+ */
830
+ /** True for a tool-result envelope with an empty `content` array. */
831
+ function isEmptyEnvelope(value) {
832
+ if (!value || typeof value !== "object") return false;
833
+ const content = value.content;
834
+ return Array.isArray(content) && content.length === 0;
835
+ }
836
+ function extractResultText(result) {
837
+ if (!result || typeof result !== "object") return void 0;
838
+ const content = result.content;
839
+ if (!Array.isArray(content)) return void 0;
840
+ const parts = [];
841
+ for (const item of content) {
842
+ if (!item || typeof item !== "object") continue;
843
+ const entry = item;
844
+ if (entry.type === "text" && typeof entry.text === "string") parts.push(entry.text);
845
+ else if (entry.type === "image" || entry.omitted === true) parts.push("[image omitted]");
846
+ }
847
+ if (parts.length === 0) return void 0;
848
+ return parts.join("\n");
849
+ }
850
+ //#endregion
622
851
  //#region src/plugin.ts
623
852
  /**
624
853
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -668,17 +897,58 @@ function buildToolActivity(data) {
668
897
  const rawStatus = (phase ?? "").toLowerCase();
669
898
  let status;
670
899
  if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
671
- else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
900
+ else if (rawStatus === "result" || rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
672
901
  else status = "running";
673
902
  const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
674
903
  const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
904
+ const truncated = {};
905
+ let argsText;
906
+ let progressText;
907
+ let resultText;
908
+ let isError;
909
+ if (rawStatus === "start" || rawStatus === "running" || rawStatus === "") {
910
+ const a = serializeArgs(data.args ?? data.arguments ?? data.input);
911
+ if (a) {
912
+ argsText = a.text;
913
+ if (a.truncated) truncated.args = true;
914
+ }
915
+ }
916
+ const partial = data.partialResult ?? data.partial ?? data.delta;
917
+ if (partial !== void 0) {
918
+ const p = serializeProgress(partial);
919
+ if (p) {
920
+ progressText = p.text;
921
+ if (p.truncated) truncated.progress = true;
922
+ }
923
+ }
924
+ const resultRaw = data.result ?? data.output;
925
+ if (resultRaw !== void 0 || status === "done" || status === "failed") {
926
+ if (resultRaw !== void 0) {
927
+ const r = serializeResult(resultRaw);
928
+ if (r) {
929
+ resultText = r.text;
930
+ if (r.truncated) truncated.result = true;
931
+ }
932
+ }
933
+ if (data.isError === true || status === "failed") isError = true;
934
+ else if (data.isError === false) isError = false;
935
+ }
936
+ const durationRaw = data.durationMs ?? data.duration;
937
+ const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
938
+ const hasTruncated = truncated.args === true || truncated.progress === true || truncated.result === true;
675
939
  return {
676
940
  kind: "tool",
677
941
  toolCallId,
678
942
  name,
679
943
  ...phase ? { phase } : {},
680
944
  status,
681
- ...summary ? { summary } : {}
945
+ ...summary ? { summary } : {},
946
+ ...argsText !== void 0 ? { argsText } : {},
947
+ ...progressText !== void 0 ? { progressText } : {},
948
+ ...resultText !== void 0 ? { resultText } : {},
949
+ ...isError !== void 0 ? { isError } : {},
950
+ ...durationMs !== void 0 ? { durationMs } : {},
951
+ ...hasTruncated ? { truncated } : {}
682
952
  };
683
953
  }
684
954
  /**
@@ -859,6 +1129,19 @@ async function handleAgentRequest(request, log) {
859
1129
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
860
1130
  await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
861
1131
  let resolvedOpenClawKey = null;
1132
+ const UPDATE_THROTTLE_MS = 250;
1133
+ const pendingUpdates = /* @__PURE__ */ new Map();
1134
+ const flushToolUpdate = (toolCallId) => {
1135
+ const pending = pendingUpdates.get(toolCallId);
1136
+ if (!pending) return;
1137
+ clearTimeout(pending.timer);
1138
+ pendingUpdates.delete(toolCallId);
1139
+ pending.send();
1140
+ };
1141
+ const clearAllToolUpdates = () => {
1142
+ for (const pending of pendingUpdates.values()) clearTimeout(pending.timer);
1143
+ pendingUpdates.clear();
1144
+ };
862
1145
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
863
1146
  if (!evt.sessionKey) return;
864
1147
  if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
@@ -890,17 +1173,38 @@ async function handleAgentRequest(request, log) {
890
1173
  }
891
1174
  if (evt.stream === "tool") {
892
1175
  const activity = buildToolActivity(evt.data);
893
- if (activity) chatClient?.sendEvent("chat-activity", {
894
- runId: evt.runId,
895
- sessionKey: legacySessionKey,
896
- seq: evt.seq,
897
- state: "activity",
898
- activity: {
899
- ...activity,
900
- ts: evt.ts,
901
- seq: evt.seq
1176
+ if (activity?.kind !== "tool") return;
1177
+ const toolCallId = activity.toolCallId;
1178
+ const send = () => {
1179
+ chatClient?.sendEvent("chat-activity", {
1180
+ runId: evt.runId,
1181
+ sessionKey: legacySessionKey,
1182
+ seq: evt.seq,
1183
+ state: "activity",
1184
+ activity: {
1185
+ ...activity,
1186
+ ts: evt.ts,
1187
+ seq: evt.seq
1188
+ }
1189
+ });
1190
+ };
1191
+ const rawPhase = (activity.phase ?? "").toLowerCase();
1192
+ if (rawPhase === "update" || activity.status === "running" && rawPhase !== "start" && rawPhase !== "") {
1193
+ const existing = pendingUpdates.get(toolCallId);
1194
+ if (existing) existing.send = send;
1195
+ else {
1196
+ const timer = setTimeout(() => {
1197
+ flushToolUpdate(toolCallId);
1198
+ }, UPDATE_THROTTLE_MS);
1199
+ pendingUpdates.set(toolCallId, {
1200
+ timer,
1201
+ send
1202
+ });
902
1203
  }
903
- });
1204
+ return;
1205
+ }
1206
+ flushToolUpdate(toolCallId);
1207
+ send();
904
1208
  return;
905
1209
  }
906
1210
  });
@@ -1001,6 +1305,7 @@ async function handleAgentRequest(request, log) {
1001
1305
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1002
1306
  } finally {
1003
1307
  unsubscribe();
1308
+ clearAllToolUpdates();
1004
1309
  if (isA2A) disarmA2AEndSignal();
1005
1310
  }
1006
1311
  }
@@ -1235,6 +1540,12 @@ Object.defineProperty(exports, "buildA2ACompletePayload", {
1235
1540
  return buildA2ACompletePayload;
1236
1541
  }
1237
1542
  });
1543
+ Object.defineProperty(exports, "buildToolActivity", {
1544
+ enumerable: true,
1545
+ get: function() {
1546
+ return buildToolActivity;
1547
+ }
1548
+ });
1238
1549
  Object.defineProperty(exports, "createAlfeChannelPlugin", {
1239
1550
  enumerable: true,
1240
1551
  get: function() {
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.cjs";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { i as stripLeakedToolSummary, n as buildToolActivity, r as plugin, t as buildA2ACompletePayload } from "./plugin.cjs";
2
+ export { buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as plugin, r as stripLeakedToolSummary, t as buildA2ACompletePayload } from "./plugin.js";
2
- export { buildA2ACompletePayload, plugin as default, stripLeakedToolSummary };
1
+ import { i as stripLeakedToolSummary, n as buildToolActivity, r as plugin, t as buildA2ACompletePayload } from "./plugin.js";
2
+ export { buildA2ACompletePayload, buildToolActivity, plugin as default, stripLeakedToolSummary };
package/dist/plugin2.js CHANGED
@@ -619,6 +619,235 @@ function validateAttachmentUrl(input, opts = {}) {
619
619
  };
620
620
  }
621
621
  //#endregion
622
+ //#region src/activity-serialize.ts
623
+ /**
624
+ * Console-grade tool-activity serialization (Slice 3).
625
+ *
626
+ * The chat plugin forwards a curated activity stream to the chat service.
627
+ * For tool calls we now carry the args / streaming progress / final result
628
+ * so the client can render an OpenClaw-console-grade card. Those payloads
629
+ * arrive from OpenClaw's `onAgentEvent` bus as arbitrary `unknown`-shaped
630
+ * objects (`args` is the raw tool-call arguments; `partialResult` / `result`
631
+ * are tool-result envelopes). We must render them to BOUNDED strings BEFORE
632
+ * they cross the WS to the chat service, because:
633
+ *
634
+ * 1. Daemon WS frames are `maxPayload`-bounded — an uncapped multi-MB tool
635
+ * stdout would drop the WHOLE frame (losing the status transition too),
636
+ * so the cap has to live on the emit side, not the client.
637
+ * 2. The relay is a dumb passthrough and the reducer is pure — neither
638
+ * should have to serialize or sanitize unknown objects. The wire carries
639
+ * display text, never raw objects.
640
+ *
641
+ * OpenClaw's own `sanitizeToolResult` already strips standard image content
642
+ * blocks (`{type:'image', data}` → `{omitted:true}`) and truncates text on
643
+ * the `result`/`partialResult` path — but (a) its helpers are NOT importable
644
+ * from the plugin (openclaw is a runtime peer dep, absent from node_modules),
645
+ * and (b) `args` is NOT sanitized at all and can carry base64 (an inline
646
+ * image arg, a `data:` URI in a write). So we reimplement a defensive
647
+ * strip+cap here and apply it to every field, regardless of upstream
648
+ * sanitization.
649
+ */
650
+ /** Args render cap — args are usually small; 4KB is generous. */
651
+ const ARGS_CAP = 4 * 1024;
652
+ /** Streaming progress window — keep the newest 4KB (tail-truncated). */
653
+ const PROGRESS_CAP = 4 * 1024;
654
+ /** Final result cap — results can be large (file reads, search); 16KB. */
655
+ const RESULT_CAP = 16 * 1024;
656
+ /**
657
+ * Replace long base64 runs, `data:` URIs, and known image content-block
658
+ * shapes with a placeholder. Runs BEFORE JSON serialization on a deep-cloned
659
+ * structure so we never mutate OpenClaw's live objects.
660
+ *
661
+ * Detection heuristics (all conservative — we only replace clear payloads):
662
+ * - A string that is a `data:` URI → `[data-uri omitted]`.
663
+ * - A string that is a long (≥256 char) run of base64 chars → `[base64 omitted]`.
664
+ * 256 is well above any realistic id/token/hash and below inline media.
665
+ * - An object that looks like an image content block (`type:'image'` with a
666
+ * `data`/`source`/`url` field, OR an Anthropic `{source:{data}}` shape) →
667
+ * the payload field is replaced with `[image omitted]`.
668
+ */
669
+ const DATA_URI_RE = /^data:[^;,]*(;[^,]*)?,/i;
670
+ const BASE64_BLOB_RE = /^[A-Za-z0-9+/=\r\n]+$/;
671
+ const BASE64_MIN_LEN = 256;
672
+ function looksLikeBase64Blob(s) {
673
+ if (s.length < BASE64_MIN_LEN) return false;
674
+ return s.replace(/[\r\n]/g, "").length >= BASE64_MIN_LEN && BASE64_BLOB_RE.test(s);
675
+ }
676
+ const EMBEDDED_DATA_URI_RE = /data:[^;,\s"'`]{0,64}(?:;[^,\s"'`]{0,32})?,[A-Za-z0-9+/=]{128,}/g;
677
+ const EMBEDDED_BASE64_RE = /[A-Za-z0-9+/]{512,}={0,2}/g;
678
+ /**
679
+ * Post-match filter for EMBEDDED_BASE64_RE: real base64 media is
680
+ * high-entropy — a 512+ char run virtually always mixes lower, upper, and
681
+ * digits. This keeps legitimate long runs (a repeated fill char in test
682
+ * output, an ASCII divider) from being eaten by the strip.
683
+ */
684
+ function looksHighEntropyBase64(run) {
685
+ return /[a-z]/.test(run) && /[A-Z]/.test(run) && /\d/.test(run);
686
+ }
687
+ function stripStringMedia(s) {
688
+ if (DATA_URI_RE.test(s)) return "[data-uri omitted]";
689
+ if (looksLikeBase64Blob(s)) return "[base64 omitted]";
690
+ if (s.length >= 128) return s.replace(EMBEDDED_DATA_URI_RE, "[data-uri omitted]").replace(EMBEDDED_BASE64_RE, (m) => looksHighEntropyBase64(m) ? "[base64 omitted]" : m);
691
+ return s;
692
+ }
693
+ const MEDIA_PAYLOAD_KEYS = new Set([
694
+ "data",
695
+ "base64",
696
+ "b64_json",
697
+ "bytes"
698
+ ]);
699
+ function isImageBlock(obj) {
700
+ const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
701
+ if (type === "image" || type === "image_url" || type === "input_image") return true;
702
+ if (obj.source && typeof obj.source === "object") {
703
+ const src = obj.source;
704
+ if (typeof src.data === "string" || typeof src.url === "string") return true;
705
+ }
706
+ return false;
707
+ }
708
+ /**
709
+ * Deep-clone `value` while stripping media. Guards against cycles (returns
710
+ * `'[circular]'`) and caps recursion depth. Non-plain values (functions,
711
+ * symbols) are dropped by JSON semantics later; here we normalize them so the
712
+ * safe-JSON pass can't throw.
713
+ */
714
+ function stripMediaDeep(value, seen, depth) {
715
+ if (depth > 12) return "[max-depth]";
716
+ if (value === null) return null;
717
+ const t = typeof value;
718
+ if (t === "string") return stripStringMedia(value);
719
+ if (t === "number" || t === "boolean") return value;
720
+ if (t === "bigint") return `${value.toString()}n`;
721
+ if (t === "undefined" || t === "function" || t === "symbol") return void 0;
722
+ const obj = value;
723
+ if (seen.has(obj)) return "[circular]";
724
+ seen.add(obj);
725
+ try {
726
+ if (Array.isArray(obj)) return obj.map((item) => stripMediaDeep(item, seen, depth + 1));
727
+ const record = obj;
728
+ const imageBlock = isImageBlock(record);
729
+ const out = {};
730
+ for (const [key, v] of Object.entries(record)) {
731
+ if (imageBlock && MEDIA_PAYLOAD_KEYS.has(key)) {
732
+ out[key] = "[image omitted]";
733
+ continue;
734
+ }
735
+ if (imageBlock && key === "source" && v && typeof v === "object") {
736
+ const src = { ...v };
737
+ for (const mk of MEDIA_PAYLOAD_KEYS) if (typeof src[mk] === "string") src[mk] = "[image omitted]";
738
+ out[key] = src;
739
+ continue;
740
+ }
741
+ out[key] = stripMediaDeep(v, seen, depth + 1);
742
+ }
743
+ return out;
744
+ } finally {
745
+ seen.delete(obj);
746
+ }
747
+ }
748
+ /**
749
+ * Cycle- and BigInt-safe JSON stringify. Returns `undefined` when there's
750
+ * nothing meaningful to render (null/undefined). A plain string value is
751
+ * returned as-is (unquoted) — args like `"ls -la"` read better raw than
752
+ * `"\"ls -la\""`.
753
+ */
754
+ function safeJson(value) {
755
+ if (value === null || value === void 0) return void 0;
756
+ const stripped = stripMediaDeep(value, /* @__PURE__ */ new WeakSet(), 0);
757
+ if (stripped === void 0) return void 0;
758
+ if (typeof stripped === "string") return stripped;
759
+ try {
760
+ return JSON.stringify(stripped, null, 2);
761
+ } catch {
762
+ return "[unserializable]";
763
+ }
764
+ }
765
+ /** Head-truncate: keep the START of the text (for args/results). */
766
+ function capHead(text, cap) {
767
+ if (text.length <= cap) return {
768
+ text,
769
+ truncated: false
770
+ };
771
+ return {
772
+ text: `${text.slice(0, cap)}\n… [truncated ${String(text.length - cap)} chars]`,
773
+ truncated: true
774
+ };
775
+ }
776
+ /** Tail-truncate: keep the END of the text (for streaming progress). */
777
+ function capTail(text, cap) {
778
+ if (text.length <= cap) return {
779
+ text,
780
+ truncated: false
781
+ };
782
+ return {
783
+ text: `… [truncated ${String(text.length - cap)} chars]\n${text.slice(text.length - cap)}`,
784
+ truncated: true
785
+ };
786
+ }
787
+ /**
788
+ * Serialize + strip + head-cap the tool-call `args` (from `phase:start`).
789
+ * Returns `undefined` when there's nothing to show.
790
+ */
791
+ function serializeArgs(args) {
792
+ const rendered = safeJson(args);
793
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
794
+ return capHead(rendered, ARGS_CAP);
795
+ }
796
+ /**
797
+ * Serialize + strip + head-cap the final tool `result` (from `phase:result`).
798
+ * Prefers rendering the result's text content when it's a standard
799
+ * `{content:[{type:'text',text}]}` envelope (reads far better than the raw
800
+ * JSON), falling back to the full JSON otherwise.
801
+ */
802
+ function serializeResult(result) {
803
+ if (isEmptyEnvelope(result)) return void 0;
804
+ const rendered = extractResultText(result) ?? safeJson(result);
805
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
806
+ return capHead(stripStringMedia(rendered), RESULT_CAP);
807
+ }
808
+ /**
809
+ * Serialize + strip + TAIL-cap a streaming `partialResult` (from
810
+ * `phase:update`). Tail-truncation keeps the NEWEST output — for a bash/exec
811
+ * stream the tail is what the user cares about. Whether OpenClaw's
812
+ * `partialResult` is cumulative or incremental is version-dependent; treating
813
+ * it as "the latest window" (overwrite, not append — see the reducer) is
814
+ * correct for both: a cumulative stream's newest frame already contains the
815
+ * full window (tail-capped), and an incremental stream's newest frame is the
816
+ * latest chunk.
817
+ */
818
+ function serializeProgress(partial) {
819
+ if (isEmptyEnvelope(partial)) return void 0;
820
+ const rendered = extractResultText(partial) ?? safeJson(partial);
821
+ if (rendered === void 0 || rendered === "" || rendered === "{}") return void 0;
822
+ return capTail(stripStringMedia(rendered), PROGRESS_CAP);
823
+ }
824
+ /**
825
+ * Pull the concatenated `text` blocks out of a standard tool-result envelope
826
+ * (`{content:[{type:'text',text}, {type:'image',omitted:true}]}`). Returns
827
+ * `undefined` when the shape isn't a text-bearing envelope, so callers fall
828
+ * back to full-JSON rendering.
829
+ */
830
+ /** True for a tool-result envelope with an empty `content` array. */
831
+ function isEmptyEnvelope(value) {
832
+ if (!value || typeof value !== "object") return false;
833
+ const content = value.content;
834
+ return Array.isArray(content) && content.length === 0;
835
+ }
836
+ function extractResultText(result) {
837
+ if (!result || typeof result !== "object") return void 0;
838
+ const content = result.content;
839
+ if (!Array.isArray(content)) return void 0;
840
+ const parts = [];
841
+ for (const item of content) {
842
+ if (!item || typeof item !== "object") continue;
843
+ const entry = item;
844
+ if (entry.type === "text" && typeof entry.text === "string") parts.push(entry.text);
845
+ else if (entry.type === "image" || entry.omitted === true) parts.push("[image omitted]");
846
+ }
847
+ if (parts.length === 0) return void 0;
848
+ return parts.join("\n");
849
+ }
850
+ //#endregion
622
851
  //#region src/plugin.ts
623
852
  /**
624
853
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -668,17 +897,58 @@ function buildToolActivity(data) {
668
897
  const rawStatus = (phase ?? "").toLowerCase();
669
898
  let status;
670
899
  if (rawStatus === "error" || rawStatus === "failed" || data.error != null || data.isError === true) status = "failed";
671
- else if (rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
900
+ else if (rawStatus === "result" || rawStatus === "end" || rawStatus === "done" || rawStatus === "success" || rawStatus === "complete") status = "done";
672
901
  else status = "running";
673
902
  const summaryRaw = asString(data.summary) ?? asString(data.title) ?? asString(data.label) ?? asString(data.description);
674
903
  const summary = summaryRaw ? summaryRaw.slice(0, 200) : void 0;
904
+ const truncated = {};
905
+ let argsText;
906
+ let progressText;
907
+ let resultText;
908
+ let isError;
909
+ if (rawStatus === "start" || rawStatus === "running" || rawStatus === "") {
910
+ const a = serializeArgs(data.args ?? data.arguments ?? data.input);
911
+ if (a) {
912
+ argsText = a.text;
913
+ if (a.truncated) truncated.args = true;
914
+ }
915
+ }
916
+ const partial = data.partialResult ?? data.partial ?? data.delta;
917
+ if (partial !== void 0) {
918
+ const p = serializeProgress(partial);
919
+ if (p) {
920
+ progressText = p.text;
921
+ if (p.truncated) truncated.progress = true;
922
+ }
923
+ }
924
+ const resultRaw = data.result ?? data.output;
925
+ if (resultRaw !== void 0 || status === "done" || status === "failed") {
926
+ if (resultRaw !== void 0) {
927
+ const r = serializeResult(resultRaw);
928
+ if (r) {
929
+ resultText = r.text;
930
+ if (r.truncated) truncated.result = true;
931
+ }
932
+ }
933
+ if (data.isError === true || status === "failed") isError = true;
934
+ else if (data.isError === false) isError = false;
935
+ }
936
+ const durationRaw = data.durationMs ?? data.duration;
937
+ const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
938
+ const hasTruncated = truncated.args === true || truncated.progress === true || truncated.result === true;
675
939
  return {
676
940
  kind: "tool",
677
941
  toolCallId,
678
942
  name,
679
943
  ...phase ? { phase } : {},
680
944
  status,
681
- ...summary ? { summary } : {}
945
+ ...summary ? { summary } : {},
946
+ ...argsText !== void 0 ? { argsText } : {},
947
+ ...progressText !== void 0 ? { progressText } : {},
948
+ ...resultText !== void 0 ? { resultText } : {},
949
+ ...isError !== void 0 ? { isError } : {},
950
+ ...durationMs !== void 0 ? { durationMs } : {},
951
+ ...hasTruncated ? { truncated } : {}
682
952
  };
683
953
  }
684
954
  /**
@@ -859,6 +1129,19 @@ async function handleAgentRequest(request, log) {
859
1129
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
860
1130
  await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
861
1131
  let resolvedOpenClawKey = null;
1132
+ const UPDATE_THROTTLE_MS = 250;
1133
+ const pendingUpdates = /* @__PURE__ */ new Map();
1134
+ const flushToolUpdate = (toolCallId) => {
1135
+ const pending = pendingUpdates.get(toolCallId);
1136
+ if (!pending) return;
1137
+ clearTimeout(pending.timer);
1138
+ pendingUpdates.delete(toolCallId);
1139
+ pending.send();
1140
+ };
1141
+ const clearAllToolUpdates = () => {
1142
+ for (const pending of pendingUpdates.values()) clearTimeout(pending.timer);
1143
+ pendingUpdates.clear();
1144
+ };
862
1145
  const unsubscribe = runtime.events.onAgentEvent((evt) => {
863
1146
  if (!evt.sessionKey) return;
864
1147
  if (evt.stream === "assistant" || evt.stream === "thinking" || evt.stream === "tool") resolvedOpenClawKey ??= evt.sessionKey;
@@ -890,17 +1173,38 @@ async function handleAgentRequest(request, log) {
890
1173
  }
891
1174
  if (evt.stream === "tool") {
892
1175
  const activity = buildToolActivity(evt.data);
893
- if (activity) chatClient?.sendEvent("chat-activity", {
894
- runId: evt.runId,
895
- sessionKey: legacySessionKey,
896
- seq: evt.seq,
897
- state: "activity",
898
- activity: {
899
- ...activity,
900
- ts: evt.ts,
901
- seq: evt.seq
1176
+ if (activity?.kind !== "tool") return;
1177
+ const toolCallId = activity.toolCallId;
1178
+ const send = () => {
1179
+ chatClient?.sendEvent("chat-activity", {
1180
+ runId: evt.runId,
1181
+ sessionKey: legacySessionKey,
1182
+ seq: evt.seq,
1183
+ state: "activity",
1184
+ activity: {
1185
+ ...activity,
1186
+ ts: evt.ts,
1187
+ seq: evt.seq
1188
+ }
1189
+ });
1190
+ };
1191
+ const rawPhase = (activity.phase ?? "").toLowerCase();
1192
+ if (rawPhase === "update" || activity.status === "running" && rawPhase !== "start" && rawPhase !== "") {
1193
+ const existing = pendingUpdates.get(toolCallId);
1194
+ if (existing) existing.send = send;
1195
+ else {
1196
+ const timer = setTimeout(() => {
1197
+ flushToolUpdate(toolCallId);
1198
+ }, UPDATE_THROTTLE_MS);
1199
+ pendingUpdates.set(toolCallId, {
1200
+ timer,
1201
+ send
1202
+ });
902
1203
  }
903
- });
1204
+ return;
1205
+ }
1206
+ flushToolUpdate(toolCallId);
1207
+ send();
904
1208
  return;
905
1209
  }
906
1210
  });
@@ -1001,6 +1305,7 @@ async function handleAgentRequest(request, log) {
1001
1305
  chatClient?.sendResponse(request.id, false, { message: errMsg });
1002
1306
  } finally {
1003
1307
  unsubscribe();
1308
+ clearAllToolUpdates();
1004
1309
  if (isA2A) disarmA2AEndSignal();
1005
1310
  }
1006
1311
  }
@@ -1229,4 +1534,4 @@ const plugin = {
1229
1534
  }
1230
1535
  };
1231
1536
  //#endregion
1232
- export { createAlfeChannelPlugin as i, plugin as n, stripLeakedToolSummary as r, buildA2ACompletePayload as t };
1537
+ export { createAlfeChannelPlugin as a, stripLeakedToolSummary as i, buildToolActivity as n, plugin as r, buildA2ACompletePayload as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -27,9 +27,9 @@
27
27
  "openclaw.plugin.json"
28
28
  ],
29
29
  "dependencies": {
30
- "@alfe.ai/agent-api-client": "^0.3.0",
31
- "@alfe.ai/chat": "^0.0.12",
32
- "@alfe.ai/config": "^0.1.0"
30
+ "@alfe.ai/agent-api-client": "^0.4.0",
31
+ "@alfe.ai/chat": "^0.0.13",
32
+ "@alfe.ai/config": "^0.2.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "openclaw": ">=2026.3.0"
@@ -40,6 +40,17 @@
40
40
  }
41
41
  },
42
42
  "license": "UNLICENSED",
43
+ "homepage": "https://alfe.ai",
44
+ "author": "Alfe (https://alfe.ai)",
45
+ "keywords": [
46
+ "alfe",
47
+ "ai-agents",
48
+ "agent",
49
+ "llm",
50
+ "openclaw",
51
+ "plugin",
52
+ "sdk"
53
+ ],
43
54
  "scripts": {
44
55
  "build": "tsdown",
45
56
  "dev": "tsdown --watch",