larkway 0.3.52 → 0.3.53
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 +1 -1
- package/README.zh.md +1 -1
- package/dist/cli/index.js +34 -0
- package/dist/main.js +237 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
You @ the bot in a Feishu thread. It runs on your machine — reading your real codebase, executing commands, opening MRs — and posts the result back. You define what the agent knows and what it can do. Larkway just carries the messages.
|
|
10
10
|
|
|
11
|
-
**Current release: v0.3.
|
|
11
|
+
**Current release: v0.3.53**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/README.zh.md
CHANGED
package/dist/cli/index.js
CHANGED
|
@@ -108729,6 +108729,40 @@ var init_channelClient = __esm({
|
|
|
108729
108729
|
log(`cardAction \u2192 synthesized turn: value=${JSON.stringify(evt.action?.value)} thread=${ev.thread_id ?? "?"}`);
|
|
108730
108730
|
this.queue.push(ev);
|
|
108731
108731
|
}
|
|
108732
|
+
/**
|
|
108733
|
+
* Local-dispatch fast path (peer handoff): push an event that carries a REAL
|
|
108734
|
+
* Feishu message_id — one the bridge itself just sent on a sibling bot's
|
|
108735
|
+
* behalf — through the SAME dedup bookkeeping as a live WS delivery. The
|
|
108736
|
+
* point of going through here instead of {@link enqueueSyntheticEvent}: the
|
|
108737
|
+
* real message WILL also arrive over this bot's own WS (it @-mentions this
|
|
108738
|
+
* bot), and marking it in-flight now is what makes that later copy a no-op.
|
|
108739
|
+
* Ordering is symmetric — if the WS copy somehow wins the race, THIS call
|
|
108740
|
+
* becomes the no-op. Either way exactly one turn runs.
|
|
108741
|
+
*
|
|
108742
|
+
* Failure keeps the existing self-heal semantics: markUnhandled removes the
|
|
108743
|
+
* id from in-flight, so gap-fill can re-dispatch the real message later.
|
|
108744
|
+
*
|
|
108745
|
+
* @returns true when the event was dispatched, false when it was deduped
|
|
108746
|
+
* (already seen/in-flight) or the client is closed.
|
|
108747
|
+
*/
|
|
108748
|
+
ingestLocalEvent(ev, sourceTag) {
|
|
108749
|
+
if (this.closed) return false;
|
|
108750
|
+
const log = (s) => console.log(`[channel.client] ${s}`);
|
|
108751
|
+
this.noteSeenChat(ev.chat_id);
|
|
108752
|
+
if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
|
|
108753
|
+
this.chatTypesById.set(ev.chat_id, ev.chat_type);
|
|
108754
|
+
}
|
|
108755
|
+
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
108756
|
+
log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
|
|
108757
|
+
return false;
|
|
108758
|
+
}
|
|
108759
|
+
this.inFlightMessageIds.add(ev.message_id);
|
|
108760
|
+
this.noteInFlightMeta(ev);
|
|
108761
|
+
this.noteDispatchAttempt(ev.message_id);
|
|
108762
|
+
log(`dispatching (local-handoff from ${sourceTag}): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
|
|
108763
|
+
this.queue.push(ev);
|
|
108764
|
+
return true;
|
|
108765
|
+
}
|
|
108732
108766
|
/**
|
|
108733
108767
|
* Push an already-built synthetic LarkMessageEvent onto the inbound queue,
|
|
108734
108768
|
* so handler.ts processes it as an ordinary turn. Same mechanism as
|
package/dist/main.js
CHANGED
|
@@ -112856,6 +112856,40 @@ var ChannelClient = class {
|
|
|
112856
112856
|
log(`cardAction \u2192 synthesized turn: value=${JSON.stringify(evt.action?.value)} thread=${ev.thread_id ?? "?"}`);
|
|
112857
112857
|
this.queue.push(ev);
|
|
112858
112858
|
}
|
|
112859
|
+
/**
|
|
112860
|
+
* Local-dispatch fast path (peer handoff): push an event that carries a REAL
|
|
112861
|
+
* Feishu message_id — one the bridge itself just sent on a sibling bot's
|
|
112862
|
+
* behalf — through the SAME dedup bookkeeping as a live WS delivery. The
|
|
112863
|
+
* point of going through here instead of {@link enqueueSyntheticEvent}: the
|
|
112864
|
+
* real message WILL also arrive over this bot's own WS (it @-mentions this
|
|
112865
|
+
* bot), and marking it in-flight now is what makes that later copy a no-op.
|
|
112866
|
+
* Ordering is symmetric — if the WS copy somehow wins the race, THIS call
|
|
112867
|
+
* becomes the no-op. Either way exactly one turn runs.
|
|
112868
|
+
*
|
|
112869
|
+
* Failure keeps the existing self-heal semantics: markUnhandled removes the
|
|
112870
|
+
* id from in-flight, so gap-fill can re-dispatch the real message later.
|
|
112871
|
+
*
|
|
112872
|
+
* @returns true when the event was dispatched, false when it was deduped
|
|
112873
|
+
* (already seen/in-flight) or the client is closed.
|
|
112874
|
+
*/
|
|
112875
|
+
ingestLocalEvent(ev, sourceTag) {
|
|
112876
|
+
if (this.closed) return false;
|
|
112877
|
+
const log = (s) => console.log(`[channel.client] ${s}`);
|
|
112878
|
+
this.noteSeenChat(ev.chat_id);
|
|
112879
|
+
if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
|
|
112880
|
+
this.chatTypesById.set(ev.chat_id, ev.chat_type);
|
|
112881
|
+
}
|
|
112882
|
+
if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
|
|
112883
|
+
log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
|
|
112884
|
+
return false;
|
|
112885
|
+
}
|
|
112886
|
+
this.inFlightMessageIds.add(ev.message_id);
|
|
112887
|
+
this.noteInFlightMeta(ev);
|
|
112888
|
+
this.noteDispatchAttempt(ev.message_id);
|
|
112889
|
+
log(`dispatching (local-handoff from ${sourceTag}): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
|
|
112890
|
+
this.queue.push(ev);
|
|
112891
|
+
return true;
|
|
112892
|
+
}
|
|
112859
112893
|
/**
|
|
112860
112894
|
* Push an already-built synthetic LarkMessageEvent onto the inbound queue,
|
|
112861
112895
|
* so handler.ts processes it as an ordinary turn. Same mechanism as
|
|
@@ -114773,7 +114807,8 @@ function renderStateContract(stateFilePath) {
|
|
|
114773
114807
|
"- \u9700\u8981\u7528\u6237\u5355\u9009:status=ready + choices(\u6700\u591A 5 \u4E2A `{label, value}`,\u53EF\u914D choice_prompt \u4E00\u884C\u95EE\u9898)\u3002label \u662F\u7B80\u77ED\u9009\u9879\u542B\u4E49;value \u662F\u70B9\u9009\u540E**\u9010\u5B57\u56DE\u4F20\u7ED9\u4F60**\u7684\u5B8C\u6574\u81EA\u63CF\u8FF0\u6307\u4EE4,\u522B\u5199 `optA` \u8FD9\u79CD\u4EE3\u53F7\u3002bridge \u81EA\u52A8\u7F16\u53F7 A/B/C \u5E76\u5728\u6B63\u6587\u751F\u6210\u56FE\u4F8B,**\u6B63\u6587\u522B\u518D\u624B\u52A8\u5217\u4E00\u904D\u9009\u9879**\u3002\u4EC5\u9650\u300C\u5355\u4E2A\u79BB\u6563\u9009\u62E9\u3001\u70B9\u4E00\u4E0B\u5C31\u7B54\u5168\u300D;\u4FE1\u606F\u6536\u96C6/\u591A\u90E8\u5206\u63D0\u95EE\u8BA9\u7528\u6237\u76F4\u63A5\u6253\u5B57,\u522B\u7528\u6309\u94AE\u3002",
|
|
114774
114808
|
'- \u56FE\u6587\u6DF7\u6392:status=ready + content_blocks(\u6709\u5E8F\u5757\u6570\u7EC4,\u6700\u591A 12 \u5757\u3001\u5176\u4E2D image \u6700\u591A 4 \u5757;\u53EA\u652F\u6301 `{type:"markdown", content}` \u548C `{type:"image", img_key, alt?}`,img_key \u5FC5\u987B\u662F\u4F60\u5DF2\u4E0A\u4F20\u3001\u53EF\u7528\u4E8E\u5361\u7247\u7684 Feishu \u56FE\u7247 key;\u975E\u7A7A\u65F6\u5B83\u5C31\u662F\u4E3B\u6B63\u6587)\u3002',
|
|
114775
114809
|
"- \u9700\u8981\u8986\u76D6\u5361\u7247\u6B63\u6587:status=ready + last_message(\u4E0D\u5199\u65F6\u6B63\u6587=\u7B54\u6848\u901A\u9053\u5185\u5BB9);dev_url/mr_url \u7B49\u4E1A\u52A1\u5B57\u6BB5\u53EF\u81EA\u7531\u5199\u5165,\u4F46 bridge \u4E0D\u611F\u77E5\u5176\u542B\u4E49,\u8981\u8BA9\u7528\u6237\u770B\u5230\u5C31\u5199\u8FDB\u6B63\u6587\u3002",
|
|
114776
|
-
"- \u9700\u8981\u5728\u7EC8\u6001\u5361\u4E0A @ \u4EBA:response_surface \u5199 `{post:{mentions:[{user_id}]}}`;\u8FD9\u53EA\u662F\u89C6\u89C9\u63D0\u793A,\u8981 peer bot \u6D88\u8D39\u6B63\u6587\u5FC5\u987B\u53E6\u53D1\u771F\u5B9E post(\u5361\u7247\u5BF9 agent \u4E0D\u53EF\u8BFB)\u3002",
|
|
114810
|
+
"- \u9700\u8981\u5728\u7EC8\u6001\u5361\u4E0A @ \u4EBA:response_surface \u5199 `{post:{mentions:[{user_id}]}}`;\u8FD9\u53EA\u662F\u89C6\u89C9\u63D0\u793A,\u8981 peer bot \u6D88\u8D39\u6B63\u6587\u5FC5\u987B\u8D70 handoffs \u6216\u53E6\u53D1\u771F\u5B9E post(\u5361\u7247\u5BF9 agent \u4E0D\u53EF\u8BFB)\u3002",
|
|
114811
|
+
"- \u4EA4\u63A5\u7ED9 peer bot:handoffs(\u6700\u591A 3 \u6761 `{to, text}`,to \u5199 peer-bots \u540D\u518C\u91CC\u7684\u540D\u5B57)\u3002bridge \u66FF\u4F60\u53D1\u4E00\u6761\u5E26\u771F\u5B9E at \u6807\u7B7E\u7684 post \u7559\u75D5,\u540C\u6865\u8FDB\u7A0B\u5185\u7684 peer \u8FD8\u4F1A\u88AB**\u672C\u5730\u76F4\u9012**\u7ACB\u5373\u5524\u9192\u2014\u2014\u6BD4\u81EA\u5DF1\u7528 lark-cli \u53D1 post \u66F4\u5FEB\u66F4\u53EF\u9760\u3002text \u5FC5\u987B\u81EA\u5305\u542B:\u5BF9\u65B9\u53EA\u770B\u5F97\u5230\u8FD9\u6761\u6587\u672C,\u80CC\u666F\u3001\u8981\u505A\u4EC0\u4E48\u3001\u671F\u671B\u4EA7\u51FA\u90FD\u5199\u6E05\u695A\u3002",
|
|
114777
114812
|
"",
|
|
114778
114813
|
"\u8FB9\u754C\u4E0E\u8D23\u4EFB:",
|
|
114779
114814
|
"- **\u7EDD\u4E0D\u81EA\u5DF1 PATCH/PUT bridge \u7BA1\u7406\u7684\u5361\u7247/post** \u2014\u2014 \u7F51\u7EDC\u66F4\u65B0\u662F bridge \u7684\u6D3B,\u81EA\u5DF1\u6539\u4F1A\u548C\u5361\u7247 finalize\u3001\u6309\u94AE\u56DE\u4F20\u3001\u5D29\u6E83\u6062\u590D\u51B2\u7A81\u3002",
|
|
@@ -114786,7 +114821,7 @@ function renderContractAnchor(stateFilePath) {
|
|
|
114786
114821
|
const stateTarget = stateFilePath ? `\`${stateFilePath}\`` : "\u5DE5\u4F5C\u76EE\u5F55\u91CC\u7684 `.larkway/state.json`";
|
|
114787
114822
|
return [
|
|
114788
114823
|
"<contract-anchor>",
|
|
114789
|
-
`\u5951\u7EA6\u540C\u672C\u8BDD\u9898\u9996\u8F6E\u6CE8\u5165,\u672A\u53D8\u5316:\u7ED9\u7528\u6237\u770B\u7684\u7B54\u6848\u6B63\u6587\u5305\u5728\u72EC\u7ACB\u884C marker \`${ANSWER_BEGIN_MARKER}\` / \`${ANSWER_END_MARKER}\` \u4E4B\u95F4\u8F93\u51FA;\u7ED3\u8BBA\u4E00\u6210\u5F62\u5C31\u5148\u6D41\u51FA\u7B54\u6848\u3002\u7EAF\u6587\u5B57\u56DE\u7B54\u4E0D\u7528\u5199 state.json;\u5931\u8D25/\u7B49\u8865\u5145/choices/\u56FE\u6587\u6DF7\u6392\u65F6\u624D\u5199 ${stateTarget}(\u539F\u5B50\u5199 + \u5237\u65B0 updated_at)\u3002\u505A\u5B8C\u5E72\u51C0\u9000\u51FA\u8FDB\u7A0B\u3002`,
|
|
114824
|
+
`\u5951\u7EA6\u540C\u672C\u8BDD\u9898\u9996\u8F6E\u6CE8\u5165,\u672A\u53D8\u5316:\u7ED9\u7528\u6237\u770B\u7684\u7B54\u6848\u6B63\u6587\u5305\u5728\u72EC\u7ACB\u884C marker \`${ANSWER_BEGIN_MARKER}\` / \`${ANSWER_END_MARKER}\` \u4E4B\u95F4\u8F93\u51FA;\u7ED3\u8BBA\u4E00\u6210\u5F62\u5C31\u5148\u6D41\u51FA\u7B54\u6848\u3002\u7EAF\u6587\u5B57\u56DE\u7B54\u4E0D\u7528\u5199 state.json;\u5931\u8D25/\u7B49\u8865\u5145/choices/\u56FE\u6587\u6DF7\u6392/handoffs \u4EA4\u63A5\u65F6\u624D\u5199 ${stateTarget}(\u539F\u5B50\u5199 + \u5237\u65B0 updated_at)\u3002\u505A\u5B8C\u5E72\u51C0\u9000\u51FA\u8FDB\u7A0B\u3002`,
|
|
114790
114825
|
"</contract-anchor>"
|
|
114791
114826
|
];
|
|
114792
114827
|
}
|
|
@@ -114802,7 +114837,9 @@ function renderPeersBlock(peers) {
|
|
|
114802
114837
|
"- \u53EA\u5728\u4F60\u786E\u8BA4\u81EA\u5DF1\u80FD\u529B\u8303\u56F4\u4E4B\u5916\u624D @ peer",
|
|
114803
114838
|
"- @ peer \u65F6\u5728\u6D88\u606F\u91CC\u8BF4\u6E05\u695A\u300C\u4F60\u9700\u8981\u5B83\u505A\u4EC0\u4E48\u300D",
|
|
114804
114839
|
"- \u4E0D\u8981\u628A\u540C\u4E00\u4EFB\u52A1\u540C\u65F6\u8F6C\u53D1\u7ED9\u591A\u4E2A peer",
|
|
114805
|
-
|
|
114840
|
+
"- \u4EA4\u63A5\u9996\u9009 state.json \u7684 `handoffs` \u5B57\u6BB5(\u89C1 state-contract):bridge \u4EE3\u53D1\u5E26\u771F\u5B9E at \u6807\u7B7E\u7684 post,",
|
|
114841
|
+
" \u540C\u6865 peer \u8FD8\u4F1A\u88AB\u672C\u5730\u76F4\u9012\u7ACB\u5373\u5524\u9192\u3002\u4EC5\u5F53\u8F6E\u6B21**\u4E2D\u9014**\u9700\u8981\u5373\u65F6 ack/\u591A\u8F6E\u5F80\u6765\u65F6,\u624D\u81EA\u5DF1\u7528 lark-cli \u53D1",
|
|
114842
|
+
' **post \u6D88\u606F** + at \u6807\u7B7E `{"tag":"at","user_id":"ou_xxx"}`(\u7528\u4E0A\u9762\u7684 open_id),',
|
|
114806
114843
|
" **\u4E25\u7981\u7528\u7EAF text \u7684 @xxx**(\u7EAF\u6587\u672C @ \u4E0D\u4F1A\u771F\u6B63\u89E6\u8FBE\u5BF9\u65B9 bot)",
|
|
114807
114844
|
"- **\u5BF9\u65B9\u9700\u8981\u884C\u52A8\u7684\u5B9E\u8D28\u5185\u5BB9,\u5FC5\u987B\u5199\u8FDB\u8FD9\u6761 post \u6D88\u606F\u672C\u4F53,\u4E0D\u8981\u6307\u671B\u5BF9\u65B9\u53BB\u8BFB\u4F60\u7684\u5361\u7247\u603B\u7ED3**\u2014\u2014\u5B9E\u6D4B\u8FC7 peer \u7528\u5DE5\u5177\u8BFB\u53D6\u4F60\u4E4B\u524D\u53D1\u7684\u5361\u7247\u6D88\u606F\u65F6,\u53EA\u62FF\u5230\u300C\u8BF7\u5347\u7EA7\u5BA2\u6237\u7AEF\u67E5\u770B\u300D\u4E4B\u7C7B\u7684\u964D\u7EA7\u5360\u4F4D,\u8BFB\u4E0D\u5230\u5361\u7247\u91CC\u7684\u771F\u5B9E\u7ED3\u8BBA\u3002\u5361\u7247\u662F\u7ED9\u4EBA\u770B\u7684\u5C55\u793A\u5C42,\u4E0D\u662F\u53EF\u9760\u7684 agent \u95F4\u6570\u636E\u901A\u9053\u3002",
|
|
114808
114845
|
"- \u53D1\u8D77 peer handoff \u540E,\u5728\u4F60\u7684\u534F\u8C03\u5C42/\u5DE5\u4F5C\u533A\u53F0\u8D26\u8BB0\u5F55 task_id\u3001assignee\u3001\u6765\u6E90\u3001\u671F\u671B\u4EA7\u51FA\u3001deadline \u548C\u5347\u7EA7\u4EBA;\u6CA1\u6709\u4E13\u7528 skill \u65F6\u81F3\u5C11\u5199\u5165\u672C session summary\u3002",
|
|
@@ -116694,6 +116731,27 @@ var StateFileSchema = external_exports.object({
|
|
|
116694
116731
|
* ignored and the legacy card path remains authoritative.
|
|
116695
116732
|
*/
|
|
116696
116733
|
response_surface: ResponseSurfaceStateSchema.optional(),
|
|
116734
|
+
/**
|
|
116735
|
+
* Peer handoff declarations (local-dispatch fast path). Each entry asks the
|
|
116736
|
+
* bridge to (a) send ONE real Feishu post into this thread with a true at
|
|
116737
|
+
* tag for the named peer bot — the durable, human-visible mirror — and
|
|
116738
|
+
* (b) dispatch the same message to that peer's inbound queue locally when
|
|
116739
|
+
* the peer lives in this bridge process, so the handoff no longer depends
|
|
116740
|
+
* on the peer's WS delivery. `to` is the peer's display name or bot id as
|
|
116741
|
+
* listed in <peer-bots>; unknown targets are skipped with a diagnostic
|
|
116742
|
+
* (never fail the turn). Bridge-opaque beyond that: `text` is sent
|
|
116743
|
+
* VERBATIM — the bridge does not interpret it (thin channel).
|
|
116744
|
+
*
|
|
116745
|
+
* `.catch(undefined)`: soft-fail doctrine — a malformed optional field
|
|
116746
|
+
* degrades to absent, never drags down `status` (same class as
|
|
116747
|
+
* memory_updates above).
|
|
116748
|
+
*/
|
|
116749
|
+
handoffs: external_exports.array(
|
|
116750
|
+
external_exports.object({
|
|
116751
|
+
to: external_exports.string().trim().min(1).max(64),
|
|
116752
|
+
text: external_exports.string().trim().min(1).max(1e4)
|
|
116753
|
+
})
|
|
116754
|
+
).max(3).optional().catch(void 0),
|
|
116697
116755
|
/**
|
|
116698
116756
|
* 话题 ↔ 飞书任务句柄认领声明(docs/task-handle.md §5.1/§5.2)。agent 在 turn
|
|
116699
116757
|
* 内检索到候选任务、消解歧义后,把 guid 写进这里;bridge finalize 时读取并
|
|
@@ -116874,6 +116932,149 @@ function tryRepairBareQuotesInLastMessage(raw) {
|
|
|
116874
116932
|
return raw.slice(0, valueStart) + escaped + tail.slice(bestEnd);
|
|
116875
116933
|
}
|
|
116876
116934
|
|
|
116935
|
+
// src/lark/postContent.ts
|
|
116936
|
+
function assertSafeUserId(userId) {
|
|
116937
|
+
if (!/^[A-Za-z0-9_:-]+$/.test(userId)) {
|
|
116938
|
+
throw new Error("post mention user_id contains unsupported characters");
|
|
116939
|
+
}
|
|
116940
|
+
}
|
|
116941
|
+
function normalizeText(text) {
|
|
116942
|
+
return text.replace(/\r\n/g, "\n").trim();
|
|
116943
|
+
}
|
|
116944
|
+
function buildPostContent(input) {
|
|
116945
|
+
const text = normalizeText(input.text);
|
|
116946
|
+
const mentions = input.mentions ?? [];
|
|
116947
|
+
if (!text && mentions.length === 0) {
|
|
116948
|
+
throw new Error("post content requires text or at least one mention");
|
|
116949
|
+
}
|
|
116950
|
+
if (mentions.length > 5) {
|
|
116951
|
+
throw new Error("post content supports at most 5 mentions");
|
|
116952
|
+
}
|
|
116953
|
+
const segments = [];
|
|
116954
|
+
if (text) {
|
|
116955
|
+
segments.push({ tag: "text", text });
|
|
116956
|
+
}
|
|
116957
|
+
for (const mention of mentions) {
|
|
116958
|
+
assertSafeUserId(mention.userId);
|
|
116959
|
+
if (segments.length > 0) {
|
|
116960
|
+
segments.push({ tag: "text", text: " " });
|
|
116961
|
+
}
|
|
116962
|
+
segments.push({
|
|
116963
|
+
tag: "at",
|
|
116964
|
+
user_id: mention.userId,
|
|
116965
|
+
user_name: mention.label?.trim() || ""
|
|
116966
|
+
});
|
|
116967
|
+
}
|
|
116968
|
+
const locale = input.locale ?? "zh_cn";
|
|
116969
|
+
return JSON.stringify({
|
|
116970
|
+
[locale]: {
|
|
116971
|
+
title: input.title?.trim() ?? "",
|
|
116972
|
+
content: [segments]
|
|
116973
|
+
}
|
|
116974
|
+
});
|
|
116975
|
+
}
|
|
116976
|
+
|
|
116977
|
+
// src/bridge/localHandoff.ts
|
|
116978
|
+
var LocalHandoffRegistry = class {
|
|
116979
|
+
members = /* @__PURE__ */ new Map();
|
|
116980
|
+
register(info, client) {
|
|
116981
|
+
this.members.set(info.botId, { info, client });
|
|
116982
|
+
}
|
|
116983
|
+
describe(botId) {
|
|
116984
|
+
return this.members.get(botId)?.info;
|
|
116985
|
+
}
|
|
116986
|
+
/** @returns true when the event was locally dispatched (not deduped/unknown). */
|
|
116987
|
+
dispatch(botId, ev, sourceTag) {
|
|
116988
|
+
const member = this.members.get(botId);
|
|
116989
|
+
if (!member) return false;
|
|
116990
|
+
try {
|
|
116991
|
+
return member.client.ingestLocalEvent(ev, sourceTag);
|
|
116992
|
+
} catch (err) {
|
|
116993
|
+
console.warn(`[local-handoff] dispatch to "${botId}" failed (WS delivery will cover):`, err);
|
|
116994
|
+
return false;
|
|
116995
|
+
}
|
|
116996
|
+
}
|
|
116997
|
+
};
|
|
116998
|
+
function normalizeTarget(s) {
|
|
116999
|
+
return s.trim().replace(/^@/, "").toLowerCase();
|
|
117000
|
+
}
|
|
117001
|
+
async function processHandoffs(ctx) {
|
|
117002
|
+
const outcomes = [];
|
|
117003
|
+
for (const [i, declared] of ctx.handoffs.entries()) {
|
|
117004
|
+
const wanted = normalizeTarget(declared.to);
|
|
117005
|
+
const peer = ctx.peers.find((p) => normalizeTarget(p.name) === wanted);
|
|
117006
|
+
const rosterEntry = ctx.roster.find(
|
|
117007
|
+
(r) => normalizeTarget(r.name) === wanted || normalizeTarget(r.botId) === wanted
|
|
117008
|
+
);
|
|
117009
|
+
if (!peer) {
|
|
117010
|
+
outcomes.push({
|
|
117011
|
+
to: declared.to,
|
|
117012
|
+
posted: false,
|
|
117013
|
+
localDispatched: false,
|
|
117014
|
+
detail: `\u672A\u5728 <peer-bots> \u91CC\u627E\u5230 "${declared.to}"\uFF0C\u6B64\u6761 handoff \u8DF3\u8FC7`
|
|
117015
|
+
});
|
|
117016
|
+
continue;
|
|
117017
|
+
}
|
|
117018
|
+
if (!ctx.postClient) {
|
|
117019
|
+
outcomes.push({
|
|
117020
|
+
to: declared.to,
|
|
117021
|
+
posted: false,
|
|
117022
|
+
localDispatched: false,
|
|
117023
|
+
detail: "postClient \u4E0D\u53EF\u7528\uFF0C\u65E0\u6CD5\u53D1\u955C\u50CF post\uFF0C\u6B64\u6761 handoff \u8DF3\u8FC7"
|
|
117024
|
+
});
|
|
117025
|
+
continue;
|
|
117026
|
+
}
|
|
117027
|
+
let mirrorMessageId;
|
|
117028
|
+
try {
|
|
117029
|
+
const content = buildPostContent({
|
|
117030
|
+
text: declared.text,
|
|
117031
|
+
mentions: [{ userId: peer.id, label: peer.name }]
|
|
117032
|
+
});
|
|
117033
|
+
const sent = await ctx.postClient.createPostReply(ctx.replyAnchorId, content, {
|
|
117034
|
+
replyInThread: true,
|
|
117035
|
+
idempotencyKey: `handoff:${ctx.triggerMessageId}:${i}:${rosterEntry?.botId ?? wanted}`
|
|
117036
|
+
});
|
|
117037
|
+
mirrorMessageId = sent.messageId;
|
|
117038
|
+
} catch (err) {
|
|
117039
|
+
outcomes.push({
|
|
117040
|
+
to: declared.to,
|
|
117041
|
+
posted: false,
|
|
117042
|
+
localDispatched: false,
|
|
117043
|
+
detail: `\u955C\u50CF post \u53D1\u9001\u5931\u8D25\uFF08\u672A\u505A\u672C\u5730\u76F4\u9012\uFF09: ${String(err.message ?? err)}`
|
|
117044
|
+
});
|
|
117045
|
+
continue;
|
|
117046
|
+
}
|
|
117047
|
+
let localDispatched = false;
|
|
117048
|
+
let detail = `\u955C\u50CF post \u5DF2\u53D1 (message_id=${mirrorMessageId})`;
|
|
117049
|
+
const targetInfo = rosterEntry && ctx.registry?.describe(rosterEntry.botId);
|
|
117050
|
+
if (ctx.localDispatchEnabled === false) {
|
|
117051
|
+
detail += "\uFF1B\u672C\u5730\u76F4\u9012\u5DF2\u7981\u7528\uFF0C\u8D70 WS \u9001\u8FBE";
|
|
117052
|
+
} else if (!rosterEntry || !targetInfo) {
|
|
117053
|
+
detail += "\uFF1B\u76EE\u6807\u4E0D\u5728\u672C bridge \u8FDB\u7A0B\u5185\uFF0C\u8D70 WS \u9001\u8FBE";
|
|
117054
|
+
} else {
|
|
117055
|
+
const ev = {
|
|
117056
|
+
message_id: mirrorMessageId,
|
|
117057
|
+
chat_id: ctx.chatId,
|
|
117058
|
+
chat_type: "group",
|
|
117059
|
+
thread_id: ctx.threadId,
|
|
117060
|
+
root_id: ctx.threadId,
|
|
117061
|
+
// Informational marker, mirroring "task_comment" — nothing branches on it.
|
|
117062
|
+
larkway_trigger_type: "local_handoff",
|
|
117063
|
+
sender_id: ctx.selfBotId,
|
|
117064
|
+
// Same shape the target's WS copy would carry: an @_user_N placeholder
|
|
117065
|
+
// in the text (stripped by extractText) + mention metadata resolving it.
|
|
117066
|
+
mentions: targetInfo.botOpenId ? [{ key: "@_user_1", id: { open_id: targetInfo.botOpenId }, name: targetInfo.name }] : void 0,
|
|
117067
|
+
content: JSON.stringify({ text: `@_user_1 ${declared.text}` }),
|
|
117068
|
+
create_time: String(Date.now())
|
|
117069
|
+
};
|
|
117070
|
+
localDispatched = ctx.registry.dispatch(rosterEntry.botId, ev, ctx.selfBotId);
|
|
117071
|
+
detail += localDispatched ? `\uFF1B\u5DF2\u672C\u5730\u76F4\u9012\u7ED9 ${rosterEntry.botId}\uFF08WS \u526F\u672C\u5C06\u88AB\u53BB\u91CD\uFF09` : `\uFF1B\u672C\u5730\u76F4\u9012\u672A\u751F\u6548\uFF08\u53BB\u91CD\u6216\u76EE\u6807\u5F02\u5E38\uFF09\uFF0C\u8D70 WS \u9001\u8FBE`;
|
|
117072
|
+
}
|
|
117073
|
+
outcomes.push({ to: declared.to, posted: true, localDispatched, detail });
|
|
117074
|
+
}
|
|
117075
|
+
return outcomes;
|
|
117076
|
+
}
|
|
117077
|
+
|
|
116877
117078
|
// src/bridge/cardFile.ts
|
|
116878
117079
|
import fs7 from "node:fs/promises";
|
|
116879
117080
|
import path10 from "node:path";
|
|
@@ -118193,48 +118394,6 @@ async function createCotProgressHandle(opts) {
|
|
|
118193
118394
|
return handle;
|
|
118194
118395
|
}
|
|
118195
118396
|
|
|
118196
|
-
// src/lark/postContent.ts
|
|
118197
|
-
function assertSafeUserId(userId) {
|
|
118198
|
-
if (!/^[A-Za-z0-9_:-]+$/.test(userId)) {
|
|
118199
|
-
throw new Error("post mention user_id contains unsupported characters");
|
|
118200
|
-
}
|
|
118201
|
-
}
|
|
118202
|
-
function normalizeText(text) {
|
|
118203
|
-
return text.replace(/\r\n/g, "\n").trim();
|
|
118204
|
-
}
|
|
118205
|
-
function buildPostContent(input) {
|
|
118206
|
-
const text = normalizeText(input.text);
|
|
118207
|
-
const mentions = input.mentions ?? [];
|
|
118208
|
-
if (!text && mentions.length === 0) {
|
|
118209
|
-
throw new Error("post content requires text or at least one mention");
|
|
118210
|
-
}
|
|
118211
|
-
if (mentions.length > 5) {
|
|
118212
|
-
throw new Error("post content supports at most 5 mentions");
|
|
118213
|
-
}
|
|
118214
|
-
const segments = [];
|
|
118215
|
-
if (text) {
|
|
118216
|
-
segments.push({ tag: "text", text });
|
|
118217
|
-
}
|
|
118218
|
-
for (const mention of mentions) {
|
|
118219
|
-
assertSafeUserId(mention.userId);
|
|
118220
|
-
if (segments.length > 0) {
|
|
118221
|
-
segments.push({ tag: "text", text: " " });
|
|
118222
|
-
}
|
|
118223
|
-
segments.push({
|
|
118224
|
-
tag: "at",
|
|
118225
|
-
user_id: mention.userId,
|
|
118226
|
-
user_name: mention.label?.trim() || ""
|
|
118227
|
-
});
|
|
118228
|
-
}
|
|
118229
|
-
const locale = input.locale ?? "zh_cn";
|
|
118230
|
-
return JSON.stringify({
|
|
118231
|
-
[locale]: {
|
|
118232
|
-
title: input.title?.trim() ?? "",
|
|
118233
|
-
content: [segments]
|
|
118234
|
-
}
|
|
118235
|
-
});
|
|
118236
|
-
}
|
|
118237
|
-
|
|
118238
118397
|
// src/lark/idempotency.ts
|
|
118239
118398
|
import { createHash as createHash2 } from "node:crypto";
|
|
118240
118399
|
var KEY_PREFIX = "lw-p-";
|
|
@@ -120180,6 +120339,33 @@ ${memoryVisibilityLines.join("\n")}` : cardBody;
|
|
|
120180
120339
|
await deleteCardFile(worktreePath);
|
|
120181
120340
|
}
|
|
120182
120341
|
}
|
|
120342
|
+
const declaredHandoffs = reportedState?.handoffs;
|
|
120343
|
+
if (declaredHandoffs && declaredHandoffs.length > 0) {
|
|
120344
|
+
try {
|
|
120345
|
+
const outcomes = await processHandoffs({
|
|
120346
|
+
handoffs: declaredHandoffs,
|
|
120347
|
+
peers: effectivePeers ?? [],
|
|
120348
|
+
roster: this.deps.taskHandleMentionRoster ?? [],
|
|
120349
|
+
selfBotId: this.deps.botConfig?.id ?? "v1-default",
|
|
120350
|
+
postClient: this.deps.postClient,
|
|
120351
|
+
registry: this.deps.localHandoffRegistry,
|
|
120352
|
+
replyAnchorId,
|
|
120353
|
+
chatId: parsed.chatId,
|
|
120354
|
+
threadId,
|
|
120355
|
+
triggerMessageId: messageId,
|
|
120356
|
+
localDispatchEnabled: process.env["LARKWAY_LOCAL_HANDOFF"] !== "off"
|
|
120357
|
+
});
|
|
120358
|
+
for (const o of outcomes) {
|
|
120359
|
+
await recordEvent({
|
|
120360
|
+
status: "running",
|
|
120361
|
+
appendPath: "peer handoff",
|
|
120362
|
+
reason: `\u2192 ${o.to}: ${o.detail}`
|
|
120363
|
+
});
|
|
120364
|
+
}
|
|
120365
|
+
} catch (err) {
|
|
120366
|
+
console.warn("[bridge.handler] processHandoffs failed (turn unaffected):", err);
|
|
120367
|
+
}
|
|
120368
|
+
}
|
|
120183
120369
|
settle(true);
|
|
120184
120370
|
await recordEvent({
|
|
120185
120371
|
status: "completed",
|
|
@@ -129270,6 +129456,7 @@ async function runV2Mode({
|
|
|
129270
129456
|
const allTaskHandleStores = [];
|
|
129271
129457
|
const tasklistPollersByGuid = /* @__PURE__ */ new Map();
|
|
129272
129458
|
const tasklistGuidGroups = /* @__PURE__ */ new Map();
|
|
129459
|
+
const localHandoffRegistry = new LocalHandoffRegistry();
|
|
129273
129460
|
for (const bot of healthyBots) {
|
|
129274
129461
|
const appSecret = process.env[bot.app_secret_env];
|
|
129275
129462
|
const tokenEnvName = bot.git_token_env ?? bot.gitlab_token_env;
|
|
@@ -129310,6 +129497,10 @@ async function runV2Mode({
|
|
|
129310
129497
|
larkwayDir: larkwayHome()
|
|
129311
129498
|
});
|
|
129312
129499
|
console.log(`[larkway] bot "${bot.id}" inbound transport = Channel SDK (WS in-process)`);
|
|
129500
|
+
localHandoffRegistry.register(
|
|
129501
|
+
{ botId: bot.id, name: bot.name, botOpenId: bot.bot_open_id },
|
|
129502
|
+
client
|
|
129503
|
+
);
|
|
129313
129504
|
const outbound = client.outboundCardClient();
|
|
129314
129505
|
const cardRenderer = new CardRenderer({
|
|
129315
129506
|
patchIntervalMs: 1500,
|
|
@@ -129601,6 +129792,7 @@ async function runV2Mode({
|
|
|
129601
129792
|
permissionMode: configJson.permissions.mode,
|
|
129602
129793
|
peers: resolvedPeers,
|
|
129603
129794
|
taskHandleMentionRoster,
|
|
129795
|
+
localHandoffRegistry,
|
|
129604
129796
|
botConfig: {
|
|
129605
129797
|
id: bot.id,
|
|
129606
129798
|
name: bot.name,
|