mercury-agent 0.5.16 → 0.5.18-beta.3
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/examples/extensions/napkin/index.ts +45 -2
- package/package.json +5 -1
- package/src/cli/mercury.ts +8 -0
- package/src/core/conversation.ts +1 -0
- package/src/core/debounce.ts +135 -0
- package/src/core/handler.ts +129 -88
- package/src/core/routes/config-builtin.ts +9 -0
- package/src/core/routes/dashboard.ts +8 -1
- package/src/extensions/catalog.ts +3 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import { delimiter, dirname, join } from "node:path";
|
|
14
|
+
import { getApiKeyFromPiAuthFile } from "mercury-agent/storage/pi-auth";
|
|
14
15
|
|
|
15
16
|
const KNOWLEDGE_DIR = "knowledge";
|
|
16
17
|
const VAULT_DIRS = ["people", "projects", "references", "daily", "episodes", "weekly", "monthly", "templates"];
|
|
@@ -175,6 +176,7 @@ function runPromptAgent(
|
|
|
175
176
|
vaultDir: string,
|
|
176
177
|
promptPath: string,
|
|
177
178
|
instruction: string,
|
|
179
|
+
extraEnv?: Record<string, string>,
|
|
178
180
|
): Promise<{ ok: boolean; detail?: string }> {
|
|
179
181
|
let promptText: string;
|
|
180
182
|
try {
|
|
@@ -206,7 +208,7 @@ function runPromptAgent(
|
|
|
206
208
|
],
|
|
207
209
|
{
|
|
208
210
|
cwd: vaultDir,
|
|
209
|
-
env: envWithPiOnPath(),
|
|
211
|
+
env: { ...envWithPiOnPath(), ...extraEnv },
|
|
210
212
|
// Capture stderr (a background job has no console to inherit usefully):
|
|
211
213
|
// the captured tail is what makes a non-zero exit diagnosable in logs.
|
|
212
214
|
stdio: ["ignore", "inherit", "pipe"],
|
|
@@ -236,11 +238,13 @@ function runPromptAgent(
|
|
|
236
238
|
function runDistiller(
|
|
237
239
|
vaultDir: string,
|
|
238
240
|
dateFile: string,
|
|
241
|
+
extraEnv?: Record<string, string>,
|
|
239
242
|
): Promise<{ ok: boolean; detail?: string }> {
|
|
240
243
|
return runPromptAgent(
|
|
241
244
|
vaultDir,
|
|
242
245
|
KB_DISTILLER_PROMPT_PATH,
|
|
243
246
|
`Distill knowledge from: ${dateFile}`,
|
|
247
|
+
extraEnv,
|
|
244
248
|
);
|
|
245
249
|
}
|
|
246
250
|
|
|
@@ -399,6 +403,38 @@ export default function (mercury: {
|
|
|
399
403
|
};
|
|
400
404
|
});
|
|
401
405
|
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// LLM credential resolution for host-side pi spawns
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async function resolvePiAuthEnv(config: {
|
|
411
|
+
authPath?: string;
|
|
412
|
+
globalDir: string;
|
|
413
|
+
modelProvider: string;
|
|
414
|
+
}): Promise<Record<string, string>> {
|
|
415
|
+
const env: Record<string, string> = {};
|
|
416
|
+
|
|
417
|
+
// 1. Explicit env vars (strip MERCURY_ prefix, matching container-runner)
|
|
418
|
+
if (process.env.MERCURY_ANTHROPIC_API_KEY) {
|
|
419
|
+
env.ANTHROPIC_API_KEY = process.env.MERCURY_ANTHROPIC_API_KEY;
|
|
420
|
+
}
|
|
421
|
+
if (process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN) {
|
|
422
|
+
env.ANTHROPIC_API_KEY = process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 2. Fall back to Mercury's auth.json (OAuth token refresh)
|
|
426
|
+
if (!env.ANTHROPIC_API_KEY) {
|
|
427
|
+
const authPath = config.authPath ?? join(config.globalDir, "auth.json");
|
|
428
|
+
const key = await getApiKeyFromPiAuthFile({
|
|
429
|
+
provider: config.modelProvider,
|
|
430
|
+
authPath,
|
|
431
|
+
});
|
|
432
|
+
if (key) env.ANTHROPIC_API_KEY = key;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return env;
|
|
436
|
+
}
|
|
437
|
+
|
|
402
438
|
// ---------------------------------------------------------------------------
|
|
403
439
|
// KB Distillation job
|
|
404
440
|
// ---------------------------------------------------------------------------
|
|
@@ -417,6 +453,8 @@ export default function (mercury: {
|
|
|
417
453
|
return;
|
|
418
454
|
}
|
|
419
455
|
|
|
456
|
+
const piAuthEnv = await resolvePiAuthEnv(ctx.config);
|
|
457
|
+
|
|
420
458
|
const db = new Database(dbPath, { readonly: true });
|
|
421
459
|
|
|
422
460
|
const spaces = db
|
|
@@ -561,7 +599,7 @@ export default function (mercury: {
|
|
|
561
599
|
// --- Step 6: Distill each eligible date ---
|
|
562
600
|
for (const date of eligibleDates) {
|
|
563
601
|
const dateFile = join(messagesDir, `${date}.jsonl`);
|
|
564
|
-
const result = await runDistiller(knowledgeDir, dateFile);
|
|
602
|
+
const result = await runDistiller(knowledgeDir, dateFile, piAuthEnv);
|
|
565
603
|
if (result.ok) {
|
|
566
604
|
ctx.log.info("Distillation complete", { spaceId, date });
|
|
567
605
|
// Only persist past dates to distilled set (today will always be re-checked)
|
|
@@ -642,6 +680,9 @@ export default function (mercury: {
|
|
|
642
680
|
|
|
643
681
|
const dbPath = join(ctx.config.dataDir, "state.db");
|
|
644
682
|
if (!existsSync(dbPath)) return;
|
|
683
|
+
|
|
684
|
+
const piAuthEnv = await resolvePiAuthEnv(ctx.config);
|
|
685
|
+
|
|
645
686
|
const db = new Database(dbPath, { readonly: true });
|
|
646
687
|
|
|
647
688
|
const spaces = db
|
|
@@ -706,6 +747,7 @@ export default function (mercury: {
|
|
|
706
747
|
knowledgeDir,
|
|
707
748
|
WEEKLY_CONSOLIDATION_PROMPT_PATH,
|
|
708
749
|
instruction,
|
|
750
|
+
piAuthEnv,
|
|
709
751
|
);
|
|
710
752
|
if (result.ok) {
|
|
711
753
|
ctx.log.info("Weekly consolidation complete", { spaceId, week });
|
|
@@ -777,6 +819,7 @@ export default function (mercury: {
|
|
|
777
819
|
knowledgeDir,
|
|
778
820
|
MONTHLY_CONSOLIDATION_PROMPT_PATH,
|
|
779
821
|
instruction,
|
|
822
|
+
piAuthEnv,
|
|
780
823
|
);
|
|
781
824
|
if (result.ok) {
|
|
782
825
|
ctx.log.info("Monthly consolidation complete", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.18-beta.3",
|
|
4
4
|
"description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avishai Tsabari",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./src/extensions/types.ts",
|
|
25
25
|
"default": "./src/extensions/types.ts"
|
|
26
26
|
},
|
|
27
|
+
"./storage/pi-auth": {
|
|
28
|
+
"types": "./src/storage/pi-auth.ts",
|
|
29
|
+
"default": "./src/storage/pi-auth.ts"
|
|
30
|
+
},
|
|
27
31
|
"./tts": {
|
|
28
32
|
"types": "./src/tts/index.ts",
|
|
29
33
|
"default": "./src/tts/index.ts"
|
package/src/cli/mercury.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { Command } from "commander";
|
|
20
20
|
import { loadConfig, resolveProjectPath } from "../config.js";
|
|
21
|
+
import { getCatalogEntryByName } from "../extensions/catalog.js";
|
|
21
22
|
import {
|
|
22
23
|
checkExtensionIndexLoads,
|
|
23
24
|
getProjectDataDir,
|
|
@@ -1930,6 +1931,13 @@ function extensionsListAction(): void {
|
|
|
1930
1931
|
console.log(
|
|
1931
1932
|
`${ext.name.padEnd(nameWidth)} ${features.padEnd(featWidth)}${tag}${desc}`,
|
|
1932
1933
|
);
|
|
1934
|
+
const cat = getCatalogEntryByName(ext.name);
|
|
1935
|
+
if (cat?.prerequisites?.length) {
|
|
1936
|
+
const prereq = cat.prerequisites.join(", ");
|
|
1937
|
+
console.log(
|
|
1938
|
+
`${"".padEnd(nameWidth)} ${"".padEnd(featWidth)} Requires: ${prereq.slice(0, 60)}${prereq.length > 60 ? "…" : ""}`,
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1933
1941
|
}
|
|
1934
1942
|
}
|
|
1935
1943
|
|
package/src/core/conversation.ts
CHANGED
|
@@ -86,6 +86,7 @@ export function resolveConversation(
|
|
|
86
86
|
|
|
87
87
|
seedSpaceConfigIfAbsent(db, spaceId, "trigger.match", "always");
|
|
88
88
|
seedSpaceConfigIfAbsent(db, spaceId, "context.mode", "context");
|
|
89
|
+
seedSpaceConfigIfAbsent(db, spaceId, "debounce.idle_timeout_ms", "2000");
|
|
89
90
|
if (autoSpace.defaultMemberPermissions) {
|
|
90
91
|
seedSpaceConfigIfAbsent(
|
|
91
92
|
db,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { logger } from "../logger.js";
|
|
2
|
+
import type { IngressMessage } from "../types.js";
|
|
3
|
+
|
|
4
|
+
interface DebounceEntry {
|
|
5
|
+
ingresses: IngressMessage[];
|
|
6
|
+
timer: ReturnType<typeof setTimeout>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ProcessingEntry {
|
|
10
|
+
pending: IngressMessage[] | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FlushCallback = (merged: IngressMessage) => Promise<void>;
|
|
14
|
+
|
|
15
|
+
const DEBOUNCE_PLATFORMS: Record<string, number> = {
|
|
16
|
+
whatsapp: 2000,
|
|
17
|
+
telegram: 2000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getDefaultDebounceMs(platform: string): number {
|
|
21
|
+
return DEBOUNCE_PLATFORMS[platform] ?? 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mergeIngresses(batch: IngressMessage[]): IngressMessage {
|
|
25
|
+
if (batch.length === 1) return batch[0];
|
|
26
|
+
|
|
27
|
+
const first = batch[0];
|
|
28
|
+
const last = batch[batch.length - 1];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
platform: first.platform,
|
|
32
|
+
spaceId: first.spaceId,
|
|
33
|
+
conversationExternalId: first.conversationExternalId,
|
|
34
|
+
callerId: first.callerId,
|
|
35
|
+
authorName: first.authorName,
|
|
36
|
+
text: batch.map((m) => m.text).join("\n"),
|
|
37
|
+
isDM: first.isDM,
|
|
38
|
+
isReplyToBot: first.isReplyToBot,
|
|
39
|
+
attachments: batch.flatMap((m) => m.attachments),
|
|
40
|
+
hadIncomingAttachments: batch.some((m) => m.hadIncomingAttachments),
|
|
41
|
+
replyToPlatformMessageId: first.replyToPlatformMessageId,
|
|
42
|
+
platformMessageId: last.platformMessageId,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class MessageDebouncer {
|
|
47
|
+
private batches = new Map<string, DebounceEntry>();
|
|
48
|
+
private processing = new Map<string, ProcessingEntry>();
|
|
49
|
+
|
|
50
|
+
submit(
|
|
51
|
+
key: string,
|
|
52
|
+
ingress: IngressMessage,
|
|
53
|
+
timeoutMs: number,
|
|
54
|
+
onFlush: FlushCallback,
|
|
55
|
+
): void {
|
|
56
|
+
const proc = this.processing.get(key);
|
|
57
|
+
if (proc) {
|
|
58
|
+
if (!proc.pending) {
|
|
59
|
+
proc.pending = [ingress];
|
|
60
|
+
} else {
|
|
61
|
+
proc.pending.push(ingress);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existing = this.batches.get(key);
|
|
67
|
+
if (existing) {
|
|
68
|
+
clearTimeout(existing.timer);
|
|
69
|
+
existing.ingresses.push(ingress);
|
|
70
|
+
existing.timer = setTimeout(() => {
|
|
71
|
+
this.flush(key, onFlush);
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
} else {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
this.flush(key, onFlush);
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
this.batches.set(key, { ingresses: [ingress], timer });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
flushKey(key: string, onFlush: FlushCallback): void {
|
|
82
|
+
const entry = this.batches.get(key);
|
|
83
|
+
if (!entry) return;
|
|
84
|
+
clearTimeout(entry.timer);
|
|
85
|
+
this.batches.delete(key);
|
|
86
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
isProcessing(key: string): boolean {
|
|
90
|
+
return this.processing.has(key);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
flushAll(onFlush: FlushCallback): void {
|
|
94
|
+
for (const [key, entry] of this.batches) {
|
|
95
|
+
clearTimeout(entry.timer);
|
|
96
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
97
|
+
}
|
|
98
|
+
this.batches.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private flush(key: string, onFlush: FlushCallback): void {
|
|
102
|
+
const entry = this.batches.get(key);
|
|
103
|
+
if (!entry) return;
|
|
104
|
+
this.batches.delete(key);
|
|
105
|
+
this.executeFlush(key, entry.ingresses, onFlush);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private executeFlush(
|
|
109
|
+
key: string,
|
|
110
|
+
ingresses: IngressMessage[],
|
|
111
|
+
onFlush: FlushCallback,
|
|
112
|
+
): void {
|
|
113
|
+
const merged = mergeIngresses(ingresses);
|
|
114
|
+
this.processing.set(key, { pending: null });
|
|
115
|
+
|
|
116
|
+
onFlush(merged)
|
|
117
|
+
.catch((err) => {
|
|
118
|
+
logger.error("Debounce flush error", {
|
|
119
|
+
key,
|
|
120
|
+
error: err instanceof Error ? err.message : String(err),
|
|
121
|
+
});
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
const proc = this.processing.get(key);
|
|
125
|
+
if (proc?.pending) {
|
|
126
|
+
const batch = proc.pending;
|
|
127
|
+
proc.pending = null;
|
|
128
|
+
this.processing.delete(key);
|
|
129
|
+
this.executeFlush(key, batch, onFlush);
|
|
130
|
+
} else {
|
|
131
|
+
this.processing.delete(key);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/core/handler.ts
CHANGED
|
@@ -2,12 +2,17 @@ import type { Adapter, Message } from "chat";
|
|
|
2
2
|
import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
|
|
3
3
|
import type { AppConfig } from "../config.js";
|
|
4
4
|
import { logger } from "../logger.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
IngressMessage,
|
|
7
|
+
NormalizeContext,
|
|
8
|
+
PlatformBridge,
|
|
9
|
+
} from "../types.js";
|
|
6
10
|
import {
|
|
7
11
|
type AutoSpaceConfig,
|
|
8
12
|
inferConversationKind,
|
|
9
13
|
resolveConversation,
|
|
10
14
|
} from "./conversation.js";
|
|
15
|
+
import { getDefaultDebounceMs, MessageDebouncer } from "./debounce.js";
|
|
11
16
|
import type { MercuryCoreRuntime } from "./runtime.js";
|
|
12
17
|
import { loadTriggerConfig, matchTrigger } from "./trigger.js";
|
|
13
18
|
|
|
@@ -38,6 +43,105 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
|
|
|
38
43
|
}
|
|
39
44
|
: undefined;
|
|
40
45
|
|
|
46
|
+
const debouncer = new MessageDebouncer();
|
|
47
|
+
|
|
48
|
+
async function processIngress(
|
|
49
|
+
ingress: IngressMessage,
|
|
50
|
+
threadId: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
let lastStatusMessageId: string | undefined;
|
|
54
|
+
let hadStatusMessage = false;
|
|
55
|
+
|
|
56
|
+
const heartbeat = setInterval(() => {
|
|
57
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
58
|
+
const statusText = `⏳ Processing… (${elapsed}s)`;
|
|
59
|
+
const currentId = lastStatusMessageId;
|
|
60
|
+
|
|
61
|
+
if (currentId && bridge.editMessage) {
|
|
62
|
+
bridge
|
|
63
|
+
.editMessage(threadId, currentId, statusText)
|
|
64
|
+
.then((ok) => {
|
|
65
|
+
if (!ok) {
|
|
66
|
+
bridge
|
|
67
|
+
.sendReply(threadId, statusText)
|
|
68
|
+
.then(async (id) => {
|
|
69
|
+
await bridge
|
|
70
|
+
.deleteMessages?.(threadId, [currentId])
|
|
71
|
+
.catch(() => {});
|
|
72
|
+
if (id) lastStatusMessageId = id;
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
} else {
|
|
79
|
+
const prevId = lastStatusMessageId;
|
|
80
|
+
bridge
|
|
81
|
+
.sendReply(threadId, statusText)
|
|
82
|
+
.then(async (id) => {
|
|
83
|
+
if (prevId) {
|
|
84
|
+
await bridge.deleteMessages?.(threadId, [prevId]).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
if (id) {
|
|
87
|
+
lastStatusMessageId = id;
|
|
88
|
+
hadStatusMessage = true;
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
}, 30_000);
|
|
94
|
+
|
|
95
|
+
let result: Awaited<ReturnType<typeof core.handleRawInput>>;
|
|
96
|
+
try {
|
|
97
|
+
result = await core.handleRawInput(ingress, "chat-sdk");
|
|
98
|
+
} finally {
|
|
99
|
+
clearInterval(heartbeat);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (lastStatusMessageId) {
|
|
103
|
+
await bridge
|
|
104
|
+
.deleteMessages?.(threadId, [lastStatusMessageId])
|
|
105
|
+
.catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result.type === "ignore") return;
|
|
109
|
+
|
|
110
|
+
if (result.type === "denied") {
|
|
111
|
+
await bridge.sendReply(threadId, result.reason);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (result.result) {
|
|
116
|
+
const { reply, files, assistantMessageId } = result.result;
|
|
117
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
118
|
+
const finalReply =
|
|
119
|
+
hadStatusMessage && reply
|
|
120
|
+
? `${reply}\n\n_(responded in ${elapsed}s)_`
|
|
121
|
+
: reply;
|
|
122
|
+
if (finalReply || files.length > 0) {
|
|
123
|
+
const sentPlatformId = await bridge.sendReply(
|
|
124
|
+
threadId,
|
|
125
|
+
finalReply,
|
|
126
|
+
files.length > 0 ? files : undefined,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
sentPlatformId &&
|
|
131
|
+
assistantMessageId &&
|
|
132
|
+
ingress.conversationExternalId
|
|
133
|
+
) {
|
|
134
|
+
core.recordOutboundPlatformId(
|
|
135
|
+
assistantMessageId,
|
|
136
|
+
bridge.platform,
|
|
137
|
+
ingress.conversationExternalId,
|
|
138
|
+
sentPlatformId,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
41
145
|
return async (
|
|
42
146
|
adapter: Adapter,
|
|
43
147
|
threadId: string,
|
|
@@ -120,101 +224,38 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
|
|
|
120
224
|
}
|
|
121
225
|
}
|
|
122
226
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const heartbeat = setInterval(() => {
|
|
128
|
-
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
129
|
-
const statusText = `⏳ Processing… (${elapsed}s)`;
|
|
130
|
-
const currentId = lastStatusMessageId;
|
|
131
|
-
|
|
132
|
-
if (currentId && bridge.editMessage) {
|
|
133
|
-
bridge
|
|
134
|
-
.editMessage(threadId, currentId, statusText)
|
|
135
|
-
.then((ok) => {
|
|
136
|
-
if (!ok) {
|
|
137
|
-
// Edit failed — fall back to delete+send
|
|
138
|
-
bridge
|
|
139
|
-
.sendReply(threadId, statusText)
|
|
140
|
-
.then(async (id) => {
|
|
141
|
-
await bridge
|
|
142
|
-
.deleteMessages?.(threadId, [currentId])
|
|
143
|
-
.catch(() => {});
|
|
144
|
-
if (id) lastStatusMessageId = id;
|
|
145
|
-
})
|
|
146
|
-
.catch(() => {});
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
.catch(() => {});
|
|
150
|
-
} else {
|
|
151
|
-
const prevId = lastStatusMessageId;
|
|
152
|
-
bridge
|
|
153
|
-
.sendReply(threadId, statusText)
|
|
154
|
-
.then(async (id) => {
|
|
155
|
-
if (prevId) {
|
|
156
|
-
await bridge
|
|
157
|
-
.deleteMessages?.(threadId, [prevId])
|
|
158
|
-
.catch(() => {});
|
|
159
|
-
}
|
|
160
|
-
if (id) {
|
|
161
|
-
lastStatusMessageId = id;
|
|
162
|
-
hadStatusMessage = true;
|
|
163
|
-
}
|
|
164
|
-
})
|
|
165
|
-
.catch(() => {});
|
|
166
|
-
}
|
|
167
|
-
}, 30_000);
|
|
168
|
-
|
|
169
|
-
let result: Awaited<ReturnType<typeof core.handleRawInput>>;
|
|
170
|
-
try {
|
|
171
|
-
result = await core.handleRawInput(ingress, "chat-sdk");
|
|
172
|
-
} finally {
|
|
173
|
-
clearInterval(heartbeat);
|
|
174
|
-
}
|
|
227
|
+
const isCommand = text.startsWith("/");
|
|
228
|
+
const shouldProcess =
|
|
229
|
+
triggerResult.matched || (ingress.isReplyToBot && !isDM);
|
|
175
230
|
|
|
176
|
-
if (
|
|
177
|
-
await bridge
|
|
178
|
-
.deleteMessages?.(threadId, [lastStatusMessageId])
|
|
179
|
-
.catch(() => {});
|
|
180
|
-
}
|
|
231
|
+
if (!shouldProcess && !isCommand) return;
|
|
181
232
|
|
|
182
|
-
|
|
233
|
+
const debounceKey = `${spaceId}:${ingress.callerId}`;
|
|
234
|
+
const onFlush = (merged: IngressMessage) =>
|
|
235
|
+
processIngress(merged, threadId);
|
|
183
236
|
|
|
184
|
-
if (
|
|
185
|
-
|
|
237
|
+
if (isCommand) {
|
|
238
|
+
debouncer.flushKey(debounceKey, onFlush);
|
|
239
|
+
await processIngress(ingress, threadId);
|
|
186
240
|
return;
|
|
187
241
|
}
|
|
188
242
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
threadId,
|
|
199
|
-
finalReply,
|
|
200
|
-
files.length > 0 ? files : undefined,
|
|
201
|
-
);
|
|
243
|
+
const configuredMs = core.db.getSpaceConfig(
|
|
244
|
+
spaceId,
|
|
245
|
+
"debounce.idle_timeout_ms",
|
|
246
|
+
);
|
|
247
|
+
const parsed =
|
|
248
|
+
configuredMs !== null ? Number.parseInt(configuredMs, 10) : Number.NaN;
|
|
249
|
+
const timeoutMs = Number.isNaN(parsed)
|
|
250
|
+
? getDefaultDebounceMs(bridge.platform)
|
|
251
|
+
: parsed;
|
|
202
252
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
assistantMessageId &&
|
|
207
|
-
ingress.conversationExternalId
|
|
208
|
-
) {
|
|
209
|
-
core.recordOutboundPlatformId(
|
|
210
|
-
assistantMessageId,
|
|
211
|
-
bridge.platform,
|
|
212
|
-
ingress.conversationExternalId,
|
|
213
|
-
sentPlatformId,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
253
|
+
if (timeoutMs <= 0) {
|
|
254
|
+
await processIngress(ingress, threadId);
|
|
255
|
+
return;
|
|
217
256
|
}
|
|
257
|
+
|
|
258
|
+
debouncer.submit(debounceKey, ingress, timeoutMs, onFlush);
|
|
218
259
|
} catch (err) {
|
|
219
260
|
logger.error("Message handler error", {
|
|
220
261
|
platform: bridge.platform,
|
|
@@ -13,6 +13,7 @@ export const BUILTIN_CONFIG_KEYS = new Set([
|
|
|
13
13
|
"rate_limit",
|
|
14
14
|
"rate_limit.member",
|
|
15
15
|
"rate_limit.admin",
|
|
16
|
+
"debounce.idle_timeout_ms",
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -46,6 +47,8 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
|
|
|
46
47
|
"Daily message cap for members in this space. Overrides global rate_limit_daily_member. Integer ≥ 1, or 0 for unlimited.",
|
|
47
48
|
"rate_limit.admin":
|
|
48
49
|
"Daily message cap for admins in this space. Overrides global rate_limit_daily_admin. Integer ≥ 1, or 0 for unlimited.",
|
|
50
|
+
"debounce.idle_timeout_ms":
|
|
51
|
+
"Milliseconds to wait for additional messages before processing a batch. 0 disables debounce. Platform default: 2000 for WhatsApp/Telegram, 0 for others.",
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
@@ -103,6 +106,12 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
|
|
|
103
106
|
? null
|
|
104
107
|
: "Invalid rate_limit.admin value. Must be an integer between 0 and 10000";
|
|
105
108
|
},
|
|
109
|
+
"debounce.idle_timeout_ms": (v) => {
|
|
110
|
+
const n = Number.parseInt(v, 10);
|
|
111
|
+
return Number.isInteger(n) && n >= 0 && n <= 10000
|
|
112
|
+
? null
|
|
113
|
+
: "Invalid debounce.idle_timeout_ms value. Must be an integer between 0 and 10000";
|
|
114
|
+
},
|
|
106
115
|
};
|
|
107
116
|
|
|
108
117
|
export function isBuiltinConfigKey(key: string): boolean {
|
|
@@ -1698,11 +1698,14 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1698
1698
|
.filter(Boolean)
|
|
1699
1699
|
.join(", ");
|
|
1700
1700
|
const label = cat?.label ?? ext.name;
|
|
1701
|
+
const prereqHint = cat?.prerequisites?.length
|
|
1702
|
+
? `<div class="muted" style="font-size:12px;margin-top:4px">Requires: ${escapeHtml(cat.prerequisites.join(", "))}</div>`
|
|
1703
|
+
: "";
|
|
1701
1704
|
return `
|
|
1702
1705
|
<tr>
|
|
1703
1706
|
<td class="mono">${escapeHtml(ext.name)}</td>
|
|
1704
1707
|
<td>${escapeHtml(label)}</td>
|
|
1705
|
-
<td class="muted" style="max-width:320px">${escapeHtml(desc)}</td>
|
|
1708
|
+
<td class="muted" style="max-width:320px">${escapeHtml(desc)}${prereqHint}</td>
|
|
1706
1709
|
<td class="muted">${escapeHtml(feats || "—")}</td>
|
|
1707
1710
|
<td>
|
|
1708
1711
|
<button type="button" class="btn btn-sm btn-danger"
|
|
@@ -1731,6 +1734,9 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1731
1734
|
const envHint = entry.requiredEnvVars?.length
|
|
1732
1735
|
? `<div class="muted" style="font-size:12px;margin-top:4px">Env: ${escapeHtml(entry.requiredEnvVars.join(", "))}</div>`
|
|
1733
1736
|
: "";
|
|
1737
|
+
const prereqHint = entry.prerequisites?.length
|
|
1738
|
+
? `<div class="muted" style="font-size:12px;margin-top:4px">Requires: ${escapeHtml(entry.prerequisites.join(", "))}</div>`
|
|
1739
|
+
: "";
|
|
1734
1740
|
const installBtn = missing
|
|
1735
1741
|
? `<button type="button" class="btn btn-sm" disabled title="examples/extensions missing in this install">Unavailable</button>`
|
|
1736
1742
|
: `<form style="display:inline" hx-post="/dashboard/api/extensions/install" hx-target="#features-toast" hx-swap="innerHTML">
|
|
@@ -1744,6 +1750,7 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1744
1750
|
<td class="muted" style="max-width:320px">
|
|
1745
1751
|
${escapeHtml(entry.description)}
|
|
1746
1752
|
${envHint}
|
|
1753
|
+
${prereqHint}
|
|
1747
1754
|
</td>
|
|
1748
1755
|
<td class="muted">${escapeHtml(entry.category)}</td>
|
|
1749
1756
|
<td>${installBtn}</td>
|
|
@@ -19,6 +19,8 @@ export interface ExtensionCatalogEntry {
|
|
|
19
19
|
/** Subdirectory of `examples/extensions/` to copy from */
|
|
20
20
|
sourceDir: string;
|
|
21
21
|
requiredEnvVars?: string[];
|
|
22
|
+
/** Host-level prerequisites (CLI tools, system packages) shown to the user before install */
|
|
23
|
+
prerequisites?: string[];
|
|
22
24
|
requiresRestart: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -39,6 +41,7 @@ export const EXTENSION_CATALOG: ExtensionCatalogEntry[] = [
|
|
|
39
41
|
"Obsidian-style vault, napkin CLI, and optional KB distillation job.",
|
|
40
42
|
category: "knowledge",
|
|
41
43
|
sourceDir: "napkin",
|
|
44
|
+
prerequisites: ["pi CLI: bun install -g @earendil-works/pi-coding-agent"],
|
|
42
45
|
requiresRestart: true,
|
|
43
46
|
},
|
|
44
47
|
{
|