larkin 0.5.2 → 0.5.4
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/CHANGELOG.md +10 -0
- package/README.md +15 -0
- package/dist/agent/agent-cli-capabilities.cjs +1 -1
- package/dist/agent/agent-cli-capabilities.mjs +1 -1
- package/dist/agent/config-cli-contract.cjs +3 -1
- package/dist/agent/config-cli-contract.mjs +3 -1
- package/dist/agent/inbox-audit-heartbeat.cjs +157 -37
- package/dist/agent/inbox-audit-heartbeat.mjs +122 -36
- package/dist/agent/missed-outbound-scan.cjs +326 -54
- package/dist/agent/missed-outbound-scan.mjs +319 -53
- package/dist/app/agent-cli.mjs +37 -6
- package/dist/app/agent-config.mjs +36 -5
- package/dist/app/cli.mjs +1 -0
- package/dist/dashboard/dashboard-config-controller.mjs +15 -0
- package/dist/dashboard/web/assets/dashboard.css +1 -1
- package/dist/dashboard/web/assets/dashboard.js +15 -15
- package/dist/feishu/host-shell.cjs +110 -19
- package/dist/feishu/host-shell.mjs +113 -22
- package/dist/platform/config.cjs +173 -5
- package/dist/platform/config.mjs +167 -4
- package/dist/runtime/provider-error-classifier.cjs +14 -0
- package/dist/runtime/provider-error-classifier.mjs +13 -0
- package/dist/runtime/runtime-adapters.mjs +40 -13
- package/dist/runtime/runtime-host.cjs +46 -26
- package/dist/runtime/runtime-host.mjs +46 -26
- package/package.json +1 -1
|
@@ -1,22 +1,22 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
*/
|
|
4
|
+
import { acquireProcessLock } from "../platform/process-state.mjs";
|
|
5
|
+
/** Historical reminder loops are deliberately unrelated to this bounded audit registry. */
|
|
9
6
|
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
7
|
export const MAX_INBOX_AUDIT_TARGETS = 96;
|
|
13
8
|
const MAX_STORED_TARGETS = MAX_INBOX_AUDIT_TARGETS;
|
|
9
|
+
export const MAX_INBOX_AUDIT_REGISTRY_BYTES = 64 * 1024;
|
|
10
|
+
export const MAX_INBOX_AUDIT_REGISTRY_ROWS = MAX_INBOX_AUDIT_TARGETS;
|
|
11
|
+
const LOCK_WAIT_MS = 5_000;
|
|
12
|
+
const LOCK_RETRY_MS = 25;
|
|
14
13
|
const CHAT = /^oc_[A-Za-z0-9]+$/;
|
|
15
14
|
const THREAD = /^omt_[A-Za-z0-9]+$/;
|
|
16
15
|
const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
16
|
+
const GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const OUTCOMES = new Set(["no-finding", "handled"]);
|
|
18
|
+
export function inboxAuditRegistryFile(larkinHome) { return path.join(larkinHome, "inbox-audit.json"); }
|
|
19
|
+
export function inboxAuditRetryFile(larkinHome) { return path.join(larkinHome, "inbox-audit-retry.json"); }
|
|
20
20
|
function parseTarget(event) {
|
|
21
21
|
const chatId = String(event.chat_id || "");
|
|
22
22
|
const threadId = event.thread_id ? String(event.thread_id) : "";
|
|
@@ -25,70 +25,336 @@ function parseTarget(event) {
|
|
|
25
25
|
return null;
|
|
26
26
|
return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
|
|
27
27
|
}
|
|
28
|
+
function emptyRegistry() { return { version: 4, targets: [] }; }
|
|
29
|
+
function assertSafeExistingFile(file, label) {
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.lstatSync(file);
|
|
32
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
33
|
+
throw new Error(`${label} is not a regular file`);
|
|
34
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
35
|
+
throw new Error(`${label} is not owned by the current user`);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== "ENOENT")
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function prepareRegistryDirectory(file) {
|
|
43
|
+
const directory = path.dirname(file);
|
|
44
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
45
|
+
const stat = fs.lstatSync(directory);
|
|
46
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
47
|
+
throw new Error("inbox audit registry directory is unsafe");
|
|
48
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
49
|
+
throw new Error("inbox audit registry directory is not owned by the current user");
|
|
50
|
+
}
|
|
28
51
|
function load(file) {
|
|
29
|
-
let
|
|
52
|
+
let fd;
|
|
53
|
+
let bytes;
|
|
30
54
|
try {
|
|
31
|
-
|
|
55
|
+
assertSafeExistingFile(file, "inbox audit registry");
|
|
56
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
57
|
+
const stat = fs.fstatSync(fd);
|
|
58
|
+
if (!stat.isFile())
|
|
59
|
+
throw new Error("inbox audit registry is not a regular file");
|
|
60
|
+
const buffer = Buffer.alloc(MAX_INBOX_AUDIT_REGISTRY_BYTES + 1);
|
|
61
|
+
let offset = 0;
|
|
62
|
+
while (offset < buffer.length) {
|
|
63
|
+
const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
64
|
+
if (read === 0)
|
|
65
|
+
break;
|
|
66
|
+
offset += read;
|
|
67
|
+
}
|
|
68
|
+
if (offset > MAX_INBOX_AUDIT_REGISTRY_BYTES)
|
|
69
|
+
throw new Error("inbox audit registry exceeds the bounded byte limit");
|
|
70
|
+
bytes = buffer.subarray(0, offset);
|
|
32
71
|
}
|
|
33
72
|
catch (error) {
|
|
34
73
|
if (error.code === "ENOENT")
|
|
35
|
-
return
|
|
74
|
+
return emptyRegistry();
|
|
36
75
|
throw error;
|
|
37
76
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
77
|
+
finally {
|
|
78
|
+
if (fd !== undefined)
|
|
79
|
+
fs.closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
82
|
+
// v1 had no wake provenance; v2 had no durable observation generation.
|
|
83
|
+
// Neither can safely issue a completion receipt after this lifecycle upgrade.
|
|
84
|
+
if (value?.version !== 4 || !Array.isArray(value.targets))
|
|
85
|
+
return emptyRegistry();
|
|
86
|
+
if (value.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
|
|
87
|
+
throw new Error("inbox audit registry exceeds the bounded row limit");
|
|
88
|
+
return { version: 4, targets: value.targets.flatMap((row) => {
|
|
41
89
|
if (!row || typeof row !== "object")
|
|
42
90
|
return [];
|
|
43
91
|
const candidate = row;
|
|
44
92
|
if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
|
|
45
93
|
return [];
|
|
94
|
+
const parts = candidate.target.split(":");
|
|
46
95
|
const parsed = parseTarget({
|
|
47
|
-
chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) :
|
|
48
|
-
thread_id: candidate.target.startsWith("thread:") ?
|
|
96
|
+
chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : parts[1],
|
|
97
|
+
thread_id: candidate.target.startsWith("thread:") ? parts[2] : null,
|
|
49
98
|
message_id: candidate.anchor,
|
|
50
99
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
100
|
+
const status = candidate.status === "completed" ? "completed" : candidate.status === "pending" ? "pending" : null;
|
|
101
|
+
if (!status || typeof candidate.agent_id !== "string" || !candidate.agent_id || typeof candidate.generation !== "string" || !GENERATION.test(candidate.generation) || !parsed || candidate.target !== parsed.target
|
|
102
|
+
|| !Number.isSafeInteger(candidate.source_seq) || Number(candidate.source_seq) < 1
|
|
103
|
+
|| typeof candidate.observed_at !== "string" || !Number.isFinite(Date.parse(candidate.observed_at)))
|
|
104
|
+
return [];
|
|
105
|
+
const completed_at = typeof candidate.completed_at === "string" && Number.isFinite(Date.parse(candidate.completed_at)) ? candidate.completed_at : undefined;
|
|
106
|
+
const completed_anchor = typeof candidate.completed_anchor === "string" && ANCHOR.test(candidate.completed_anchor) ? candidate.completed_anchor : undefined;
|
|
107
|
+
const completed_outcome = OUTCOMES.has(String(candidate.completed_outcome)) ? candidate.completed_outcome : undefined;
|
|
108
|
+
return [{ agent_id: candidate.agent_id, generation: candidate.generation, ...parsed, source_seq: Number(candidate.source_seq), observed_at: candidate.observed_at, status,
|
|
109
|
+
...(completed_at ? { completed_at } : {}), ...(completed_anchor ? { completed_anchor } : {}),
|
|
110
|
+
...(completed_outcome ? { completed_outcome } : {}) }];
|
|
54
111
|
}) };
|
|
55
112
|
}
|
|
56
113
|
function save(file, registry) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
114
|
+
if (registry.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
|
|
115
|
+
throw new Error("inbox audit registry exceeds the bounded row limit");
|
|
116
|
+
const serialized = `${JSON.stringify(registry)}\n`;
|
|
117
|
+
if (Buffer.byteLength(serialized) > MAX_INBOX_AUDIT_REGISTRY_BYTES)
|
|
118
|
+
throw new Error("inbox audit registry exceeds the bounded byte limit");
|
|
119
|
+
prepareRegistryDirectory(file);
|
|
120
|
+
assertSafeExistingFile(file, "inbox audit registry");
|
|
121
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
122
|
+
const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(fd, serialized);
|
|
125
|
+
fs.fsyncSync(fd);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
fs.closeSync(fd);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
fs.renameSync(temporary, file);
|
|
132
|
+
fs.chmodSync(file, 0o600);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
try {
|
|
136
|
+
fs.unlinkSync(temporary);
|
|
137
|
+
}
|
|
138
|
+
catch { /* best effort */ }
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function emptyRetryJournal() { return { version: 2, targets: [] }; }
|
|
143
|
+
function loadRetryJournal(file) {
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
assertSafeExistingFile(file, "inbox audit retry journal");
|
|
147
|
+
const stat = fs.statSync(file);
|
|
148
|
+
if (stat.size > MAX_INBOX_AUDIT_REGISTRY_BYTES)
|
|
149
|
+
throw new Error("inbox audit retry journal exceeds the bounded byte limit");
|
|
150
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code === "ENOENT")
|
|
154
|
+
return emptyRetryJournal();
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
const raw = value;
|
|
158
|
+
if (raw.version !== 2 || !Array.isArray(raw.targets) || raw.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
|
|
159
|
+
return emptyRetryJournal();
|
|
160
|
+
return { version: 2, targets: raw.targets.flatMap((candidate) => {
|
|
161
|
+
if (!candidate || typeof candidate !== "object")
|
|
162
|
+
return [];
|
|
163
|
+
const row = candidate;
|
|
164
|
+
const parsed = parseTarget({ chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor });
|
|
165
|
+
if (typeof row.agent_id !== "string" || !row.agent_id || typeof row.target !== "string" || !parsed || parsed.target !== row.target
|
|
166
|
+
|| typeof row.chat_id !== "string" || (row.thread_id !== null && typeof row.thread_id !== "string")
|
|
167
|
+
|| !Number.isSafeInteger(row.source_seq) || Number(row.source_seq) < 1
|
|
168
|
+
|| typeof row.queued_at !== "string" || !Number.isFinite(Date.parse(row.queued_at)))
|
|
169
|
+
return [];
|
|
170
|
+
return [{ agent_id: row.agent_id, target: row.target, anchor: parsed.anchor, chat_id: row.chat_id, thread_id: row.thread_id ?? null, source_seq: Number(row.source_seq), queued_at: row.queued_at }];
|
|
171
|
+
}) };
|
|
172
|
+
}
|
|
173
|
+
function saveRetryJournal(file, journal) {
|
|
174
|
+
if (journal.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
|
|
175
|
+
throw new Error("inbox audit retry journal exceeds the bounded row limit");
|
|
176
|
+
const serialized = `${JSON.stringify(journal)}\n`;
|
|
177
|
+
if (Buffer.byteLength(serialized) > MAX_INBOX_AUDIT_REGISTRY_BYTES)
|
|
178
|
+
throw new Error("inbox audit retry journal exceeds the bounded byte limit");
|
|
179
|
+
prepareRegistryDirectory(file);
|
|
180
|
+
assertSafeExistingFile(file, "inbox audit retry journal");
|
|
181
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
182
|
+
const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
183
|
+
try {
|
|
184
|
+
fs.writeFileSync(fd, serialized);
|
|
185
|
+
fs.fsyncSync(fd);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
fs.closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
fs.renameSync(temporary, file);
|
|
192
|
+
fs.chmodSync(file, 0o600);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
try {
|
|
196
|
+
fs.unlinkSync(temporary);
|
|
197
|
+
}
|
|
198
|
+
catch { /* best effort */ }
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
203
|
+
/** Serialize Host observers and CLI completion without replacing a newer registry snapshot. */
|
|
204
|
+
function withAuditLock(file, operation) {
|
|
205
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
206
|
+
const lockFile = `${file}.mutation-lock`;
|
|
207
|
+
prepareRegistryDirectory(file);
|
|
208
|
+
while (Date.now() <= deadline) {
|
|
209
|
+
let lock;
|
|
210
|
+
try {
|
|
211
|
+
assertSafeExistingFile(lockFile, "inbox audit mutation lock");
|
|
212
|
+
lock = acquireProcessLock(lockFile, path.basename(process.execPath), { malformedGraceMs: 1_000 });
|
|
213
|
+
return operation();
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (lock || !/lock 已被|无法取得 lock|正在创建|暂不能接管|并发重试耗尽/.test(error instanceof Error ? error.message : String(error)))
|
|
217
|
+
throw error;
|
|
218
|
+
sleep(LOCK_RETRY_MS);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
lock?.release();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw new Error("inbox audit registry busy: bounded lock contention");
|
|
225
|
+
}
|
|
226
|
+
function mutate(file, operation) {
|
|
227
|
+
return withAuditLock(file, () => {
|
|
228
|
+
const registry = load(file);
|
|
229
|
+
const result = operation(registry);
|
|
230
|
+
save(file, registry);
|
|
231
|
+
return result;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function mutateRetryJournal(file, operation) {
|
|
235
|
+
return withAuditLock(file, () => {
|
|
236
|
+
const journal = loadRetryJournal(file);
|
|
237
|
+
const result = operation(journal);
|
|
238
|
+
saveRetryJournal(file, journal);
|
|
239
|
+
return result;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function revision(row) {
|
|
243
|
+
return `sha256:${crypto.createHash("sha256").update(JSON.stringify({
|
|
244
|
+
v: 2, agent_id: row.agent_id, target: row.target, anchor: row.anchor, generation: row.generation, status: row.status,
|
|
245
|
+
})).digest("hex")}`;
|
|
246
|
+
}
|
|
247
|
+
function receipt(row) {
|
|
248
|
+
const payload = { v: 1, agent_id: row.agent_id, target: row.target, anchor: row.anchor, revision: revision(row) };
|
|
249
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
250
|
+
}
|
|
251
|
+
function parseReceipt(value) {
|
|
252
|
+
try {
|
|
253
|
+
const raw = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
254
|
+
if (raw.v !== 1 || typeof raw.agent_id !== "string" || !raw.agent_id || typeof raw.target !== "string" || typeof raw.anchor !== "string"
|
|
255
|
+
|| typeof raw.revision !== "string" || !raw.revision.startsWith("sha256:"))
|
|
256
|
+
return null;
|
|
257
|
+
const parts = raw.target.split(":");
|
|
258
|
+
const parsed = parseTarget({ chat_id: raw.target.startsWith("chat:") ? raw.target.slice(5) : parts[1], thread_id: raw.target.startsWith("thread:") ? parts[2] : null, message_id: raw.anchor });
|
|
259
|
+
return parsed?.target === raw.target ? raw : null;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
62
264
|
}
|
|
63
|
-
/** Record only an authoritative human group/topic source; DMs and bots are ignored. */
|
|
64
265
|
export function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
|
|
65
|
-
if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
|
|
266
|
+
if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
|
|
66
267
|
return false;
|
|
67
268
|
const parsed = parseTarget(event);
|
|
68
|
-
|
|
269
|
+
const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
|
|
270
|
+
if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
|
|
271
|
+
return false;
|
|
272
|
+
return mutate(file, (registry) => {
|
|
273
|
+
const existing = registry.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
|
|
274
|
+
if (existing && sourceSeq <= existing.source_seq)
|
|
275
|
+
return false;
|
|
276
|
+
registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
|
|
277
|
+
registry.targets.unshift({ agent_id: agentId, generation: crypto.randomUUID(), ...parsed, source_seq: sourceSeq, observed_at: now.toISOString(), status: "pending" });
|
|
278
|
+
registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
|
|
279
|
+
return true;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Durable recovery intent, recorded only after the same original wake gate. */
|
|
283
|
+
export function enqueueInboxAuditTargetRetry(file, agentId, event, now = new Date()) {
|
|
284
|
+
if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
|
|
285
|
+
return false;
|
|
286
|
+
const parsed = parseTarget(event);
|
|
287
|
+
const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
|
|
288
|
+
if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
|
|
69
289
|
return false;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
290
|
+
return mutateRetryJournal(file, (journal) => {
|
|
291
|
+
const existing = journal.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
|
|
292
|
+
if (existing && sourceSeq <= existing.source_seq)
|
|
293
|
+
return false;
|
|
294
|
+
journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
|
|
295
|
+
journal.targets.unshift({ agent_id: agentId, ...parsed, source_seq: sourceSeq, chat_id: String(event.chat_id), thread_id: event.thread_id ? String(event.thread_id) : null, queued_at: now.toISOString() });
|
|
296
|
+
journal.targets = journal.targets.slice(0, MAX_STORED_TARGETS);
|
|
297
|
+
return true;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/** Remove only the exact recovered/newer target so a late retry cannot erase a new anchor. */
|
|
301
|
+
export function discardInboxAuditTargetRetry(file, agentId, event) {
|
|
302
|
+
const parsed = parseTarget(event);
|
|
303
|
+
if (!parsed)
|
|
304
|
+
return;
|
|
305
|
+
mutateRetryJournal(file, (journal) => { journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target || row.source_seq > Number(event.source_seq ?? 0)); });
|
|
306
|
+
}
|
|
307
|
+
/** Replay persisted, already-authorized observer intents. Failures remain durable for a later bounded retry/startup. */
|
|
308
|
+
export function reconcileInboxAuditTargetRetries(registryFile, retryFile, agentId) {
|
|
309
|
+
const rows = loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId);
|
|
310
|
+
let recovered = 0;
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
try {
|
|
313
|
+
observeInboxAuditTarget(registryFile, row.agent_id, { chat_id: row.chat_id, chat_type: "group", thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq, wake: true, _sender_is_bot: false, _scan_authority: true });
|
|
314
|
+
discardInboxAuditTargetRetry(retryFile, row.agent_id, { chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq });
|
|
315
|
+
recovered += 1;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { recovered, remaining: loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId).length };
|
|
322
|
+
}
|
|
323
|
+
function instruction(target, receiptValue) {
|
|
324
|
+
const history = target.target.startsWith("thread:") ? "Use larkin im +threads-messages-list for this exact thread." : "Use larkin im +chat-messages-list for this exact chat.";
|
|
325
|
+
return `${history} Inspect first. When finished, run larkin inbox audit complete --receipt ${receiptValue} --outcome <no-finding|handled> --json. If a real finding needs a response, use the guarded reply path anchored at ${target.anchor}; otherwise stay silent.`;
|
|
326
|
+
}
|
|
327
|
+
function pendingRows(file, agentId) {
|
|
328
|
+
return load(file).targets.filter((row) => row.agent_id === agentId && row.status === "pending").sort((left, right) => right.observed_at.localeCompare(left.observed_at));
|
|
329
|
+
}
|
|
330
|
+
export function hasPendingInboxAuditTargets(file, agentId) { return pendingRows(file, agentId).length > 0; }
|
|
331
|
+
/** Public read is intentionally completion-free: a caller crash leaves the target pending. */
|
|
85
332
|
export function readInboxAuditTargets(file, agentId) {
|
|
86
|
-
const rows =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
has_more: rows.length > MAX_INBOX_AUDIT_TARGETS,
|
|
92
|
-
|
|
93
|
-
|
|
333
|
+
const rows = pendingRows(file, agentId);
|
|
334
|
+
return { version: 4, targets: rows.slice(0, MAX_INBOX_AUDIT_TARGETS).map((row) => {
|
|
335
|
+
const target = { target: row.target, anchor: row.anchor, observed_at: row.observed_at, source_seq: row.source_seq };
|
|
336
|
+
const receiptValue = receipt(row);
|
|
337
|
+
return { ...target, revision: revision(row), receipt: receiptValue, instruction: instruction(target, receiptValue) };
|
|
338
|
+
}), has_more: rows.length > MAX_INBOX_AUDIT_TARGETS, no_finding: "stay_silent" };
|
|
339
|
+
}
|
|
340
|
+
export function completeInboxAuditTarget(file, agentId, receiptValue, outcome, now = new Date()) {
|
|
341
|
+
if (!OUTCOMES.has(outcome))
|
|
342
|
+
return { completed: false, reason: "invalid_outcome" };
|
|
343
|
+
const decoded = parseReceipt(receiptValue);
|
|
344
|
+
if (!decoded || decoded.agent_id !== agentId)
|
|
345
|
+
return { completed: false, reason: "invalid_receipt" };
|
|
346
|
+
return mutate(file, (registry) => {
|
|
347
|
+
const row = registry.targets.find((candidate) => candidate.agent_id === decoded.agent_id && candidate.target === decoded.target);
|
|
348
|
+
if (!row || row.anchor !== decoded.anchor)
|
|
349
|
+
return { completed: false, reason: "stale" };
|
|
350
|
+
if (row.status === "completed")
|
|
351
|
+
return { completed: false, reason: "already_completed" };
|
|
352
|
+
if (revision(row) !== decoded.revision)
|
|
353
|
+
return { completed: false, reason: "stale" };
|
|
354
|
+
row.status = "completed";
|
|
355
|
+
row.completed_at = now.toISOString();
|
|
356
|
+
row.completed_anchor = row.anchor;
|
|
357
|
+
row.completed_outcome = outcome;
|
|
358
|
+
return { completed: true, reason: "completed" };
|
|
359
|
+
});
|
|
94
360
|
}
|
package/dist/app/agent-cli.mjs
CHANGED
|
@@ -7,7 +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
|
+
import { completeInboxAuditTarget, inboxAuditRegistryFile, readInboxAuditTargets } from "../agent/missed-outbound-scan.mjs";
|
|
11
11
|
import { createReminderRoutes } from "../agent/reminder-routes.mjs";
|
|
12
12
|
import { InteractionStateMachine } from "../agent/interaction-state-machine.mjs";
|
|
13
13
|
import { issueCallbackProbe, readCallbackCapability } from "../platform/callback-capability.mjs";
|
|
@@ -142,7 +142,7 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
|
|
|
142
142
|
const [operation = "show", ...rest] = argv;
|
|
143
143
|
const authority = { kind: "agent", agentId: agent.agentId };
|
|
144
144
|
const options = parseOptions(rest, new Set(["--json"]));
|
|
145
|
-
const unknownFlags = [...options.values.keys()].filter((flag) => !["--agent", "--chat", "--model"].includes(flag));
|
|
145
|
+
const unknownFlags = [...options.values.keys()].filter((flag) => !["--agent", "--chat", "--model", "--interval"].includes(flag));
|
|
146
146
|
if (unknownFlags.length)
|
|
147
147
|
throw new Error(`config 不支持参数:${unknownFlags.join(", ")};运行 larkin config --help`);
|
|
148
148
|
const assertOnlyFlags = (valueFlags, booleanFlags = []) => {
|
|
@@ -238,6 +238,24 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
|
|
|
238
238
|
else
|
|
239
239
|
throw new Error("用法: larkin config mention global <require|free> | mention agent <inherit|require|free> | mention chat <oc_id> <inherit|require|free> [--agent <App ID>]");
|
|
240
240
|
}
|
|
241
|
+
else if (operation === "inbox-audit") {
|
|
242
|
+
const [scope, first] = options.positionals;
|
|
243
|
+
if (scope === "global")
|
|
244
|
+
assertOnlyFlags(["--interval"]);
|
|
245
|
+
else if (scope === "agent")
|
|
246
|
+
assertOnlyFlags(["--agent", "--interval"]);
|
|
247
|
+
else
|
|
248
|
+
assertOnlyFlags([]);
|
|
249
|
+
if (options.positionals.length !== 2) {
|
|
250
|
+
throw new Error("用法: larkin config inbox-audit global <on|off> [--interval <15m|1h>] | inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]");
|
|
251
|
+
}
|
|
252
|
+
mutation = larkinConfig.inboxAuditMutationFromCli({
|
|
253
|
+
scope: scope || "",
|
|
254
|
+
enabled: first,
|
|
255
|
+
interval: options.values.get("--interval"),
|
|
256
|
+
agentId: targetId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
241
259
|
else if (operation === "apply") {
|
|
242
260
|
assertOnlyFlags(["--agent"]);
|
|
243
261
|
if (options.positionals.length)
|
|
@@ -251,7 +269,7 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
|
|
|
251
269
|
}).catch((error) => { throw new Error(`配置已保存但未应用:${error instanceof Error ? error.message : String(error)}`); });
|
|
252
270
|
}
|
|
253
271
|
else
|
|
254
|
-
throw new Error("config 只支持 show/runtime/model/effort/mention/apply;运行 larkin config --help");
|
|
272
|
+
throw new Error("config 只支持 show/runtime/model/effort/mention/inbox-audit/apply;运行 larkin config --help");
|
|
255
273
|
const result = larkinConfig.mutateConfig(env, mutation, authority);
|
|
256
274
|
return { ok: true, revision: result.revision, persisted: true, applyState: result.applyState, changedScope: result.changedScope };
|
|
257
275
|
}
|
|
@@ -519,10 +537,23 @@ export function runAgentCli(argv, env = process.env, dependencies = {}) {
|
|
|
519
537
|
}
|
|
520
538
|
const options = parseOptions(rest, new Set(["--json"]));
|
|
521
539
|
if (subcommand === "audit") {
|
|
522
|
-
|
|
523
|
-
|
|
540
|
+
const file = inboxAuditRegistryFile(config.larkinHome);
|
|
541
|
+
if (options.positionals[0] === "complete") {
|
|
542
|
+
if (options.positionals.length !== 1 || options.booleans.size > 1 || [...options.values.keys()].some((key) => !["--receipt", "--outcome"].includes(key))) {
|
|
543
|
+
throw new Error("用法: larkin inbox audit complete --receipt <receipt> --outcome <no-finding|handled> [--json]");
|
|
544
|
+
}
|
|
545
|
+
const receipt = options.values.get("--receipt");
|
|
546
|
+
const outcome = options.values.get("--outcome");
|
|
547
|
+
if (!receipt || !outcome)
|
|
548
|
+
throw new Error("用法: larkin inbox audit complete --receipt <receipt> --outcome <no-finding|handled> [--json]");
|
|
549
|
+
const completion = completeInboxAuditTarget(file, agent.agentId, receipt, outcome);
|
|
550
|
+
emitJson(io, completion);
|
|
551
|
+
return completion.reason === "invalid_receipt" || completion.reason === "invalid_outcome" ? 2 : 0;
|
|
524
552
|
}
|
|
525
|
-
|
|
553
|
+
if (options.positionals.length || options.values.size || options.booleans.size > 1)
|
|
554
|
+
throw new Error("inbox audit 只接受 --json;完成检查用 inbox audit complete");
|
|
555
|
+
const audit = readInboxAuditTargets(file, agent.agentId);
|
|
556
|
+
emitJson(io, audit);
|
|
526
557
|
return 0;
|
|
527
558
|
}
|
|
528
559
|
if (options.positionals.length || [...options.values.keys()].some((flag) => !["--target", "--limit"].includes(flag))) {
|
|
@@ -52,11 +52,11 @@ const die = (message) => {
|
|
|
52
52
|
process.exit(1);
|
|
53
53
|
};
|
|
54
54
|
if (!kind || !["agents", "model", "runtime", "effort", "chats", "config"].includes(kind)) {
|
|
55
|
-
die("用法: larkin agents | larkin config <show|mention|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
|
|
55
|
+
die("用法: larkin agents | larkin config <show|mention|inbox-audit|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
|
|
56
56
|
}
|
|
57
57
|
const allowedValueFlags = {
|
|
58
58
|
agents: new Set(), model: new Set(["--agent"]), runtime: new Set(["--agent", "--model"]),
|
|
59
|
-
effort: new Set(["--agent"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat"]),
|
|
59
|
+
effort: new Set(["--agent"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat", "--interval"]),
|
|
60
60
|
};
|
|
61
61
|
const allowedBooleanFlags = {
|
|
62
62
|
agents: new Set(["--json"]), model: new Set(), runtime: new Set(), effort: new Set(), chats: new Set(), config: new Set(["--json"]),
|
|
@@ -92,6 +92,7 @@ for (let index = 0; index < rest.length; index += 1) {
|
|
|
92
92
|
const flagAgent = parsedValues.get("--agent");
|
|
93
93
|
const flagModel = parsedValues.get("--model");
|
|
94
94
|
const flagChat = parsedValues.get("--chat");
|
|
95
|
+
const flagInterval = parsedValues.get("--interval");
|
|
95
96
|
const flagJson = parsedBooleans.has("--json");
|
|
96
97
|
const value = positionals[0];
|
|
97
98
|
function assertOnlyFlags(valueFlags, booleanFlags = []) {
|
|
@@ -151,13 +152,23 @@ else if (kind === "config") {
|
|
|
151
152
|
if (positionals.length !== 4)
|
|
152
153
|
die("用法: larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]");
|
|
153
154
|
}
|
|
155
|
+
else if (operation === "inbox-audit" && scope === "global") {
|
|
156
|
+
assertOnlyFlags(["--interval"]);
|
|
157
|
+
if (positionals.length !== 3)
|
|
158
|
+
die("用法: larkin config inbox-audit global <on|off> [--interval <15m|1h>]");
|
|
159
|
+
}
|
|
160
|
+
else if (operation === "inbox-audit" && scope === "agent") {
|
|
161
|
+
assertOnlyFlags(["--agent", "--interval"]);
|
|
162
|
+
if (positionals.length !== 3)
|
|
163
|
+
die("用法: larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]");
|
|
164
|
+
}
|
|
154
165
|
else if (operation === "apply") {
|
|
155
166
|
assertOnlyFlags(["--agent"]);
|
|
156
167
|
if (positionals.length !== 1)
|
|
157
168
|
die("用法: larkin config apply [--agent <App ID>]");
|
|
158
169
|
}
|
|
159
170
|
else {
|
|
160
|
-
die("config 只支持 show/mention/apply;运行 larkin config --help");
|
|
171
|
+
die("config 只支持 show/mention/inbox-audit/apply;运行 larkin config --help");
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
const loaded = (() => {
|
|
@@ -339,12 +350,17 @@ if (kind === "config") {
|
|
|
339
350
|
say(JSON.stringify(view, null, 2));
|
|
340
351
|
else {
|
|
341
352
|
say(`全局群消息策略=${String(view.mentionPolicy)}(free 会唤醒所有继承它的 Agent)`);
|
|
353
|
+
const inboxAudit = view.inboxAudit;
|
|
354
|
+
say(`全局 Inbox Audit=${inboxAudit?.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(inboxAudit?.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}(默认关闭;未 @ 的 require 群消息不会进入审计)`);
|
|
342
355
|
for (const item of view.agents) {
|
|
343
356
|
const mention = item.mention;
|
|
357
|
+
const agentAudit = item.inboxAudit;
|
|
344
358
|
const apply = item.apply;
|
|
345
|
-
say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} apply=${apply.applyState || "unknown"}`);
|
|
359
|
+
say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} inbox-audit=${agentAudit?.override?.enabled || "inherit"} apply=${apply.applyState || "unknown"}`);
|
|
346
360
|
if (mention.chat)
|
|
347
361
|
say(` chat=${mention.chat.chatId} override=${mention.chat.override} effective=${mention.chat.effective} source=${mention.chat.source}`);
|
|
362
|
+
if (agentAudit?.effective)
|
|
363
|
+
say(` inbox-audit effective=${agentAudit.effective.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(agentAudit.effective.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}`);
|
|
348
364
|
}
|
|
349
365
|
}
|
|
350
366
|
process.exit(0);
|
|
@@ -374,6 +390,21 @@ if (kind === "config") {
|
|
|
374
390
|
say(JSON.stringify({ ok: true, revision: result.revision, persisted: result.persisted, applyState: result.applyState, changedScope: result.changedScope }, null, 2));
|
|
375
391
|
process.exit(0);
|
|
376
392
|
}
|
|
393
|
+
if (operation === "inbox-audit") {
|
|
394
|
+
try {
|
|
395
|
+
const result = larkinConfig.mutateConfig(process.env, larkinConfig.inboxAuditMutationFromCli({
|
|
396
|
+
scope: scope || "",
|
|
397
|
+
enabled: first,
|
|
398
|
+
interval: flagInterval,
|
|
399
|
+
agentId: scope === "agent" ? requireTarget() : (selected || keys[0] || ""),
|
|
400
|
+
}), authority);
|
|
401
|
+
say(JSON.stringify({ ok: true, revision: result.revision, persisted: result.persisted, applyState: result.applyState, changedScope: result.changedScope }, null, 2));
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
die(error instanceof Error ? error.message : String(error));
|
|
405
|
+
}
|
|
406
|
+
process.exit(0);
|
|
407
|
+
}
|
|
377
408
|
if (operation === "apply") {
|
|
378
409
|
const agentId = requireTarget();
|
|
379
410
|
const expectedSignature = larkinConfig.runtimeConfigSignature(config, agentId);
|
|
@@ -393,7 +424,7 @@ if (kind === "config") {
|
|
|
393
424
|
}
|
|
394
425
|
process.exit(0);
|
|
395
426
|
}
|
|
396
|
-
die("config 只支持 show/mention/apply");
|
|
427
|
+
die("config 只支持 show/mention/inbox-audit/apply");
|
|
397
428
|
}
|
|
398
429
|
let key = flagAgent;
|
|
399
430
|
if (!key && runtimeAgentId)
|
package/dist/app/cli.mjs
CHANGED
|
@@ -73,6 +73,7 @@ Examples:
|
|
|
73
73
|
larkin config show --agent cli_x --chat oc_x --json
|
|
74
74
|
larkin config runtime pi --agent cli_x --model default
|
|
75
75
|
larkin config mention chat oc_x free --agent cli_x
|
|
76
|
+
larkin config inbox-audit global on --interval 15m
|
|
76
77
|
|
|
77
78
|
Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
|
|
78
79
|
session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
|
|
@@ -401,12 +401,27 @@ function dashboardMutation(value) {
|
|
|
401
401
|
"set-global-mention": ["operation", "value"], "set-agent-mention": ["operation", "agentId", "value"],
|
|
402
402
|
"set-chat-mention": ["operation", "agentId", "chatId", "value"], "set-agent-runtime": ["operation", "agentId", "runtime", "model"],
|
|
403
403
|
"set-agent-model": ["operation", "agentId", "model"], "set-agent-effort": ["operation", "agentId", "effort"],
|
|
404
|
+
"set-global-inbox-audit": ["operation", "enabled", "intervalMs"], "set-agent-inbox-audit": ["operation", "agentId", "enabled", "intervalMs"],
|
|
404
405
|
};
|
|
405
406
|
if (!allowed[operation] || Object.keys(value).some((key) => !allowed[operation].includes(key)))
|
|
406
407
|
throw new Error("unsupported operation");
|
|
407
408
|
if (operation === "set-global-mention")
|
|
408
409
|
return { kind: operation, value: String(value.value) };
|
|
410
|
+
if (operation === "set-global-inbox-audit") {
|
|
411
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean")
|
|
412
|
+
throw new Error("invalid inbox-audit enabled");
|
|
413
|
+
if (value.intervalMs !== undefined && (typeof value.intervalMs !== "number" || !Number.isSafeInteger(value.intervalMs)))
|
|
414
|
+
throw new Error("invalid inbox-audit interval");
|
|
415
|
+
return { kind: operation, ...(value.enabled !== undefined ? { enabled: value.enabled } : {}), ...(value.intervalMs !== undefined ? { intervalMs: value.intervalMs } : {}) };
|
|
416
|
+
}
|
|
409
417
|
const agentId = String(value.agentId || "");
|
|
418
|
+
if (operation === "set-agent-inbox-audit") {
|
|
419
|
+
if (value.enabled !== undefined && value.enabled !== "inherit" && typeof value.enabled !== "boolean")
|
|
420
|
+
throw new Error("invalid inbox-audit enabled");
|
|
421
|
+
if (value.intervalMs !== undefined && value.intervalMs !== "inherit" && (typeof value.intervalMs !== "number" || !Number.isSafeInteger(value.intervalMs)))
|
|
422
|
+
throw new Error("invalid inbox-audit interval");
|
|
423
|
+
return { kind: operation, agentId, ...(value.enabled !== undefined ? { enabled: value.enabled } : {}), ...(value.intervalMs !== undefined ? { intervalMs: value.intervalMs } : {}) };
|
|
424
|
+
}
|
|
410
425
|
if (operation === "set-agent-mention")
|
|
411
426
|
return { kind: operation, agentId, value: String(value.value) };
|
|
412
427
|
if (operation === "set-chat-mention")
|