larkin 0.2.75 → 0.2.76

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.
@@ -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-v18";
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v19";
20
20
  /**
21
21
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
22
22
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -154,7 +154,7 @@ class ContextPromptBuilder {
154
154
  "For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
155
155
  `For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
156
156
  "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, freshness, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
157
- "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe.",
157
+ "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
158
158
  "Both `freshness_unavailable` and `freshness_conflict` are pre-commit, provider-not-reached results. If a retry is warranted for an unchanged exact operation, rerun the identical safe ordinary command: `--text` for a direct literal or the same deterministic `--content` dataflow for a tool source. The wrapper reuses its derived key. If the target or body changes after reconsideration, run the revised ordinary command and let the wrapper derive a new key. A result with `committed=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
159
159
  "For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
160
160
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
@@ -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-v18";
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v19";
17
17
  /**
18
18
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
19
19
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -151,7 +151,7 @@ export class ContextPromptBuilder {
151
151
  "For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
152
152
  `For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
153
153
  "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, freshness, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
154
- "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe.",
154
+ "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
155
155
  "Both `freshness_unavailable` and `freshness_conflict` are pre-commit, provider-not-reached results. If a retry is warranted for an unchanged exact operation, rerun the identical safe ordinary command: `--text` for a direct literal or the same deterministic `--content` dataflow for a tool source. The wrapper reuses its derived key. If the target or body changes after reconsideration, run the revised ordinary command and let the wrapper derive a new key. A result with `committed=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
156
156
  "For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
157
157
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
@@ -269,6 +269,24 @@ function parseCommentReply(argv) {
269
269
  throw new Error("comment reply 的 --text 超过 20000 字符");
270
270
  return { messageId, text };
271
271
  }
272
+ const IM_WRITE_MEMO_LIMIT = 512;
273
+ // 只标注、不拦截:每次成功写把「实际生效的幂等 key → 服务端返回的 message_id」记进备忘。
274
+ // 同 key 再次成功且服务端返回同一个 message_id,说明服务端走了幂等去重(没有产生新消息),
275
+ // 返回 true 供输出标注 duplicate。拦截权始终在服务端,备忘不会吞掉任何发送。
276
+ function recordImWriteMemo(store, key, messageId) {
277
+ let duplicate = false;
278
+ store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
279
+ state.im_write_memo ??= {};
280
+ const prior = state.im_write_memo[key];
281
+ if (prior && prior.message_id === messageId)
282
+ duplicate = true;
283
+ state.im_write_memo[key] = { message_id: messageId, updated_at: new Date().toISOString() };
284
+ const keys = Object.keys(state.im_write_memo);
285
+ for (const stale of keys.slice(0, Math.max(0, keys.length - IM_WRITE_MEMO_LIMIT)))
286
+ delete state.im_write_memo[stale];
287
+ });
288
+ return duplicate;
289
+ }
272
290
  function runCommentReply(argv, privateEnv, io, dependencies, store) {
273
291
  const input = parseCommentReply(argv);
274
292
  const targetKey = store.resolveInboxMessageTarget(input.messageId);
@@ -499,9 +517,12 @@ function parseHistory(result, target, requireBotIdentity = false) {
499
517
  }
500
518
  return { messages };
501
519
  }
502
- function intentId(target, cursor, argv) {
520
+ // 防重编号只由 target + argv 决定:同一逻辑命令的重试永远得到同一个 key,
521
+ // 服务端幂等去重才能生效。cursor(freshness 水位)会随每次观察读/冲突合并推进,
522
+ // 掺入会让「已送达但回执丢失」的重试漂移成新 key,从而发出第二条消息。
523
+ function intentId(target, argv) {
503
524
  const fingerprint = createHash("sha256")
504
- .update(JSON.stringify([target, cursor, argv])).digest("hex");
525
+ .update(JSON.stringify([target, argv])).digest("hex");
505
526
  return `larkin-${fingerprint.slice(0, 32)}`;
506
527
  }
507
528
  function emitFreshnessError(io, input) {
@@ -709,6 +730,26 @@ function emitNativeResult(result, io) {
709
730
  }
710
731
  return result.status ?? (result.signal ? portableSignalCode(result.signal) : 1);
711
732
  }
733
+ function emitDuplicatedWrite(result, io, input) {
734
+ let providerResponse;
735
+ try {
736
+ providerResponse = JSON.parse(result.stdout || "");
737
+ }
738
+ catch {
739
+ providerResponse = { raw_stdout: result.stdout || "" };
740
+ }
741
+ const providerDocument = providerResponse && typeof providerResponse === "object" && !Array.isArray(providerResponse)
742
+ ? providerResponse
743
+ : { provider_response: providerResponse };
744
+ io.stdout(`${JSON.stringify({
745
+ ...providerDocument,
746
+ ok: true,
747
+ duplicate: true,
748
+ target: input.target,
749
+ ...(result.stderr ? { provider_stderr_present: true } : {}),
750
+ })}\n`);
751
+ return 0;
752
+ }
712
753
  function emitCommittedUnverified(result, io, input) {
713
754
  let providerResponse;
714
755
  try {
@@ -817,7 +858,16 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
817
858
  store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, generation);
818
859
  return 3;
819
860
  }
820
- const write = callNative(botArgv(effectiveArgv, intentId(targetKey, gated.current, effectiveArgv)), privateEnv, io, nativeDependencies);
861
+ const intentKey = policyFlagValue(effectiveArgv, "--idempotency-key") ?? intentId(targetKey, effectiveArgv);
862
+ const write = callNative(botArgv(effectiveArgv, intentKey), privateEnv, io, nativeDependencies);
863
+ const writeMessage = writeResponseMessage(write);
864
+ const duplicate = !write.error && write.status === 0 && writeMessage
865
+ ? recordImWriteMemo(store, intentKey, writeMessage.message_id) : false;
866
+ if (duplicate) {
867
+ observeSuccessfulWrite(write, target, targetKey, store, generation);
868
+ emitDuplicatedWrite(write, io, { target: targetKey });
869
+ return 0;
870
+ }
821
871
  if (!write.error && write.status === 0 && !observeSuccessfulWrite(write, target, targetKey, store, generation)) {
822
872
  const responseMessage = writeResponseMessage(write);
823
873
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.2.75",
3
+ "version": "0.2.76",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [