talon-agent 3.11.1 → 3.12.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.11.1",
3
+ "version": "3.12.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ## Discord Mode
2
2
 
3
- In servers (guilds), you'll see messages prefixed with [Name]: — use their name naturally. In DMs, just one user.
3
+ In servers (guilds), you'll see messages prefixed with `[Name (@handle)]:` — use their name naturally in prose, and the `@handle` when you need to address someone directly. In DMs, just one user.
4
4
 
5
5
  How replies are delivered (end_turn / send / react and what counts as a valid turn) is defined in the **Response flow** section at the end of these instructions — that contract wins over anything here.
6
6
 
@@ -1,6 +1,6 @@
1
1
  ## Telegram Mode
2
2
 
3
- In groups, you'll see messages prefixed with [Name]: — use their name naturally.
3
+ In groups, you'll see messages prefixed with `[Name (@handle)]:` — use their name naturally in prose. The `@handle` is their real Telegram username: use it verbatim (with the `@`) whenever you actually need to ping someone, and especially in scheduled/cron messages, where a display name is just inert text and will not notify anyone. A sender with no username shows as `[Name]:` and simply can't be pinged.
4
4
 
5
5
  How replies are delivered (end_turn / send / react and what counts as a valid turn) is defined in the **Response flow** section at the end of these instructions — that contract wins over anything here.
6
6
 
@@ -151,7 +151,7 @@ export async function* runChatTurn(
151
151
  ): AsyncIterable<AgentEvent> {
152
152
  const config = getConfig();
153
153
 
154
- const { chatId, text, senderName, isGroup } = params;
154
+ const { chatId, text, senderName, senderHandle, isGroup } = params;
155
155
  const session = getSession(chatId);
156
156
  const t0 = Date.now();
157
157
 
@@ -196,6 +196,7 @@ export async function* runChatTurn(
196
196
  let prompt = formatUserPrompt({
197
197
  text,
198
198
  senderName: senderName ?? "user",
199
+ senderHandle,
199
200
  isGroup,
200
201
  messageId: params.messageId,
201
202
  });
@@ -200,6 +200,7 @@ export async function handleMessage(
200
200
  chatId,
201
201
  text,
202
202
  senderName,
203
+ senderHandle,
203
204
  isGroup,
204
205
  messageId,
205
206
  onTextBlock,
@@ -261,6 +262,7 @@ export async function handleMessage(
261
262
  const prompt = formatUserPrompt({
262
263
  text,
263
264
  senderName: senderName ?? "user",
265
+ senderHandle,
264
266
  isGroup,
265
267
  messageId,
266
268
  });
@@ -61,6 +61,7 @@ export async function handleMessage(
61
61
  chatId,
62
62
  text,
63
63
  senderName,
64
+ senderHandle,
64
65
  isGroup,
65
66
  messageId,
66
67
  onTextBlock,
@@ -96,6 +97,7 @@ export async function handleMessage(
96
97
  const prompt = formatUserPrompt({
97
98
  text,
98
99
  senderName: senderName ?? "user",
100
+ senderHandle,
99
101
  isGroup,
100
102
  messageId,
101
103
  });
@@ -105,6 +105,7 @@ export async function handleMessage(
105
105
  chatId,
106
106
  text,
107
107
  senderName,
108
+ senderHandle,
108
109
  isGroup,
109
110
  messageId,
110
111
  onTextBlock,
@@ -143,6 +144,7 @@ export async function handleMessage(
143
144
  let prompt = formatUserPrompt({
144
145
  text,
145
146
  senderName: senderName ?? "user",
147
+ senderHandle,
146
148
  isGroup,
147
149
  messageId,
148
150
  });
@@ -62,6 +62,7 @@ export async function handleMessage(
62
62
  chatId,
63
63
  text,
64
64
  senderName,
65
+ senderHandle,
65
66
  isGroup,
66
67
  messageId,
67
68
  onTextBlock,
@@ -93,6 +94,7 @@ export async function handleMessage(
93
94
  const prompt = formatUserPrompt({
94
95
  text,
95
96
  senderName: senderName ?? "user",
97
+ senderHandle,
96
98
  isGroup,
97
99
  messageId,
98
100
  });
@@ -82,6 +82,7 @@ export async function* handlerToEvents(
82
82
  model: params.model.id,
83
83
  text: params.text,
84
84
  senderName: params.senderName,
85
+ senderHandle: params.senderHandle,
85
86
  isGroup: params.isGroup,
86
87
  messageId: params.messageId,
87
88
  onStreamDelta: (accumulated) => {
@@ -28,6 +28,8 @@ export type QueryParams = {
28
28
  model?: string;
29
29
  text: string;
30
30
  senderName: string;
31
+ /** Sender's platform handle without `@` (Telegram username, Discord username). */
32
+ senderHandle?: string;
31
33
  isGroup?: boolean;
32
34
  /**
33
35
  * Provider message ID. Telegram is numeric; Discord snowflakes are strings.
@@ -10,7 +10,7 @@
10
10
  * doesn't" drift that motivated the shared-framework refactor.
11
11
  *
12
12
  * Examples:
13
- * group: "[2026-05-15 11:01:23] [Dylan] [msg_id:2485]: actual text"
13
+ * group: "[2026-05-15 11:01:23] [Dylan (@dylanneve1)] [msg_id:2485]: actual text"
14
14
  * DM: "[2026-05-15 11:01:23] [msg_id:2485] actual text"
15
15
  * DM (no msg_id): "[2026-05-15 11:01:23] actual text"
16
16
  */
@@ -25,6 +25,13 @@ export type PromptFormatInputs = {
25
25
  text: string;
26
26
  /** Display name of the sender (e.g. "Dylan"). */
27
27
  senderName: string;
28
+ /**
29
+ * Platform handle of the sender WITHOUT the leading `@` (e.g. `dylanneve1`).
30
+ * Rendered next to the display name in group chats so the model can address
31
+ * or mention someone correctly — display names are not addressable, handles
32
+ * are. Absent for users who have no handle set.
33
+ */
34
+ senderHandle?: string;
28
35
  /** True when the chat is a group; influences whether the `[Name]` label is included. */
29
36
  isGroup?: boolean;
30
37
  /** Provider message id (Telegram numeric, Discord snowflake string). */
@@ -47,11 +54,11 @@ export function formatUserPrompt(inputs: PromptFormatInputs): string {
47
54
  inputs.messageId !== undefined ? ` [msg_id:${inputs.messageId}]` : "";
48
55
 
49
56
  if (inputs.isGroup) {
50
- return joinNonEmpty(
51
- timeTag,
52
- `[${inputs.senderName}]${msgIdHint}:`,
53
- inputs.text,
54
- );
57
+ const handle = inputs.senderHandle?.trim().replace(/^@+/, "");
58
+ const who = handle
59
+ ? `${inputs.senderName} (@${handle})`
60
+ : inputs.senderName;
61
+ return joinNonEmpty(timeTag, `[${who}]${msgIdHint}:`, inputs.text);
55
62
  }
56
63
 
57
64
  // DM: no [Name] label needed (Telegram already shows sender)
@@ -51,6 +51,8 @@ export interface ChatRunParams {
51
51
  model: ModelRef;
52
52
  text: string;
53
53
  senderName: string;
54
+ /** Sender's platform handle without `@` (Telegram username, Discord username). */
55
+ senderHandle?: string;
54
56
  isGroup?: boolean;
55
57
  /** Provider message ID. Telegram is numeric; Discord snowflakes are strings. */
56
58
  messageId?: number | string;
@@ -147,7 +147,10 @@ async function pulseChat(chatId: string): Promise<void> {
147
147
  .map((m) => {
148
148
  const time = formatSmartTimestamp(m.timestamp);
149
149
  const media = m.mediaType ? ` [${m.mediaType}]` : "";
150
- return `[msg:${m.msgId} ${time}] ${m.senderName}${media}: ${m.text.slice(0, 200)}`;
150
+ const who = m.senderHandle
151
+ ? `${m.senderName} (@${m.senderHandle})`
152
+ : m.senderName;
153
+ return `[msg:${m.msgId} ${time}] ${who}${media}: ${m.text.slice(0, 200)}`;
151
154
  })
152
155
  .join("\n");
153
156
 
package/src/core/types.ts CHANGED
@@ -214,6 +214,8 @@ export type ExecuteParams = {
214
214
  numericChatId: number;
215
215
  prompt: string;
216
216
  senderName: string;
217
+ /** Sender's platform handle without `@` (Telegram username, Discord username). */
218
+ senderHandle?: string;
217
219
  isGroup: boolean;
218
220
  /** Provider message ID. Numeric for Telegram, string snowflake for Discord. */
219
221
  messageId?: number | string;
@@ -231,6 +231,7 @@ export class Weaver {
231
231
  model: warp.ref,
232
232
  text: params.prompt,
233
233
  senderName: params.senderName,
234
+ senderHandle: params.senderHandle,
234
235
  isGroup: params.isGroup,
235
236
  messageId: params.messageId,
236
237
  });
@@ -52,6 +52,7 @@ export async function processAndReply(p: ProcessAndReplyParams): Promise<void> {
52
52
  numericChatId: p.numericChatId,
53
53
  prompt: p.prompt,
54
54
  senderName: p.senderName,
55
+ senderHandle: p.senderUsername,
55
56
  isGroup: p.isGroup,
56
57
  // Use the real Discord snowflake string, not the hashed numeric.
57
58
  // The hash collides with Telegram-style 32-bit IDs and Discord's API
@@ -40,6 +40,9 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
40
40
  const numericMessageId = deriveNumericChatId(msg.id);
41
41
  const senderId = deriveNumericChatId(msg.author.id);
42
42
  const senderName = getSenderName(msg);
43
+ // Display names aren't addressable; the username is. Persist it so
44
+ // later readers can mention the person rather than guess a handle.
45
+ const senderHandle = msg.author.username;
43
46
  const replyToMsgId = msg.reference?.messageId
44
47
  ? deriveNumericChatId(msg.reference.messageId)
45
48
  : undefined;
@@ -59,6 +62,7 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
59
62
  msgId: numericMessageId,
60
63
  senderId,
61
64
  senderName,
65
+ senderHandle,
62
66
  text: msg.content || `(${mediaType})`,
63
67
  replyToMsgId,
64
68
  timestamp,
@@ -69,6 +73,7 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
69
73
  msgId: numericMessageId,
70
74
  senderId,
71
75
  senderName,
76
+ senderHandle,
72
77
  text: msg.content,
73
78
  replyToMsgId,
74
79
  timestamp,
@@ -195,6 +195,7 @@ export async function processAndReply(
195
195
  numericChatId,
196
196
  prompt,
197
197
  senderName,
198
+ senderHandle: senderUsername,
198
199
  isGroup,
199
200
  messageId,
200
201
  source: "message",
@@ -28,6 +28,10 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
28
28
  bot.on("message", (ctx, next) => {
29
29
  const chatId = String(ctx.chat.id);
30
30
  const sender = getSenderName(ctx.from);
31
+ // The handle is the only addressable form of a user — persist it with
32
+ // every row so later readers (history views, heartbeat-composed
33
+ // messages) can mention someone instead of guessing.
34
+ const senderHandle = ctx.from?.username;
31
35
  const senderId = ctx.from?.id ?? 0;
32
36
  const msgId = ctx.message.message_id;
33
37
  const replyToMsgId = ctx.message.reply_to_message?.message_id;
@@ -44,6 +48,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
44
48
  msgId,
45
49
  senderId,
46
50
  senderName: sender,
51
+ senderHandle,
47
52
  text: ctx.message.text,
48
53
  replyToMsgId,
49
54
  timestamp,
@@ -53,6 +58,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
53
58
  msgId,
54
59
  senderId,
55
60
  senderName: sender,
61
+ senderHandle,
56
62
  text: ctx.message.caption || "(photo)",
57
63
  replyToMsgId,
58
64
  timestamp,
@@ -64,6 +70,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
64
70
  msgId,
65
71
  senderId,
66
72
  senderName: sender,
73
+ senderHandle,
67
74
  text: ctx.message.caption || `(sent ${name})`,
68
75
  replyToMsgId,
69
76
  timestamp,
@@ -74,6 +81,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
74
81
  msgId,
75
82
  senderId,
76
83
  senderName: sender,
84
+ senderHandle,
77
85
  text: "(voice message)",
78
86
  replyToMsgId,
79
87
  timestamp,
@@ -84,6 +92,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
84
92
  msgId,
85
93
  senderId,
86
94
  senderName: sender,
95
+ senderHandle,
87
96
  text: ctx.message.sticker.emoji || "(sticker)",
88
97
  replyToMsgId,
89
98
  timestamp,
@@ -95,6 +104,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
95
104
  msgId,
96
105
  senderId,
97
106
  senderName: sender,
107
+ senderHandle,
98
108
  text: ctx.message.caption || "(video)",
99
109
  replyToMsgId,
100
110
  timestamp,
@@ -105,6 +115,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
105
115
  msgId,
106
116
  senderId,
107
117
  senderName: sender,
118
+ senderHandle,
108
119
  text: ctx.message.caption || "(GIF)",
109
120
  replyToMsgId,
110
121
  timestamp,
@@ -117,6 +128,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
117
128
  msgId,
118
129
  senderId,
119
130
  senderName: sender,
131
+ senderHandle,
120
132
  text: ctx.message.caption || `(audio: ${title})`,
121
133
  replyToMsgId,
122
134
  timestamp,
@@ -127,6 +139,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
127
139
  msgId,
128
140
  senderId,
129
141
  senderName: sender,
142
+ senderHandle,
130
143
  text: "(video note)",
131
144
  replyToMsgId,
132
145
  timestamp,
@@ -137,6 +150,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
137
150
  msgId,
138
151
  senderId,
139
152
  senderName: sender,
153
+ senderHandle,
140
154
  text: `(shared location: ${ctx.message.location.latitude}, ${ctx.message.location.longitude})`,
141
155
  replyToMsgId,
142
156
  timestamp,
@@ -152,6 +166,7 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
152
166
  msgId,
153
167
  senderId,
154
168
  senderName: sender,
169
+ senderHandle,
155
170
  text: `(shared contact: ${name})`,
156
171
  replyToMsgId,
157
172
  timestamp,
package/src/storage/db.ts CHANGED
@@ -84,6 +84,11 @@ function ensureSchema(database: SqlDatabase): void {
84
84
  } catch {
85
85
  /* duplicate column or no such table — both mean nothing to do */
86
86
  }
87
+ try {
88
+ database.exec(dbSql.addHistorySenderHandleColumn);
89
+ } catch {
90
+ /* duplicate column or no such table — both mean nothing to do */
91
+ }
87
92
  try {
88
93
  database.exec(dbSql.addSessionsMetricsColumn);
89
94
  } catch {
@@ -142,7 +142,13 @@ function formatMessage(m: HistoryMessage): string {
142
142
  : "";
143
143
  const fileTag = m.filePath ? ` (file: ${m.filePath})` : "";
144
144
  const time = formatSmartTimestamp(m.timestamp);
145
- return `[msg:${m.msgId} ${time}] ${m.senderName}${replyTag}${mediaTag}${stickerTag}${fileTag}: ${m.text}`;
145
+ // The handle rides along with the name: a reader deciding to mention
146
+ // someone (a scheduled message, a heartbeat-composed reply) has no other
147
+ // way to learn it, and a display name notifies nobody.
148
+ const who = m.senderHandle
149
+ ? `${m.senderName} (@${m.senderHandle})`
150
+ : m.senderName;
151
+ return `[msg:${m.msgId} ${time}] ${who}${replyTag}${mediaTag}${stickerTag}${fileTag}: ${m.text}`;
146
152
  }
147
153
 
148
154
  export function getRecentFormatted(chatId: string, limit = 20): string {
@@ -209,7 +215,7 @@ export function getKnownUsers(chatId: string): string {
209
215
  return users
210
216
  .map(
211
217
  (u) =>
212
- `${u.name} (user_id: ${u.senderId}) — ${u.messageCount} msgs, last seen ${formatRelativeAge(u.lastSeen)}`,
218
+ `${u.name}${u.handle ? ` (@${u.handle})` : ""} (user_id: ${u.senderId}) — ${u.messageCount} msgs, last seen ${formatRelativeAge(u.lastSeen)}`,
213
219
  )
214
220
  .join("\n");
215
221
  }
@@ -13,6 +13,12 @@ export type HistoryMessage = {
13
13
  msgId: number;
14
14
  senderId: number;
15
15
  senderName: string;
16
+ /**
17
+ * Platform handle without `@` (Telegram username / Discord username).
18
+ * Undefined for users who have none. Display names can't be mentioned;
19
+ * this is the addressable form a later reader needs.
20
+ */
21
+ senderHandle?: string;
16
22
  text: string;
17
23
  replyToMsgId?: number;
18
24
  timestamp: number;
@@ -27,6 +33,7 @@ type Row = {
27
33
  msg_id: number;
28
34
  sender_id: number;
29
35
  sender_name: string;
36
+ sender_handle: string | null;
30
37
  text: string;
31
38
  reply_to_msg_id: number | null;
32
39
  timestamp: number;
@@ -40,6 +47,7 @@ function rowToMessage(row: Row): HistoryMessage {
40
47
  msgId: row.msg_id,
41
48
  senderId: row.sender_id,
42
49
  senderName: row.sender_name,
50
+ senderHandle: row.sender_handle ?? undefined,
43
51
  text: row.text,
44
52
  replyToMsgId: row.reply_to_msg_id ?? undefined,
45
53
  timestamp: row.timestamp,
@@ -57,6 +65,7 @@ export function insert(chatId: string, msg: HistoryMessage): void {
57
65
  msg.msgId,
58
66
  msg.senderId,
59
67
  msg.senderName,
68
+ msg.senderHandle ?? null,
60
69
  msg.text,
61
70
  msg.replyToMsgId ?? null,
62
71
  msg.timestamp,
@@ -168,6 +177,8 @@ export function latestMsgId(chatId: string): number | undefined {
168
177
  export type KnownUser = {
169
178
  senderId: number;
170
179
  name: string;
180
+ /** Latest known platform handle without `@`, if the user has one. */
181
+ handle?: string;
171
182
  lastSeen: number;
172
183
  messageCount: number;
173
184
  };
@@ -181,10 +192,12 @@ export function knownUsers(chatId: string): KnownUser[] {
181
192
  last_seen: number;
182
193
  message_count: number;
183
194
  name: string;
195
+ handle: string | null;
184
196
  }>;
185
197
  return rows.map((r) => ({
186
198
  senderId: r.sender_id,
187
199
  name: r.name,
200
+ handle: r.handle ?? undefined,
188
201
  lastSeen: r.last_seen,
189
202
  messageCount: r.message_count,
190
203
  }));
@@ -10,6 +10,11 @@ PRAGMA wal_checkpoint(TRUNCATE)
10
10
  -- "no such table" (fresh databases get the column via schema.sql).
11
11
  ALTER TABLE media_index ADD COLUMN content_hash TEXT
12
12
 
13
+ -- name: addHistorySenderHandleColumn
14
+ -- Column reconciliation for databases that shipped before sender handles
15
+ -- were recorded. Fresh databases get the column via schema.sql.
16
+ ALTER TABLE history_messages ADD COLUMN sender_handle TEXT
17
+
13
18
  -- name: addSessionsMetricsColumn
14
19
  -- Column reconciliation for databases that shipped before per-session
15
20
  -- metrics existed. Fresh databases get the column via schema.sql.
@@ -3,12 +3,12 @@
3
3
 
4
4
  -- name: insert
5
5
  INSERT OR IGNORE INTO history_messages
6
- (chat_id, msg_id, sender_id, sender_name, text, reply_to_msg_id,
7
- timestamp, media_type, sticker_file_id, file_path)
8
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6
+ (chat_id, msg_id, sender_id, sender_name, sender_handle, text,
7
+ reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path)
8
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9
9
 
10
10
  -- name: recent
11
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
11
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
12
12
  timestamp, media_type, sticker_file_id, file_path
13
13
  FROM history_messages
14
14
  WHERE chat_id = ? ORDER BY id DESC LIMIT ?
@@ -16,7 +16,7 @@ WHERE chat_id = ? ORDER BY id DESC LIMIT ?
16
16
  -- name: recentBefore
17
17
  -- Scroll-back pagination: the window of messages strictly older than a
18
18
  -- given msg_id, newest-first (the repository reverses to chronological).
19
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
19
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
20
20
  timestamp, media_type, sticker_file_id, file_path
21
21
  FROM history_messages
22
22
  WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
@@ -30,7 +30,7 @@ DELETE FROM history_messages WHERE chat_id = ?
30
30
  -- name: searchFts
31
31
  -- The match param must already be a valid FTS5 expression
32
32
  -- (see history.ts ftsQuery).
33
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
33
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
34
34
  timestamp, media_type, sticker_file_id, file_path
35
35
  FROM history_messages
36
36
  WHERE chat_id = ?
@@ -39,20 +39,20 @@ ORDER BY id DESC LIMIT ?
39
39
 
40
40
  -- name: bySenderName
41
41
  -- The fragment param is LIKE-escaped by the repository (backslash escape).
42
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
42
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
43
43
  timestamp, media_type, sticker_file_id, file_path
44
44
  FROM history_messages
45
45
  WHERE chat_id = ? AND lower(sender_name) LIKE ? ESCAPE '\'
46
46
  ORDER BY id DESC LIMIT ?
47
47
 
48
48
  -- name: byMsgId
49
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
49
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
50
50
  timestamp, media_type, sticker_file_id, file_path
51
51
  FROM history_messages
52
52
  WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1
53
53
 
54
54
  -- name: bySenderId
55
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
55
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
56
56
  timestamp, media_type, sticker_file_id, file_path
57
57
  FROM history_messages
58
58
  WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?
@@ -66,7 +66,13 @@ SELECT sender_id,
66
66
  COUNT(*) AS message_count,
67
67
  (SELECT sender_name FROM history_messages i
68
68
  WHERE i.chat_id = o.chat_id AND i.sender_id = o.sender_id
69
- ORDER BY i.id DESC LIMIT 1) AS name
69
+ ORDER BY i.id DESC LIMIT 1) AS name,
70
+ -- Most recent NON-NULL handle: people set a username long after
71
+ -- their first message, and the newest row may predate it.
72
+ (SELECT i.sender_handle FROM history_messages i
73
+ WHERE i.chat_id = o.chat_id AND i.sender_id = o.sender_id
74
+ AND i.sender_handle IS NOT NULL
75
+ ORDER BY i.id DESC LIMIT 1) AS handle
70
76
  FROM history_messages o
71
77
  WHERE chat_id = ?
72
78
  GROUP BY sender_id
@@ -11,6 +11,10 @@ CREATE TABLE IF NOT EXISTS history_messages (
11
11
  msg_id INTEGER NOT NULL,
12
12
  sender_id INTEGER NOT NULL,
13
13
  sender_name TEXT NOT NULL,
14
+ -- Platform handle without `@` (Telegram username / Discord username).
15
+ -- Null for users who have none — display names are not addressable, so
16
+ -- this is what a later reader needs to actually mention someone.
17
+ sender_handle TEXT,
14
18
  text TEXT NOT NULL,
15
19
  reply_to_msg_id INTEGER,
16
20
  timestamp INTEGER NOT NULL,
@@ -16,6 +16,10 @@ CREATE TABLE IF NOT EXISTS history_messages (
16
16
  msg_id INTEGER NOT NULL,
17
17
  sender_id INTEGER NOT NULL,
18
18
  sender_name TEXT NOT NULL,
19
+ -- Platform handle without \`@\` (Telegram username / Discord username).
20
+ -- Null for users who have none — display names are not addressable, so
21
+ -- this is what a later reader needs to actually mention someone.
22
+ sender_handle TEXT,
19
23
  text TEXT NOT NULL,
20
24
  reply_to_msg_id INTEGER,
21
25
  timestamp INTEGER NOT NULL,
@@ -293,6 +297,9 @@ export const dbSql = {
293
297
  -- attempts this on every open and swallows "duplicate column name" /
294
298
  -- "no such table" (fresh databases get the column via schema.sql).
295
299
  ALTER TABLE media_index ADD COLUMN content_hash TEXT`,
300
+ addHistorySenderHandleColumn: `-- Column reconciliation for databases that shipped before sender handles
301
+ -- were recorded. Fresh databases get the column via schema.sql.
302
+ ALTER TABLE history_messages ADD COLUMN sender_handle TEXT`,
296
303
  addSessionsMetricsColumn: `-- Column reconciliation for databases that shipped before per-session
297
304
  -- metrics existed. Fresh databases get the column via schema.sql.
298
305
  ALTER TABLE sessions ADD COLUMN metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'`,
@@ -326,16 +333,16 @@ WHERE chat_id = ? AND status IN (/* statuses */)`,
326
333
 
327
334
  export const historySql = {
328
335
  insert: `INSERT OR IGNORE INTO history_messages
329
- (chat_id, msg_id, sender_id, sender_name, text, reply_to_msg_id,
330
- timestamp, media_type, sticker_file_id, file_path)
331
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
332
- recent: `SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
336
+ (chat_id, msg_id, sender_id, sender_name, sender_handle, text,
337
+ reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path)
338
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
339
+ recent: `SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
333
340
  timestamp, media_type, sticker_file_id, file_path
334
341
  FROM history_messages
335
342
  WHERE chat_id = ? ORDER BY id DESC LIMIT ?`,
336
343
  recentBefore: `-- Scroll-back pagination: the window of messages strictly older than a
337
344
  -- given msg_id, newest-first (the repository reverses to chronological).
338
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
345
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
339
346
  timestamp, media_type, sticker_file_id, file_path
340
347
  FROM history_messages
341
348
  WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?`,
@@ -343,23 +350,23 @@ WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?`,
343
350
  deleteChat: `DELETE FROM history_messages WHERE chat_id = ?`,
344
351
  searchFts: `-- The match param must already be a valid FTS5 expression
345
352
  -- (see history.ts ftsQuery).
346
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
353
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
347
354
  timestamp, media_type, sticker_file_id, file_path
348
355
  FROM history_messages
349
356
  WHERE chat_id = ?
350
357
  AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
351
358
  ORDER BY id DESC LIMIT ?`,
352
359
  bySenderName: `-- The fragment param is LIKE-escaped by the repository (backslash escape).
353
- SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
360
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
354
361
  timestamp, media_type, sticker_file_id, file_path
355
362
  FROM history_messages
356
363
  WHERE chat_id = ? AND lower(sender_name) LIKE ? ESCAPE '\\'
357
364
  ORDER BY id DESC LIMIT ?`,
358
- byMsgId: `SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
365
+ byMsgId: `SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
359
366
  timestamp, media_type, sticker_file_id, file_path
360
367
  FROM history_messages
361
368
  WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1`,
362
- bySenderId: `SELECT msg_id, sender_id, sender_name, text, reply_to_msg_id,
369
+ bySenderId: `SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
363
370
  timestamp, media_type, sticker_file_id, file_path
364
371
  FROM history_messages
365
372
  WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?`,
@@ -369,7 +376,13 @@ WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?`,
369
376
  COUNT(*) AS message_count,
370
377
  (SELECT sender_name FROM history_messages i
371
378
  WHERE i.chat_id = o.chat_id AND i.sender_id = o.sender_id
372
- ORDER BY i.id DESC LIMIT 1) AS name
379
+ ORDER BY i.id DESC LIMIT 1) AS name,
380
+ -- Most recent NON-NULL handle: people set a username long after
381
+ -- their first message, and the newest row may predate it.
382
+ (SELECT i.sender_handle FROM history_messages i
383
+ WHERE i.chat_id = o.chat_id AND i.sender_id = o.sender_id
384
+ AND i.sender_handle IS NOT NULL
385
+ ORDER BY i.id DESC LIMIT 1) AS handle
373
386
  FROM history_messages o
374
387
  WHERE chat_id = ?
375
388
  GROUP BY sender_id