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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/backend/kilo/factory.ts +39 -119
  3. package/src/backend/kilo/handler/index.ts +1 -11
  4. package/src/backend/kilo/handler/message.ts +20 -317
  5. package/src/backend/kilo/server.ts +55 -272
  6. package/src/backend/kilo/sessions.ts +5 -83
  7. package/src/backend/opencode/factory.ts +41 -118
  8. package/src/backend/opencode/handler/index.ts +1 -11
  9. package/src/backend/opencode/handler/message.ts +20 -321
  10. package/src/backend/opencode/server.ts +57 -217
  11. package/src/backend/opencode/sessions.ts +5 -83
  12. package/src/backend/remote-server/chat-turn.ts +359 -0
  13. package/src/backend/remote-server/factory.ts +150 -0
  14. package/src/backend/remote-server/index.ts +14 -19
  15. package/src/backend/remote-server/server-bindings.ts +232 -0
  16. package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
  17. package/src/core/mesh/bridge-links.ts +319 -0
  18. package/src/core/mesh/common.ts +30 -0
  19. package/src/core/mesh/device-files.ts +710 -0
  20. package/src/core/mesh/service.ts +72 -894
  21. package/src/frontend/discord/handlers/access.ts +7 -56
  22. package/src/frontend/discord/handlers/state.ts +15 -8
  23. package/src/frontend/native/server.ts +391 -323
  24. package/src/frontend/shared/access.ts +152 -0
  25. package/src/frontend/telegram/handlers/access.ts +8 -25
  26. package/src/frontend/telegram/handlers/queue.ts +3 -35
  27. package/src/frontend/telegram/handlers/state.ts +15 -8
  28. package/src/backend/kilo/events.ts +0 -60
  29. package/src/backend/kilo/handler/state.ts +0 -9
  30. package/src/backend/opencode/handler/state.ts +0 -9
  31. 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
- knownDmUsers,
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
- unauthorizedCooldown,
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
- if (knownDmUsers.has(senderId)) return;
28
- // Evict oldest 10% when cap reached (Set maintains insertion order)
29
- if (knownDmUsers.size >= KNOWN_DM_USERS_CAP) {
30
- const evictCount = Math.floor(KNOWN_DM_USERS_CAP * 0.1);
31
- const iter = knownDmUsers.values();
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
- const now = Date.now();
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, logDebug } from "../../../util/log.js";
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
- userMessageTimestamps,
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
- const now = Date.now();
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 knownDmUsers = new Set<number>();
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 unauthorizedCooldown = new Map<string, number>();
41
- export const UNAUTHORIZED_COOLDOWN_MS = 10 * 60 * 1000;
42
- export const MAX_UNAUTHORIZED_COOLDOWNS = 5000;
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
- export const userMessageTimestamps = new Map<number, number[]>();
76
- export const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute window
77
- export const RATE_LIMIT_MAX_MESSAGES = 15; // max 15 messages per minute per user
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>();