typeclaw 0.37.3 → 0.37.4

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.
@@ -48,6 +48,7 @@ import {
48
48
  registerCommands,
49
49
  type DiscordCommandDeclaration,
50
50
  } from './discord-bot-slash-commands'
51
+ import { addDiscordMentionHints, type DiscordMentionUser } from './mention-hints'
51
52
 
52
53
  // One declared slash command per logical agent gesture. /stop maps to the
53
54
  // existing channel-command of the same name in the router. Adding new
@@ -507,6 +508,7 @@ type DiscordRawHistoryMessage = {
507
508
  author: { id: string; username?: string; global_name?: string | null; bot?: boolean }
508
509
  content: string
509
510
  timestamp: string
511
+ mentions?: DiscordMentionUser[]
510
512
  message_reference?: { message_id?: string; channel_id?: string }
511
513
  attachments?: DiscordFile[]
512
514
  embeds?: DiscordGatewayEmbed[]
@@ -597,7 +599,7 @@ function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | nu
597
599
  // never resolve them. Mirror the classifier's splitInbound: bake placeholders
598
600
  // into text and carry the structured attachments so the router can resolve ids.
599
601
  const attachments = describeDiscordMedia(source)
600
- const text = bodyOf(source)
602
+ const text = addDiscordMentionHints(bodyOf(source), mentionUserMap(source.mentions), { botUserId })
601
603
  return {
602
604
  externalMessageId: msg.id,
603
605
  authorId: source.author.id,
@@ -617,6 +619,10 @@ function bodyOf(msg: DiscordRawHistoryMessage): string {
617
619
  return msg.content === '' ? placeholders : `${msg.content}\n${placeholders}`
618
620
  }
619
621
 
622
+ function mentionUserMap(mentions: readonly DiscordMentionUser[] | undefined): Map<string, DiscordMentionUser> {
623
+ return new Map((mentions ?? []).map((user) => [user.id, user]))
624
+ }
625
+
620
626
  function clampLimit(requested: number, max: number): number {
621
627
  if (!Number.isFinite(requested) || requested <= 0) return max
622
628
  return Math.min(Math.floor(requested), max)
@@ -932,9 +938,10 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
932
938
  return
933
939
  }
934
940
 
941
+ const hintedText = addDiscordMentionHints(verdict.payload.text, mentionUserMap(event.mentions), { botUserId })
935
942
  const replyMessageId = event.message_reference?.message_id
936
943
  const referenceResult = await enrichDiscordMessageReferences({
937
- text: verdict.payload.text,
944
+ text: hintedText,
938
945
  ...(replyMessageId !== undefined
939
946
  ? { reply: { channelId: event.message_reference?.channel_id ?? event.channel_id, messageId: replyMessageId } }
940
947
  : {}),
@@ -950,8 +957,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
950
957
  })
951
958
  const payload =
952
959
  referenceResult.referenceContext === undefined
953
- ? verdict.payload
954
- : { ...verdict.payload, referenceContext: referenceResult.referenceContext }
960
+ ? { ...verdict.payload, text: hintedText }
961
+ : { ...verdict.payload, text: hintedText, referenceContext: referenceResult.referenceContext }
955
962
 
956
963
  const routedTag = await formatChannelTag(payload.workspace, payload.chat)
957
964
  logger.info(
@@ -0,0 +1,58 @@
1
+ export type DiscordMentionUser = { id: string; username?: string; global_name?: string | null }
2
+
3
+ export type MentionHintOptions = { botUserId?: string | null }
4
+
5
+ // Slack encodes user mentions as `<@U…>`/`<@W…>`, optionally with a native
6
+ // `|label` fallback suffix. We capture the id and the whole token so the bare
7
+ // `<@id>` can be reconstructed (dropping any legacy label) and a resolved hint
8
+ // appended after it.
9
+ const SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
10
+
11
+ // Discord uses `<@id>` and the nickname form `<@!id>`; the `!` is optional and
12
+ // irrelevant to the target user, so it is captured but discarded on rewrite.
13
+ const DISCORD_MENTION_PATTERN = /<@!?(\d+)>/g
14
+
15
+ export async function addSlackMentionHints(
16
+ text: string,
17
+ resolveUserName: (id: string) => Promise<string>,
18
+ options: MentionHintOptions = {},
19
+ ): Promise<string> {
20
+ const ids = new Set<string>()
21
+ for (const match of text.matchAll(SLACK_MENTION_PATTERN)) ids.add(match[1]!)
22
+ if (ids.size === 0) return text
23
+
24
+ const hints = new Map<string, string>()
25
+ await Promise.all(
26
+ Array.from(ids).map(async (id) => {
27
+ const hint = resolveHint(id, await resolveUserName(id), options.botUserId)
28
+ if (hint !== null) hints.set(id, hint)
29
+ }),
30
+ )
31
+
32
+ return text.replace(SLACK_MENTION_PATTERN, (_token, id: string) => renderToken(id, hints.get(id)))
33
+ }
34
+
35
+ export function addDiscordMentionHints(
36
+ text: string,
37
+ usersById: Map<string, DiscordMentionUser>,
38
+ options: MentionHintOptions = {},
39
+ ): string {
40
+ return text.replace(DISCORD_MENTION_PATTERN, (token, id: string) => {
41
+ const user = usersById.get(id)
42
+ const name = user === undefined ? id : (user.global_name ?? user.username ?? id)
43
+ const hint = resolveHint(id, name, options.botUserId)
44
+ return hint === null ? token : `${token} (${hint})`
45
+ })
46
+ }
47
+
48
+ function resolveHint(id: string, resolvedName: string, botUserId: string | null | undefined): string | null {
49
+ if (id === botUserId) return 'you'
50
+ // The resolver echoes the id back when it cannot find a name; a bare id is
51
+ // not a useful hint, so leave the token unannotated in that case.
52
+ if (resolvedName === id || resolvedName === '') return null
53
+ return resolvedName
54
+ }
55
+
56
+ function renderToken(id: string, hint: string | undefined): string {
57
+ return hint === undefined ? `<@${id}>` : `<@${id}> (${hint})`
58
+ }
@@ -32,6 +32,7 @@ import type {
32
32
  } from '@/channels/types'
33
33
  import { chunkMarkdown } from '@/markdown'
34
34
 
35
+ import { addSlackMentionHints } from './mention-hints'
35
36
  import { createSlackAuthorResolver, type SlackAuthorResolver } from './slack-bot-author-resolver'
36
37
  import { createSlackChannelResolver } from './slack-bot-channel-resolver'
37
38
  import {
@@ -703,11 +704,14 @@ export function createSlackHistoryCallback(deps: {
703
704
  // users.info entry. The resolver caches/coalesces, so repeated authors
704
705
  // cost one lookup each.
705
706
  if (authorResolver !== undefined) {
707
+ const resolver = authorResolver
708
+ const botId = botUserIdRef()
706
709
  await Promise.all(
707
710
  mapped.map(async (message, index) => {
711
+ message.text = await addSlackMentionHints(message.text, resolver.resolve, { botUserId: botId })
708
712
  const userId = rawMessages[index]?.user
709
713
  if (userId === undefined || userId === '') return
710
- message.authorName = await authorResolver.resolve(userId)
714
+ message.authorName = await resolver.resolve(userId)
711
715
  }),
712
716
  )
713
717
  }
@@ -1133,9 +1137,10 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1133
1137
  }
1134
1138
 
1135
1139
  dedupe.mark(event)
1140
+ const hintedText = await addSlackMentionHints(verdict.payload.text, authorResolver.resolve, { botUserId })
1136
1141
  const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
1137
1142
  const referenceResult = await enrichSlackReferenceContext({
1138
- text: verdict.payload.text,
1143
+ text: hintedText,
1139
1144
  channelId: event.channel,
1140
1145
  messageTs: event.ts,
1141
1146
  ...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
@@ -1143,6 +1148,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1143
1148
  })
1144
1149
  const enriched = {
1145
1150
  ...verdict.payload,
1151
+ text: hintedText,
1146
1152
  authorName: resolvedUserName,
1147
1153
  ...(referenceResult.referenceContext !== undefined
1148
1154
  ? { referenceContext: referenceResult.referenceContext }
@@ -13,6 +13,14 @@
13
13
  // descriptive ("I checked and it's fine") or other-directed ("you can continue")
14
14
  // usage. This is a HINT, not a control-flow authority: the abort still fires
15
15
  // regardless; only the optional nudge is gated on it.
16
+ //
17
+ // Detection is two-pass: a phrase-substring pass (ALL_PHRASES) for analytic
18
+ // languages where future intent is a separate word ("I'll", "voy a", "我会"),
19
+ // plus a morpheme pass (MORPHEME_PATTERNS + the Japanese check) for languages
20
+ // where it is a verb inflection/affix. The morpheme pass matches the marker
21
+ // itself, so it generalizes across EVERY action verb (update/configure/fix/…)
22
+ // instead of enumerating each — what the per-verb KO/TR/HI/JA lists used to do
23
+ // by hand.
16
24
 
17
25
  // Strip markdown emphasis/code fences before matching so an inline `gh` span
18
26
  // inside "바로 `gh`로 확인할게요" does not split the phrase.
@@ -62,70 +70,80 @@ const EN_PHRASES: readonly string[] = [
62
70
  'lemme check',
63
71
  'lemme look',
64
72
  'lemme take a look',
73
+ // Action/config verb family. The retrieval verbs above ("check/look/look up")
74
+ // miss the much larger class of "I'll DO X" promises — update/configure/set up/
75
+ // schedule/fix/apply/create — which is exactly the class that silently truncates
76
+ // when the model forgets `continue: true` (the cron-update production miss).
77
+ "i'll update",
78
+ "i'll set up",
79
+ "i'll set it up",
80
+ "i'll configure",
81
+ "i'll schedule",
82
+ "i'll fix",
83
+ "i'll apply",
84
+ "i'll add",
85
+ "i'll create",
86
+ "i'll handle",
87
+ 'let me update',
88
+ 'let me fix',
89
+ 'let me set up',
90
+ 'let me configure',
91
+ 'let me add',
92
+ 'let me create',
93
+ 'let me handle',
65
94
  ]
66
95
 
67
- // Korean: -ㄹ게요 / -겠습니다 future-volitional endings on check/look/continue/
68
- // proceed verbs. These endings are first-person volitional in Korean they
69
- // cannot address the listener, so they are safe self-direction anchors that
70
- // descriptive or other-directed sentences do not produce. Bare "계속" is
71
- // excluded ("계속 진행하세요" = "you go ahead", terminal).
96
+ // Korean: the -겠습니다/-겠어요 and -ㄹ게요 verb endings are first-person
97
+ // volitional they cannot address the listener, so they are safe self-direction
98
+ // anchors. The -겠습니다/-겠어요 form is matched by MORPHEME_PATTERNS below (it
99
+ // generalizes across all action verbs), so only the -게요/-게여 forms and stall
100
+ // idioms are enumerated here. Bare adverb+noun fragments ("바로 확인", "계속 확인",
101
+ // "곧 알려") are deliberately NOT listed: without the volitional ending they match
102
+ // other-directed requests ("바로 확인 부탁드려요" = "please check") and descriptive
103
+ // progressives ("계속 확인 중입니다" = "I'm still checking") — the exact false
104
+ // positives the design forbids. Their volitional forms are caught by the morpheme
105
+ // regex regardless.
72
106
  const KO_PHRASES: readonly string[] = [
73
107
  '확인해볼게요',
74
108
  '확인해 볼게요',
75
109
  '확인할게요',
76
- '확인하겠습니다',
77
- '확인해보겠습니다',
78
- '확인해 보겠습니다',
79
- '다시 확인하겠습니다',
80
- '다시 확인해보겠습니다',
81
- '이어서 확인',
82
- '계속 확인',
110
+ '확인할게여',
83
111
  '계속 진행할게요',
84
- '계속 진행하겠습니다',
85
- '계속하겠습니다',
86
112
  '계속할게요',
87
- '바로 확인',
88
113
  '바로 볼게요',
89
- '바로 진행',
90
114
  '살펴볼게요',
91
- '살펴보겠습니다',
92
- '진행하겠습니다',
93
- '잠시만요',
94
- '잠깐만요',
95
- '곧 알려',
96
- // Bare first-person-volitional verb endings: the -ㄹ게요/-겠습니다 ending is
97
- // self-directed regardless of the preceding adverb, so the "바로 …" prefix in
98
- // the entries above is not load-bearing. "볼게요" alone (and "먼저/한번/지금 볼게요"
99
- // by substring) is the exact production miss — the ack "…먼저 볼게요" did not
100
- // match because only the "바로 볼게요" compound was listed. Common work verbs
101
- // (검토/조회/찾아/알아/처리) in the same volitional form join here for parity with
102
- // "확인/살펴" above; "볼게여" is the casual -여 variant seen in chat.
103
115
  '볼게요',
104
116
  '볼게여',
105
- '확인할게여',
106
117
  '검토할게요',
107
118
  '검토해볼게요',
108
- '검토하겠습니다',
109
119
  '조회해볼게요',
110
- '조회하겠습니다',
111
120
  '찾아볼게요',
112
- '찾아보겠습니다',
113
121
  '알아볼게요',
114
- '알아보겠습니다',
115
122
  '처리할게요',
116
- '처리하겠습니다',
123
+ '알려드릴게요',
124
+ // Action/config verb -게요 forms (the -겠습니다 siblings are covered by the
125
+ // morpheme regex; these are the casual-polite variants chat models also emit).
126
+ '업데이트할게요',
127
+ '수정할게요',
128
+ '설정할게요',
129
+ '반영할게요',
130
+ '적용할게요',
131
+ '추가할게요',
132
+ '생성할게요',
133
+ '잠시만요',
134
+ '잠깐만요',
117
135
  ]
118
136
 
119
137
  // The remaining languages mirror the precision-first selection above: every
120
138
  // entry pairs a FIRST-PERSON future/volitional anchor with a work verb
121
- // (check/look/continue/proceed/verify) or is an immediate-work idiom ("on it
122
- // now"). The same false-negative bias holds — bare verbs, bare acknowledgments
123
- // ("ok", "sí", "好"), second-person imperatives ("you continue"), and
124
- // descriptive past forms ("I checked") are deliberately excluded because a
125
- // substring match on those would mis-fire. Latin/Cyrillic/Arabic/Indic entries
126
- // are inflected first-person-future forms (or multi-word) so they cannot
127
- // collide with a bare common word; CJK entries are full 4+ character
128
- // intent phrases, never a lone noun.
139
+ // (check/look/continue/proceed/verify or update/configure/fix/create) or is an
140
+ // immediate-work idiom ("on it now"). The same false-negative bias holds — bare
141
+ // verbs, bare acknowledgments ("ok", "sí", "好"), second-person imperatives ("you
142
+ // continue"), and descriptive past forms ("I checked") are deliberately excluded
143
+ // because a substring match on those would mis-fire. Latin/Cyrillic/Arabic/Indic
144
+ // entries are inflected first-person-future forms (or multi-word) so they cannot
145
+ // collide with a bare common word; CJK entries are full 4+ character intent
146
+ // phrases, never a lone noun.
129
147
 
130
148
  // Spanish: "voy a" / "déjame" + work verb; "enseguida" (right away) idioms.
131
149
  const ES_PHRASES: readonly string[] = [
@@ -152,6 +170,17 @@ const ES_PHRASES: readonly string[] = [
152
170
  'un momento',
153
171
  'dame un momento',
154
172
  'dame un segundo',
173
+ // Action/config verb family.
174
+ 'voy a actualizar',
175
+ 'voy a configurar',
176
+ 'voy a corregir',
177
+ 'voy a arreglar',
178
+ 'voy a crear',
179
+ 'voy a añadir',
180
+ 'voy a programar',
181
+ 'voy a aplicar',
182
+ 'déjame actualizar',
183
+ 'déjame corregir',
155
184
  ]
156
185
 
157
186
  // French: "je vais" + work verb; "laisse-moi" idioms.
@@ -174,6 +203,16 @@ const FR_PHRASES: readonly string[] = [
174
203
  'un instant',
175
204
  'donne-moi un instant',
176
205
  'donne-moi une seconde',
206
+ // Action/config verb family.
207
+ 'je vais mettre à jour',
208
+ 'je vais configurer',
209
+ 'je vais corriger',
210
+ 'je vais créer',
211
+ 'je vais ajouter',
212
+ 'je vais programmer',
213
+ 'je vais appliquer',
214
+ 'laisse-moi corriger',
215
+ 'laisse-moi mettre à jour',
177
216
  ]
178
217
 
179
218
  // Italian: "vado a" / "fammi" + work verb; "controllo subito" idioms.
@@ -194,6 +233,15 @@ const IT_PHRASES: readonly string[] = [
194
233
  'un momento',
195
234
  'dammi un momento',
196
235
  'dammi un secondo',
236
+ // Action/config verb family.
237
+ 'vado ad aggiornare',
238
+ 'vado a configurare',
239
+ 'vado a correggere',
240
+ 'vado a creare',
241
+ 'vado ad aggiungere',
242
+ 'vado ad applicare',
243
+ 'fammi aggiornare',
244
+ 'fammi correggere',
197
245
  ]
198
246
 
199
247
  // Portuguese: "vou" + work verb; "deixa eu" idioms.
@@ -214,6 +262,16 @@ const PT_PHRASES: readonly string[] = [
214
262
  'um momento',
215
263
  'me dê um momento',
216
264
  'me dá um segundo',
265
+ // Action/config verb family.
266
+ 'vou atualizar',
267
+ 'vou configurar',
268
+ 'vou corrigir',
269
+ 'vou criar',
270
+ 'vou adicionar',
271
+ 'vou agendar',
272
+ 'vou aplicar',
273
+ 'deixa eu atualizar',
274
+ 'deixa eu corrigir',
217
275
  ]
218
276
 
219
277
  // German: "ich werde" / "lass mich" + work verb; "ich schaue gleich" idioms.
@@ -238,10 +296,22 @@ const DE_PHRASES: readonly string[] = [
238
296
  'einen moment',
239
297
  'einen augenblick',
240
298
  'gib mir eine sekunde',
299
+ // Action/config verb family.
300
+ 'ich werde aktualisieren',
301
+ 'ich werde konfigurieren',
302
+ 'ich werde korrigieren',
303
+ 'ich werde einrichten',
304
+ 'ich werde erstellen',
305
+ 'ich werde hinzufügen',
306
+ 'ich werde anwenden',
307
+ 'lass mich aktualisieren',
308
+ 'lass mich korrigieren',
241
309
  ]
242
310
 
243
311
  // Russian: first-person-future verbs (проверю/посмотрю/продолжу) — the -ю/-у
244
- // inflection is unambiguously "I will", so it is a safe self-anchor.
312
+ // inflection is unambiguously "I will", so it is a safe self-anchor. (Note: the
313
+ // bare -ю ending is shared with present-imperfective "я делаю" = "I do", so this
314
+ // stays an enumerated list rather than a morpheme regex.)
245
315
  const RU_PHRASES: readonly string[] = [
246
316
  'сейчас проверю',
247
317
  'я проверю',
@@ -254,6 +324,15 @@ const RU_PHRASES: readonly string[] = [
254
324
  'дайте мне минуту',
255
325
  'одну секунду',
256
326
  'минутку',
327
+ // Action/config verb family (perfective first-person futures).
328
+ 'я обновлю',
329
+ 'я настрою',
330
+ 'я исправлю',
331
+ 'я создам',
332
+ 'я добавлю',
333
+ 'я применю',
334
+ 'сейчас обновлю',
335
+ 'сейчас исправлю',
257
336
  ]
258
337
 
259
338
  // Chinese: 我会/我来/我再 + work verb. Full multi-character intent phrases only;
@@ -277,26 +356,39 @@ const ZH_PHRASES: readonly string[] = [
277
356
  '让我检查一下',
278
357
  '稍等一下',
279
358
  '我看一下',
359
+ // Action/config verb family.
360
+ '我来更新',
361
+ '我会更新',
362
+ '我来配置',
363
+ '我来设置',
364
+ '我会设置',
365
+ '我来修改',
366
+ '我会修改',
367
+ '我来修复',
368
+ '我来创建',
369
+ '我来添加',
370
+ '我马上更新',
371
+ '让我更新',
372
+ '让我改一下',
280
373
  ]
281
374
 
282
- // Japanese: -てみます / -します first-person volitional on check/look/continue.
283
- // Bare nouns (確認) are excluded; the verb ending carries the self-direction.
375
+ // Japanese: handled by the JA_VOLITIONAL morpheme check below (-します/-いたします/
376
+ // -してみます generalizes across all する action verbs). Only the regular-verb
377
+ // ます forms (調べます/見てみます/続けます — bare ます is too broad to regex) and
378
+ // stall idioms are enumerated here.
284
379
  const JA_PHRASES: readonly string[] = [
285
- '確認します',
286
- '確認してみます',
287
- '確認いたします',
288
380
  '調べてみます',
289
381
  '調べます',
290
382
  '見てみます',
291
383
  '続けます',
292
- '引き続き確認します',
293
- 'すぐ確認します',
294
384
  '少々お待ちください',
295
385
  'ちょっと待ってください',
296
386
  ]
297
387
 
298
388
  // Arabic: future particle سـ prefixed first-person verb (سأتحقق = "I will
299
- // verify"). The سأ prefix is unambiguously first-person-future.
389
+ // verify"). The سأ prefix is first-person-future, but as a bare substring it
390
+ // collides with the root س-أ-ل ("ask": سألت "I asked", المسألة "the matter"), so
391
+ // this stays an enumerated list of full verbs rather than a سأ-prefix regex.
300
392
  const AR_PHRASES: readonly string[] = [
301
393
  'سأتحقق',
302
394
  'سأتأكد',
@@ -307,30 +399,31 @@ const AR_PHRASES: readonly string[] = [
307
399
  'دعني أتحقق',
308
400
  'دعني أراجع',
309
401
  'لحظة من فضلك',
402
+ // Action/config verb family.
403
+ 'سأحدث',
404
+ 'سأحدّث',
405
+ 'سأعدل',
406
+ 'سأعدّل',
407
+ 'سأضبط',
408
+ 'سأصلح',
409
+ 'سأنشئ',
410
+ 'سأضيف',
310
411
  ]
311
412
 
312
- // Hindi: first-person-future "मैं करूँगा/देखूँगा" forms (multi-word so they
313
- // cannot collide with a bare common word).
314
- const HI_PHRASES: readonly string[] = [
315
- 'जाँच करूँगा',
316
- 'जांच करूंगा',
317
- 'देख लूँगा',
318
- 'देख लूंगा',
319
- 'जारी रखूँगा',
320
- 'जारी रखूंगा',
321
- 'एक मिनट रुकिए',
322
- ]
413
+ // Hindi: first-person-future is the -ūṅgā/-ūṅgī suffix, matched by
414
+ // MORPHEME_PATTERNS below (it covers all X-करना compounds). Only the stall idiom
415
+ // is enumerated here.
416
+ const HI_PHRASES: readonly string[] = ['एक मिनट रुकिए']
323
417
 
324
- // Turkish: first-person-future "-eceğim/-acağım" on check/look/continue verbs.
418
+ // Turkish: first-person-future "-eceğim/-acağım" is matched by MORPHEME_PATTERNS
419
+ // below. The present-progressive ("ediyorum" = "I'm checking now"), optative
420
+ // ("bir bakayım" = "let me look"), and stall idioms stay enumerated — the
421
+ // progressive -ıyorum ending is too polysemous to regex ("biliyorum" = "I know").
325
422
  const TR_PHRASES: readonly string[] = [
326
- 'kontrol edeceğim',
327
423
  'kontrol ediyorum',
328
- 'bakacağım',
329
424
  'bir bakayım',
330
425
  'bir kontrol edeyim',
331
426
  'kontrol edeyim',
332
- 'inceleyeceğim',
333
- 'devam edeceğim',
334
427
  'hemen kontrol ediyorum',
335
428
  'hemen bakıyorum',
336
429
  'bir saniye',
@@ -348,6 +441,14 @@ const VI_PHRASES: readonly string[] = [
348
441
  'tôi xem ngay',
349
442
  'chờ một chút',
350
443
  'đợi một chút',
444
+ // Action/config verb family.
445
+ 'tôi sẽ cập nhật',
446
+ 'tôi sẽ cấu hình',
447
+ 'tôi sẽ sửa',
448
+ 'tôi sẽ tạo',
449
+ 'tôi sẽ thêm',
450
+ 'để tôi cập nhật',
451
+ 'để tôi sửa',
351
452
  ]
352
453
 
353
454
  // Indonesian: "saya akan" / "biar saya" (I will / let me) + work verb.
@@ -362,6 +463,16 @@ const ID_PHRASES: readonly string[] = [
362
463
  'saya periksa dulu',
363
464
  'tunggu sebentar',
364
465
  'sebentar ya',
466
+ // Action/config verb family.
467
+ 'saya akan perbarui',
468
+ 'saya akan memperbarui',
469
+ 'saya akan atur',
470
+ 'saya akan konfigurasi',
471
+ 'saya akan perbaiki',
472
+ 'saya akan buat',
473
+ 'saya akan tambah',
474
+ 'biar saya perbaiki',
475
+ 'biar saya perbarui',
365
476
  ]
366
477
 
367
478
  const ALL_PHRASES: readonly string[] = [
@@ -382,6 +493,41 @@ const ALL_PHRASES: readonly string[] = [
382
493
  ...ID_PHRASES,
383
494
  ]
384
495
 
496
+ // First-person future/volitional realized as a verb inflection or affix (not a
497
+ // separate word), so matching the marker generalizes across ALL action verbs.
498
+ const MORPHEME_PATTERNS: readonly RegExp[] = [
499
+ // Korean first-person volitional: a verb stem + -겠습니다/-겠어요. The stem must
500
+ // be one of the action/auxiliary verbs 하 (the 하다 light verb behind every
501
+ // X하다 — 업데이트하겠습니다/반영하겠어요), 보 (살펴보겠습니다), 두 (반영해두겠습니다), or
502
+ // 놓 (해놓겠습니다). Anchoring on the verb stem is what keeps this self-directed:
503
+ // bare 겠 also matches adjective-stem CONJECTURE (좋겠어요 "that'd be nice",
504
+ // 괜찮겠어요 "must be fine") and the idioms 알겠/모르겠, none of which promise work.
505
+ // Listener-directed conjecture takes the honorific 시 (피곤하시겠어요 → 시겠, not
506
+ // 하겠), so it is excluded too.
507
+ /(?:하|보|두|놓)겠(?:습니다|어요)/,
508
+ // Turkish first-person-singular future "-acağım/-eceğim" ("I will VERB").
509
+ // Vowel harmony yields exactly these two suffixes; the y-buffer
510
+ // ("bekleyeceğim") leaves the suffix intact.
511
+ /acağım|eceğim/,
512
+ // Hindi first-person future "-ūṅgā/-ūṅgī" on any verb, covering all X-करना
513
+ // compounds ("अपडेट करूँगा"). Both nasal spellings (ँ U+0901 / ं U+0902) and
514
+ // both genders (ा/ी) are included.
515
+ /ू[ँं]ग[ाी]/,
516
+ ]
517
+
518
+ // Japanese する-verb volitional します/いたします/してみます — covers the action class
519
+ // (更新します/設定します/対応します). Bare ます is the universal polite verb ending and
520
+ // far too broad to match, so this keys on します specifically. Two request/greeting
521
+ // idioms end in します without being work intent — お願い(いた)します ("please") and
522
+ // 失礼します ("excuse me") — and are stripped before the test so they don't fire.
523
+ // The (?![か??]) lookahead drops question forms — both the か question particle
524
+ // (どうしますか "what should I do?") and a trailing question mark, fullwidth ? or
525
+ // ASCII ? (どうします?, 更新します? "shall I update?"). A question awaits the user;
526
+ // it is not a commitment to act this turn. A statement keeps its 。/, so
527
+ // 更新します。 still matches.
528
+ const JA_VOLITIONAL_IDIOMS = /お願い(?:いた)?します|失礼します/g
529
+ const JA_VOLITIONAL = /します(?![か??])|してみます(?![か??])/
530
+
385
531
  // Reply texts shorter than this are almost always a complete final answer
386
532
  // ("네", "ok", "done") where a partial match would be noise. The shortest
387
533
  // legitimate intent phrases ("on it now", "확인할게요") clear this floor.
@@ -391,5 +537,7 @@ export function detectContinuationWillingness(text: string): boolean {
391
537
  if (text.length < MIN_LENGTH) return false
392
538
  const normalized = normalize(text)
393
539
  if (normalized.length < MIN_LENGTH) return false
394
- return ALL_PHRASES.some((phrase) => normalized.includes(phrase))
540
+ if (ALL_PHRASES.some((phrase) => normalized.includes(phrase))) return true
541
+ if (MORPHEME_PATTERNS.some((pattern) => pattern.test(normalized))) return true
542
+ return JA_VOLITIONAL.test(normalized.replace(JA_VOLITIONAL_IDIOMS, ''))
395
543
  }
@@ -97,6 +97,11 @@ export const MAX_DEBOUNCE_MS = 4000
97
97
  export const HOT_THRESHOLD_MS = 3000
98
98
  export const MAX_CONSECUTIVE_ABORTS = 3
99
99
  export const CONTEXT_BUFFER_SIZE = 20
100
+ // Observed ("Recent context") messages are awareness-only and replayed in full
101
+ // on every turn (uncached), so one long paste would otherwise re-bloat every
102
+ // subsequent turn until it ages out. Cap each observed message's text; the
103
+ // addressed current message is never capped (it's the actual request).
104
+ export const OBSERVED_MESSAGE_MAX_CHARS = 800
100
105
  // Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
101
106
  // continuously visible while we debounce + generate without spamming the API.
102
107
  export const TYPING_HEARTBEAT_MS = 8000
@@ -4462,7 +4467,7 @@ function composeTurnPrompt(
4462
4467
  if (observed.length > 0) {
4463
4468
  parts.push('## Recent context (not addressed to you, for awareness only)')
4464
4469
  for (const o of observed) {
4465
- parts.push(formatInboundPromptLines(o, adapter))
4470
+ parts.push(formatInboundPromptLines(o, adapter, OBSERVED_MESSAGE_MAX_CHARS))
4466
4471
  }
4467
4472
  parts.push('')
4468
4473
  }
@@ -4521,10 +4526,22 @@ function formatAuthorLine(
4521
4526
  authorName: string,
4522
4527
  authorIsBot: boolean,
4523
4528
  text: string,
4529
+ maxChars?: number,
4524
4530
  ): string {
4525
4531
  const tag = authorIsBot ? ' [bot]' : ''
4526
4532
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
4527
- return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
4533
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${capObservedText(text, maxChars)}`
4534
+ }
4535
+
4536
+ // Cap by whole code points so truncation never splits a surrogate pair (emoji,
4537
+ // astral-plane chars) into a dangling half. `text.length` (UTF-16 code units) is
4538
+ // a cheap upper bound on the code-point count, so a string already within the
4539
+ // cap skips the array build.
4540
+ function capObservedText(text: string, maxChars: number | undefined): string {
4541
+ if (maxChars === undefined || text.length <= maxChars) return text
4542
+ const points = Array.from(text)
4543
+ if (points.length <= maxChars) return text
4544
+ return `${points.slice(0, maxChars).join('')} […truncated]`
4528
4545
  }
4529
4546
 
4530
4547
  function formatInboundPromptLines(
@@ -4537,10 +4554,19 @@ function formatInboundPromptLines(
4537
4554
  referenceContext?: InboundReferenceContext
4538
4555
  },
4539
4556
  adapter: AdapterId,
4557
+ maxTextChars?: number,
4540
4558
  ): string {
4541
4559
  const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
4542
4560
  lines.push(
4543
- formatAuthorLine(inbound.ts, adapter, inbound.authorId, inbound.authorName, inbound.authorIsBot, inbound.text),
4561
+ formatAuthorLine(
4562
+ inbound.ts,
4563
+ adapter,
4564
+ inbound.authorId,
4565
+ inbound.authorName,
4566
+ inbound.authorIsBot,
4567
+ inbound.text,
4568
+ maxTextChars,
4569
+ ),
4544
4570
  )
4545
4571
  return lines.join('\n')
4546
4572
  }