larkin 0.5.13 → 0.5.15
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/agent/context-prompt.cjs +12 -1
- package/dist/agent/context-prompt.mjs +12 -1
- package/dist/agent/freshness-gate.cjs +27 -0
- package/dist/app/lark-cli.mjs +46 -1
- package/dist/feishu/host-processing-eye.cjs +50 -3
- package/dist/feishu/host-processing-eye.mjs +50 -3
- package/dist/feishu/host-shell.cjs +38 -2
- package/dist/feishu/host-shell.mjs +37 -2
- package/dist/feishu/im-freshness-adapter.cjs +96 -0
- package/package.json +1 -1
|
@@ -16,7 +16,7 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
|
|
|
16
16
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
17
17
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
18
18
|
*/
|
|
19
|
-
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
19
|
+
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
|
|
20
20
|
/**
|
|
21
21
|
* Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
|
|
22
22
|
* 不声明 tmux 一定可用,也不复述扩展的工具细节。
|
|
@@ -43,6 +43,8 @@ const FEISHU_IM_COMMAND_GROUPS = [
|
|
|
43
43
|
["Messages", ["im +messages-send", "im +messages-reply", "im messages delete", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
|
|
44
44
|
["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
|
|
45
45
|
];
|
|
46
|
+
// 云文档 shortcut 与 IM 一样是官方 lark-cli 直通,不属于 Agent CLI 自有命令清单。
|
|
47
|
+
const FEISHU_DOCS_COMMANDS = ["docs +create", "docs +update", "docs +fetch"];
|
|
46
48
|
function clean(value) {
|
|
47
49
|
return value.replaceAll("\r", "").trim();
|
|
48
50
|
}
|
|
@@ -187,6 +189,15 @@ class ContextPromptBuilder {
|
|
|
187
189
|
...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
|
|
188
190
|
`- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
|
|
189
191
|
"",
|
|
192
|
+
"## Feishu docs command map",
|
|
193
|
+
"",
|
|
194
|
+
`Create, read, or update a Feishu cloud document only through these Larkin-owned shortcuts. Identity stays Runtime-locked: use \`${executable}\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a docs command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary or invent a scope grant.`,
|
|
195
|
+
"A missing document URL or token is fail-closed: stop and report the missing locator. Do not guess a token, and do not call any docs shortcut that is absent from this map.",
|
|
196
|
+
`To create a document from supplied Markdown or plain text, run \`${executable} docs +create --doc-format markdown --content '<exact_markdown_body>' --json\`. Pass \`--content\` as one literal argument, using the same ANSI-C quoting rule as message bodies when the body is multiline. For a local file, use \`--content @<file>\` instead of inlining it. Create an empty document only with \`${executable} docs +create --title '<exact_title>' --json\` and no \`--content\`. \`--parent-token\` and \`--parent-position\` are optional and mutually exclusive. Official default \`--doc-format\` is \`xml\`; use \`markdown\` for this ordinary supplied-text recipe.`,
|
|
197
|
+
`To read an existing document, run \`${executable} docs +fetch --doc <doc_url_or_token> --json\`. Add \`--scope outline|range|keyword|section\` only for a partial read. Add \`--detail with-ids\` before an update that needs block ids. Omit \`--scope\` only when the whole document is required.`,
|
|
198
|
+
`To change an existing document, fetch its current content first, then run \`${executable} docs +update --doc <doc_url_or_token> --command <command> --json\`. For a simple inline replacement use \`--command str_replace --pattern '<exact_old>' --content '<exact_new>'\`. To append, use \`--command append --content '<exact_body>'\`. Use \`--command overwrite\` only when the user explicitly asks to replace the whole document. If the fetch fails or the scope is missing, fail closed and do not write from memory.`,
|
|
199
|
+
`- Documents: ${FEISHU_DOCS_COMMANDS.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`,
|
|
200
|
+
"",
|
|
190
201
|
"Do not invent Larkin commands that are absent from this list. Project instructions and memory remain owned by the runtime's native workspace discovery.",
|
|
191
202
|
].filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""));
|
|
192
203
|
const content = `${sections.join("\n").trim()}\n`;
|
|
@@ -13,7 +13,7 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
|
|
|
13
13
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
14
14
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
15
15
|
*/
|
|
16
|
-
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
16
|
+
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
|
|
17
17
|
/**
|
|
18
18
|
* Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
|
|
19
19
|
* 不声明 tmux 一定可用,也不复述扩展的工具细节。
|
|
@@ -40,6 +40,8 @@ const FEISHU_IM_COMMAND_GROUPS = [
|
|
|
40
40
|
["Messages", ["im +messages-send", "im +messages-reply", "im messages delete", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
|
|
41
41
|
["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
|
|
42
42
|
];
|
|
43
|
+
// 云文档 shortcut 与 IM 一样是官方 lark-cli 直通,不属于 Agent CLI 自有命令清单。
|
|
44
|
+
const FEISHU_DOCS_COMMANDS = ["docs +create", "docs +update", "docs +fetch"];
|
|
43
45
|
function clean(value) {
|
|
44
46
|
return value.replaceAll("\r", "").trim();
|
|
45
47
|
}
|
|
@@ -184,6 +186,15 @@ export class ContextPromptBuilder {
|
|
|
184
186
|
...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
|
|
185
187
|
`- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
|
|
186
188
|
"",
|
|
189
|
+
"## Feishu docs command map",
|
|
190
|
+
"",
|
|
191
|
+
`Create, read, or update a Feishu cloud document only through these Larkin-owned shortcuts. Identity stays Runtime-locked: use \`${executable}\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a docs command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary or invent a scope grant.`,
|
|
192
|
+
"A missing document URL or token is fail-closed: stop and report the missing locator. Do not guess a token, and do not call any docs shortcut that is absent from this map.",
|
|
193
|
+
`To create a document from supplied Markdown or plain text, run \`${executable} docs +create --doc-format markdown --content '<exact_markdown_body>' --json\`. Pass \`--content\` as one literal argument, using the same ANSI-C quoting rule as message bodies when the body is multiline. For a local file, use \`--content @<file>\` instead of inlining it. Create an empty document only with \`${executable} docs +create --title '<exact_title>' --json\` and no \`--content\`. \`--parent-token\` and \`--parent-position\` are optional and mutually exclusive. Official default \`--doc-format\` is \`xml\`; use \`markdown\` for this ordinary supplied-text recipe.`,
|
|
194
|
+
`To read an existing document, run \`${executable} docs +fetch --doc <doc_url_or_token> --json\`. Add \`--scope outline|range|keyword|section\` only for a partial read. Add \`--detail with-ids\` before an update that needs block ids. Omit \`--scope\` only when the whole document is required.`,
|
|
195
|
+
`To change an existing document, fetch its current content first, then run \`${executable} docs +update --doc <doc_url_or_token> --command <command> --json\`. For a simple inline replacement use \`--command str_replace --pattern '<exact_old>' --content '<exact_new>'\`. To append, use \`--command append --content '<exact_body>'\`. Use \`--command overwrite\` only when the user explicitly asks to replace the whole document. If the fetch fails or the scope is missing, fail closed and do not write from memory.`,
|
|
196
|
+
`- Documents: ${FEISHU_DOCS_COMMANDS.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`,
|
|
197
|
+
"",
|
|
187
198
|
"Do not invent Larkin commands that are absent from this list. Project instructions and memory remain owned by the runtime's native workspace discovery.",
|
|
188
199
|
].filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""));
|
|
189
200
|
const content = `${sections.join("\n").trim()}\n`;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.evaluateFreshness = evaluateFreshness;
|
|
4
|
+
/** Provider-neutral optimistic gate. Provider fields and cursor ordering stay adapter-owned. */
|
|
5
|
+
function evaluateFreshness(input) {
|
|
6
|
+
let snapshot;
|
|
7
|
+
try {
|
|
8
|
+
snapshot = input.probe();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const current = input.adapter.cursor(snapshot);
|
|
15
|
+
const comparison = input.adapter.compare(input.seen, current);
|
|
16
|
+
if (comparison === "gap")
|
|
17
|
+
return { status: "unavailable", reason: "authoritative history cannot establish a comparable head" };
|
|
18
|
+
if (comparison === "fresh")
|
|
19
|
+
return { status: "fresh", current, snapshot };
|
|
20
|
+
if (!current)
|
|
21
|
+
return { status: "unavailable", reason: "authoritative history returned an invalid empty cursor" };
|
|
22
|
+
return { status: "conflict", current, snapshot, context: input.adapter.unseen(input.seen, snapshot) };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/app/lark-cli.mjs
CHANGED
|
@@ -284,6 +284,45 @@ function parseCommentReply(argv) {
|
|
|
284
284
|
return { messageId, text };
|
|
285
285
|
}
|
|
286
286
|
const IM_WRITE_MEMO_LIMIT = 512;
|
|
287
|
+
function normalizeImBodyEscapes(argv) {
|
|
288
|
+
const next = [...argv];
|
|
289
|
+
const boundary = next.indexOf("--");
|
|
290
|
+
const limit = boundary < 0 ? next.length : boundary;
|
|
291
|
+
for (let index = 0; index < limit; index += 1) {
|
|
292
|
+
const argument = next[index];
|
|
293
|
+
const flag = ["--text", "--markdown"].find((candidate) => argument === candidate || argument.startsWith(`${candidate}=`));
|
|
294
|
+
if (!flag)
|
|
295
|
+
continue;
|
|
296
|
+
const valueIndex = argument === flag ? index + 1 : index;
|
|
297
|
+
if (valueIndex >= limit)
|
|
298
|
+
continue;
|
|
299
|
+
const value = argument === flag ? next[valueIndex] : argument.slice(flag.length + 1);
|
|
300
|
+
const hasEscapedWhitespace = /\\[nt]/.test(value);
|
|
301
|
+
// 已含真实换行、Windows 路径和 fenced code 的反斜杠均可能是用户的原文;
|
|
302
|
+
// 仅修复无歧义的单行普通文本,避免破坏代码、正则或路径。
|
|
303
|
+
const preserveVerbatim = /[\r\n]/.test(value)
|
|
304
|
+
|| value.includes("```")
|
|
305
|
+
|| /(?:^|[\s("'`])[A-Za-z]:\\/.test(value);
|
|
306
|
+
const normalized = hasEscapedWhitespace && !preserveVerbatim
|
|
307
|
+
? value.replace(/\\([nt])/g, (_match, escape) => escape === "n" ? "\n" : "\t")
|
|
308
|
+
: value;
|
|
309
|
+
if (argument === flag)
|
|
310
|
+
next[valueIndex] = normalized;
|
|
311
|
+
else
|
|
312
|
+
next[index] = `${flag}=${normalized}`;
|
|
313
|
+
if (argument === flag)
|
|
314
|
+
index += 1;
|
|
315
|
+
}
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
function recordOutboundAt(store, target) {
|
|
319
|
+
store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
|
|
320
|
+
const at = new Date().toISOString();
|
|
321
|
+
state.last_outbound_at = at;
|
|
322
|
+
state.last_outbound_by_target ??= {};
|
|
323
|
+
state.last_outbound_by_target[target] = at;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
287
326
|
// 只标注、不拦截:每次成功写把「实际生效的幂等 key → 服务端返回的 message_id」记进备忘。
|
|
288
327
|
// 同 key 再次成功且服务端返回同一个 message_id,说明服务端走了幂等去重(没有产生新消息),
|
|
289
328
|
// 返回 true 供输出标注 duplicate。拦截权始终在服务端,备忘不会吞掉任何发送。
|
|
@@ -1117,7 +1156,7 @@ function emitCommittedUnverified(result, io, input) {
|
|
|
1117
1156
|
}
|
|
1118
1157
|
export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
1119
1158
|
const io = dependencies.io ?? defaultIo();
|
|
1120
|
-
|
|
1159
|
+
let effectiveArgv = argv;
|
|
1121
1160
|
const runtimeAgentId = larkinConfig.resolveRuntimeAuthority(env);
|
|
1122
1161
|
if (!runtimeAgentId) {
|
|
1123
1162
|
try {
|
|
@@ -1175,6 +1214,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1175
1214
|
}
|
|
1176
1215
|
if (decision.kind === "passthrough")
|
|
1177
1216
|
return passthroughWithObservation(effectiveArgv, privateEnv, io, nativeDependencies, store);
|
|
1217
|
+
if (decision.kind === "guarded" && (decision.operation === "send" || decision.operation === "reply")) {
|
|
1218
|
+
effectiveArgv = normalizeImBodyEscapes(effectiveArgv);
|
|
1219
|
+
}
|
|
1178
1220
|
try {
|
|
1179
1221
|
const recallMessageId = decision.operation === "recall" ? assertRecallSyntax(effectiveArgv) : null;
|
|
1180
1222
|
const priorRecall = recallMessageId ? recallLedgerEntry(store, recallMessageId) : null;
|
|
@@ -1233,6 +1275,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1233
1275
|
}
|
|
1234
1276
|
const intentKey = policyFlagValue(effectiveArgv, "--idempotency-key") ?? intentId(targetKey, effectiveArgv);
|
|
1235
1277
|
const write = callNative(botArgv(effectiveArgv, intentKey, decision), privateEnv, io, nativeDependencies);
|
|
1278
|
+
if (!write.error && write.status === 0 && (decision.operation === "send" || decision.operation === "reply")) {
|
|
1279
|
+
recordOutboundAt(store, targetKey);
|
|
1280
|
+
}
|
|
1236
1281
|
if (decision.operation === "urgent-app") {
|
|
1237
1282
|
try {
|
|
1238
1283
|
assertUrgentAppNativeAccepted(write);
|
|
@@ -15,6 +15,7 @@ class ProcessingEyeOrchestrator {
|
|
|
15
15
|
recordError;
|
|
16
16
|
setTimer;
|
|
17
17
|
clearTimer;
|
|
18
|
+
now;
|
|
18
19
|
constructor(options = {}) {
|
|
19
20
|
this.options = options;
|
|
20
21
|
this.execFile = options.execFile ?? node_child_process_1.execFile;
|
|
@@ -22,6 +23,7 @@ class ProcessingEyeOrchestrator {
|
|
|
22
23
|
this.recordError = options.recordStatusError ?? (() => { });
|
|
23
24
|
this.setTimer = options.setTimer ?? setTimeout;
|
|
24
25
|
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
26
|
+
this.now = options.now ?? Date.now;
|
|
25
27
|
}
|
|
26
28
|
larkApi(agent, method, apiPath, data, callback) {
|
|
27
29
|
const args = ["api", method, apiPath];
|
|
@@ -64,7 +66,7 @@ class ProcessingEyeOrchestrator {
|
|
|
64
66
|
const existing = this.state.get(agent.agentId);
|
|
65
67
|
if (existing)
|
|
66
68
|
return existing;
|
|
67
|
-
const created = { sawActive: false, fallbackTimer: null, completionTimer: null, gen: 0 };
|
|
69
|
+
const created = { sawActive: false, fallbackTimer: null, completionTimer: null, progressTimer: null, turnStartedAt: 0, gen: 0 };
|
|
68
70
|
this.state.set(agent.agentId, created);
|
|
69
71
|
return created;
|
|
70
72
|
}
|
|
@@ -74,6 +76,12 @@ class ProcessingEyeOrchestrator {
|
|
|
74
76
|
this.clearTimer(state.completionTimer);
|
|
75
77
|
state.completionTimer = null;
|
|
76
78
|
}
|
|
79
|
+
cancelProgress(state) {
|
|
80
|
+
if (!state.progressTimer)
|
|
81
|
+
return;
|
|
82
|
+
this.clearTimer(state.progressTimer);
|
|
83
|
+
state.progressTimer = null;
|
|
84
|
+
}
|
|
77
85
|
restoreAndClear(agent) {
|
|
78
86
|
let leftovers = [];
|
|
79
87
|
try {
|
|
@@ -87,7 +95,7 @@ class ProcessingEyeOrchestrator {
|
|
|
87
95
|
this.pending.set(agent.agentId, leftovers);
|
|
88
96
|
this.clear(agent, "启动清扫遗留");
|
|
89
97
|
}
|
|
90
|
-
add(agent, msgId) {
|
|
98
|
+
add(agent, msgId, options = {}) {
|
|
91
99
|
if (!agent.feishuProfile || !msgId || !msgId.startsWith("om_")) {
|
|
92
100
|
this.log(`👀 跳过 agent=${agent.name} msg=${msgId || "?"} profile=${agent.feishuProfile || "缺"}(不满足点表情条件)`);
|
|
93
101
|
return;
|
|
@@ -100,7 +108,9 @@ class ProcessingEyeOrchestrator {
|
|
|
100
108
|
// add itself therefore establishes an active generation instead of
|
|
101
109
|
// erasing activity already observed for that turn.
|
|
102
110
|
state.sawActive = true;
|
|
111
|
+
state.turnStartedAt = this.now();
|
|
103
112
|
this.cancelCompletion(state);
|
|
113
|
+
this.cancelProgress(state);
|
|
104
114
|
if (state.fallbackTimer)
|
|
105
115
|
this.clearTimer(state.fallbackTimer);
|
|
106
116
|
const generation = state.gen;
|
|
@@ -112,6 +122,22 @@ class ProcessingEyeOrchestrator {
|
|
|
112
122
|
this.clear(agent, "15分钟兜底");
|
|
113
123
|
}, 15 * 60 * 1000);
|
|
114
124
|
state.fallbackTimer = fallbackTimer;
|
|
125
|
+
let progressTimer;
|
|
126
|
+
progressTimer = this.setTimer(() => {
|
|
127
|
+
const current = this.state.get(agent.agentId);
|
|
128
|
+
if (current?.gen !== generation || current.progressTimer !== progressTimer)
|
|
129
|
+
return;
|
|
130
|
+
current.progressTimer = null;
|
|
131
|
+
const pending = this.pending.get(agent.agentId) || [];
|
|
132
|
+
const anchor = pending.at(-1);
|
|
133
|
+
if (!anchor)
|
|
134
|
+
return;
|
|
135
|
+
const lastOutboundAt = this.options.lastOutboundAt?.(agent, anchor.target) ?? null;
|
|
136
|
+
if (lastOutboundAt !== null && lastOutboundAt >= current.turnStartedAt)
|
|
137
|
+
return;
|
|
138
|
+
this.sendProgress(agent, anchor);
|
|
139
|
+
}, 2 * 60 * 1000);
|
|
140
|
+
state.progressTimer = progressTimer;
|
|
115
141
|
this.larkApi(agent, "POST", `/open-apis/im/v1/messages/${msgId}/reactions`, { reaction_type: { emoji_type: "OnIt" } }, (error, result) => {
|
|
116
142
|
const reactionId = result?.data?.reaction_id;
|
|
117
143
|
if (!reactionId) {
|
|
@@ -124,7 +150,8 @@ class ProcessingEyeOrchestrator {
|
|
|
124
150
|
return;
|
|
125
151
|
}
|
|
126
152
|
const list = this.pending.get(agent.agentId) || [];
|
|
127
|
-
list.push({ msgId, reactionId })
|
|
153
|
+
list.push({ msgId, reactionId, ...(options.replyInThread ? { replyInThread: true } : {}),
|
|
154
|
+
...(options.target ? { target: options.target } : {}) });
|
|
128
155
|
this.pending.set(agent.agentId, list);
|
|
129
156
|
this.save(agent);
|
|
130
157
|
});
|
|
@@ -139,6 +166,7 @@ class ProcessingEyeOrchestrator {
|
|
|
139
166
|
state.fallbackTimer = null;
|
|
140
167
|
}
|
|
141
168
|
this.cancelCompletion(state);
|
|
169
|
+
this.cancelProgress(state);
|
|
142
170
|
}
|
|
143
171
|
const list = this.pending.get(agent.agentId) || [];
|
|
144
172
|
if (!list.length) {
|
|
@@ -157,6 +185,25 @@ class ProcessingEyeOrchestrator {
|
|
|
157
185
|
// 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
|
|
158
186
|
this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
|
|
159
187
|
}
|
|
188
|
+
sendProgress(agent, anchor) {
|
|
189
|
+
const selected = this.options.cliForAgent?.(agent) ?? (() => {
|
|
190
|
+
const managed = (0, agent_lark_cli_workspace_js_1.managedOfficialLarkCli)(agent, this.options.envForAgent?.(agent) ?? process.env);
|
|
191
|
+
return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
|
|
192
|
+
})();
|
|
193
|
+
const args = ["im", "+messages-reply", "--message-id", anchor.msgId,
|
|
194
|
+
...(anchor.replyInThread ? ["--reply-in-thread"] : []),
|
|
195
|
+
"--text", "我还在处理中,完成后会汇总更新。", "--json", "--as", "bot"];
|
|
196
|
+
this.execFile(selected.command, [...selected.argsPrefix, ...args], { encoding: "utf8", timeout: 10_000, env: selected.env }, (error, stdout, stderr) => {
|
|
197
|
+
if (error) {
|
|
198
|
+
const code = error.code;
|
|
199
|
+
const detail = String(stderr || "").trim().slice(0, 160) || String(stdout || "").trim().slice(0, 120);
|
|
200
|
+
this.log(`长任务进度发送失败 agent=${agent.name}: exit=${code ?? "?"}${detail ? ` | ${detail}` : ""}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.options.recordOutboundAt?.(agent, anchor.target);
|
|
204
|
+
this.log(`长任务进度已发送 agent=${agent.name} msg=${anchor.msgId}`);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
160
207
|
observeActivity(agent, activity) {
|
|
161
208
|
const state = this.getState(agent);
|
|
162
209
|
if (activity === "error" || activity === "offline") {
|
|
@@ -12,6 +12,7 @@ export class ProcessingEyeOrchestrator {
|
|
|
12
12
|
recordError;
|
|
13
13
|
setTimer;
|
|
14
14
|
clearTimer;
|
|
15
|
+
now;
|
|
15
16
|
constructor(options = {}) {
|
|
16
17
|
this.options = options;
|
|
17
18
|
this.execFile = options.execFile ?? nodeExecFile;
|
|
@@ -19,6 +20,7 @@ export class ProcessingEyeOrchestrator {
|
|
|
19
20
|
this.recordError = options.recordStatusError ?? (() => { });
|
|
20
21
|
this.setTimer = options.setTimer ?? setTimeout;
|
|
21
22
|
this.clearTimer = options.clearTimer ?? clearTimeout;
|
|
23
|
+
this.now = options.now ?? Date.now;
|
|
22
24
|
}
|
|
23
25
|
larkApi(agent, method, apiPath, data, callback) {
|
|
24
26
|
const args = ["api", method, apiPath];
|
|
@@ -61,7 +63,7 @@ export class ProcessingEyeOrchestrator {
|
|
|
61
63
|
const existing = this.state.get(agent.agentId);
|
|
62
64
|
if (existing)
|
|
63
65
|
return existing;
|
|
64
|
-
const created = { sawActive: false, fallbackTimer: null, completionTimer: null, gen: 0 };
|
|
66
|
+
const created = { sawActive: false, fallbackTimer: null, completionTimer: null, progressTimer: null, turnStartedAt: 0, gen: 0 };
|
|
65
67
|
this.state.set(agent.agentId, created);
|
|
66
68
|
return created;
|
|
67
69
|
}
|
|
@@ -71,6 +73,12 @@ export class ProcessingEyeOrchestrator {
|
|
|
71
73
|
this.clearTimer(state.completionTimer);
|
|
72
74
|
state.completionTimer = null;
|
|
73
75
|
}
|
|
76
|
+
cancelProgress(state) {
|
|
77
|
+
if (!state.progressTimer)
|
|
78
|
+
return;
|
|
79
|
+
this.clearTimer(state.progressTimer);
|
|
80
|
+
state.progressTimer = null;
|
|
81
|
+
}
|
|
74
82
|
restoreAndClear(agent) {
|
|
75
83
|
let leftovers = [];
|
|
76
84
|
try {
|
|
@@ -84,7 +92,7 @@ export class ProcessingEyeOrchestrator {
|
|
|
84
92
|
this.pending.set(agent.agentId, leftovers);
|
|
85
93
|
this.clear(agent, "启动清扫遗留");
|
|
86
94
|
}
|
|
87
|
-
add(agent, msgId) {
|
|
95
|
+
add(agent, msgId, options = {}) {
|
|
88
96
|
if (!agent.feishuProfile || !msgId || !msgId.startsWith("om_")) {
|
|
89
97
|
this.log(`👀 跳过 agent=${agent.name} msg=${msgId || "?"} profile=${agent.feishuProfile || "缺"}(不满足点表情条件)`);
|
|
90
98
|
return;
|
|
@@ -97,7 +105,9 @@ export class ProcessingEyeOrchestrator {
|
|
|
97
105
|
// add itself therefore establishes an active generation instead of
|
|
98
106
|
// erasing activity already observed for that turn.
|
|
99
107
|
state.sawActive = true;
|
|
108
|
+
state.turnStartedAt = this.now();
|
|
100
109
|
this.cancelCompletion(state);
|
|
110
|
+
this.cancelProgress(state);
|
|
101
111
|
if (state.fallbackTimer)
|
|
102
112
|
this.clearTimer(state.fallbackTimer);
|
|
103
113
|
const generation = state.gen;
|
|
@@ -109,6 +119,22 @@ export class ProcessingEyeOrchestrator {
|
|
|
109
119
|
this.clear(agent, "15分钟兜底");
|
|
110
120
|
}, 15 * 60 * 1000);
|
|
111
121
|
state.fallbackTimer = fallbackTimer;
|
|
122
|
+
let progressTimer;
|
|
123
|
+
progressTimer = this.setTimer(() => {
|
|
124
|
+
const current = this.state.get(agent.agentId);
|
|
125
|
+
if (current?.gen !== generation || current.progressTimer !== progressTimer)
|
|
126
|
+
return;
|
|
127
|
+
current.progressTimer = null;
|
|
128
|
+
const pending = this.pending.get(agent.agentId) || [];
|
|
129
|
+
const anchor = pending.at(-1);
|
|
130
|
+
if (!anchor)
|
|
131
|
+
return;
|
|
132
|
+
const lastOutboundAt = this.options.lastOutboundAt?.(agent, anchor.target) ?? null;
|
|
133
|
+
if (lastOutboundAt !== null && lastOutboundAt >= current.turnStartedAt)
|
|
134
|
+
return;
|
|
135
|
+
this.sendProgress(agent, anchor);
|
|
136
|
+
}, 2 * 60 * 1000);
|
|
137
|
+
state.progressTimer = progressTimer;
|
|
112
138
|
this.larkApi(agent, "POST", `/open-apis/im/v1/messages/${msgId}/reactions`, { reaction_type: { emoji_type: "OnIt" } }, (error, result) => {
|
|
113
139
|
const reactionId = result?.data?.reaction_id;
|
|
114
140
|
if (!reactionId) {
|
|
@@ -121,7 +147,8 @@ export class ProcessingEyeOrchestrator {
|
|
|
121
147
|
return;
|
|
122
148
|
}
|
|
123
149
|
const list = this.pending.get(agent.agentId) || [];
|
|
124
|
-
list.push({ msgId, reactionId })
|
|
150
|
+
list.push({ msgId, reactionId, ...(options.replyInThread ? { replyInThread: true } : {}),
|
|
151
|
+
...(options.target ? { target: options.target } : {}) });
|
|
125
152
|
this.pending.set(agent.agentId, list);
|
|
126
153
|
this.save(agent);
|
|
127
154
|
});
|
|
@@ -136,6 +163,7 @@ export class ProcessingEyeOrchestrator {
|
|
|
136
163
|
state.fallbackTimer = null;
|
|
137
164
|
}
|
|
138
165
|
this.cancelCompletion(state);
|
|
166
|
+
this.cancelProgress(state);
|
|
139
167
|
}
|
|
140
168
|
const list = this.pending.get(agent.agentId) || [];
|
|
141
169
|
if (!list.length) {
|
|
@@ -154,6 +182,25 @@ export class ProcessingEyeOrchestrator {
|
|
|
154
182
|
// 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
|
|
155
183
|
this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
|
|
156
184
|
}
|
|
185
|
+
sendProgress(agent, anchor) {
|
|
186
|
+
const selected = this.options.cliForAgent?.(agent) ?? (() => {
|
|
187
|
+
const managed = managedOfficialLarkCli(agent, this.options.envForAgent?.(agent) ?? process.env);
|
|
188
|
+
return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
|
|
189
|
+
})();
|
|
190
|
+
const args = ["im", "+messages-reply", "--message-id", anchor.msgId,
|
|
191
|
+
...(anchor.replyInThread ? ["--reply-in-thread"] : []),
|
|
192
|
+
"--text", "我还在处理中,完成后会汇总更新。", "--json", "--as", "bot"];
|
|
193
|
+
this.execFile(selected.command, [...selected.argsPrefix, ...args], { encoding: "utf8", timeout: 10_000, env: selected.env }, (error, stdout, stderr) => {
|
|
194
|
+
if (error) {
|
|
195
|
+
const code = error.code;
|
|
196
|
+
const detail = String(stderr || "").trim().slice(0, 160) || String(stdout || "").trim().slice(0, 120);
|
|
197
|
+
this.log(`长任务进度发送失败 agent=${agent.name}: exit=${code ?? "?"}${detail ? ` | ${detail}` : ""}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.options.recordOutboundAt?.(agent, anchor.target);
|
|
201
|
+
this.log(`长任务进度已发送 agent=${agent.name} msg=${anchor.msgId}`);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
157
204
|
observeActivity(agent, activity) {
|
|
158
205
|
const state = this.getState(agent);
|
|
159
206
|
if (activity === "error" || activity === "offline") {
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.memberNamesFromPayloads = memberNamesFromPayloads;
|
|
40
|
+
exports.lastOutboundAtForTarget = lastOutboundAtForTarget;
|
|
40
41
|
exports.createHostShell = createHostShell;
|
|
41
42
|
const node_child_process_1 = require("node:child_process");
|
|
42
43
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -49,6 +50,7 @@ const workspace_service_js_1 = require("../platform/workspace-service.cjs");
|
|
|
49
50
|
const agent_state_store_js_1 = require("../agent/agent-state-store.cjs");
|
|
50
51
|
const host_business_state_js_1 = require("./host-business-state.cjs");
|
|
51
52
|
const host_processing_eye_js_1 = require("./host-processing-eye.cjs");
|
|
53
|
+
const im_freshness_adapter_js_1 = require("./im-freshness-adapter.cjs");
|
|
52
54
|
const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
|
|
53
55
|
const host_reminder_orchestrator_js_1 = require("../agent/host-reminder-orchestrator.cjs");
|
|
54
56
|
const inbox_audit_heartbeat_js_1 = require("../agent/inbox-audit-heartbeat.cjs");
|
|
@@ -120,6 +122,34 @@ function memberNamesFromPayloads(payloads) {
|
|
|
120
122
|
return names;
|
|
121
123
|
}
|
|
122
124
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
125
|
+
function canonicalImTarget(target) {
|
|
126
|
+
if (!target)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
return (0, im_freshness_adapter_js_1.serializeFeishuImTarget)((0, im_freshness_adapter_js_1.feishuImTarget)(target));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function lastOutboundAtForTarget(store, target) {
|
|
136
|
+
const state = store.readJson("freshnessState", {});
|
|
137
|
+
const canonicalTarget = canonicalImTarget(target);
|
|
138
|
+
const value = canonicalTarget ? state.last_outbound_by_target?.[canonicalTarget] : state.last_outbound_at;
|
|
139
|
+
const timestamp = typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
140
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
141
|
+
}
|
|
142
|
+
function recordOutboundAtForTarget(store, target) {
|
|
143
|
+
const canonicalTarget = canonicalImTarget(target);
|
|
144
|
+
store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
|
|
145
|
+
const at = new Date().toISOString();
|
|
146
|
+
state.last_outbound_at = at;
|
|
147
|
+
if (!canonicalTarget)
|
|
148
|
+
return;
|
|
149
|
+
state.last_outbound_by_target ??= {};
|
|
150
|
+
state.last_outbound_by_target[canonicalTarget] = at;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
123
153
|
function safeRuntimeStatusDiagnostic(error) {
|
|
124
154
|
return (0, provider_error_classifier_js_1.safeProviderDiagnostic)(error, "Runtime entered an error state", 500);
|
|
125
155
|
}
|
|
@@ -338,6 +368,8 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
338
368
|
recordStatusError: (agent, text) => hostState.recordStatusError(agent, text),
|
|
339
369
|
readPending: (agent) => stateStore(agent).readJson("pendingReact", {}).items || [],
|
|
340
370
|
writePending: (agent, items) => stateStore(agent).writeJson("pendingReact", { items }),
|
|
371
|
+
lastOutboundAt: (agent, target) => lastOutboundAtForTarget(stateStore(agent), target),
|
|
372
|
+
recordOutboundAt: (agent, target) => recordOutboundAtForTarget(stateStore(agent), target),
|
|
341
373
|
cliForAgent: (agent) => {
|
|
342
374
|
const managed = managedCliForAgent(agent);
|
|
343
375
|
return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
|
|
@@ -567,8 +599,12 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
567
599
|
excerpt: (0, host_business_state_js_1.safeConversationExcerpt)(event.content, 180),
|
|
568
600
|
at: new Date().toISOString(),
|
|
569
601
|
}, 30);
|
|
570
|
-
if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
|
|
571
|
-
processingEyes.add(agent, String(inboxEnvelope.message_id || "")
|
|
602
|
+
if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent") {
|
|
603
|
+
processingEyes.add(agent, String(inboxEnvelope.message_id || ""), {
|
|
604
|
+
replyInThread: String(inboxEnvelope.target || "").startsWith("thread:"),
|
|
605
|
+
target: String(inboxEnvelope.target || ""),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
572
608
|
if (receipt.status === "deferred")
|
|
573
609
|
log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
|
|
574
610
|
}
|
|
@@ -9,6 +9,7 @@ import { reconcileAgentWorkspace } from "../platform/workspace-service.mjs";
|
|
|
9
9
|
import { createAgentStateStore } from "../agent/agent-state-store.mjs";
|
|
10
10
|
import { HostEnvelopeProjector, HostStateProjection, SenderIdentityCache, projectActivityStatus, projectSessionStatus, safeConversationExcerpt, shouldPreventiveReconnect, } from "./host-business-state.mjs";
|
|
11
11
|
import { ProcessingEyeOrchestrator } from "./host-processing-eye.mjs";
|
|
12
|
+
import { feishuImTarget, serializeFeishuImTarget } from "./im-freshness-adapter.mjs";
|
|
12
13
|
import { projectInboxEnvelope, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
|
|
13
14
|
import { HostReminderOrchestrator } from "../agent/host-reminder-orchestrator.mjs";
|
|
14
15
|
import { boundedInboxAuditDiagnostic, InboxAuditHeartbeat, INBOX_AUDIT_CADENCE_MS } from "../agent/inbox-audit-heartbeat.mjs";
|
|
@@ -80,6 +81,34 @@ export function memberNamesFromPayloads(payloads) {
|
|
|
80
81
|
return names;
|
|
81
82
|
}
|
|
82
83
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
84
|
+
function canonicalImTarget(target) {
|
|
85
|
+
if (!target)
|
|
86
|
+
return null;
|
|
87
|
+
try {
|
|
88
|
+
return serializeFeishuImTarget(feishuImTarget(target));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function lastOutboundAtForTarget(store, target) {
|
|
95
|
+
const state = store.readJson("freshnessState", {});
|
|
96
|
+
const canonicalTarget = canonicalImTarget(target);
|
|
97
|
+
const value = canonicalTarget ? state.last_outbound_by_target?.[canonicalTarget] : state.last_outbound_at;
|
|
98
|
+
const timestamp = typeof value === "string" ? Date.parse(value) : Number.NaN;
|
|
99
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
100
|
+
}
|
|
101
|
+
function recordOutboundAtForTarget(store, target) {
|
|
102
|
+
const canonicalTarget = canonicalImTarget(target);
|
|
103
|
+
store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
|
|
104
|
+
const at = new Date().toISOString();
|
|
105
|
+
state.last_outbound_at = at;
|
|
106
|
+
if (!canonicalTarget)
|
|
107
|
+
return;
|
|
108
|
+
state.last_outbound_by_target ??= {};
|
|
109
|
+
state.last_outbound_by_target[canonicalTarget] = at;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
83
112
|
function safeRuntimeStatusDiagnostic(error) {
|
|
84
113
|
return safeProviderDiagnostic(error, "Runtime entered an error state", 500);
|
|
85
114
|
}
|
|
@@ -298,6 +327,8 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
298
327
|
recordStatusError: (agent, text) => hostState.recordStatusError(agent, text),
|
|
299
328
|
readPending: (agent) => stateStore(agent).readJson("pendingReact", {}).items || [],
|
|
300
329
|
writePending: (agent, items) => stateStore(agent).writeJson("pendingReact", { items }),
|
|
330
|
+
lastOutboundAt: (agent, target) => lastOutboundAtForTarget(stateStore(agent), target),
|
|
331
|
+
recordOutboundAt: (agent, target) => recordOutboundAtForTarget(stateStore(agent), target),
|
|
301
332
|
cliForAgent: (agent) => {
|
|
302
333
|
const managed = managedCliForAgent(agent);
|
|
303
334
|
return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
|
|
@@ -527,8 +558,12 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
527
558
|
excerpt: safeConversationExcerpt(event.content, 180),
|
|
528
559
|
at: new Date().toISOString(),
|
|
529
560
|
}, 30);
|
|
530
|
-
if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
|
|
531
|
-
processingEyes.add(agent, String(inboxEnvelope.message_id || "")
|
|
561
|
+
if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent") {
|
|
562
|
+
processingEyes.add(agent, String(inboxEnvelope.message_id || ""), {
|
|
563
|
+
replyInThread: String(inboxEnvelope.target || "").startsWith("thread:"),
|
|
564
|
+
target: String(inboxEnvelope.target || ""),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
532
567
|
if (receipt.status === "deferred")
|
|
533
568
|
log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
|
|
534
569
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.feishuImFreshnessAdapter = void 0;
|
|
4
|
+
exports.messageRevisionTime = messageRevisionTime;
|
|
5
|
+
exports.feishuImTarget = feishuImTarget;
|
|
6
|
+
exports.serializeFeishuImTarget = serializeFeishuImTarget;
|
|
7
|
+
exports.mergeFeishuImCursor = mergeFeishuImCursor;
|
|
8
|
+
function integerMillis(value) {
|
|
9
|
+
if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
return BigInt(value);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function messageRevisionTime(message) {
|
|
19
|
+
const created = integerMillis(message.create_time);
|
|
20
|
+
const updated = message.update_time === undefined || message.update_time === "" ? created : integerMillis(message.update_time);
|
|
21
|
+
if (created === null || updated === null)
|
|
22
|
+
throw new Error(`malformed Feishu IM revision for ${message.message_id || "unknown message"}`);
|
|
23
|
+
return created > updated ? created : updated;
|
|
24
|
+
}
|
|
25
|
+
function feishuImTarget(value) {
|
|
26
|
+
const thread = /^thread:([^:]+):(.+)$/.exec(value);
|
|
27
|
+
if (thread)
|
|
28
|
+
return { provider: "feishu.im", resourceKind: "thread", resourceId: `${thread[1]}/${thread[2]}` };
|
|
29
|
+
const chat = /^chat:(.+)$/.exec(value);
|
|
30
|
+
if (chat)
|
|
31
|
+
return { provider: "feishu.im", resourceKind: "chat", resourceId: chat[1] };
|
|
32
|
+
throw new Error(`unsupported Feishu IM freshness target: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
function serializeFeishuImTarget(target) {
|
|
35
|
+
return `${target.provider}/${target.resourceKind}/${target.resourceId}`;
|
|
36
|
+
}
|
|
37
|
+
function normalizedMessages(snapshot) {
|
|
38
|
+
if (!snapshot || !Array.isArray(snapshot.messages))
|
|
39
|
+
throw new Error("malformed Feishu IM history payload: messages must be an array");
|
|
40
|
+
return snapshot.messages.map((message) => {
|
|
41
|
+
if (!message || typeof message !== "object" || typeof message.message_id !== "string" || !message.message_id) {
|
|
42
|
+
throw new Error("malformed Feishu IM history payload: message_id is required");
|
|
43
|
+
}
|
|
44
|
+
return { ...message, revision: messageRevisionTime(message) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
exports.feishuImFreshnessAdapter = {
|
|
48
|
+
cursor(snapshot) {
|
|
49
|
+
const messages = normalizedMessages(snapshot);
|
|
50
|
+
if (!messages.length)
|
|
51
|
+
return null;
|
|
52
|
+
const revision = messages.reduce((maximum, message) => message.revision > maximum ? message.revision : maximum, 0n);
|
|
53
|
+
return {
|
|
54
|
+
schema: 1,
|
|
55
|
+
revisionTime: revision.toString(),
|
|
56
|
+
messageIds: [...new Set(messages.filter((message) => message.revision === revision).map((message) => message.message_id))].sort(),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
compare(seen, current) {
|
|
60
|
+
if (!current)
|
|
61
|
+
return seen ? "gap" : "fresh";
|
|
62
|
+
if (!seen)
|
|
63
|
+
return "conflict";
|
|
64
|
+
const seenTime = integerMillis(seen.revisionTime);
|
|
65
|
+
const currentTime = integerMillis(current.revisionTime);
|
|
66
|
+
if (seen.schema !== 1 || seenTime === null || currentTime === null)
|
|
67
|
+
return "gap";
|
|
68
|
+
if (currentTime < seenTime)
|
|
69
|
+
return "gap";
|
|
70
|
+
if (currentTime > seenTime)
|
|
71
|
+
return "conflict";
|
|
72
|
+
return current.messageIds.every((id) => seen.messageIds.includes(id)) ? "fresh" : "conflict";
|
|
73
|
+
},
|
|
74
|
+
unseen(seen, snapshot) {
|
|
75
|
+
const messages = normalizedMessages(snapshot);
|
|
76
|
+
if (!seen)
|
|
77
|
+
return messages.map(({ revision: _revision, ...message }) => message);
|
|
78
|
+
const seenTime = integerMillis(seen.revisionTime);
|
|
79
|
+
if (seenTime === null)
|
|
80
|
+
return messages.map(({ revision: _revision, ...message }) => message);
|
|
81
|
+
return messages.filter((message) => message.revision > seenTime
|
|
82
|
+
|| (message.revision === seenTime && !seen.messageIds.includes(message.message_id)))
|
|
83
|
+
.map(({ revision: _revision, ...message }) => message);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
function mergeFeishuImCursor(left, right) {
|
|
87
|
+
if (!left)
|
|
88
|
+
return { ...right, messageIds: [...right.messageIds] };
|
|
89
|
+
const leftTime = BigInt(left.revisionTime);
|
|
90
|
+
const rightTime = BigInt(right.revisionTime);
|
|
91
|
+
if (rightTime < leftTime)
|
|
92
|
+
return left;
|
|
93
|
+
if (rightTime > leftTime)
|
|
94
|
+
return { ...right, messageIds: [...right.messageIds] };
|
|
95
|
+
return { schema: 1, revisionTime: left.revisionTime, messageIds: [...new Set([...left.messageIds, ...right.messageIds])].sort() };
|
|
96
|
+
}
|