talon-agent 3.33.0 → 3.33.2
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/package.json +3 -3
- package/src/backend/kilo/factory.ts +39 -119
- package/src/backend/kilo/handler/index.ts +1 -11
- package/src/backend/kilo/handler/message.ts +20 -317
- package/src/backend/kilo/server.ts +55 -272
- package/src/backend/kilo/sessions.ts +5 -83
- package/src/backend/opencode/factory.ts +41 -118
- package/src/backend/opencode/handler/index.ts +1 -11
- package/src/backend/opencode/handler/message.ts +20 -321
- package/src/backend/opencode/server.ts +57 -217
- package/src/backend/opencode/sessions.ts +5 -83
- package/src/backend/remote-server/chat-turn.ts +359 -0
- package/src/backend/remote-server/factory.ts +150 -0
- package/src/backend/remote-server/index.ts +14 -19
- package/src/backend/remote-server/server-bindings.ts +232 -0
- package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
- package/src/core/mesh/bridge-links.ts +319 -0
- package/src/core/mesh/common.ts +30 -0
- package/src/core/mesh/device-files.ts +710 -0
- package/src/core/mesh/service.ts +72 -894
- package/src/frontend/discord/handlers/access.ts +7 -56
- package/src/frontend/discord/handlers/state.ts +15 -8
- package/src/frontend/native/server.ts +391 -323
- package/src/frontend/shared/access.ts +152 -0
- package/src/frontend/telegram/handlers/access.ts +8 -25
- package/src/frontend/telegram/handlers/queue.ts +3 -35
- package/src/frontend/telegram/handlers/state.ts +15 -8
- package/src/backend/kilo/events.ts +0 -60
- package/src/backend/kilo/handler/state.ts +0 -9
- package/src/backend/opencode/handler/state.ts +0 -9
- package/src/backend/opencode/handler/turn.ts +0 -314
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access-gate primitives shared by the chat frontends.
|
|
3
|
+
*
|
|
4
|
+
* Telegram and Discord each carried their own copy of the same three
|
|
5
|
+
* mechanisms — a per-sender sliding-window rate limiter, a bounded
|
|
6
|
+
* first-seen set for "new DM user" logging, and a keyed cooldown for
|
|
7
|
+
* unauthorized-access notices — differing only in the sender-id type.
|
|
8
|
+
* The copies had identical bodies and identical magic numbers; this is
|
|
9
|
+
* the one implementation, generic over the id.
|
|
10
|
+
*
|
|
11
|
+
* What is NOT here: the allow/deny semantics themselves. Telegram gates
|
|
12
|
+
* on a user allowlist/denylist plus admin group membership; Discord on
|
|
13
|
+
* users, guilds, channels, and a respond mode. Those are platform
|
|
14
|
+
* policy and stay with each frontend.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
18
|
+
import { log, logDebug } from "../../util/log.js";
|
|
19
|
+
|
|
20
|
+
// ── Rate limiter ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface RateLimiterOptions {
|
|
23
|
+
/** Sliding window length. */
|
|
24
|
+
windowMs: number;
|
|
25
|
+
/** Messages allowed per sender inside one window. */
|
|
26
|
+
maxMessages: number;
|
|
27
|
+
/**
|
|
28
|
+
* When the tracked-sender map grows past this, senders idle for
|
|
29
|
+
* `staleAfterMs` are evicted until the map is at `evictTo` or the scan
|
|
30
|
+
* completes. Bounds memory under a flood of distinct senders.
|
|
31
|
+
*/
|
|
32
|
+
evictAbove?: number;
|
|
33
|
+
evictTo?: number;
|
|
34
|
+
staleAfterMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RateLimiter<Id> {
|
|
38
|
+
/**
|
|
39
|
+
* Record one message from `id` and report whether it exceeded the
|
|
40
|
+
* window. A limited message is NOT recorded, so the sender is admitted
|
|
41
|
+
* again as soon as the oldest timestamp ages out.
|
|
42
|
+
*/
|
|
43
|
+
isLimited(id: Id): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createRateLimiter<Id>(
|
|
47
|
+
options: RateLimiterOptions,
|
|
48
|
+
): RateLimiter<Id> {
|
|
49
|
+
const {
|
|
50
|
+
windowMs,
|
|
51
|
+
maxMessages,
|
|
52
|
+
evictAbove = 5_000,
|
|
53
|
+
evictTo = 2_500,
|
|
54
|
+
staleAfterMs = 10 * 60_000,
|
|
55
|
+
} = options;
|
|
56
|
+
const timestamps = new Map<Id, number[]>();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
isLimited(id) {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
let ts = timestamps.get(id);
|
|
62
|
+
if (!ts) {
|
|
63
|
+
ts = [];
|
|
64
|
+
timestamps.set(id, ts);
|
|
65
|
+
}
|
|
66
|
+
// Drop entries that have aged out of the window.
|
|
67
|
+
while (ts.length > 0 && ts[0] < now - windowMs) ts.shift();
|
|
68
|
+
|
|
69
|
+
if (ts.length >= maxMessages) {
|
|
70
|
+
logDebug("bot", `Rate-limited user ${String(id)}`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
ts.push(now);
|
|
74
|
+
|
|
75
|
+
if (timestamps.size > evictAbove) {
|
|
76
|
+
const cutoff = now - staleAfterMs;
|
|
77
|
+
for (const [sender, list] of timestamps) {
|
|
78
|
+
if (list.length === 0 || list[list.length - 1] < cutoff) {
|
|
79
|
+
timestamps.delete(sender);
|
|
80
|
+
}
|
|
81
|
+
if (timestamps.size <= evictTo) break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── First-seen DM users ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface DmUserTracker<Id> {
|
|
92
|
+
/**
|
|
93
|
+
* Log the first message from a sender (talon.log + daily log). No-op
|
|
94
|
+
* for senders already seen. `tag` is an optional handle rendered in
|
|
95
|
+
* parentheses after the name, e.g. `@alice`.
|
|
96
|
+
*/
|
|
97
|
+
track(id: Id, senderName: string, tag?: string): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Insertion-ordered set capped at `cap`; on overflow the oldest 10% are
|
|
102
|
+
* evicted so a flood of one-off senders cannot grow it without bound.
|
|
103
|
+
*/
|
|
104
|
+
export function createDmUserTracker<Id>(cap: number): DmUserTracker<Id> {
|
|
105
|
+
const seen = new Set<Id>();
|
|
106
|
+
return {
|
|
107
|
+
track(id, senderName, tag) {
|
|
108
|
+
if (seen.has(id)) return;
|
|
109
|
+
if (seen.size >= cap) {
|
|
110
|
+
const evictCount = Math.floor(cap * 0.1);
|
|
111
|
+
const iter = seen.values();
|
|
112
|
+
for (let i = 0; i < evictCount; i++) {
|
|
113
|
+
seen.delete(iter.next().value as Id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
seen.add(id);
|
|
117
|
+
const tagStr = tag ? ` (${tag})` : "";
|
|
118
|
+
const line = `New DM user: ${senderName}${tagStr} [id:${String(id)}]`;
|
|
119
|
+
log("users", line);
|
|
120
|
+
appendDailyLog("System", line);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Notice cooldown ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface NoticeCooldown {
|
|
128
|
+
/**
|
|
129
|
+
* Whether a notice for `key` should fire now. Returns true at most once
|
|
130
|
+
* per `ttlMs` per key, recording the send. The map is cleared outright
|
|
131
|
+
* at `cap` entries — cheaper than an LRU and the worst case is one
|
|
132
|
+
* extra notice per key.
|
|
133
|
+
*/
|
|
134
|
+
shouldNotify(key: string): boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createNoticeCooldown(options: {
|
|
138
|
+
ttlMs: number;
|
|
139
|
+
cap: number;
|
|
140
|
+
}): NoticeCooldown {
|
|
141
|
+
const last = new Map<string, number>();
|
|
142
|
+
return {
|
|
143
|
+
shouldNotify(key) {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const previous = last.get(key);
|
|
146
|
+
if (previous && now - previous < options.ttlMs) return false;
|
|
147
|
+
if (last.size >= options.cap) last.clear();
|
|
148
|
+
last.set(key, now);
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -8,15 +8,12 @@ import { appendDailyLog } from "../../../storage/daily-log.js";
|
|
|
8
8
|
import { log, logWarn } from "../../../util/log.js";
|
|
9
9
|
import { getSenderName } from "./context.js";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
KNOWN_DM_USERS_CAP,
|
|
11
|
+
dmUsers,
|
|
13
12
|
accessConfig,
|
|
14
13
|
verifiedGroups,
|
|
15
14
|
MAX_VERIFIED_GROUPS,
|
|
16
15
|
VERIFIED_GROUP_TTL_MS,
|
|
17
|
-
|
|
18
|
-
UNAUTHORIZED_COOLDOWN_MS,
|
|
19
|
-
MAX_UNAUTHORIZED_COOLDOWNS,
|
|
16
|
+
unauthorizedNotices,
|
|
20
17
|
} from "./state.js";
|
|
21
18
|
|
|
22
19
|
export function trackDmUser(
|
|
@@ -24,19 +21,11 @@ export function trackDmUser(
|
|
|
24
21
|
senderName: string,
|
|
25
22
|
senderUsername?: string,
|
|
26
23
|
): void {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
for (let i = 0; i < evictCount; i++) {
|
|
33
|
-
knownDmUsers.delete(iter.next().value as number);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
knownDmUsers.add(senderId);
|
|
37
|
-
const tag = senderUsername ? ` (@${senderUsername})` : "";
|
|
38
|
-
log("users", `New DM user: ${senderName}${tag} [id:${senderId}]`);
|
|
39
|
-
appendDailyLog("System", `New DM user: ${senderName}${tag} [id:${senderId}]`);
|
|
24
|
+
dmUsers.track(
|
|
25
|
+
senderId,
|
|
26
|
+
senderName,
|
|
27
|
+
senderUsername ? `@${senderUsername}` : undefined,
|
|
28
|
+
);
|
|
40
29
|
}
|
|
41
30
|
|
|
42
31
|
export function setAccessControl(cfg: {
|
|
@@ -274,13 +263,7 @@ async function notifyUnauthorized(
|
|
|
274
263
|
}
|
|
275
264
|
|
|
276
265
|
const key = type === "dm" ? `dm:${ctx.from?.id}` : `group:${ctx.chat?.id}`;
|
|
277
|
-
|
|
278
|
-
const lastWarned = unauthorizedCooldown.get(key);
|
|
279
|
-
if (lastWarned && now - lastWarned < UNAUTHORIZED_COOLDOWN_MS) return;
|
|
280
|
-
if (unauthorizedCooldown.size >= MAX_UNAUTHORIZED_COOLDOWNS) {
|
|
281
|
-
unauthorizedCooldown.clear();
|
|
282
|
-
}
|
|
283
|
-
unauthorizedCooldown.set(key, now);
|
|
266
|
+
if (!unauthorizedNotices.shouldNotify(key)) return;
|
|
284
267
|
|
|
285
268
|
// Warn the user
|
|
286
269
|
try {
|
|
@@ -24,52 +24,20 @@ import {
|
|
|
24
24
|
recordMessageReceived,
|
|
25
25
|
recordError,
|
|
26
26
|
} from "../../../util/watchdog.js";
|
|
27
|
-
import { log, logError, logWarn
|
|
27
|
+
import { log, logError, logWarn } from "../../../util/log.js";
|
|
28
28
|
import { appendDailyLog } from "../../../storage/daily-log.js";
|
|
29
29
|
import { processAndReply, sendHtml } from "./delivery.js";
|
|
30
30
|
import {
|
|
31
31
|
messageQueues,
|
|
32
32
|
lastHandledMessageIdByChat,
|
|
33
|
-
|
|
33
|
+
rateLimiter,
|
|
34
34
|
DEBOUNCE_MS,
|
|
35
35
|
MAX_QUEUED_PER_CHAT,
|
|
36
|
-
RATE_LIMIT_WINDOW_MS,
|
|
37
|
-
RATE_LIMIT_MAX_MESSAGES,
|
|
38
36
|
type QueuedMessage,
|
|
39
37
|
} from "./state.js";
|
|
40
38
|
|
|
41
39
|
export function isUserRateLimited(senderId: number): boolean {
|
|
42
|
-
|
|
43
|
-
let timestamps = userMessageTimestamps.get(senderId);
|
|
44
|
-
if (!timestamps) {
|
|
45
|
-
timestamps = [];
|
|
46
|
-
userMessageTimestamps.set(senderId, timestamps);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Remove old entries outside the window
|
|
50
|
-
while (timestamps.length > 0 && timestamps[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
51
|
-
timestamps.shift();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (timestamps.length >= RATE_LIMIT_MAX_MESSAGES) {
|
|
55
|
-
logDebug("bot", `Rate-limited user ${senderId}`);
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
timestamps.push(now);
|
|
60
|
-
|
|
61
|
-
// Evict stale entries — remove users who haven't messaged in 10+ minutes
|
|
62
|
-
if (userMessageTimestamps.size > 5_000) {
|
|
63
|
-
const cutoff = now - 10 * 60_000;
|
|
64
|
-
for (const [userId, ts] of userMessageTimestamps) {
|
|
65
|
-
if (ts.length === 0 || ts[ts.length - 1] < cutoff) {
|
|
66
|
-
userMessageTimestamps.delete(userId);
|
|
67
|
-
}
|
|
68
|
-
if (userMessageTimestamps.size <= 2_500) break; // evict down to half
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return false;
|
|
40
|
+
return rateLimiter.isLimited(senderId);
|
|
73
41
|
}
|
|
74
42
|
|
|
75
43
|
/**
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Bot } from "grammy";
|
|
11
11
|
import type { TalonConfig } from "../../../util/config.js";
|
|
12
|
+
import {
|
|
13
|
+
createDmUserTracker,
|
|
14
|
+
createNoticeCooldown,
|
|
15
|
+
createRateLimiter,
|
|
16
|
+
} from "../../shared/access.js";
|
|
12
17
|
|
|
13
18
|
// ── First-time DM user tracking ──────────────────────────────────────────────
|
|
14
19
|
|
|
15
|
-
export const
|
|
16
|
-
export const KNOWN_DM_USERS_CAP = 10_000;
|
|
20
|
+
export const dmUsers = createDmUserTracker<number>(10_000);
|
|
17
21
|
|
|
18
22
|
// ── Access control ──────────────────────────────────────────────────────────
|
|
19
23
|
|
|
@@ -37,9 +41,10 @@ export const MAX_VERIFIED_GROUPS = 1000;
|
|
|
37
41
|
export const VERIFIED_GROUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
38
42
|
|
|
39
43
|
// Rate-limit unauthorized access warnings (one per user/group per 10 minutes)
|
|
40
|
-
export const
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
export const unauthorizedNotices = createNoticeCooldown({
|
|
45
|
+
ttlMs: 10 * 60 * 1000,
|
|
46
|
+
cap: 5000,
|
|
47
|
+
});
|
|
43
48
|
|
|
44
49
|
// ── Message queue (debounce rapid-fire messages per chat) ─────────────────────
|
|
45
50
|
|
|
@@ -72,6 +77,8 @@ export const lastHandledMessageIdByChat = new Map<string, number>();
|
|
|
72
77
|
|
|
73
78
|
// ── Per-user rate limiting ──────────────────────────────────────────────────
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
export const
|
|
77
|
-
|
|
80
|
+
/** 15 messages per minute per user. */
|
|
81
|
+
export const rateLimiter = createRateLimiter<number>({
|
|
82
|
+
windowMs: 60_000,
|
|
83
|
+
maxMessages: 15,
|
|
84
|
+
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kilo SSE event processing — thin wrapper around
|
|
3
|
-
* `backend/remote-server/events.ts` that binds the Kilo-specific
|
|
4
|
-
* `extractPartsSummary` extractor.
|
|
5
|
-
*
|
|
6
|
-
* The wire-format and event-driven state machine is identical between
|
|
7
|
-
* OpenCode and Kilo (Kilo is a fork of OpenCode and shares the same
|
|
8
|
-
* SSE schema). All the logic lives in the shared module; this file is
|
|
9
|
-
* the Kilo-side configurator + re-export surface for back-compat with
|
|
10
|
-
* existing imports from `./events.js`.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
finalizePartsIntoState as finalizePartsIntoStateShared,
|
|
15
|
-
processStreamEvent as processStreamEventShared,
|
|
16
|
-
STREAM_INTERVAL_MS,
|
|
17
|
-
maybeFireStreamDelta,
|
|
18
|
-
type EventProcessingContext as SharedEventContext,
|
|
19
|
-
type FinalizePartsInputs as SharedFinalizeInputs,
|
|
20
|
-
type ProcessEventOutcome,
|
|
21
|
-
} from "../remote-server/events.js";
|
|
22
|
-
import { extractPartsSummary } from "./sessions.js";
|
|
23
|
-
|
|
24
|
-
// ── Re-exports ─────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export { STREAM_INTERVAL_MS, maybeFireStreamDelta };
|
|
27
|
-
export type { ProcessEventOutcome };
|
|
28
|
-
|
|
29
|
-
/** Context type, with `backendLabel` defaulted to "Kilo" via the wrappers. */
|
|
30
|
-
export type EventProcessingContext = Omit<SharedEventContext, "backendLabel">;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Process one Kilo SSE event. Delegates to the shared processor with the
|
|
34
|
-
* Kilo backend label.
|
|
35
|
-
*/
|
|
36
|
-
export function processStreamEvent(
|
|
37
|
-
event: { type?: string; properties?: Record<string, unknown> },
|
|
38
|
-
ctx: EventProcessingContext,
|
|
39
|
-
): Promise<ProcessEventOutcome> {
|
|
40
|
-
return processStreamEventShared(event, { ...ctx, backendLabel: "Kilo" });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Inputs for the Kilo-side `finalizePartsIntoState`. */
|
|
44
|
-
export type FinalizePartsInputs = Omit<
|
|
45
|
-
SharedFinalizeInputs,
|
|
46
|
-
"extractPartsSummary"
|
|
47
|
-
>;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Drain Kilo's authoritative `session.messages` parts list into the
|
|
51
|
-
* stream state. Delegates to the shared finalizer with the Kilo
|
|
52
|
-
* `extractPartsSummary` extractor (which understands Kilo's
|
|
53
|
-
* `synthetic: true` text-part flag).
|
|
54
|
-
*/
|
|
55
|
-
export function finalizePartsIntoState(inputs: FinalizePartsInputs): {
|
|
56
|
-
toolsProcessed: number;
|
|
57
|
-
syntheticErrorText?: string;
|
|
58
|
-
} {
|
|
59
|
-
return finalizePartsIntoStateShared({ ...inputs, extractPartsSummary });
|
|
60
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Active-turn session registry for Kilo.
|
|
3
|
-
*
|
|
4
|
-
* Tracks the in-flight Kilo session id per chat for the duration of a
|
|
5
|
-
* turn: `message.ts` records the id on entry and clears it in its finally
|
|
6
|
-
* block, and `turn.ts` matches incoming SSE events against it.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const activeSessions = new Map<string, string>();
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Active-turn session registry for OpenCode.
|
|
3
|
-
*
|
|
4
|
-
* Tracks the in-flight OpenCode session id per chat for the duration of a
|
|
5
|
-
* turn: `message.ts` records the id on entry and clears it in its finally
|
|
6
|
-
* block, and `turn.ts` matches incoming SSE events against it.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const activeSessions = new Map<string, string>();
|