talon-agent 4.2.0 → 4.3.1
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/README.md +1 -1
- package/package.json +1 -1
- package/src/bootstrap.ts +3 -0
- package/src/cli/setup.ts +8 -2
- package/src/core/background/cron.ts +2 -4
- package/src/core/engine/cross-chat-relay.ts +125 -0
- package/src/core/engine/dispatcher.ts +10 -1
- package/src/core/engine/gateway-actions/cron.ts +16 -16
- package/src/core/engine/gateway-actions/cross-send.ts +9 -0
- package/src/core/engine/gateway-actions/goals.ts +8 -8
- package/src/core/engine/gateway-actions/history.ts +10 -17
- package/src/core/engine/gateway-actions/index.ts +9 -2
- package/src/core/engine/gateway-actions/models.ts +6 -6
- package/src/core/engine/gateway-actions/plugins.ts +2 -8
- package/src/core/engine/gateway-actions/shared.ts +3 -4
- package/src/core/engine/gateway-actions/triggers.ts +16 -25
- package/src/core/engine/gateway-actions/types.ts +11 -1
- package/src/core/engine/gateway.ts +12 -2
- package/src/core/mesh/device-files.ts +58 -5
- package/src/core/mesh/service.ts +52 -0
- package/src/core/types.ts +7 -0
- package/src/core/weaver/loom.ts +11 -0
- package/src/frontend/whatsapp/actions/index.ts +7 -1
- package/src/frontend/whatsapp/inbound.ts +7 -0
- package/src/plugins/github/provision.ts +1 -1
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/util/chat-id.ts +13 -0
package/README.md
CHANGED
|
@@ -328,7 +328,7 @@ On first boot Talon creates a venv at `~/.talon/mempalace-venv` and installs the
|
|
|
328
328
|
"backend": "mempalace",
|
|
329
329
|
"mempalace": {
|
|
330
330
|
"palacePath": "~/.talon/workspace/palace",
|
|
331
|
-
"version": "3.
|
|
331
|
+
"version": "3.10.0",
|
|
332
332
|
"autoUpdate": true,
|
|
333
333
|
"autoProvision": true
|
|
334
334
|
}
|
package/package.json
CHANGED
package/src/bootstrap.ts
CHANGED
|
@@ -568,9 +568,12 @@ export async function initBackendAndDispatcher(
|
|
|
568
568
|
const { crossSendHandlers } =
|
|
569
569
|
await import("./core/engine/gateway-actions/cross-send.js");
|
|
570
570
|
void deliverPendingProvisionReport(async (frontend, target, text) => {
|
|
571
|
+
// Chat-free: the same "no chat" sentinels handleChatFreeAction passes.
|
|
571
572
|
const result = await crossSendHandlers.send_via(
|
|
572
573
|
{ frontend, target, text },
|
|
573
574
|
0,
|
|
575
|
+
undefined,
|
|
576
|
+
"0",
|
|
574
577
|
);
|
|
575
578
|
return Boolean(result && (result as { ok?: unknown }).ok === true);
|
|
576
579
|
});
|
package/src/cli/setup.ts
CHANGED
|
@@ -29,14 +29,20 @@ const trimmedOrUndefined = (raw: string) => raw.trim() || undefined;
|
|
|
29
29
|
* the unguarded remainder still needed an `as string` cast. clack only
|
|
30
30
|
* ever resolves a symbol to mean "cancelled", so narrowing on `typeof`
|
|
31
31
|
* here removes both.
|
|
32
|
+
*
|
|
33
|
+
* The answer type is subtracted with `Exclude` rather than inferred from a
|
|
34
|
+
* `Promise<T | symbol>` parameter: clack 1.8.1 retyped `CANCEL_SYMBOL` as a
|
|
35
|
+
* `unique symbol`, which no longer cancels out against a plain `symbol` in
|
|
36
|
+
* the target, so every caller inferred `T` as the whole `answer | symbol`
|
|
37
|
+
* union and leaked the symbol into the config.
|
|
32
38
|
*/
|
|
33
|
-
async function askOrExit<T>(prompt: Promise<T
|
|
39
|
+
async function askOrExit<T>(prompt: Promise<T>): Promise<Exclude<T, symbol>> {
|
|
34
40
|
const value = await prompt;
|
|
35
41
|
if (typeof value === "symbol") {
|
|
36
42
|
p.cancel("Cancelled.");
|
|
37
43
|
process.exit(0);
|
|
38
44
|
}
|
|
39
|
-
return value
|
|
45
|
+
return value as Exclude<T, symbol>;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// ── Steps: each prompts for one section and returns its slice of the answers ──
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "../../storage/cron-store.js";
|
|
35
35
|
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
36
36
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
37
|
+
import { numericChatIdFor } from "../../util/chat-id.js";
|
|
37
38
|
import { runJobOneShot } from "./job-oneshot.js";
|
|
38
39
|
import {
|
|
39
40
|
jobAllowsRun,
|
|
@@ -464,10 +465,7 @@ const CRON_JOB_TIMEOUT_MS = 10 * 60_000; // 10-minute max per job
|
|
|
464
465
|
export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
|
|
465
466
|
if (!deps) return { status: "skipped", reason: "cron is not initialised" };
|
|
466
467
|
|
|
467
|
-
const numericChatId =
|
|
468
|
-
if (!Number.isFinite(numericChatId)) {
|
|
469
|
-
throw new Error(`Invalid chatId for job "${job.name}": ${job.chatId}`);
|
|
470
|
-
}
|
|
468
|
+
const numericChatId = numericChatIdFor(job.chatId);
|
|
471
469
|
|
|
472
470
|
if (job.type === "message") {
|
|
473
471
|
await deps.sendMessage(numericChatId, job.content, job.chatId);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-chat relay — keeps the session that sent a message somewhere
|
|
3
|
+
* else aware of what came back.
|
|
4
|
+
*
|
|
5
|
+
* `send_via` lets a Telegram session message a WhatsApp number. The
|
|
6
|
+
* reply, though, lands in the WhatsApp chat: a different chat id, a
|
|
7
|
+
* different session, a different history. The session that started the
|
|
8
|
+
* exchange never learns how it went, so the next thing it says about
|
|
9
|
+
* it is a guess — it "sent the message" and then goes blind.
|
|
10
|
+
*
|
|
11
|
+
* So a cross-send subscribes the sending chat to the target chat for a
|
|
12
|
+
* while. Inbound messages on the target are queued for each subscriber
|
|
13
|
+
* and folded into the front of that chat's next turn, the same way a
|
|
14
|
+
* person glancing at their other phone would have seen the reply before
|
|
15
|
+
* answering you.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately passive: the queued reply waits for the origin chat's
|
|
18
|
+
* next turn rather than waking it. The target chat usually has its own
|
|
19
|
+
* live session already handling that message — waking the origin too
|
|
20
|
+
* would mean two sessions independently reacting to one reply, and an
|
|
21
|
+
* unprompted Telegram message every time someone answers on WhatsApp.
|
|
22
|
+
*
|
|
23
|
+
* State is in-memory on purpose. A subscription is a fact about a
|
|
24
|
+
* conversation in flight; after a restart there is no turn waiting for
|
|
25
|
+
* the reply, and a relayed line from before the restart would arrive
|
|
26
|
+
* with no context to attach to.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** How long a cross-send keeps the sender subscribed to replies. */
|
|
30
|
+
export const RELAY_TTL_MS = 6 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Most queued lines held for one chat — a burst can't flood a prompt. */
|
|
33
|
+
export const RELAY_MAX_PENDING = 20;
|
|
34
|
+
|
|
35
|
+
/** Longest single relayed message; the rest is elided. */
|
|
36
|
+
export const RELAY_MAX_TEXT = 600;
|
|
37
|
+
|
|
38
|
+
/** target chat id → origin chat id → time of the last cross-send. */
|
|
39
|
+
const subscribers = new Map<string, Map<string, number>>();
|
|
40
|
+
|
|
41
|
+
/** origin chat id → relayed lines waiting for its next turn. */
|
|
42
|
+
const pending = new Map<string, string[]>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Record that `origin` sent into `target`, subscribing it to replies.
|
|
46
|
+
* A repeat send refreshes the window rather than adding a second entry.
|
|
47
|
+
*/
|
|
48
|
+
export function noteCrossSend(origin: string, target: string): void {
|
|
49
|
+
// Sending into your own chat is not a cross-send; subscribing would
|
|
50
|
+
// relay a chat's own inbound messages back into itself.
|
|
51
|
+
if (!origin || !target || origin === target) return;
|
|
52
|
+
const forTarget = subscribers.get(target) ?? new Map<string, number>();
|
|
53
|
+
forTarget.set(origin, Date.now());
|
|
54
|
+
subscribers.set(target, forTarget);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An inbound message arrived on `target`. Queue it for every chat still
|
|
59
|
+
* subscribed, and return how many were notified (0 when nobody is
|
|
60
|
+
* listening, which is the overwhelmingly common case).
|
|
61
|
+
*/
|
|
62
|
+
export function relayInbound(
|
|
63
|
+
target: string,
|
|
64
|
+
senderName: string,
|
|
65
|
+
text: string,
|
|
66
|
+
): number {
|
|
67
|
+
const forTarget = subscribers.get(target);
|
|
68
|
+
if (!forTarget?.size) return 0;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const body =
|
|
71
|
+
text.length > RELAY_MAX_TEXT
|
|
72
|
+
? `${text.slice(0, RELAY_MAX_TEXT)}… (truncated)`
|
|
73
|
+
: text;
|
|
74
|
+
let notified = 0;
|
|
75
|
+
for (const [origin, at] of forTarget) {
|
|
76
|
+
// Expiry is evaluated here rather than on a timer: the map is small,
|
|
77
|
+
// and a sweep that never runs can't leak a stale subscription.
|
|
78
|
+
if (now - at > RELAY_TTL_MS) {
|
|
79
|
+
forTarget.delete(origin);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const queue = pending.get(origin) ?? [];
|
|
83
|
+
queue.push(`${senderName} (in ${target}): ${body}`);
|
|
84
|
+
if (queue.length > RELAY_MAX_PENDING) {
|
|
85
|
+
queue.splice(0, queue.length - RELAY_MAX_PENDING);
|
|
86
|
+
}
|
|
87
|
+
pending.set(origin, queue);
|
|
88
|
+
notified++;
|
|
89
|
+
}
|
|
90
|
+
if (!forTarget.size) subscribers.delete(target);
|
|
91
|
+
return notified;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Drain everything queued for `chatId`. Draining is the point: each
|
|
96
|
+
* relayed line is folded into exactly one turn, so a long conversation
|
|
97
|
+
* doesn't re-read the same reply on every subsequent prompt.
|
|
98
|
+
*/
|
|
99
|
+
export function takePendingRelay(chatId: string): string[] {
|
|
100
|
+
const queue = pending.get(chatId);
|
|
101
|
+
if (!queue?.length) return [];
|
|
102
|
+
pending.delete(chatId);
|
|
103
|
+
return queue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render drained lines as the block that goes in front of a prompt.
|
|
108
|
+
* Returns "" when there is nothing, so callers can concatenate blindly.
|
|
109
|
+
*/
|
|
110
|
+
export function formatRelayBlock(lines: readonly string[]): string {
|
|
111
|
+
if (!lines.length) return "";
|
|
112
|
+
return (
|
|
113
|
+
`[Cross-chat: ${lines.length} new message(s) arrived in a chat this ` +
|
|
114
|
+
`session messaged. You have not replied to these — decide whether they ` +
|
|
115
|
+
`need one.]\n` +
|
|
116
|
+
lines.map((l) => `- ${l}`).join("\n") +
|
|
117
|
+
"\n\n"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test seam: drop all subscriptions and queued lines. */
|
|
122
|
+
export function resetCrossChatRelay(): void {
|
|
123
|
+
subscribers.clear();
|
|
124
|
+
pending.clear();
|
|
125
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExecuteParams, ExecuteResult } from "../types.js";
|
|
12
|
+
import { formatRelayBlock, takePendingRelay } from "./cross-chat-relay.js";
|
|
12
13
|
import { log } from "../../util/log.js";
|
|
13
14
|
import { taskTable, type KillOutcome } from "../tasks/index.js";
|
|
14
15
|
import { initWeaver, type Weaver, type WeaverDeps } from "../weaver/index.js";
|
|
@@ -66,8 +67,16 @@ export function stopAllTurns(): number {
|
|
|
66
67
|
* Execute an AI query with full lifecycle management.
|
|
67
68
|
* Same-chat queries are serialized (FIFO) to avoid session conflicts.
|
|
68
69
|
* Different-chat queries run in true parallel.
|
|
70
|
+
*
|
|
71
|
+
* Every turn for every frontend and every source funnels through here,
|
|
72
|
+
* which makes it the one place a cross-chat reply can be folded in
|
|
73
|
+
* without teaching each frontend about the relay.
|
|
69
74
|
*/
|
|
70
75
|
export async function execute(params: ExecuteParams): Promise<ExecuteResult> {
|
|
71
76
|
if (!weaver) throw new Error("Dispatcher not initialized");
|
|
72
|
-
|
|
77
|
+
const relayed = takePendingRelay(params.chatId);
|
|
78
|
+
const prompt = relayed.length
|
|
79
|
+
? formatRelayBlock(relayed) + params.prompt
|
|
80
|
+
: params.prompt;
|
|
81
|
+
return weaver.runTurn(relayed.length ? { ...params, prompt } : params);
|
|
73
82
|
}
|
|
@@ -33,19 +33,19 @@ import type { SharedActionHandlers } from "./types.js";
|
|
|
33
33
|
* Returns an error string, or null when valid.
|
|
34
34
|
*/
|
|
35
35
|
async function validateCronModelOverride(
|
|
36
|
-
|
|
36
|
+
chatKey: string,
|
|
37
37
|
model?: string,
|
|
38
38
|
provider?: string,
|
|
39
39
|
): Promise<string | null> {
|
|
40
|
-
const chatBackendId = getBackendIdForChat(
|
|
40
|
+
const chatBackendId = getBackendIdForChat(chatKey);
|
|
41
41
|
if (!provider || provider === chatBackendId) {
|
|
42
42
|
const capabilityErr = validateCronBackgroundCapability(
|
|
43
43
|
chatBackendId,
|
|
44
|
-
getBackendForChat(
|
|
44
|
+
getBackendForChat(chatKey),
|
|
45
45
|
);
|
|
46
46
|
if (capabilityErr) return capabilityErr;
|
|
47
47
|
if (!model) return null;
|
|
48
|
-
return validateJobModelOverride(
|
|
48
|
+
return validateJobModelOverride(chatKey, model);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
let acquired: Awaited<ReturnType<typeof acquireBackendInstance>> | null =
|
|
@@ -85,7 +85,7 @@ function validateCronBackgroundCapability(
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export const cronHandlers: SharedActionHandlers = {
|
|
88
|
-
create_cron_job: async (body, chatId) => {
|
|
88
|
+
create_cron_job: async (body, chatId, _backend, chatKey) => {
|
|
89
89
|
const parsed = parseCronSpec(body);
|
|
90
90
|
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
91
91
|
const spec = parsed.effective;
|
|
@@ -103,7 +103,7 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
103
103
|
// backend is rejected here instead of silently failing at fire time.
|
|
104
104
|
if (jobType === "query") {
|
|
105
105
|
const modelErr = await validateCronModelOverride(
|
|
106
|
-
|
|
106
|
+
chatKey,
|
|
107
107
|
spec.model,
|
|
108
108
|
spec.provider,
|
|
109
109
|
);
|
|
@@ -114,7 +114,7 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
114
114
|
addCronJob({
|
|
115
115
|
...spec,
|
|
116
116
|
id,
|
|
117
|
-
chatId:
|
|
117
|
+
chatId: chatKey,
|
|
118
118
|
enabled: true,
|
|
119
119
|
createdAt: Date.now(),
|
|
120
120
|
runCount: 0,
|
|
@@ -147,8 +147,8 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
147
147
|
};
|
|
148
148
|
},
|
|
149
149
|
|
|
150
|
-
list_cron_jobs: (body, chatId) => {
|
|
151
|
-
const jobs = getCronJobsForChat(
|
|
150
|
+
list_cron_jobs: (body, chatId, _backend, chatKey) => {
|
|
151
|
+
const jobs = getCronJobsForChat(chatKey);
|
|
152
152
|
if (jobs.length === 0)
|
|
153
153
|
return { ok: true, text: "No cron jobs in this chat." };
|
|
154
154
|
const fmt = (ms: number) =>
|
|
@@ -191,12 +191,12 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
191
191
|
};
|
|
192
192
|
},
|
|
193
193
|
|
|
194
|
-
edit_cron_job: async (body, chatId) => {
|
|
194
|
+
edit_cron_job: async (body, chatId, _backend, chatKey) => {
|
|
195
195
|
const jobId = String(body.job_id ?? "");
|
|
196
196
|
if (!jobId) return { ok: false, error: "Missing job_id" };
|
|
197
197
|
const job = getCronJob(jobId);
|
|
198
198
|
if (!job) return { ok: false, error: `Job ${jobId} not found` };
|
|
199
|
-
if (job.chatId !==
|
|
199
|
+
if (job.chatId !== chatKey)
|
|
200
200
|
return { ok: false, error: "Job belongs to a different chat" };
|
|
201
201
|
|
|
202
202
|
const parsed = parseCronSpec(body, job);
|
|
@@ -210,7 +210,7 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
210
210
|
("model" in updates || "provider" in updates || "type" in updates)
|
|
211
211
|
) {
|
|
212
212
|
const modelErr = await validateCronModelOverride(
|
|
213
|
-
|
|
213
|
+
chatKey,
|
|
214
214
|
effective.model,
|
|
215
215
|
effective.provider,
|
|
216
216
|
);
|
|
@@ -233,12 +233,12 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
233
233
|
};
|
|
234
234
|
},
|
|
235
235
|
|
|
236
|
-
run_cron_job: async (body, chatId) => {
|
|
236
|
+
run_cron_job: async (body, chatId, _backend, chatKey) => {
|
|
237
237
|
const jobId = String(body.job_id ?? "");
|
|
238
238
|
if (!jobId) return { ok: false, error: "Missing job_id" };
|
|
239
239
|
const job = getCronJob(jobId);
|
|
240
240
|
if (!job) return { ok: false, error: `Job ${jobId} not found` };
|
|
241
|
-
if (job.chatId !==
|
|
241
|
+
if (job.chatId !== chatKey)
|
|
242
242
|
return { ok: false, error: "Job belongs to a different chat" };
|
|
243
243
|
const result = await runJobNow(jobId);
|
|
244
244
|
if (!result.ok) return { ok: false, error: result.error ?? "Run failed" };
|
|
@@ -246,12 +246,12 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
246
246
|
return { ok: true, text: `Ran job "${job.name}" (${jobId}) now.` };
|
|
247
247
|
},
|
|
248
248
|
|
|
249
|
-
delete_cron_job: (body, chatId) => {
|
|
249
|
+
delete_cron_job: (body, chatId, _backend, chatKey) => {
|
|
250
250
|
const jobId = String(body.job_id ?? "");
|
|
251
251
|
if (!jobId) return { ok: false, error: "Missing job_id" };
|
|
252
252
|
const job = getCronJob(jobId);
|
|
253
253
|
if (!job) return { ok: false, error: `Job ${jobId} not found` };
|
|
254
|
-
if (job.chatId !==
|
|
254
|
+
if (job.chatId !== chatKey)
|
|
255
255
|
return { ok: false, error: "Job belongs to a different chat" };
|
|
256
256
|
deleteCronJob(jobId);
|
|
257
257
|
return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { FrontendActionHandler } from "../../types.js";
|
|
29
|
+
import { noteCrossSend } from "../cross-chat-relay.js";
|
|
29
30
|
import type { SharedActionHandlers } from "./types.js";
|
|
30
31
|
|
|
31
32
|
const targets = new Map<string, FrontendActionHandler>();
|
|
@@ -188,6 +189,14 @@ export const crossSendHandlers: SharedActionHandlers = {
|
|
|
188
189
|
error: `send_via: the ${frontend} frontend does not implement ${action}`,
|
|
189
190
|
};
|
|
190
191
|
}
|
|
192
|
+
// Subscribe the calling session to whatever comes back. `_chatId` is
|
|
193
|
+
// the bridge's own caller tag, and the frontend reports the canonical
|
|
194
|
+
// id it resolved — a phone number is not a chat id, so without that
|
|
195
|
+
// report there would be nothing to subscribe to.
|
|
196
|
+
if (result.ok) {
|
|
197
|
+
const origin = body._chatId ? String(body._chatId) : "";
|
|
198
|
+
noteCrossSend(origin, result.chat_id ?? target);
|
|
199
|
+
}
|
|
191
200
|
return result;
|
|
192
201
|
},
|
|
193
202
|
};
|
|
@@ -26,7 +26,7 @@ import { parseDueDate } from "./shared.js";
|
|
|
26
26
|
import type { SharedActionHandlers } from "./types.js";
|
|
27
27
|
|
|
28
28
|
export const goalHandlers: SharedActionHandlers = {
|
|
29
|
-
add_goal: (body, chatId) => {
|
|
29
|
+
add_goal: (body, chatId, _backend, chatKey) => {
|
|
30
30
|
const title = String(body.title ?? "").trim();
|
|
31
31
|
const description = body.description ? String(body.description) : undefined;
|
|
32
32
|
const priority = body.priority ?? "normal";
|
|
@@ -48,7 +48,7 @@ export const goalHandlers: SharedActionHandlers = {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const chatIdStr =
|
|
51
|
+
const chatIdStr = chatKey;
|
|
52
52
|
// No open-goal cap: goals grow freely. The heartbeat re-reads every open
|
|
53
53
|
// goal, so keep them tidy by closing finished ones (completed/abandoned).
|
|
54
54
|
|
|
@@ -75,10 +75,10 @@ export const goalHandlers: SharedActionHandlers = {
|
|
|
75
75
|
};
|
|
76
76
|
},
|
|
77
77
|
|
|
78
|
-
list_goals: (body, chatId) => {
|
|
78
|
+
list_goals: (body, chatId, _backend, chatKey) => {
|
|
79
79
|
const includeClosed = body.include_closed === true;
|
|
80
80
|
const goals = getGoalsForChat(
|
|
81
|
-
|
|
81
|
+
chatKey,
|
|
82
82
|
includeClosed ? GOAL_STATUSES : OPEN_GOAL_STATUSES,
|
|
83
83
|
);
|
|
84
84
|
if (goals.length === 0)
|
|
@@ -94,12 +94,12 @@ export const goalHandlers: SharedActionHandlers = {
|
|
|
94
94
|
};
|
|
95
95
|
},
|
|
96
96
|
|
|
97
|
-
update_goal: (body, chatId) => {
|
|
97
|
+
update_goal: (body, chatId, _backend, chatKey) => {
|
|
98
98
|
const goalId = String(body.goal_id ?? "");
|
|
99
99
|
if (!goalId) return { ok: false, error: "Missing goal_id" };
|
|
100
100
|
const goal = getGoal(goalId);
|
|
101
101
|
if (!goal) return { ok: false, error: `Goal ${goalId} not found` };
|
|
102
|
-
if (goal.chatId !==
|
|
102
|
+
if (goal.chatId !== chatKey)
|
|
103
103
|
return { ok: false, error: "Goal belongs to a different chat" };
|
|
104
104
|
|
|
105
105
|
const updates: Parameters<typeof updateGoal>[1] = {};
|
|
@@ -158,12 +158,12 @@ export const goalHandlers: SharedActionHandlers = {
|
|
|
158
158
|
};
|
|
159
159
|
},
|
|
160
160
|
|
|
161
|
-
delete_goal: (body, chatId) => {
|
|
161
|
+
delete_goal: (body, chatId, _backend, chatKey) => {
|
|
162
162
|
const goalId = String(body.goal_id ?? "");
|
|
163
163
|
if (!goalId) return { ok: false, error: "Missing goal_id" };
|
|
164
164
|
const goal = getGoal(goalId);
|
|
165
165
|
if (!goal) return { ok: false, error: `Goal ${goalId} not found` };
|
|
166
|
-
if (goal.chatId !==
|
|
166
|
+
if (goal.chatId !== chatKey)
|
|
167
167
|
return { ok: false, error: "Goal belongs to a different chat" };
|
|
168
168
|
deleteGoal(goalId);
|
|
169
169
|
return { ok: true, text: `Deleted goal "${goal.title}" (${goalId})` };
|
|
@@ -15,9 +15,9 @@ import { formatMediaIndex } from "../../../storage/media-index.js";
|
|
|
15
15
|
import type { SharedActionHandlers } from "./types.js";
|
|
16
16
|
|
|
17
17
|
export const historyHandlers: SharedActionHandlers = {
|
|
18
|
-
read_history: (body, chatId) => {
|
|
18
|
+
read_history: (body, chatId, _backend, chatKey) => {
|
|
19
19
|
const limit = Math.min(100, Number(body.limit ?? 30));
|
|
20
|
-
const cid =
|
|
20
|
+
const cid = chatKey;
|
|
21
21
|
// The tool schema advertises `offset_id` and `before` for paging back;
|
|
22
22
|
// this fallback used to ignore both and hand back the same newest
|
|
23
23
|
// window no matter what the model asked, so "go further back" was a
|
|
@@ -36,7 +36,7 @@ export const historyHandlers: SharedActionHandlers = {
|
|
|
36
36
|
return { ok: true, text: getRecentFormatted(cid, limit) };
|
|
37
37
|
},
|
|
38
38
|
|
|
39
|
-
search_history: (body, chatId) => {
|
|
39
|
+
search_history: (body, chatId, _backend, chatKey) => {
|
|
40
40
|
const limit = Math.min(100, Number(body.limit ?? 20));
|
|
41
41
|
// Optional ISO date bounds; an unparseable one is ignored rather
|
|
42
42
|
// than silently narrowing the search to nothing.
|
|
@@ -48,35 +48,28 @@ export const historyHandlers: SharedActionHandlers = {
|
|
|
48
48
|
};
|
|
49
49
|
return {
|
|
50
50
|
ok: true,
|
|
51
|
-
text: searchHistory(
|
|
51
|
+
text: searchHistory(chatKey, String(body.query ?? ""), limit, {
|
|
52
52
|
after: bound(body.after),
|
|
53
53
|
before: bound(body.before),
|
|
54
54
|
}),
|
|
55
55
|
};
|
|
56
56
|
},
|
|
57
57
|
|
|
58
|
-
get_user_messages: (body, chatId) => {
|
|
58
|
+
get_user_messages: (body, chatId, _backend, chatKey) => {
|
|
59
59
|
const limit = Math.min(50, Number(body.limit ?? 20));
|
|
60
60
|
return {
|
|
61
61
|
ok: true,
|
|
62
|
-
text: getMessagesByUser(
|
|
63
|
-
String(chatId),
|
|
64
|
-
String(body.user_name ?? ""),
|
|
65
|
-
limit,
|
|
66
|
-
),
|
|
62
|
+
text: getMessagesByUser(chatKey, String(body.user_name ?? ""), limit),
|
|
67
63
|
};
|
|
68
64
|
},
|
|
69
65
|
|
|
70
|
-
list_known_users: (body, chatId) => ({
|
|
66
|
+
list_known_users: (body, chatId, _backend, chatKey) => ({
|
|
71
67
|
ok: true,
|
|
72
|
-
text: getKnownUsers(
|
|
68
|
+
text: getKnownUsers(chatKey),
|
|
73
69
|
}),
|
|
74
70
|
|
|
75
|
-
list_media: (body, chatId) => ({
|
|
71
|
+
list_media: (body, chatId, _backend, chatKey) => ({
|
|
76
72
|
ok: true,
|
|
77
|
-
text: formatMediaIndex(
|
|
78
|
-
String(chatId),
|
|
79
|
-
Math.min(20, Number(body.limit ?? 10)),
|
|
80
|
-
),
|
|
73
|
+
text: formatMediaIndex(chatKey, Math.min(20, Number(body.limit ?? 10))),
|
|
81
74
|
}),
|
|
82
75
|
};
|
|
@@ -72,15 +72,22 @@ const chatFreeActions: ReadonlySet<string> = new Set([
|
|
|
72
72
|
...whatsappAccountChatFreeActions,
|
|
73
73
|
]);
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* `chatKey` is the chat's canonical string id (see `SharedActionHandler`).
|
|
77
|
+
* It defaults to `String(chatId)`, which is exact for Telegram and for the
|
|
78
|
+
* numeric-only callers (tests, chat-free dispatch); the gateway passes the
|
|
79
|
+
* real string id it holds for the active turn.
|
|
80
|
+
*/
|
|
75
81
|
export async function handleSharedAction(
|
|
76
82
|
body: Record<string, unknown>,
|
|
77
83
|
chatId: number,
|
|
78
84
|
backend?: Backend | null,
|
|
85
|
+
chatKey: string = String(chatId),
|
|
79
86
|
): Promise<ActionResult | null> {
|
|
80
87
|
const action = body.action as string;
|
|
81
88
|
const handler = handlers[action];
|
|
82
89
|
if (!handler) return null; // not a shared action — delegate to frontend
|
|
83
|
-
return handler(body, chatId, backend);
|
|
90
|
+
return handler(body, chatId, backend, chatKey);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
@@ -104,5 +111,5 @@ export async function handleChatFreeAction(
|
|
|
104
111
|
if (!isChatFreeAction(action)) return null;
|
|
105
112
|
const handler = handlers[action];
|
|
106
113
|
if (!handler) return null;
|
|
107
|
-
return handler(body, 0);
|
|
114
|
+
return handler(body, 0, undefined, "0");
|
|
108
115
|
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
import type { SharedActionHandlers } from "./types.js";
|
|
17
17
|
|
|
18
18
|
export const modelHandlers: SharedActionHandlers = {
|
|
19
|
-
list_models: async (body, chatId) => {
|
|
20
|
-
const chatIdStr =
|
|
19
|
+
list_models: async (body, chatId, _backend, chatKey) => {
|
|
20
|
+
const chatIdStr = chatKey;
|
|
21
21
|
const currentId = getBackendIdForChat(chatIdStr);
|
|
22
22
|
const requested = body.backend ? String(body.backend).trim() : "";
|
|
23
23
|
const targetId = requested || currentId;
|
|
@@ -100,8 +100,8 @@ export const modelHandlers: SharedActionHandlers = {
|
|
|
100
100
|
|
|
101
101
|
// Account-level, so it falls back to the pooled Claude backend when
|
|
102
102
|
// another provider is serving this chat.
|
|
103
|
-
plan_usage: async (_body, chatId) => {
|
|
104
|
-
const current = getPooledBackend(getBackendIdForChat(
|
|
103
|
+
plan_usage: async (_body, chatId, _backend, chatKey) => {
|
|
104
|
+
const current = getPooledBackend(getBackendIdForChat(chatKey));
|
|
105
105
|
const source = current?.usage?.getPlanUsage
|
|
106
106
|
? current
|
|
107
107
|
: getPooledBackend("claude");
|
|
@@ -131,8 +131,8 @@ export const modelHandlers: SharedActionHandlers = {
|
|
|
131
131
|
};
|
|
132
132
|
},
|
|
133
133
|
|
|
134
|
-
list_backends: (body, chatId) => {
|
|
135
|
-
const currentId = getBackendIdForChat(
|
|
134
|
+
list_backends: (body, chatId, _backend, chatKey) => {
|
|
135
|
+
const currentId = getBackendIdForChat(chatKey);
|
|
136
136
|
const backends = getAvailableBackends().map((b) => ({
|
|
137
137
|
id: b.id,
|
|
138
138
|
label: b.label,
|
|
@@ -43,7 +43,7 @@ export async function performPluginReload(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export const pluginHandlers: SharedActionHandlers = {
|
|
46
|
-
reload_plugins: async (body, chatId, backend) => {
|
|
46
|
+
reload_plugins: async (body, chatId, backend, chatKey) => {
|
|
47
47
|
try {
|
|
48
48
|
const { names } = await performPluginReload(backend);
|
|
49
49
|
|
|
@@ -52,13 +52,7 @@ export const pluginHandlers: SharedActionHandlers = {
|
|
|
52
52
|
let mcpInfo = "";
|
|
53
53
|
if (backend?.tools?.refreshTools) {
|
|
54
54
|
try {
|
|
55
|
-
|
|
56
|
-
// non-numeric IDs, e.g. Teams/terminal) over the numeric context ID.
|
|
57
|
-
const refreshChatId =
|
|
58
|
-
typeof body._chatId === "string" && body._chatId.length > 0
|
|
59
|
-
? body._chatId
|
|
60
|
-
: String(chatId);
|
|
61
|
-
const result = await backend.tools.refreshTools(refreshChatId);
|
|
55
|
+
const result = await backend.tools.refreshTools(chatKey);
|
|
62
56
|
if (result) {
|
|
63
57
|
const parts: string[] = [];
|
|
64
58
|
if (result.added.length > 0)
|
|
@@ -30,15 +30,14 @@ export function parseDueDate(value: unknown): number | undefined {
|
|
|
30
30
|
* when it isn't, or `null` when it's valid.
|
|
31
31
|
*/
|
|
32
32
|
export async function validateJobModelOverride(
|
|
33
|
-
|
|
33
|
+
chatKey: string,
|
|
34
34
|
model: string,
|
|
35
35
|
): Promise<string | null> {
|
|
36
36
|
try {
|
|
37
|
-
const chatIdStr = String(chatId);
|
|
38
37
|
const ref = await resolveExplicitModelRef(
|
|
39
38
|
model,
|
|
40
|
-
getBackendForChat(
|
|
41
|
-
getBackendIdForChat(
|
|
39
|
+
getBackendForChat(chatKey),
|
|
40
|
+
getBackendIdForChat(chatKey),
|
|
42
41
|
);
|
|
43
42
|
if (!ref) {
|
|
44
43
|
return (
|
|
@@ -31,7 +31,7 @@ import { validateJobModelOverride } from "./shared.js";
|
|
|
31
31
|
import type { SharedActionHandlers } from "./types.js";
|
|
32
32
|
|
|
33
33
|
export const triggerHandlers: SharedActionHandlers = {
|
|
34
|
-
trigger_create: async (body, chatId) => {
|
|
34
|
+
trigger_create: async (body, chatId, _backend, chatKey) => {
|
|
35
35
|
const name = String(body.name ?? "").trim();
|
|
36
36
|
const language = body.language;
|
|
37
37
|
const script = String(body.script ?? "");
|
|
@@ -55,14 +55,13 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
55
55
|
const timeoutErr = validateTimeout(timeoutSeconds);
|
|
56
56
|
if (timeoutErr) return { ok: false, error: timeoutErr };
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
if (getTriggerByName(chatIdStr, name)) {
|
|
58
|
+
if (getTriggerByName(chatKey, name)) {
|
|
60
59
|
return {
|
|
61
60
|
ok: false,
|
|
62
61
|
error: `A trigger named "${name}" already exists in this chat. Cancel it first or pick a different name.`,
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
|
-
const active = getActiveTriggersForChat(
|
|
64
|
+
const active = getActiveTriggersForChat(chatKey);
|
|
66
65
|
if (active.length >= MAX_ACTIVE_PER_CHAT) {
|
|
67
66
|
return {
|
|
68
67
|
ok: false,
|
|
@@ -70,18 +69,10 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
70
69
|
};
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
const numericChatId = Number(chatId);
|
|
74
|
-
if (!Number.isFinite(numericChatId)) {
|
|
75
|
-
return {
|
|
76
|
-
ok: false,
|
|
77
|
-
error: `Cannot derive numeric chatId from ${chatId}`,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
72
|
// Validate the model up front so a bad id is rejected here instead of
|
|
82
73
|
// silently failing at fire time.
|
|
83
74
|
if (model) {
|
|
84
|
-
const modelErr = await validateJobModelOverride(
|
|
75
|
+
const modelErr = await validateJobModelOverride(chatKey, model);
|
|
85
76
|
if (modelErr) return { ok: false, error: modelErr };
|
|
86
77
|
}
|
|
87
78
|
|
|
@@ -89,19 +80,19 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
89
80
|
const lang = language as TriggerLanguage;
|
|
90
81
|
let scriptPath: string;
|
|
91
82
|
try {
|
|
92
|
-
scriptPath = writeScriptFile(
|
|
83
|
+
scriptPath = writeScriptFile(chatKey, id, lang, script);
|
|
93
84
|
} catch (err) {
|
|
94
85
|
return {
|
|
95
86
|
ok: false,
|
|
96
87
|
error: `Failed to write script: ${err instanceof Error ? err.message : err}`,
|
|
97
88
|
};
|
|
98
89
|
}
|
|
99
|
-
const logPath = triggerLogPath(
|
|
90
|
+
const logPath = triggerLogPath(chatKey, id);
|
|
100
91
|
|
|
101
92
|
const trigger = {
|
|
102
93
|
id,
|
|
103
|
-
chatId:
|
|
104
|
-
numericChatId,
|
|
94
|
+
chatId: chatKey,
|
|
95
|
+
numericChatId: chatId,
|
|
105
96
|
name,
|
|
106
97
|
language: lang,
|
|
107
98
|
scriptPath,
|
|
@@ -151,8 +142,8 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
151
142
|
};
|
|
152
143
|
},
|
|
153
144
|
|
|
154
|
-
trigger_list: (body, chatId) => {
|
|
155
|
-
const triggers = getTriggersForChat(
|
|
145
|
+
trigger_list: (body, chatId, _backend, chatKey) => {
|
|
146
|
+
const triggers = getTriggersForChat(chatKey);
|
|
156
147
|
if (triggers.length === 0)
|
|
157
148
|
return { ok: true, text: "No triggers in this chat." };
|
|
158
149
|
const lines = triggers.map((t) => {
|
|
@@ -181,12 +172,12 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
181
172
|
};
|
|
182
173
|
},
|
|
183
174
|
|
|
184
|
-
trigger_cancel: (body, chatId) => {
|
|
175
|
+
trigger_cancel: (body, chatId, _backend, chatKey) => {
|
|
185
176
|
const triggerId = String(body.trigger_id ?? "");
|
|
186
177
|
if (!triggerId) return { ok: false, error: "Missing trigger_id" };
|
|
187
178
|
const t = getTrigger(triggerId);
|
|
188
179
|
if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
|
|
189
|
-
if (t.chatId !==
|
|
180
|
+
if (t.chatId !== chatKey)
|
|
190
181
|
return { ok: false, error: "Trigger belongs to a different chat" };
|
|
191
182
|
const wasRunning = cancelTrigger(triggerId);
|
|
192
183
|
if (!wasRunning) {
|
|
@@ -201,12 +192,12 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
201
192
|
};
|
|
202
193
|
},
|
|
203
194
|
|
|
204
|
-
trigger_logs: (body, chatId) => {
|
|
195
|
+
trigger_logs: (body, chatId, _backend, chatKey) => {
|
|
205
196
|
const triggerId = String(body.trigger_id ?? "");
|
|
206
197
|
if (!triggerId) return { ok: false, error: "Missing trigger_id" };
|
|
207
198
|
const t = getTrigger(triggerId);
|
|
208
199
|
if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
|
|
209
|
-
if (t.chatId !==
|
|
200
|
+
if (t.chatId !== chatKey)
|
|
210
201
|
return { ok: false, error: "Trigger belongs to a different chat" };
|
|
211
202
|
const lines = Math.min(500, Math.max(1, Number(body.lines ?? 80)));
|
|
212
203
|
const { tail, truncated } = readTriggerLogTail(t.logPath, lines);
|
|
@@ -219,12 +210,12 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
219
210
|
};
|
|
220
211
|
},
|
|
221
212
|
|
|
222
|
-
trigger_delete: (body, chatId) => {
|
|
213
|
+
trigger_delete: (body, chatId, _backend, chatKey) => {
|
|
223
214
|
const triggerId = String(body.trigger_id ?? "");
|
|
224
215
|
if (!triggerId) return { ok: false, error: "Missing trigger_id" };
|
|
225
216
|
const t = getTrigger(triggerId);
|
|
226
217
|
if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
|
|
227
|
-
if (t.chatId !==
|
|
218
|
+
if (t.chatId !== chatKey)
|
|
228
219
|
return { ok: false, error: "Trigger belongs to a different chat" };
|
|
229
220
|
// Cancel first if it's still running so we don't orphan a child
|
|
230
221
|
cancelTrigger(triggerId);
|
|
@@ -9,10 +9,20 @@
|
|
|
9
9
|
import type { ActionResult } from "../../types.js";
|
|
10
10
|
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* `chatId` is the frontend's numeric id — the only identity the HTTP bridge
|
|
14
|
+
* carries. `chatKey` is the chat's canonical string id (the dispatcher's
|
|
15
|
+
* `chatId`, which every store is keyed on): for Telegram it is just
|
|
16
|
+
* `String(chatId)`, for every other frontend it is the real id the numeric
|
|
17
|
+
* one was derived from (`d_…`, `wa_…`, `discord_…`). Handlers that persist,
|
|
18
|
+
* look up, or route by chat must use `chatKey`, never `String(chatId)` —
|
|
19
|
+
* a `d_…` chat's jobs stored under its numeric id fire into Telegram.
|
|
20
|
+
*/
|
|
12
21
|
type SharedActionHandler = (
|
|
13
22
|
body: Record<string, unknown>,
|
|
14
23
|
chatId: number,
|
|
15
|
-
backend
|
|
24
|
+
backend: Backend | null | undefined,
|
|
25
|
+
chatKey: string,
|
|
16
26
|
) => Promise<ActionResult> | ActionResult;
|
|
17
27
|
|
|
18
28
|
export type SharedActionHandlers = Record<string, SharedActionHandler>;
|
|
@@ -307,6 +307,11 @@ export class Gateway {
|
|
|
307
307
|
const action = typeof body.action === "string" ? body.action : "";
|
|
308
308
|
if (!action) return { ok: false, error: "Missing action" };
|
|
309
309
|
const t0 = Date.now();
|
|
310
|
+
// The canonical string id for this chat. The bridge only carries the
|
|
311
|
+
// numeric id, but the turn's Thread is keyed by the dispatcher's string
|
|
312
|
+
// chatId — hand that to the handlers so a `d_…`/`wa_…` chat's cron jobs,
|
|
313
|
+
// triggers and history land under the id the rest of the engine uses.
|
|
314
|
+
const chatKey = this.loom.stringIdForNumeric(chatId) ?? String(chatId);
|
|
310
315
|
|
|
311
316
|
try {
|
|
312
317
|
// Try frontend first — it has richer implementations (e.g. userbot history)
|
|
@@ -326,7 +331,7 @@ export class Gateway {
|
|
|
326
331
|
}
|
|
327
332
|
|
|
328
333
|
// Try plugin actions (loaded from external plugin packages)
|
|
329
|
-
const pluginResult = await handlePluginAction(body,
|
|
334
|
+
const pluginResult = await handlePluginAction(body, chatKey);
|
|
330
335
|
if (pluginResult) {
|
|
331
336
|
logDebug(
|
|
332
337
|
"gateway",
|
|
@@ -336,7 +341,12 @@ export class Gateway {
|
|
|
336
341
|
}
|
|
337
342
|
|
|
338
343
|
// Shared actions last — provides in-memory fallbacks for history, cron, etc.
|
|
339
|
-
const shared = await handleSharedAction(
|
|
344
|
+
const shared = await handleSharedAction(
|
|
345
|
+
body,
|
|
346
|
+
chatId,
|
|
347
|
+
this.backend,
|
|
348
|
+
chatKey,
|
|
349
|
+
);
|
|
340
350
|
if (shared) {
|
|
341
351
|
logDebug(
|
|
342
352
|
"gateway",
|
|
@@ -61,8 +61,53 @@ export interface DeviceFilesHost {
|
|
|
61
61
|
* fallback tolerable without bloating a single SSE frame too far.
|
|
62
62
|
*/
|
|
63
63
|
const FILE_CHUNK_BYTES = 1024 * 1024;
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Wall-clock budget for one streamed transfer (command dispatch → done).
|
|
66
|
+
*
|
|
67
|
+
* A device that accepts the transfer command and then goes silent answers
|
|
68
|
+
* never: a stalled body stream on a flaky mobile link delivers neither
|
|
69
|
+
* bytes nor a FIN, so the device-side `await` for the body never completes
|
|
70
|
+
* and never throws, and the command result it would have posted afterwards
|
|
71
|
+
* is never posted. The dispatch timeout is the only thing that ends that
|
|
72
|
+
* wait — and since turns serialize per chat, the whole chat's message loop
|
|
73
|
+
* queues behind it. A flat hour therefore takes a chat offline for an hour
|
|
74
|
+
* over one dropped TCP connection.
|
|
75
|
+
*
|
|
76
|
+
* So the budget is sized to the payload instead: a fixed grace for the
|
|
77
|
+
* handshake and device-side setup, plus the body at a deliberately
|
|
78
|
+
* pessimistic floor throughput, clamped at both ends. A genuinely slow
|
|
79
|
+
* link still finishes; a dead one gives up in minutes.
|
|
80
|
+
*/
|
|
81
|
+
const STREAM_TRANSFER_GRACE_MS = 60_000;
|
|
82
|
+
const STREAM_TRANSFER_FLOOR_BYTES_PER_SEC = 32 * 1024;
|
|
83
|
+
/**
|
|
84
|
+
* Ceiling, so a payload large enough to out-scale the formula still can't
|
|
85
|
+
* block a chat indefinitely. Deliberately well above what a big transfer
|
|
86
|
+
* over a slow link needs — the common wedge is a SMALL transfer that used
|
|
87
|
+
* to inherit the same budget as a huge one, and that case now resolves in
|
|
88
|
+
* about a minute.
|
|
89
|
+
*/
|
|
90
|
+
const STREAM_TRANSFER_MAX_MS = 30 * 60 * 1000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Budget for streaming `bytes` to or from a device. An unknown size — a
|
|
94
|
+
* pull whose caller has not already paid for a `stat` — takes the ceiling
|
|
95
|
+
* rather than an extra mesh round trip to size a budget with: still
|
|
96
|
+
* bounded, just less tightly. Either way the device-side idle timeout
|
|
97
|
+
* normally answers long before this fires; this is the backstop for a
|
|
98
|
+
* device that has stopped answering altogether.
|
|
99
|
+
*/
|
|
100
|
+
export function streamTransferTimeoutMs(bytes?: number): number {
|
|
101
|
+
if (bytes === undefined || !Number.isFinite(bytes)) {
|
|
102
|
+
return STREAM_TRANSFER_MAX_MS;
|
|
103
|
+
}
|
|
104
|
+
const forBody =
|
|
105
|
+
(Math.max(0, bytes) / STREAM_TRANSFER_FLOOR_BYTES_PER_SEC) * 1000;
|
|
106
|
+
return Math.min(
|
|
107
|
+
Math.round(STREAM_TRANSFER_GRACE_MS + forBody),
|
|
108
|
+
STREAM_TRANSFER_MAX_MS,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
66
111
|
/** readFileBytes switches to the streaming path above this size. */
|
|
67
112
|
const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
68
113
|
/**
|
|
@@ -134,13 +179,14 @@ export class DeviceFiles {
|
|
|
134
179
|
target: DeviceInfo,
|
|
135
180
|
remote: string,
|
|
136
181
|
dest: string,
|
|
182
|
+
sizeHint?: number,
|
|
137
183
|
): Promise<{ bytes: number } | { error: string }> {
|
|
138
184
|
const { token, done } = this.transfers.createPull(target.id, dest);
|
|
139
185
|
const dispatched = await this.host.dispatchCommand(
|
|
140
186
|
target.id,
|
|
141
187
|
"upload_file",
|
|
142
188
|
{ token, path: remote },
|
|
143
|
-
|
|
189
|
+
streamTransferTimeoutMs(sizeHint),
|
|
144
190
|
);
|
|
145
191
|
if ("error" in dispatched) {
|
|
146
192
|
this.transfers.cancel(token);
|
|
@@ -203,7 +249,7 @@ export class DeviceFiles {
|
|
|
203
249
|
const size = await this.statSize(target.id, p);
|
|
204
250
|
if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
|
|
205
251
|
const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
|
|
206
|
-
const pulled = await this.pullViaStream(target, p, tmp);
|
|
252
|
+
const pulled = await this.pullViaStream(target, p, tmp, size);
|
|
207
253
|
if ("error" in pulled) return { error: pulled.error };
|
|
208
254
|
try {
|
|
209
255
|
const data = await readFile(tmp);
|
|
@@ -338,12 +384,19 @@ export class DeviceFiles {
|
|
|
338
384
|
const target = resolved.target;
|
|
339
385
|
if (this.canStream(target, "download_file")) {
|
|
340
386
|
const started = Date.now();
|
|
387
|
+
// The source is on this host, so sizing the budget costs a local stat
|
|
388
|
+
// rather than a mesh round trip. A source we cannot stat still gets a
|
|
389
|
+
// bounded (if looser) budget — this decides a timeout, not whether
|
|
390
|
+
// the transfer is allowed to start.
|
|
391
|
+
const localSize = await stat(local)
|
|
392
|
+
.then((s) => s.size)
|
|
393
|
+
.catch(() => undefined);
|
|
341
394
|
const { token } = this.transfers.createPush(target.id, local);
|
|
342
395
|
const dispatched = await this.host.dispatchCommand(
|
|
343
396
|
target.id,
|
|
344
397
|
"download_file",
|
|
345
398
|
{ token, path: remote },
|
|
346
|
-
|
|
399
|
+
streamTransferTimeoutMs(localSize),
|
|
347
400
|
);
|
|
348
401
|
if ("error" in dispatched) {
|
|
349
402
|
this.transfers.cancel(token);
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -82,6 +82,8 @@ export type MeshServiceOptions = {
|
|
|
82
82
|
pollIntervalMs?: number;
|
|
83
83
|
/** How long a device command waits for its result before timing out. */
|
|
84
84
|
commandTimeoutMs?: number;
|
|
85
|
+
/** How often a long command re-checks that its device is still present. */
|
|
86
|
+
presenceWatchIntervalMs?: number;
|
|
85
87
|
/** Node-binary resolver override (tests — the real one builds/downloads). */
|
|
86
88
|
nodeBinaryResolver?: NodeBinaryResolver;
|
|
87
89
|
};
|
|
@@ -100,6 +102,8 @@ const MAX_HISTORY_LINES = 24;
|
|
|
100
102
|
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
101
103
|
/** Hard ceiling on a caller-requested exec timeout. */
|
|
102
104
|
const MAX_EXEC_TIMEOUT_MS = 300_000;
|
|
105
|
+
/** How often a long-running command re-checks that its device is still up. */
|
|
106
|
+
const PRESENCE_WATCH_INTERVAL_MS = 15_000;
|
|
103
107
|
export class MeshService {
|
|
104
108
|
private readonly waiters = new Set<() => void>();
|
|
105
109
|
private readonly transports = new Set<MeshTransport>();
|
|
@@ -126,6 +130,7 @@ export class MeshService {
|
|
|
126
130
|
private readonly freshFixTimeoutMs: number;
|
|
127
131
|
private readonly pollIntervalMs: number;
|
|
128
132
|
private readonly commandTimeoutMs: number;
|
|
133
|
+
private readonly presenceWatchIntervalMs: number;
|
|
129
134
|
private loading: Promise<void> | null = null;
|
|
130
135
|
|
|
131
136
|
constructor(
|
|
@@ -137,6 +142,8 @@ export class MeshService {
|
|
|
137
142
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
138
143
|
this.commandTimeoutMs =
|
|
139
144
|
options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
145
|
+
this.presenceWatchIntervalMs =
|
|
146
|
+
options.presenceWatchIntervalMs ?? PRESENCE_WATCH_INTERVAL_MS;
|
|
140
147
|
this.resolveNode = options.nodeBinaryResolver ?? resolveNodeBinary;
|
|
141
148
|
this.files = new DeviceFiles({
|
|
142
149
|
load: () => this.load(),
|
|
@@ -264,6 +271,38 @@ export class MeshService {
|
|
|
264
271
|
return true;
|
|
265
272
|
}
|
|
266
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Fail a pending command early when its device stops heartbeating.
|
|
276
|
+
*
|
|
277
|
+
* A device that drops mid-command will never answer, so waiting out the
|
|
278
|
+
* remaining budget only buys silence — and on a transfer budget that
|
|
279
|
+
* silence is minutes of a tool call, and therefore a chat, wedged behind
|
|
280
|
+
* a device that is plainly gone. Presence is derived from `lastSeen`, so
|
|
281
|
+
* this reports one presence grace period after the device goes quiet
|
|
282
|
+
* rather than instantly; short budgets aren't watched at all, since their
|
|
283
|
+
* own timeout lands first. Returns a stop function.
|
|
284
|
+
*/
|
|
285
|
+
private watchPresence(
|
|
286
|
+
deviceId: string,
|
|
287
|
+
timeoutMs: number,
|
|
288
|
+
onGone: (device: DeviceInfo) => void,
|
|
289
|
+
): () => void {
|
|
290
|
+
const every = this.presenceWatchIntervalMs;
|
|
291
|
+
if (timeoutMs <= every) return () => {};
|
|
292
|
+
const timer = setInterval(() => {
|
|
293
|
+
const current = this.registry
|
|
294
|
+
.list()
|
|
295
|
+
.devices.find((d) => d.id === deviceId);
|
|
296
|
+
// Unknown device: leave it to the timeout — an entry evicted from the
|
|
297
|
+
// registry is not evidence that the device stopped answering.
|
|
298
|
+
if (!current || current.online) return;
|
|
299
|
+
clearInterval(timer);
|
|
300
|
+
onGone(current);
|
|
301
|
+
}, every);
|
|
302
|
+
timer.unref?.();
|
|
303
|
+
return () => clearInterval(timer);
|
|
304
|
+
}
|
|
305
|
+
|
|
267
306
|
/**
|
|
268
307
|
* Push one command to a device and await its result (or time out). The
|
|
269
308
|
* low-level primitive under every command tool; exposed for tests and
|
|
@@ -282,8 +321,10 @@ export class MeshService {
|
|
|
282
321
|
params,
|
|
283
322
|
};
|
|
284
323
|
return new Promise<DeviceCommandResult>((resolve) => {
|
|
324
|
+
let stopWatch: () => void = () => {};
|
|
285
325
|
const timer = setTimeout(() => {
|
|
286
326
|
this.pendingCommands.delete(command.id);
|
|
327
|
+
stopWatch();
|
|
287
328
|
resolve({
|
|
288
329
|
commandId: command.id,
|
|
289
330
|
deviceId: device.id,
|
|
@@ -292,10 +333,21 @@ export class MeshService {
|
|
|
292
333
|
});
|
|
293
334
|
}, timeoutMs);
|
|
294
335
|
timer.unref?.();
|
|
336
|
+
stopWatch = this.watchPresence(device.id, timeoutMs, (current) => {
|
|
337
|
+
if (!this.pendingCommands.delete(command.id)) return;
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
resolve({
|
|
340
|
+
commandId: command.id,
|
|
341
|
+
deviceId: device.id,
|
|
342
|
+
ok: false,
|
|
343
|
+
message: `${device.name} went offline (last seen ${age(Date.now() - current.lastSeen)}) before answering "${name}".`,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
295
346
|
this.pendingCommands.set(command.id, {
|
|
296
347
|
deviceId: device.id,
|
|
297
348
|
resolve: (result) => {
|
|
298
349
|
clearTimeout(timer);
|
|
350
|
+
stopWatch();
|
|
299
351
|
resolve(result);
|
|
300
352
|
},
|
|
301
353
|
});
|
package/src/core/types.ts
CHANGED
|
@@ -257,6 +257,13 @@ export type ActionResult = {
|
|
|
257
257
|
* tool calls (react/edit/delete) that target this message.
|
|
258
258
|
*/
|
|
259
259
|
message_id?: number | string;
|
|
260
|
+
/**
|
|
261
|
+
* The canonical Talon chat id the action actually addressed. Set when
|
|
262
|
+
* a frontend resolved an explicit `target` (a phone number, a JID) to
|
|
263
|
+
* one of its chats, so the caller learns where its message landed —
|
|
264
|
+
* the cross-chat relay needs it to subscribe to the replies.
|
|
265
|
+
*/
|
|
266
|
+
chat_id?: string;
|
|
260
267
|
/**
|
|
261
268
|
* Set by tools that return a viewable image (e.g. `read` on a photo). The
|
|
262
269
|
* MCP layer turns this into an image content block so the model sees the
|
package/src/core/weaver/loom.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface ContextRegistry {
|
|
|
22
22
|
activeContextCount(): number;
|
|
23
23
|
/** Resolve a string chat id to the numeric id of its active context. */
|
|
24
24
|
numericForStringId(stringId: string): number | null;
|
|
25
|
+
/** Resolve a numeric id to the string chat id of its active context. */
|
|
26
|
+
stringIdForNumeric(numericChatId: number): string | null;
|
|
25
27
|
/** Number of live Threads in the registry. */
|
|
26
28
|
size(): number;
|
|
27
29
|
}
|
|
@@ -146,6 +148,15 @@ export class Loom implements ContextRegistry {
|
|
|
146
148
|
return thread?.contextActive ? (thread.numericChatId ?? null) : null;
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a numeric id to the string chat id of its active context — the
|
|
153
|
+
* Thread key, which is the dispatcher's `chatId` (see `acquireContext`).
|
|
154
|
+
* Null outside a turn: the numeric index only mirrors held contexts.
|
|
155
|
+
*/
|
|
156
|
+
stringIdForNumeric(numericChatId: number): string | null {
|
|
157
|
+
return this.byNumeric.get(numericChatId)?.chatId ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
149
160
|
private resolve(id: number | string): Thread | undefined {
|
|
150
161
|
if (typeof id === "number") return this.byNumeric.get(id);
|
|
151
162
|
const direct = this.threads.get(id);
|
|
@@ -110,6 +110,12 @@ export function createWhatsAppActionHandler(
|
|
|
110
110
|
chat,
|
|
111
111
|
scheduledMessages,
|
|
112
112
|
};
|
|
113
|
-
|
|
113
|
+
const result = await handler(body, chatId, ctx);
|
|
114
|
+
// An explicitly targeted action resolved a chat the caller could not
|
|
115
|
+
// name itself (a phone number is not a chat id). Report where the
|
|
116
|
+
// message landed so cross-frontend callers can follow the thread.
|
|
117
|
+
return result && chat && body.target !== undefined
|
|
118
|
+
? { ...result, chat_id: chat.chatId }
|
|
119
|
+
: result;
|
|
114
120
|
};
|
|
115
121
|
}
|
|
@@ -11,6 +11,7 @@ import { execute } from "../../core/engine/dispatcher.js";
|
|
|
11
11
|
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
12
12
|
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
13
13
|
import { pushMessage } from "../../storage/history.js";
|
|
14
|
+
import { relayInbound } from "../../core/engine/cross-chat-relay.js";
|
|
14
15
|
import {
|
|
15
16
|
recordMessageProcessed,
|
|
16
17
|
recordMessageReceived,
|
|
@@ -175,6 +176,12 @@ async function recordInbound(
|
|
|
175
176
|
...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
|
|
176
177
|
...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
|
|
177
178
|
});
|
|
179
|
+
|
|
180
|
+
// If another chat's session messaged this one via send_via, it is
|
|
181
|
+
// waiting to hear back — hand it the reply for its next turn. A no-op
|
|
182
|
+
// (and one Map miss) for every chat nobody cross-sent into.
|
|
183
|
+
relayInbound(chat.chatId, senderName, text || `[${media?.type ?? "media"}]`);
|
|
184
|
+
|
|
178
185
|
return { ...admitted, chat, msgId, text, media, senderName, platformTs };
|
|
179
186
|
}
|
|
180
187
|
|
|
@@ -26,7 +26,7 @@ import { dirs } from "../../util/paths.js";
|
|
|
26
26
|
* The github-mcp-server image tag Talon runs. Bump deliberately, with
|
|
27
27
|
* the canary workflow green — see .github/workflows/native-provision.yml.
|
|
28
28
|
*/
|
|
29
|
-
export const GITHUB_MCP_PINNED_TAG = "v1.12.
|
|
29
|
+
export const GITHUB_MCP_PINNED_TAG = "v1.12.2";
|
|
30
30
|
|
|
31
31
|
const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server";
|
|
32
32
|
|
|
@@ -47,7 +47,7 @@ import { dirs, files } from "../../util/paths.js";
|
|
|
47
47
|
* to. Bump deliberately, with the canary workflow green — see
|
|
48
48
|
* .github/workflows/native-provision.yml.
|
|
49
49
|
*/
|
|
50
|
-
export const MEMPALACE_PINNED_VERSION = "3.
|
|
50
|
+
export const MEMPALACE_PINNED_VERSION = "3.10.0";
|
|
51
51
|
|
|
52
52
|
/** Minimum python for the managed venv (matches mempalace's supported floor). */
|
|
53
53
|
const PYTHON_MIN = { major: 3, minor: 10 };
|
package/src/util/chat-id.ts
CHANGED
|
@@ -10,6 +10,19 @@ export function deriveNumericChatId(chatId: string): number {
|
|
|
10
10
|
return hash.readUInt32BE(0);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The numeric id a frontend uses for a chat. Telegram ids are numeric
|
|
15
|
+
* already; every other frontend (native, WhatsApp, Discord, Teams, terminal)
|
|
16
|
+
* derives its numeric id from the string one with `deriveNumericChatId`.
|
|
17
|
+
* This is the inverse the background runtimes need to address a stored
|
|
18
|
+
* string chat id the way its frontend does.
|
|
19
|
+
*/
|
|
20
|
+
export function numericChatIdFor(chatId: string): number {
|
|
21
|
+
return isTelegramChatId(chatId)
|
|
22
|
+
? Number(chatId)
|
|
23
|
+
: deriveNumericChatId(chatId);
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/** Generate a unique terminal chat ID. */
|
|
14
27
|
export function generateTerminalChatId(): string {
|
|
15
28
|
return `t_${Date.now()}`;
|