larkin 0.4.21 → 0.4.22

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.
@@ -12,7 +12,7 @@ const internal_command_js_1 = require("../app/internal-command.cjs");
12
12
  exports.AGENT_CLI_CAPABILITIES = Object.freeze({
13
13
  version: 1,
14
14
  commands: Object.freeze({
15
- inbox: Object.freeze(["check", "poll"]),
15
+ inbox: Object.freeze(["check", "poll", "audit"]),
16
16
  comment: Object.freeze(["reply"]),
17
17
  reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
18
18
  interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
@@ -31,7 +31,9 @@ function resolveAgentCliExecutable(cliPath, runtimeExecutable = node_process_1.d
31
31
  function agentCliPromptCapabilities(executable = "larkin") {
32
32
  const commands = Object.entries(exports.AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
33
33
  command: `${group} ${operation}`,
34
- purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages." : "Poll full messages and direct-ack the returned batch.") :
34
+ purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
35
+ : operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
36
+ : "Poll full messages and direct-ack the returned batch.") :
35
37
  group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
36
38
  group === "reminder" ? "Manage this Agent's durable reminders." :
37
39
  group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
@@ -4,7 +4,7 @@ import { INTERNAL_AGENT_CLI, internalCommandShell } from "../app/internal-comman
4
4
  export const AGENT_CLI_CAPABILITIES = Object.freeze({
5
5
  version: 1,
6
6
  commands: Object.freeze({
7
- inbox: Object.freeze(["check", "poll"]),
7
+ inbox: Object.freeze(["check", "poll", "audit"]),
8
8
  comment: Object.freeze(["reply"]),
9
9
  reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
10
10
  interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
@@ -23,7 +23,9 @@ export function resolveAgentCliExecutable(cliPath, runtimeExecutable = process.e
23
23
  export function agentCliPromptCapabilities(executable = "larkin") {
24
24
  const commands = Object.entries(AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
25
25
  command: `${group} ${operation}`,
26
- purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages." : "Poll full messages and direct-ack the returned batch.") :
26
+ purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
27
+ : operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
28
+ : "Poll full messages and direct-ack the returned batch.") :
27
29
  group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
28
30
  group === "reminder" ? "Manage this Agent's durable reminders." :
29
31
  group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InboxAuditHeartbeat = exports.INBOX_AUDIT_CADENCE_MS = void 0;
4
+ const inbox_projection_js_1 = require("./inbox-projection.cjs");
5
+ exports.INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
6
+ /** One Host-owned timer fans a targetless internal wake to each active Agent. */
7
+ class InboxAuditHeartbeat {
8
+ options;
9
+ timer = null;
10
+ running = false;
11
+ sequence = 0;
12
+ cadenceMs;
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.cadenceMs = options.cadenceMs ?? exports.INBOX_AUDIT_CADENCE_MS;
16
+ if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
17
+ throw new Error("inbox audit cadence must be a positive integer");
18
+ }
19
+ start() {
20
+ if (this.running)
21
+ return;
22
+ this.running = true;
23
+ this.schedule();
24
+ }
25
+ stop() {
26
+ this.running = false;
27
+ if (this.timer)
28
+ (this.options.clearTimer ?? clearTimeout)(this.timer);
29
+ this.timer = null;
30
+ }
31
+ schedule() {
32
+ if (!this.running || this.timer)
33
+ return;
34
+ this.timer = (this.options.setTimer ?? setTimeout)(() => {
35
+ this.timer = null;
36
+ return this.fire().finally(() => { if (this.running)
37
+ this.schedule(); });
38
+ }, this.cadenceMs);
39
+ this.timer.unref?.();
40
+ }
41
+ async fire() {
42
+ const now = this.options.now ?? Date.now;
43
+ for (const agent of this.options.agents) {
44
+ const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
45
+ const envelope = {
46
+ kind: "reminder",
47
+ message_id,
48
+ target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET,
49
+ wake: true,
50
+ content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
51
+ };
52
+ try {
53
+ const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
54
+ if (appended.status === "appended")
55
+ await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
56
+ }
57
+ catch (error) {
58
+ this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ exports.InboxAuditHeartbeat = InboxAuditHeartbeat;
@@ -0,0 +1,59 @@
1
+ import { RUNTIME_REMINDER_TARGET } from "./inbox-projection.mjs";
2
+ export const INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
3
+ /** One Host-owned timer fans a targetless internal wake to each active Agent. */
4
+ export class InboxAuditHeartbeat {
5
+ options;
6
+ timer = null;
7
+ running = false;
8
+ sequence = 0;
9
+ cadenceMs;
10
+ constructor(options) {
11
+ this.options = options;
12
+ this.cadenceMs = options.cadenceMs ?? INBOX_AUDIT_CADENCE_MS;
13
+ if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
14
+ throw new Error("inbox audit cadence must be a positive integer");
15
+ }
16
+ start() {
17
+ if (this.running)
18
+ return;
19
+ this.running = true;
20
+ this.schedule();
21
+ }
22
+ stop() {
23
+ this.running = false;
24
+ if (this.timer)
25
+ (this.options.clearTimer ?? clearTimeout)(this.timer);
26
+ this.timer = null;
27
+ }
28
+ schedule() {
29
+ if (!this.running || this.timer)
30
+ return;
31
+ this.timer = (this.options.setTimer ?? setTimeout)(() => {
32
+ this.timer = null;
33
+ return this.fire().finally(() => { if (this.running)
34
+ this.schedule(); });
35
+ }, this.cadenceMs);
36
+ this.timer.unref?.();
37
+ }
38
+ async fire() {
39
+ const now = this.options.now ?? Date.now;
40
+ for (const agent of this.options.agents) {
41
+ const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
42
+ const envelope = {
43
+ kind: "reminder",
44
+ message_id,
45
+ target: RUNTIME_REMINDER_TARGET,
46
+ wake: true,
47
+ content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
48
+ };
49
+ try {
50
+ const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
51
+ if (appended.status === "appended")
52
+ await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
53
+ }
54
+ catch (error) {
55
+ this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ }
59
+ }
@@ -1,123 +1,133 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.DEFAULT_MISSED_OUTBOUND_REPEAT = exports.DEFAULT_MISSED_OUTBOUND_TITLE = void 0;
7
- exports.parseOmMessageId = parseOmMessageId;
8
- exports.parseScanDeliveryTarget = parseScanDeliveryTarget;
9
- exports.isDefaultMissedOutboundReminder = isDefaultMissedOutboundReminder;
10
- exports.ensureDefaultMissedOutboundScanReminder = ensureDefaultMissedOutboundScanReminder;
11
- exports.persistInboundScanTarget = persistInboundScanTarget;
12
- const node_path_1 = __importDefault(require("node:path"));
13
- const reminder_store_js_1 = require("./reminder-store.cjs");
14
- exports.DEFAULT_MISSED_OUTBOUND_TITLE = "Read scoped history from this reminder envelope's persisted deliveryTarget (chat: +chat-messages-list; thread: +threads-messages-list). Judge unanswered asks, undelivered follow-ups, and stalled work. On a hit, post a short status in the same conversation using that persisted deliveryTarget/anchor; otherwise stay silent. Never infer recipients from the title.";
15
- exports.DEFAULT_MISSED_OUTBOUND_REPEAT = "every:15m";
16
- const CHAT_TARGET = /^chat:(oc_[A-Za-z0-9]+)$/;
17
- const THREAD_TARGET = /^thread:(oc_[A-Za-z0-9]+):(omt_[A-Za-z0-9]+)$/;
18
- const OM_ID = /^om_[A-Za-z0-9]+$/;
19
- function parseOmMessageId(value, label = "message-id") {
20
- const id = String(value || "").trim();
21
- if (!OM_ID.test(id))
22
- throw new Error(`${label} 必须是严格 om_ 语法`);
23
- return id;
24
- }
25
- function parseScanDeliveryTarget(target, anchor) {
26
- const deliveryTarget = String(target || "").trim();
27
- if (!deliveryTarget) {
28
- throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
29
7
  }
30
- const chat = deliveryTarget.match(CHAT_TARGET);
31
- if (chat) {
32
- return {
33
- deliveryTarget,
34
- scope: "chat",
35
- chatId: chat[1],
36
- threadId: null,
37
- deliveryAnchor: anchor ? parseOmMessageId(anchor, "delivery anchor") : null,
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
38
24
  };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAX_INBOX_AUDIT_TARGETS = exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = void 0;
37
+ exports.inboxAuditRegistryFile = inboxAuditRegistryFile;
38
+ exports.observeInboxAuditTarget = observeInboxAuditTarget;
39
+ exports.readInboxAuditTargets = readInboxAuditTargets;
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ /**
43
+ * #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
44
+ * Pre-v0.4.21 records have no unique ownership marker and can collide exactly
45
+ * with user reminders, so existing indistinguishable loops are never migrated
46
+ * or deleted automatically.
47
+ */
48
+ exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
49
+ // Audits return every retained target. Storage remains bounded so a heartbeat
50
+ // cannot accumulate an unbounded work list.
51
+ exports.MAX_INBOX_AUDIT_TARGETS = 96;
52
+ const MAX_STORED_TARGETS = exports.MAX_INBOX_AUDIT_TARGETS;
53
+ const CHAT = /^oc_[A-Za-z0-9]+$/;
54
+ const THREAD = /^omt_[A-Za-z0-9]+$/;
55
+ const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
56
+ function inboxAuditRegistryFile(larkinHome) {
57
+ return path.join(larkinHome, "inbox-audit.json");
58
+ }
59
+ function parseTarget(event) {
60
+ const chatId = String(event.chat_id || "");
61
+ const threadId = event.thread_id ? String(event.thread_id) : "";
62
+ const anchor = String(event.message_id || "");
63
+ if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
64
+ return null;
65
+ return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
66
+ }
67
+ function load(file) {
68
+ let value;
69
+ try {
70
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
39
71
  }
40
- const thread = deliveryTarget.match(THREAD_TARGET);
41
- if (thread) {
42
- return {
43
- deliveryTarget,
44
- scope: "thread",
45
- chatId: thread[1],
46
- threadId: thread[2],
47
- deliveryAnchor: parseOmMessageId(anchor, "delivery anchor"),
48
- };
72
+ catch (error) {
73
+ if (error.code === "ENOENT")
74
+ return { version: 1, targets: [] };
75
+ throw error;
49
76
  }
50
- throw new Error("delivery target 必须是 chat:oc_… 或 thread:oc_…:omt_…,禁止推断 DM");
77
+ if (value?.version !== 1 || !Array.isArray(value.targets))
78
+ return { version: 1, targets: [] };
79
+ return { version: 1, targets: value.targets.flatMap((row) => {
80
+ if (!row || typeof row !== "object")
81
+ return [];
82
+ const candidate = row;
83
+ if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
84
+ return [];
85
+ const parsed = parseTarget({
86
+ chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
87
+ thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
88
+ message_id: candidate.anchor,
89
+ });
90
+ return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
91
+ && typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
92
+ ? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
93
+ }) };
51
94
  }
52
- function isDefaultMissedOutboundReminder(reminder) {
53
- return reminder?.title === exports.DEFAULT_MISSED_OUTBOUND_TITLE
54
- && reminder.repeat === exports.DEFAULT_MISSED_OUTBOUND_REPEAT
55
- && reminder.deliveryMode === "user"
56
- && reminder.status === "scheduled";
95
+ function save(file, registry) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
98
+ fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
99
+ fs.renameSync(temporary, file);
100
+ fs.chmodSync(file, 0o600);
57
101
  }
58
- function ensureDefaultMissedOutboundScanReminder(input) {
59
- const parsed = parseScanDeliveryTarget(input.deliveryTarget, input.deliveryAnchor);
60
- const recurrence = (0, reminder_store_js_1.parseRepeat)(exports.DEFAULT_MISSED_OUTBOUND_REPEAT);
61
- if ("error" in recurrence)
62
- throw new Error(recurrence.error);
63
- const now = input.nowMs ?? Date.now();
64
- const wantedAnchor = parsed.deliveryAnchor;
65
- return (0, reminder_store_js_1.mutate)(input.storeFile, (store) => {
66
- const sameIdentity = (reminder) => reminder.ownerAgentId === input.agentId
67
- && reminder.title === exports.DEFAULT_MISSED_OUTBOUND_TITLE
68
- && reminder.repeat === exports.DEFAULT_MISSED_OUTBOUND_REPEAT
69
- && reminder.deliveryMode === "user"
70
- && reminder.deliveryTarget === parsed.deliveryTarget;
71
- const scheduled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "scheduled");
72
- if (scheduled) {
73
- if ((scheduled.deliveryAnchor || null) !== wantedAnchor)
74
- scheduled.deliveryAnchor = wantedAnchor;
75
- return { created: false, reminderId: scheduled.reminderId, rebuilt: false };
76
- }
77
- const canceled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "canceled");
78
- if (canceled)
79
- return { created: false, reminderId: canceled.reminderId, rebuilt: false };
80
- const hadFired = store.reminders.some((reminder) => sameIdentity(reminder) && reminder.status === "fired");
81
- store.reminders = store.reminders.filter((reminder) => !(sameIdentity(reminder) && reminder.status === "fired"));
82
- const reminderId = (0, reminder_store_js_1.newId)();
83
- store.reminders.push({
84
- reminderId,
85
- ownerAgentId: input.agentId,
86
- title: exports.DEFAULT_MISSED_OUTBOUND_TITLE,
87
- fireAt: (0, reminder_store_js_1.nowIso)(now + 15 * 60_000),
88
- firedAt: null,
89
- createdAt: (0, reminder_store_js_1.nowIso)(now),
90
- status: "scheduled",
91
- version: 1,
92
- events: [],
93
- deliveryTarget: parsed.deliveryTarget,
94
- deliveryAnchor: wantedAnchor,
95
- deliveryMode: "user",
96
- repeat: exports.DEFAULT_MISSED_OUTBOUND_REPEAT,
97
- });
98
- return { created: true, reminderId, rebuilt: hadFired };
99
- });
102
+ /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
103
+ function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
104
+ if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
105
+ return false;
106
+ const parsed = parseTarget(event);
107
+ if (!parsed)
108
+ return false;
109
+ const registry = load(file);
110
+ const observed_at = now.toISOString();
111
+ registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
112
+ registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
113
+ registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
114
+ save(file, registry);
115
+ return true;
100
116
  }
101
- function persistInboundScanTarget(stateDir, event, agentId, storeFile = node_path_1.default.join(stateDir, "reminders.json")) {
102
- if (event._sender_is_bot)
103
- throw new Error("scan reminder 只接受 human inbound");
104
- if (event._scan_authority !== true)
105
- throw new Error("scan reminder 需要权威 chatType/senderIsBot");
106
- if (event.chat_type !== "group")
107
- throw new Error("scan reminder 只接受 group/thread,禁止 DM");
108
- const chatId = String(event.chat_id || "");
109
- if (!/^oc_[A-Za-z0-9]+$/.test(chatId)) {
110
- throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
111
- }
112
- const threadId = event.thread_id ? String(event.thread_id) : "";
113
- const parsed = threadId
114
- ? parseScanDeliveryTarget(`thread:${chatId}:${threadId}`, event.message_id)
115
- : parseScanDeliveryTarget(`chat:${chatId}`, event.message_id || null);
116
- ensureDefaultMissedOutboundScanReminder({
117
- storeFile,
118
- agentId,
119
- deliveryTarget: parsed.deliveryTarget,
120
- deliveryAnchor: parsed.deliveryAnchor,
121
- });
122
- return parsed;
117
+ function instruction(target) {
118
+ const history = target.target.startsWith("thread:")
119
+ ? "Use larkin im +threads-messages-list for this exact thread."
120
+ : "Use larkin im +chat-messages-list for this exact chat.";
121
+ return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
122
+ }
123
+ /** Content-free, bounded audit work list consumed by the Agent CLI. */
124
+ function readInboxAuditTargets(file, agentId) {
125
+ const rows = load(file).targets.filter((row) => row.agent_id === agentId)
126
+ .sort((left, right) => right.observed_at.localeCompare(left.observed_at));
127
+ return {
128
+ version: 1,
129
+ targets: rows.slice(0, exports.MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
130
+ has_more: rows.length > exports.MAX_INBOX_AUDIT_TARGETS,
131
+ no_finding: "stay_silent",
132
+ };
123
133
  }
@@ -1,112 +1,94 @@
1
- import path from "node:path";
2
- import { mutate, newId, nowIso, parseRepeat } from "./reminder-store.mjs";
3
- export const DEFAULT_MISSED_OUTBOUND_TITLE = "Read scoped history from this reminder envelope's persisted deliveryTarget (chat: +chat-messages-list; thread: +threads-messages-list). Judge unanswered asks, undelivered follow-ups, and stalled work. On a hit, post a short status in the same conversation using that persisted deliveryTarget/anchor; otherwise stay silent. Never infer recipients from the title.";
4
- export const DEFAULT_MISSED_OUTBOUND_REPEAT = "every:15m";
5
- const CHAT_TARGET = /^chat:(oc_[A-Za-z0-9]+)$/;
6
- const THREAD_TARGET = /^thread:(oc_[A-Za-z0-9]+):(omt_[A-Za-z0-9]+)$/;
7
- const OM_ID = /^om_[A-Za-z0-9]+$/;
8
- export function parseOmMessageId(value, label = "message-id") {
9
- const id = String(value || "").trim();
10
- if (!OM_ID.test(id))
11
- throw new Error(`${label} 必须是严格 om_ 语法`);
12
- return id;
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
5
+ * Pre-v0.4.21 records have no unique ownership marker and can collide exactly
6
+ * with user reminders, so existing indistinguishable loops are never migrated
7
+ * or deleted automatically.
8
+ */
9
+ export const INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
10
+ // Audits return every retained target. Storage remains bounded so a heartbeat
11
+ // cannot accumulate an unbounded work list.
12
+ export const MAX_INBOX_AUDIT_TARGETS = 96;
13
+ const MAX_STORED_TARGETS = MAX_INBOX_AUDIT_TARGETS;
14
+ const CHAT = /^oc_[A-Za-z0-9]+$/;
15
+ const THREAD = /^omt_[A-Za-z0-9]+$/;
16
+ const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
17
+ export function inboxAuditRegistryFile(larkinHome) {
18
+ return path.join(larkinHome, "inbox-audit.json");
13
19
  }
14
- export function parseScanDeliveryTarget(target, anchor) {
15
- const deliveryTarget = String(target || "").trim();
16
- if (!deliveryTarget) {
17
- throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
18
- }
19
- const chat = deliveryTarget.match(CHAT_TARGET);
20
- if (chat) {
21
- return {
22
- deliveryTarget,
23
- scope: "chat",
24
- chatId: chat[1],
25
- threadId: null,
26
- deliveryAnchor: anchor ? parseOmMessageId(anchor, "delivery anchor") : null,
27
- };
20
+ function parseTarget(event) {
21
+ const chatId = String(event.chat_id || "");
22
+ const threadId = event.thread_id ? String(event.thread_id) : "";
23
+ const anchor = String(event.message_id || "");
24
+ if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
25
+ return null;
26
+ return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
27
+ }
28
+ function load(file) {
29
+ let value;
30
+ try {
31
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
28
32
  }
29
- const thread = deliveryTarget.match(THREAD_TARGET);
30
- if (thread) {
31
- return {
32
- deliveryTarget,
33
- scope: "thread",
34
- chatId: thread[1],
35
- threadId: thread[2],
36
- deliveryAnchor: parseOmMessageId(anchor, "delivery anchor"),
37
- };
33
+ catch (error) {
34
+ if (error.code === "ENOENT")
35
+ return { version: 1, targets: [] };
36
+ throw error;
38
37
  }
39
- throw new Error("delivery target 必须是 chat:oc_… 或 thread:oc_…:omt_…,禁止推断 DM");
38
+ if (value?.version !== 1 || !Array.isArray(value.targets))
39
+ return { version: 1, targets: [] };
40
+ return { version: 1, targets: value.targets.flatMap((row) => {
41
+ if (!row || typeof row !== "object")
42
+ return [];
43
+ const candidate = row;
44
+ if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
45
+ return [];
46
+ const parsed = parseTarget({
47
+ chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
48
+ thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
49
+ message_id: candidate.anchor,
50
+ });
51
+ return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
52
+ && typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
53
+ ? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
54
+ }) };
40
55
  }
41
- export function isDefaultMissedOutboundReminder(reminder) {
42
- return reminder?.title === DEFAULT_MISSED_OUTBOUND_TITLE
43
- && reminder.repeat === DEFAULT_MISSED_OUTBOUND_REPEAT
44
- && reminder.deliveryMode === "user"
45
- && reminder.status === "scheduled";
56
+ function save(file, registry) {
57
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
58
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
59
+ fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
60
+ fs.renameSync(temporary, file);
61
+ fs.chmodSync(file, 0o600);
46
62
  }
47
- export function ensureDefaultMissedOutboundScanReminder(input) {
48
- const parsed = parseScanDeliveryTarget(input.deliveryTarget, input.deliveryAnchor);
49
- const recurrence = parseRepeat(DEFAULT_MISSED_OUTBOUND_REPEAT);
50
- if ("error" in recurrence)
51
- throw new Error(recurrence.error);
52
- const now = input.nowMs ?? Date.now();
53
- const wantedAnchor = parsed.deliveryAnchor;
54
- return mutate(input.storeFile, (store) => {
55
- const sameIdentity = (reminder) => reminder.ownerAgentId === input.agentId
56
- && reminder.title === DEFAULT_MISSED_OUTBOUND_TITLE
57
- && reminder.repeat === DEFAULT_MISSED_OUTBOUND_REPEAT
58
- && reminder.deliveryMode === "user"
59
- && reminder.deliveryTarget === parsed.deliveryTarget;
60
- const scheduled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "scheduled");
61
- if (scheduled) {
62
- if ((scheduled.deliveryAnchor || null) !== wantedAnchor)
63
- scheduled.deliveryAnchor = wantedAnchor;
64
- return { created: false, reminderId: scheduled.reminderId, rebuilt: false };
65
- }
66
- const canceled = store.reminders.find((reminder) => sameIdentity(reminder) && reminder.status === "canceled");
67
- if (canceled)
68
- return { created: false, reminderId: canceled.reminderId, rebuilt: false };
69
- const hadFired = store.reminders.some((reminder) => sameIdentity(reminder) && reminder.status === "fired");
70
- store.reminders = store.reminders.filter((reminder) => !(sameIdentity(reminder) && reminder.status === "fired"));
71
- const reminderId = newId();
72
- store.reminders.push({
73
- reminderId,
74
- ownerAgentId: input.agentId,
75
- title: DEFAULT_MISSED_OUTBOUND_TITLE,
76
- fireAt: nowIso(now + 15 * 60_000),
77
- firedAt: null,
78
- createdAt: nowIso(now),
79
- status: "scheduled",
80
- version: 1,
81
- events: [],
82
- deliveryTarget: parsed.deliveryTarget,
83
- deliveryAnchor: wantedAnchor,
84
- deliveryMode: "user",
85
- repeat: DEFAULT_MISSED_OUTBOUND_REPEAT,
86
- });
87
- return { created: true, reminderId, rebuilt: hadFired };
88
- });
63
+ /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
64
+ export function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
65
+ if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
66
+ return false;
67
+ const parsed = parseTarget(event);
68
+ if (!parsed)
69
+ return false;
70
+ const registry = load(file);
71
+ const observed_at = now.toISOString();
72
+ registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
73
+ registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
74
+ registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
75
+ save(file, registry);
76
+ return true;
89
77
  }
90
- export function persistInboundScanTarget(stateDir, event, agentId, storeFile = path.join(stateDir, "reminders.json")) {
91
- if (event._sender_is_bot)
92
- throw new Error("scan reminder 只接受 human inbound");
93
- if (event._scan_authority !== true)
94
- throw new Error("scan reminder 需要权威 chatType/senderIsBot");
95
- if (event.chat_type !== "group")
96
- throw new Error("scan reminder 只接受 group/thread,禁止 DM");
97
- const chatId = String(event.chat_id || "");
98
- if (!/^oc_[A-Za-z0-9]+$/.test(chatId)) {
99
- throw new Error("user-facing reminder 必须显式指定 delivery target,不得从标题推断收件人");
100
- }
101
- const threadId = event.thread_id ? String(event.thread_id) : "";
102
- const parsed = threadId
103
- ? parseScanDeliveryTarget(`thread:${chatId}:${threadId}`, event.message_id)
104
- : parseScanDeliveryTarget(`chat:${chatId}`, event.message_id || null);
105
- ensureDefaultMissedOutboundScanReminder({
106
- storeFile,
107
- agentId,
108
- deliveryTarget: parsed.deliveryTarget,
109
- deliveryAnchor: parsed.deliveryAnchor,
110
- });
111
- return parsed;
78
+ function instruction(target) {
79
+ const history = target.target.startsWith("thread:")
80
+ ? "Use larkin im +threads-messages-list for this exact thread."
81
+ : "Use larkin im +chat-messages-list for this exact chat.";
82
+ return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
83
+ }
84
+ /** Content-free, bounded audit work list consumed by the Agent CLI. */
85
+ export function readInboxAuditTargets(file, agentId) {
86
+ const rows = load(file).targets.filter((row) => row.agent_id === agentId)
87
+ .sort((left, right) => right.observed_at.localeCompare(left.observed_at));
88
+ return {
89
+ version: 1,
90
+ targets: rows.slice(0, MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
91
+ has_more: rows.length > MAX_INBOX_AUDIT_TARGETS,
92
+ no_finding: "stay_silent",
93
+ };
112
94
  }
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
8
8
  import * as larkinConfig from "../platform/config.mjs";
9
9
  import { projectInboxCheck, projectInboxEvents } from "../agent/inbox-projection.mjs";
10
+ import { inboxAuditRegistryFile, readInboxAuditTargets } from "../agent/missed-outbound-scan.mjs";
10
11
  import { createReminderRoutes } from "../agent/reminder-routes.mjs";
11
12
  import { InteractionStateMachine } from "../agent/interaction-state-machine.mjs";
12
13
  import { issueCallbackProbe, readCallbackCapability } from "../platform/callback-capability.mjs";
@@ -501,10 +502,17 @@ export function runAgentCli(argv, env = process.env, dependencies = {}) {
501
502
  const { config, agent } = requireAgent(env);
502
503
  const stateStore = dependencies.stateStore ?? createAgentStateStore(config.larkinHome, agent.agentId);
503
504
  if (group === "inbox") {
504
- if (!["check", "poll"].includes(subcommand || "")) {
505
- throw new Error("inbox 只支持 `larkin inbox check` 与 `larkin inbox poll`");
505
+ if (!["check", "poll", "audit"].includes(subcommand || "")) {
506
+ throw new Error("inbox 只支持 `larkin inbox check`、`larkin inbox poll` 与 `larkin inbox audit`");
506
507
  }
507
508
  const options = parseOptions(rest, new Set(["--json"]));
509
+ if (subcommand === "audit") {
510
+ if (options.positionals.length || options.values.size || options.booleans.size > 1) {
511
+ throw new Error("inbox audit 只接受 --json");
512
+ }
513
+ emitJson(io, readInboxAuditTargets(inboxAuditRegistryFile(config.larkinHome), agent.agentId));
514
+ return 0;
515
+ }
508
516
  if (options.positionals.length || [...options.values.keys()].some((flag) => !["--target", "--limit"].includes(flag))) {
509
517
  throw new Error(`inbox ${subcommand} 只接受 --target、${subcommand === "poll" ? "--limit、" : ""}--json`);
510
518
  }
@@ -51,6 +51,7 @@ const host_business_state_js_1 = require("./host-business-state.cjs");
51
51
  const host_processing_eye_js_1 = require("./host-processing-eye.cjs");
52
52
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
53
53
  const host_reminder_orchestrator_js_1 = require("../agent/host-reminder-orchestrator.cjs");
54
+ const inbox_audit_heartbeat_js_1 = require("../agent/inbox-audit-heartbeat.cjs");
54
55
  const missed_outbound_scan_js_1 = require("../agent/missed-outbound-scan.cjs");
55
56
  const host_channel_business_js_1 = require("./host-channel-business.cjs");
56
57
  const interaction_orchestrator_js_1 = require("./interaction-orchestrator.cjs");
@@ -340,6 +341,13 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
340
341
  for (const agent of agents)
341
342
  prepareAgentState(agent);
342
343
  const reminder = new host_reminder_orchestrator_js_1.HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
344
+ const inboxAudit = new inbox_audit_heartbeat_js_1.InboxAuditHeartbeat({
345
+ agents,
346
+ stateStore,
347
+ runtimeHost,
348
+ log,
349
+ });
350
+ const auditRegistry = (0, missed_outbound_scan_js_1.inboxAuditRegistryFile)(larkinHome);
343
351
  const seenEventIds = new Set();
344
352
  const inFlightEventIds = new Set();
345
353
  const onFeishuMessage = async (agent, event, options) => {
@@ -386,13 +394,11 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
386
394
  // append/dedupe decision is durable. Agent model-seen state is untouched.
387
395
  if (event.event_id)
388
396
  seenEventIds.add(eventKey);
389
- if (!event._sender_is_bot && event._scan_authority) {
390
- try {
391
- (0, missed_outbound_scan_js_1.persistInboundScanTarget)(agent.stateDir, event, agent.agentId);
392
- }
393
- catch (error) {
394
- log(`inbound scan target 未持久化: ${error.message}`);
395
- }
397
+ try {
398
+ (0, missed_outbound_scan_js_1.observeInboxAuditTarget)(auditRegistry, agent.agentId, event);
399
+ }
400
+ catch (error) {
401
+ log(`inbox audit target 未持久化: ${error.message}`);
396
402
  }
397
403
  if (append.status === "duplicate_consumed")
398
404
  return null;
@@ -1345,6 +1351,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1345
1351
  eventSourceStartTimer = null;
1346
1352
  await Promise.resolve(eventSourceStop());
1347
1353
  reminder.stopSync();
1354
+ inboxAudit.stop();
1348
1355
  interaction.stopSync();
1349
1356
  await runtimeHost.shutdown(reason);
1350
1357
  })();
@@ -1559,6 +1566,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1559
1566
  sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
1560
1567
  })));
1561
1568
  reminder.startSync();
1569
+ inboxAudit.start();
1562
1570
  interaction.startSync();
1563
1571
  eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
1564
1572
  }
@@ -11,7 +11,8 @@ import { HostEnvelopeProjector, HostStateProjection, SenderIdentityCache, projec
11
11
  import { ProcessingEyeOrchestrator } from "./host-processing-eye.mjs";
12
12
  import { projectInboxEnvelope, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
13
13
  import { HostReminderOrchestrator } from "../agent/host-reminder-orchestrator.mjs";
14
- import { persistInboundScanTarget } from "../agent/missed-outbound-scan.mjs";
14
+ import { InboxAuditHeartbeat } from "../agent/inbox-audit-heartbeat.mjs";
15
+ import { inboxAuditRegistryFile, observeInboxAuditTarget } from "../agent/missed-outbound-scan.mjs";
15
16
  import { HostChannelBusiness } from "./host-channel-business.mjs";
16
17
  import { HostInteractionOrchestrator } from "./interaction-orchestrator.mjs";
17
18
  import { targetFor } from "./message-policy.mjs";
@@ -300,6 +301,13 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
300
301
  for (const agent of agents)
301
302
  prepareAgentState(agent);
302
303
  const reminder = new HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
304
+ const inboxAudit = new InboxAuditHeartbeat({
305
+ agents,
306
+ stateStore,
307
+ runtimeHost,
308
+ log,
309
+ });
310
+ const auditRegistry = inboxAuditRegistryFile(larkinHome);
303
311
  const seenEventIds = new Set();
304
312
  const inFlightEventIds = new Set();
305
313
  const onFeishuMessage = async (agent, event, options) => {
@@ -346,13 +354,11 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
346
354
  // append/dedupe decision is durable. Agent model-seen state is untouched.
347
355
  if (event.event_id)
348
356
  seenEventIds.add(eventKey);
349
- if (!event._sender_is_bot && event._scan_authority) {
350
- try {
351
- persistInboundScanTarget(agent.stateDir, event, agent.agentId);
352
- }
353
- catch (error) {
354
- log(`inbound scan target 未持久化: ${error.message}`);
355
- }
357
+ try {
358
+ observeInboxAuditTarget(auditRegistry, agent.agentId, event);
359
+ }
360
+ catch (error) {
361
+ log(`inbox audit target 未持久化: ${error.message}`);
356
362
  }
357
363
  if (append.status === "duplicate_consumed")
358
364
  return null;
@@ -1305,6 +1311,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1305
1311
  eventSourceStartTimer = null;
1306
1312
  await Promise.resolve(eventSourceStop());
1307
1313
  reminder.stopSync();
1314
+ inboxAudit.stop();
1308
1315
  interaction.stopSync();
1309
1316
  await runtimeHost.shutdown(reason);
1310
1317
  })();
@@ -1519,6 +1526,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1519
1526
  sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
1520
1527
  })));
1521
1528
  reminder.startSync();
1529
+ inboxAudit.start();
1522
1530
  interaction.startSync();
1523
1531
  eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
1524
1532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [