openclaw-linso 1.0.12 → 1.0.16

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/dist/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  // 插件入口:注册 channel + 启动 monitor service
3
3
  // 和飞书 index.ts 结构完全一致
4
4
  import { linsoPlugin } from "./src/channel.js";
5
- import { monitorLinsoProvider } from "./src/monitor.js";
5
+ import { monitorLinsoProvider, getActiveDeviceIds, getDeviceRunId } from "./src/monitor.js";
6
6
  import { setLinsoRuntime } from "./src/runtime.js";
7
+ import { sendToClient } from "./src/store.js";
7
8
  export { monitorLinsoProvider } from "./src/monitor.js";
8
9
  export { linsoPlugin } from "./src/channel.js";
9
10
  const plugin = {
@@ -30,6 +31,37 @@ const plugin = {
30
31
  disconnectFromRelay();
31
32
  },
32
33
  });
34
+ // 4. 注册 Tool 调用 hooks — 向所有活跃的 iOS 设备广播 tool_call 事件
35
+ api.on("before_tool_call", async (event) => {
36
+ const deviceIds = getActiveDeviceIds();
37
+ for (const deviceId of deviceIds) {
38
+ const pluginRunId = getDeviceRunId(deviceId);
39
+ const runId = pluginRunId ?? event.runId ?? "";
40
+ console.log(`[Linso] 🔧 before_tool_call tool=${event.toolName} pluginRunId=${pluginRunId ?? "none"} ocRunId=${event.runId ?? "-"} → sending runId=${runId}`);
41
+ sendToClient(deviceId, {
42
+ type: "tool_call",
43
+ runId,
44
+ status: "start",
45
+ toolName: event.toolName,
46
+ });
47
+ }
48
+ });
49
+ api.on("after_tool_call", async (event) => {
50
+ const deviceIds = getActiveDeviceIds();
51
+ for (const deviceId of deviceIds) {
52
+ const pluginRunId = getDeviceRunId(deviceId);
53
+ const runId = pluginRunId ?? event.runId ?? "";
54
+ console.log(`[Linso] 🔧 after_tool_call tool=${event.toolName} pluginRunId=${pluginRunId ?? "none"} dur=${event.durationMs ?? "-"}ms → sending runId=${runId}`);
55
+ sendToClient(deviceId, {
56
+ type: "tool_call",
57
+ runId,
58
+ status: "end",
59
+ toolName: event.toolName,
60
+ durationMs: event.durationMs,
61
+ error: event.error,
62
+ });
63
+ }
64
+ });
33
65
  },
34
66
  };
35
67
  export default plugin;
@@ -4,4 +4,8 @@ export type MonitorLinsoOpts = {
4
4
  log?: (...args: unknown[]) => void;
5
5
  abortSignal?: AbortSignal;
6
6
  };
7
+ /** 返回最近活跃(TTL 内)的所有设备 ID,供 index.ts hook 广播 tool_call 用 */
8
+ export declare function getActiveDeviceIds(): string[];
9
+ /** 获取设备当前活跃的 Plugin runId */
10
+ export declare function getDeviceRunId(deviceId: string): string | undefined;
7
11
  export declare function monitorLinsoProvider(opts?: MonitorLinsoOpts): Promise<void>;
@@ -26,6 +26,22 @@ const DEVICE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
26
26
  function touchDevice(deviceId) {
27
27
  deviceLastSeen.set(deviceId, Date.now());
28
28
  }
29
+ /** deviceId → 当前正在运行的 Plugin runId,供 index.ts hook 广播 tool_call 用 */
30
+ const deviceActiveRunId = new Map();
31
+ /** 返回最近活跃(TTL 内)的所有设备 ID,供 index.ts hook 广播 tool_call 用 */
32
+ export function getActiveDeviceIds() {
33
+ const now = Date.now();
34
+ const result = [];
35
+ for (const [id, lastSeen] of deviceLastSeen) {
36
+ if (now - lastSeen <= DEVICE_TTL_MS)
37
+ result.push(id);
38
+ }
39
+ return result;
40
+ }
41
+ /** 获取设备当前活跃的 Plugin runId */
42
+ export function getDeviceRunId(deviceId) {
43
+ return deviceActiveRunId.get(deviceId);
44
+ }
29
45
  function pruneInactiveDevices(log) {
30
46
  const now = Date.now();
31
47
  let pruned = 0;
@@ -87,6 +103,9 @@ export async function monitorLinsoProvider(opts = {}) {
87
103
  log("[Linso] 与 Relay 断开");
88
104
  },
89
105
  onMessage: (deviceId, msg) => {
106
+ const type = msg.type ?? "unknown";
107
+ const text = msg.text;
108
+ log(`[Linso] ↓ RECV type=${type} deviceId=${String(deviceId).slice(0, 8)}...${text ? ` text="${String(text).slice(0, 50)}"` : ""}`);
90
109
  if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
91
110
  touchDevice(deviceId); // [Opt #3] 记录活跃时间
92
111
  const text = msg.text.trim();
@@ -397,6 +416,10 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
397
416
  }),
398
417
  });
399
418
  const runId = `linso-${Date.now()}`;
419
+ deviceActiveRunId.set(deviceId, runId); // 记录当前活跃 runId
420
+ let delivered = false; // 是否已发送过内容
421
+ let deltaCount = 0; // 已发送的 delta 数量
422
+ let lastSentText = ""; // 上次发送的累计文本,用于计算真实增量
400
423
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
401
424
  deliver: async (payload, info) => {
402
425
  // 检查 stop 标志
@@ -404,27 +427,85 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
404
427
  log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
405
428
  return;
406
429
  }
407
- if (!payload.text || payload.isReasoning)
430
+ if (payload.isReasoning)
431
+ return;
432
+ if (!payload.text) {
433
+ log(`[Linso] deliver 跳过: kind=${info.kind} text=empty`);
434
+ return;
435
+ }
436
+ // 如果已经通过 onPartialReply 发过 delta,则 final 阶段发 stream_end 而非重复文本
437
+ if (info.kind === "final" && deltaCount > 0) {
438
+ log(`[Linso] deliver final skipped (streamed ${deltaCount} deltas): text="${payload.text.slice(0, 40)}"`);
439
+ delivered = true;
440
+ sendToClient(deviceId, { type: "stream_end", runId });
408
441
  return;
442
+ }
443
+ log(`[Linso] deliver called: kind=${info.kind} text="${payload.text.slice(0, 40)}"`);
444
+ delivered = true;
409
445
  sendToClient(deviceId, {
410
446
  type: info.kind === "final" ? "final" : "delta",
411
447
  runId,
412
448
  text: payload.text,
413
449
  });
414
450
  },
451
+ onSkip: (payload, info) => {
452
+ log(`[Linso] ⚠️ deliver SKIPPED: kind=${info.kind} reason=${info.reason}`);
453
+ if (!delivered) {
454
+ delivered = true;
455
+ sendToClient(deviceId, { type: "final", runId, text: "😶" });
456
+ }
457
+ },
458
+ onCleanup: () => {
459
+ log(`[Linso] ⚠️ onCleanup (NO_REPLY or cleanup) runId=${runId} delivered=${delivered}`);
460
+ if (!delivered) {
461
+ delivered = true;
462
+ sendToClient(deviceId, { type: "final", runId, text: "😶" });
463
+ }
464
+ },
465
+ onError: (err, info) => {
466
+ log(`[Linso] ❌ deliver ERROR: kind=${info.kind} err=${String(err)}`);
467
+ },
415
468
  });
416
469
  sendToClient(deviceId, { type: "run_start", runId });
417
- await core.channel.reply.withReplyDispatcher({
470
+ const dispatchResult = await core.channel.reply.withReplyDispatcher({
418
471
  dispatcher,
419
472
  onSettled: () => markDispatchIdle(),
420
473
  run: () => core.channel.reply.dispatchReplyFromConfig({
421
474
  ctx: inboundCtx,
422
475
  cfg: effectiveCfg,
423
476
  dispatcher,
424
- replyOptions,
477
+ replyOptions: {
478
+ ...replyOptions,
479
+ onPartialReply: (payload) => {
480
+ if (deviceStopFlags.get(deviceId))
481
+ return;
482
+ if (!payload.text)
483
+ return;
484
+ // payload.text 是累积完整文本,提取真实增量部分
485
+ const fullText = payload.text;
486
+ if (fullText.length <= lastSentText.length)
487
+ return; // 没有新内容
488
+ const increment = fullText.slice(lastSentText.length);
489
+ lastSentText = fullText;
490
+ deltaCount++;
491
+ delivered = true;
492
+ sendToClient(deviceId, { type: "delta", runId, text: increment });
493
+ },
494
+ },
425
495
  }),
426
496
  });
497
+ log(`[Linso] dispatchResult: ${JSON.stringify(dispatchResult)} delivered=${delivered}`);
498
+ // 兜底:如果 dispatcher 没有发送过任何内容
499
+ if (!delivered) {
500
+ log(`[Linso] ⚠️ 没有内容被 deliver,发送兜底回复`);
501
+ sendToClient(deviceId, {
502
+ type: "final",
503
+ runId,
504
+ text: "收到啦,但我暂时无法回复这条消息 🤔",
505
+ });
506
+ }
427
507
  sendToClient(deviceId, { type: "done", runId });
508
+ deviceActiveRunId.delete(deviceId); // 清理活跃 runId
428
509
  log(`[Linso] 回复完成: deviceId=${deviceId}`);
429
510
  }
430
511
  finally {
package/dist/src/store.js CHANGED
@@ -4,5 +4,21 @@
4
4
  import { sendToRelay } from "./relay-client.js";
5
5
  /** 向指定 iOS 设备发送消息(经由 Relay) */
6
6
  export function sendToClient(deviceId, msg) {
7
+ const type = msg.type ?? "unknown";
8
+ console.log(`[Linso] ↑ SEND type=${type} deviceId=${String(deviceId).slice(0, 8)}... ${_preview(msg)}`);
7
9
  sendToRelay({ ...msg, deviceId });
8
10
  }
11
+ function _preview(msg) {
12
+ const parts = [];
13
+ if (msg.runId)
14
+ parts.push(`runId=${String(msg.runId).slice(-6)}`);
15
+ if (msg.text)
16
+ parts.push(`text="${String(msg.text).slice(0, 40)}"`);
17
+ if (msg.toolName)
18
+ parts.push(`tool=${msg.toolName}`);
19
+ if (msg.status)
20
+ parts.push(`status=${msg.status}`);
21
+ if (msg.error)
22
+ parts.push(`error=${msg.error}`);
23
+ return parts.join(" ");
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-linso",
3
- "version": "1.0.12",
3
+ "version": "1.0.16",
4
4
  "description": "Linso iOS App channel plugin for OpenClaw — connects via cloud Relay Server",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/monitor.ts CHANGED
@@ -43,6 +43,24 @@ function touchDevice(deviceId: string): void {
43
43
  deviceLastSeen.set(deviceId, Date.now());
44
44
  }
45
45
 
46
+ /** deviceId → 当前正在运行的 Plugin runId,供 index.ts hook 广播 tool_call 用 */
47
+ const deviceActiveRunId = new Map<string, string>();
48
+
49
+ /** 返回最近活跃(TTL 内)的所有设备 ID,供 index.ts hook 广播 tool_call 用 */
50
+ export function getActiveDeviceIds(): string[] {
51
+ const now = Date.now();
52
+ const result: string[] = [];
53
+ for (const [id, lastSeen] of deviceLastSeen) {
54
+ if (now - lastSeen <= DEVICE_TTL_MS) result.push(id);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ /** 获取设备当前活跃的 Plugin runId */
60
+ export function getDeviceRunId(deviceId: string): string | undefined {
61
+ return deviceActiveRunId.get(deviceId);
62
+ }
63
+
46
64
  function pruneInactiveDevices(log: (...args: unknown[]) => void): void {
47
65
  const now = Date.now();
48
66
  let pruned = 0;
@@ -124,6 +142,10 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
124
142
  },
125
143
 
126
144
  onMessage: (deviceId, msg) => {
145
+ const type = (msg as Record<string, unknown>).type ?? "unknown";
146
+ const text = (msg as Record<string, unknown>).text;
147
+ log(`[Linso] ↓ RECV type=${type} deviceId=${String(deviceId).slice(0, 8)}...${text ? ` text="${String(text).slice(0, 50)}"` : ""}`);
148
+
127
149
  if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
128
150
  touchDevice(deviceId); // [Opt #3] 记录活跃时间
129
151
  const text = msg.text.trim();
@@ -471,6 +493,10 @@ async function handleIncomingMessage(
471
493
  });
472
494
 
473
495
  const runId = `linso-${Date.now()}`;
496
+ deviceActiveRunId.set(deviceId, runId); // 记录当前活跃 runId
497
+ let delivered = false; // 是否已发送过内容
498
+ let deltaCount = 0; // 已发送的 delta 数量
499
+ let lastSentText = ""; // 上次发送的累计文本,用于计算真实增量
474
500
 
475
501
  const { dispatcher, replyOptions, markDispatchIdle } =
476
502
  core.channel.reply.createReplyDispatcherWithTyping({
@@ -480,18 +506,48 @@ async function handleIncomingMessage(
480
506
  log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
481
507
  return;
482
508
  }
483
- if (!payload.text || payload.isReasoning) return;
509
+ if (payload.isReasoning) return;
510
+ if (!payload.text) {
511
+ log(`[Linso] deliver 跳过: kind=${info.kind} text=empty`);
512
+ return;
513
+ }
514
+ // 如果已经通过 onPartialReply 发过 delta,则 final 阶段发 stream_end 而非重复文本
515
+ if (info.kind === "final" && deltaCount > 0) {
516
+ log(`[Linso] deliver final skipped (streamed ${deltaCount} deltas): text="${payload.text.slice(0, 40)}"`);
517
+ delivered = true;
518
+ sendToClient(deviceId, { type: "stream_end", runId });
519
+ return;
520
+ }
521
+ log(`[Linso] deliver called: kind=${info.kind} text="${payload.text.slice(0, 40)}"`);
522
+ delivered = true;
484
523
  sendToClient(deviceId, {
485
524
  type: info.kind === "final" ? "final" : "delta",
486
525
  runId,
487
526
  text: payload.text,
488
527
  });
489
528
  },
529
+ onSkip: (payload, info) => {
530
+ log(`[Linso] ⚠️ deliver SKIPPED: kind=${info.kind} reason=${info.reason}`);
531
+ if (!delivered) {
532
+ delivered = true;
533
+ sendToClient(deviceId, { type: "final", runId, text: "😶" });
534
+ }
535
+ },
536
+ onCleanup: () => {
537
+ log(`[Linso] ⚠️ onCleanup (NO_REPLY or cleanup) runId=${runId} delivered=${delivered}`);
538
+ if (!delivered) {
539
+ delivered = true;
540
+ sendToClient(deviceId, { type: "final", runId, text: "😶" });
541
+ }
542
+ },
543
+ onError: (err, info) => {
544
+ log(`[Linso] ❌ deliver ERROR: kind=${info.kind} err=${String(err)}`);
545
+ },
490
546
  });
491
547
 
492
548
  sendToClient(deviceId, { type: "run_start", runId });
493
549
 
494
- await core.channel.reply.withReplyDispatcher({
550
+ const dispatchResult = await core.channel.reply.withReplyDispatcher({
495
551
  dispatcher,
496
552
  onSettled: () => markDispatchIdle(),
497
553
  run: () =>
@@ -499,11 +555,38 @@ async function handleIncomingMessage(
499
555
  ctx: inboundCtx,
500
556
  cfg: effectiveCfg,
501
557
  dispatcher,
502
- replyOptions,
558
+ replyOptions: {
559
+ ...replyOptions,
560
+ onPartialReply: (payload) => {
561
+ if (deviceStopFlags.get(deviceId)) return;
562
+ if (!payload.text) return;
563
+ // payload.text 是累积完整文本,提取真实增量部分
564
+ const fullText = payload.text;
565
+ if (fullText.length <= lastSentText.length) return; // 没有新内容
566
+ const increment = fullText.slice(lastSentText.length);
567
+ lastSentText = fullText;
568
+ deltaCount++;
569
+ delivered = true;
570
+ sendToClient(deviceId, { type: "delta", runId, text: increment });
571
+ },
572
+ },
503
573
  }),
504
574
  });
505
575
 
576
+ log(`[Linso] dispatchResult: ${JSON.stringify(dispatchResult)} delivered=${delivered}`);
577
+
578
+ // 兜底:如果 dispatcher 没有发送过任何内容
579
+ if (!delivered) {
580
+ log(`[Linso] ⚠️ 没有内容被 deliver,发送兜底回复`);
581
+ sendToClient(deviceId, {
582
+ type: "final",
583
+ runId,
584
+ text: "收到啦,但我暂时无法回复这条消息 🤔",
585
+ });
586
+ }
587
+
506
588
  sendToClient(deviceId, { type: "done", runId });
589
+ deviceActiveRunId.delete(deviceId); // 清理活跃 runId
507
590
  log(`[Linso] 回复完成: deviceId=${deviceId}`);
508
591
 
509
592
  } finally {
package/src/store.ts CHANGED
@@ -6,5 +6,18 @@ import { sendToRelay } from "./relay-client.js";
6
6
 
7
7
  /** 向指定 iOS 设备发送消息(经由 Relay) */
8
8
  export function sendToClient(deviceId: string, msg: Record<string, unknown>) {
9
+ const type = msg.type ?? "unknown";
10
+ console.log(`[Linso] ↑ SEND type=${type} deviceId=${String(deviceId).slice(0, 8)}... ${_preview(msg)}`);
9
11
  sendToRelay({ ...msg, deviceId });
10
12
  }
13
+
14
+ function _preview(msg: Record<string, unknown>): string {
15
+ const parts: string[] = [];
16
+ if (msg.runId) parts.push(`runId=${String(msg.runId).slice(-6)}`);
17
+ if (msg.text) parts.push(`text="${String(msg.text).slice(0, 40)}"`);
18
+ if (msg.toolName) parts.push(`tool=${msg.toolName}`);
19
+ if (msg.status) parts.push(`status=${msg.status}`);
20
+ if (msg.error) parts.push(`error=${msg.error}`);
21
+ return parts.join(" ");
22
+ }
23
+