typeclaw 0.26.0 → 0.27.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": "typeclaw",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -297,6 +297,7 @@ function renderChannelOrigin(
297
297
  chat: string
298
298
  chatName?: string
299
299
  thread: string | null
300
+ reactionRef?: ReactionRef
300
301
  participants?: readonly ChannelParticipant[]
301
302
  membership?: MembershipCount
302
303
  self?: ChannelSelfIdentity
@@ -354,7 +355,14 @@ function renderChannelOrigin(
354
355
  const conversationLine = renderConversationLine(origin)
355
356
  if (conversationLine !== null) lines.push('', conversationLine)
356
357
 
357
- if (platformInfo.supportsReactions) {
358
+ // Gate on `reactionRef`, not just the static `supportsReactions` platform
359
+ // fact: a turn only has a message to react to when the triggering inbound
360
+ // carried one. Reminder-only turns (restart-resume, subagent-completion,
361
+ // idle/todo continuation) wake the session with no inbound, so
362
+ // `buildLiveOrigin` omits `reactionRef`. Prompting "react like a teammate"
363
+ // there made the model call `channel_react`, which then denied with "this
364
+ // conversation has no message to react to".
365
+ if (platformInfo.supportsReactions && origin.reactionRef !== undefined) {
358
366
  lines.push(
359
367
  '',
360
368
  '**React like a teammate would.** You can drop an emoji on the message that',
@@ -66,7 +66,9 @@ Prioritize in this order:
66
66
 
67
67
  ### Re-reviews must re-decide, not observe
68
68
 
69
- When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes and asked again — your verdict's whole purpose is to **re-decide the blocking state**, so:
69
+ When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes — your verdict's whole purpose is to **re-decide the blocking state**, so:
70
+
71
+ This includes payloads where the parent says the author **addressed your prior blocking feedback** — "fixed both issues", "addressed your review", "pushed a fix" — even when the inbound was phrased conversationally rather than as an explicit "review again". An author responding to the blocker you raised IS the re-review trigger; the absence of the words "review again" does not downgrade it to a \`comment\`. Re-decide:
70
72
 
71
73
  - Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
72
74
  - Return **request-changes** if any blocker remains or a new one appeared.
@@ -187,6 +187,7 @@ export function classifyGithubInbound(
187
187
  ): InboundMessage | null {
188
188
  const repository = readRepository(payload)
189
189
  if (repository === null) return null
190
+ const mention = resolveBotMentionLogins(selfLogin, options?.authType ?? 'pat')
190
191
  const base = {
191
192
  adapter: 'github' as const,
192
193
  workspace: `${repository.owner}/${repository.name}`,
@@ -209,7 +210,7 @@ export function classifyGithubInbound(
209
210
  comment.body,
210
211
  id,
211
212
  user,
212
- selfLogin,
213
+ mention,
213
214
  comment.created_at,
214
215
  { kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
215
216
  )
@@ -228,7 +229,7 @@ export function classifyGithubInbound(
228
229
  comment.body,
229
230
  id,
230
231
  readUser(comment.user),
231
- selfLogin,
232
+ mention,
232
233
  comment.created_at,
233
234
  { kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
234
235
  )
@@ -246,7 +247,7 @@ export function classifyGithubInbound(
246
247
  comment.body,
247
248
  id,
248
249
  readUser(comment.user),
249
- selfLogin,
250
+ mention,
250
251
  comment.created_at,
251
252
  null,
252
253
  )
@@ -270,7 +271,7 @@ export function classifyGithubInbound(
270
271
  text,
271
272
  id,
272
273
  opener,
273
- selfLogin,
274
+ mention,
274
275
  issue.created_at,
275
276
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
276
277
  action === 'opened' && !hasBody,
@@ -325,7 +326,7 @@ export function classifyGithubInbound(
325
326
  prText,
326
327
  id,
327
328
  opener,
328
- selfLogin,
329
+ mention,
329
330
  pr.created_at,
330
331
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
331
332
  isOpenLike && !hasBody,
@@ -352,7 +353,7 @@ export function classifyGithubInbound(
352
353
  text,
353
354
  id,
354
355
  reviewer,
355
- selfLogin,
356
+ mention,
356
357
  review.submitted_at,
357
358
  null,
358
359
  !hasBody,
@@ -377,7 +378,7 @@ export function classifyGithubInbound(
377
378
  text,
378
379
  id,
379
380
  opener,
380
- selfLogin,
381
+ mention,
381
382
  discussion.created_at,
382
383
  null,
383
384
  action === 'created' && !hasBody,
@@ -417,6 +418,48 @@ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'):
417
418
  return slug !== '' ? slug : null
418
419
  }
419
420
 
421
+ // The @-handles that count as "addressed to us" in inbound body text. Under
422
+ // App auth `selfLogin` is the actor login `slug[bot]`, but GitHub renders a
423
+ // human's mention of the App as `@slug` (the bare slug — the decoy account's
424
+ // login), with no `[bot]` suffix and no way to type one. Matching only against
425
+ // `selfLogin` therefore never sees `@typeey` for a `typeey[bot]` actor, so a
426
+ // direct "@typeey review again" lands with isBotMention=false and falls through
427
+ // the engagement mention gate. Include the decoy slug so the bare-slug mention
428
+ // is recognized. Under PAT auth the bot IS a real user, so there is no decoy
429
+ // and only `selfLogin` applies.
430
+ export type BotMentionLogins = readonly string[]
431
+
432
+ export function resolveBotMentionLogins(selfLogin: string | null, authType: 'pat' | 'app'): BotMentionLogins {
433
+ if (selfLogin === null) return []
434
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
435
+ return decoyLogin !== null ? [selfLogin, decoyLogin] : [selfLogin]
436
+ }
437
+
438
+ // GitHub login chars are ASCII letters, digits, and hyphen. A `@login` token is
439
+ // a real mention of `login` only when the char right after it is not one of
440
+ // these — otherwise `@${login}` is a prefix of a longer, different login. This
441
+ // matters for the App decoy slug: `resolveBotMentionLogins('typeclaw[bot]')`
442
+ // yields the bare slug `typeclaw`, and a naive substring check would treat
443
+ // `@typeclaw-bot` (a different user) as a self-mention. The trailing `[` of
444
+ // `@typeclaw[bot]` is not a login char, so the full actor handle still matches.
445
+ const LOGIN_CHAR = /[A-Za-z0-9-]/
446
+
447
+ function textMentionsBot(text: string, mentionLogins: BotMentionLogins): boolean {
448
+ return mentionLogins.some((login) => mentionsLogin(text, login))
449
+ }
450
+
451
+ function mentionsLogin(text: string, login: string): boolean {
452
+ const token = `@${login}`
453
+ let from = 0
454
+ for (;;) {
455
+ const at = text.indexOf(token, from)
456
+ if (at === -1) return false
457
+ const next = text[at + token.length]
458
+ if (next === undefined || !LOGIN_CHAR.test(next)) return true
459
+ from = at + 1
460
+ }
461
+ }
462
+
420
463
  function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
421
464
  const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
422
465
  if (selfLogin === null) return null
@@ -550,7 +593,7 @@ function buildInbound(
550
593
  rawText: unknown,
551
594
  id: number,
552
595
  user: GithubUser | null,
553
- selfLogin: string | null,
596
+ mention: BotMentionLogins,
554
597
  rawTs: unknown,
555
598
  reactionTarget: GithubReactionTarget | null,
556
599
  synthesizedAwareness = false,
@@ -567,7 +610,7 @@ function buildInbound(
567
610
  // Synthesized awareness lines carry an `@author` prefix describing who acted;
568
611
  // that handle is the author, never a third-party mention of the bot, so the
569
612
  // body-text mention heuristic must not fire on it.
570
- const isBotMention = !synthesizedAwareness && selfLogin !== null && text.includes(`@${selfLogin}`)
613
+ const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
571
614
  return {
572
615
  ...key,
573
616
  text,
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
9
9
  // carry more, so the resolver paginates until it matches the root comment id
10
10
  // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
11
  // rather than fabricating a node id.
12
- const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
13
13
 
14
14
  const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
15
 
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
18
18
  isResolved: boolean
19
19
  rootCommentId: number | null
20
20
  rootAuthorLogin: string | null
21
+ rootAuthorIsBot: boolean
21
22
  }
22
23
 
23
24
  type ThreadLookup =
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
66
67
  const thread = lookup.thread
67
68
  // The load-bearing guard: only the bot may resolve the bot's own thread.
68
69
  // Resolving a human reviewer's thread would erase their open question.
69
- if (thread.rootAuthorLogin !== selfLogin) {
70
+ if (!isSelfAuthor(thread, selfLogin)) {
70
71
  return {
71
72
  ok: false,
72
73
  error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
79
80
  }
80
81
  }
81
82
 
83
+ // A GitHub App's own login differs across the two APIs this guard straddles:
84
+ // REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
85
+ // returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
86
+ // OWN thread (production: "refusing to resolve thread authored by @typeey (not
87
+ // @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
88
+ // being a `Bot`: a human `User` can legitimately own the bare slug as a login
89
+ // (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
90
+ // author must still match `selfLogin` exactly — otherwise the suffix-strip would
91
+ // let the bot close a human reviewer's thread, defeating the guard above.
92
+ const BOT_LOGIN_SUFFIX = '[bot]'
93
+
94
+ function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
95
+ if (thread.rootAuthorLogin === null) return false
96
+ if (thread.rootAuthorIsBot) {
97
+ return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
98
+ }
99
+ return thread.rootAuthorLogin === selfLogin
100
+ }
101
+
102
+ function normalizeBotLogin(login: string): string {
103
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
104
+ }
105
+
82
106
  type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
107
 
84
108
  function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
@@ -175,6 +199,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
175
199
  isResolved: n.isResolved,
176
200
  rootCommentId: root?.databaseId ?? null,
177
201
  rootAuthorLogin: root?.author?.login ?? null,
202
+ rootAuthorIsBot: root?.author?.__typename === 'Bot',
178
203
  }
179
204
  })
180
205
  return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
@@ -231,7 +256,7 @@ type GraphqlThreadsResponse = {
231
256
  nodes: Array<{
232
257
  id: string
233
258
  isResolved: boolean
234
- comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
259
+ comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
235
260
  }>
236
261
  }
237
262
  }
@@ -2854,7 +2854,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2854
2854
  return
2855
2855
  }
2856
2856
 
2857
- const { text: assistantText, source } = candidate
2857
+ const { text: candidateText, source } = candidate
2858
+ let assistantText = candidateText
2858
2859
 
2859
2860
  if (endsWithNoReplySignal(assistantText)) {
2860
2861
  const leakedReasoning = !isNoReplySignal(assistantText)
@@ -2874,10 +2875,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2874
2875
  return
2875
2876
  }
2876
2877
 
2877
- if (isLikelyPlainTextChannelToolCall(assistantText)) {
2878
- logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
2878
+ // Plain-text tool-call leak: the model serialized a channel tool call as
2879
+ // ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
2880
+ // failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
2881
+ // raw `channel_reply({...})` serialization to the channel, but for
2882
+ // reply/send the model's *intent* is unambiguous: deliver the `text` arg.
2883
+ // Extract it and recover the actual message. `skip_response` is the
2884
+ // opposite — a genuine decline — so it stays suppressed.
2885
+ const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
2886
+ if (plainTextToolCallKind === 'skip') {
2887
+ logger.warn(
2888
+ `[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
2889
+ )
2879
2890
  return
2880
2891
  }
2892
+ if (plainTextToolCallKind !== null) {
2893
+ const extracted = extractPlainTextChannelToolCallText(assistantText)
2894
+ // Unextractable (no `text` arg, empty value, or fully-truncated): fall
2895
+ // back to the historical safe behavior — drop it rather than leak plumbing.
2896
+ if (extracted === null) {
2897
+ logger.warn(
2898
+ `[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
2899
+ )
2900
+ return
2901
+ }
2902
+ // The extracted value is still untrusted model output: if it is itself a
2903
+ // no-reply signal, an empty-response sentinel, or another (nested) leaked
2904
+ // tool call, suppress it through the same guards rather than re-leaking.
2905
+ if (
2906
+ endsWithNoReplySignal(extracted) ||
2907
+ isUpstreamEmptyResponseSentinel(extracted) ||
2908
+ isLikelyKimiChannelToolLeak(extracted) ||
2909
+ isLikelyPlainTextChannelToolCall(extracted)
2910
+ ) {
2911
+ logger.warn(
2912
+ `[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
2913
+ )
2914
+ return
2915
+ }
2916
+ logger.warn(
2917
+ `[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
2918
+ )
2919
+ assistantText = extracted
2920
+ }
2881
2921
 
2882
2922
  // `source` distinguishes the three recovery shapes for log triage:
2883
2923
  // - 'leaf': the assistant message IS the leaf with stopReason 'stop'
@@ -4233,8 +4273,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4233
4273
  //
4234
4274
  // Structural-only detection (NOT a substring search): the trimmed text must
4235
4275
  // *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
4236
- // that opening paren must enclose at least one `"` (the serialized argument).
4237
- // This deliberately matches the leak shape while letting prose that merely
4276
+ // that opening paren must enclose at least one quote — `"` or `'` (the
4277
+ // serialized argument). The single-quote arm matters because the extractor
4278
+ // recovers single-quoted values too; if the classifier only matched `"`, a
4279
+ // single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
4280
+ // extractor and post raw plumbing. This deliberately matches the leak shape
4281
+ // while letting prose that merely
4238
4282
  // *mentions* a tool name (e.g. "I would normally call channel_reply here
4239
4283
  // but...") reach the user — that false-positive class is already locked in by
4240
4284
  // the `still recovers prose that mentions channel_reply` test.
@@ -4242,12 +4286,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
4242
4286
  // The trailing close paren is NOT required: the model sometimes truncates
4243
4287
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
4244
4288
  // just as user-hostile as the full shape.
4245
- const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(?:channel_(?:reply|send)|skip_response)\s*\(\s*[^)]*"/
4289
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
4290
+
4291
+ export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
4292
+
4293
+ export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
4294
+ const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
4295
+ if (match === null) return null
4296
+ switch (match[1]) {
4297
+ case 'channel_reply':
4298
+ return 'reply'
4299
+ case 'channel_send':
4300
+ return 'send'
4301
+ case 'skip_response':
4302
+ return 'skip'
4303
+ default:
4304
+ return null
4305
+ }
4306
+ }
4246
4307
 
4247
4308
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
4248
- return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
4309
+ return getPlainTextChannelToolCallKind(text) !== null
4310
+ }
4311
+
4312
+ // Tolerant single-purpose scanner that pulls the `text` argument out of a
4313
+ // plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
4314
+ // single regex covering every shape (double/single/unquoted keys, escaped
4315
+ // quotes, mid-serialization truncation) is fragile, so this walks the string
4316
+ // once and extracts only the first string-valued `text` property. `channel_send`
4317
+ // also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
4318
+ // recovery always routes back through the current channel, never a
4319
+ // model-supplied destination. Returns null when no recoverable, non-empty
4320
+ // `text` value is present so the caller can fall back to suppression.
4321
+ export function extractPlainTextChannelToolCallText(text: string): string | null {
4322
+ const trimmed = text.trim()
4323
+ if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
4324
+
4325
+ // Walk the serialization once, honoring a `text` key only at the top level of
4326
+ // the argument object (braceDepth 1, outside any array). Two failure classes
4327
+ // motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
4328
+ // `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
4329
+ // inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
4330
+ // "real" })`. Skipping string literals defeats the first; tracking
4331
+ // brace/bracket depth and matching keys only at top level defeats the second.
4332
+ // Either way the scanner lands on the real reply instead of leaking the wrong
4333
+ // value or dropping the message.
4334
+ let braceDepth = 0
4335
+ let bracketDepth = 0
4336
+ for (let i = 0; i < trimmed.length; i++) {
4337
+ const ch = trimmed[i]!
4338
+
4339
+ if (ch === '"' || ch === "'") {
4340
+ i = skipStringLiteral(trimmed, i, ch)
4341
+ continue
4342
+ }
4343
+
4344
+ if (ch === '{') {
4345
+ braceDepth++
4346
+ if (braceDepth === 1 && bracketDepth === 0) {
4347
+ const value = readTextKeyValueAt(trimmed, i + 1)
4348
+ if (value !== undefined) return value
4349
+ }
4350
+ continue
4351
+ }
4352
+ if (ch === '}') {
4353
+ if (braceDepth > 0) braceDepth--
4354
+ continue
4355
+ }
4356
+ if (ch === '[') {
4357
+ bracketDepth++
4358
+ continue
4359
+ }
4360
+ if (ch === ']') {
4361
+ if (bracketDepth > 0) bracketDepth--
4362
+ continue
4363
+ }
4364
+
4365
+ if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
4366
+ const value = readTextKeyValueAt(trimmed, i + 1)
4367
+ if (value !== undefined) return value
4368
+ }
4369
+ }
4370
+
4371
+ return null
4372
+ }
4373
+
4374
+ // Returns the recovered value (string or null) when a `text` key starts at
4375
+ // `from`, or undefined when no `text` key is present there so the scanner keeps
4376
+ // walking. The null/undefined split lets a malformed `text` value short-circuit
4377
+ // to suppression while a non-`text` delimiter is simply skipped.
4378
+ function readTextKeyValueAt(s: string, from: number): string | null | undefined {
4379
+ const afterKey = matchTextKey(s, from)
4380
+ if (afterKey === null) return undefined
4381
+
4382
+ const quote = s[afterKey]
4383
+ if (quote !== '"' && quote !== "'") return null
4384
+ return readStringValue(s, afterKey + 1, quote)
4385
+ }
4386
+
4387
+ // Returns the closing-quote index, or the last index when the literal is
4388
+ // truncated, so the caller's `i++` resumes past the consumed string.
4389
+ function skipStringLiteral(s: string, openIdx: number, quote: string): number {
4390
+ let escaped = false
4391
+ for (let i = openIdx + 1; i < s.length; i++) {
4392
+ const ch = s[i]!
4393
+ if (escaped) {
4394
+ escaped = false
4395
+ continue
4396
+ }
4397
+ if (ch === '\\') {
4398
+ escaped = true
4399
+ continue
4400
+ }
4401
+ if (ch === quote) return i
4402
+ }
4403
+ return s.length
4404
+ }
4405
+
4406
+ function matchTextKey(s: string, from: number): number | null {
4407
+ const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
4408
+ return m === null ? null : from + m[0].length
4249
4409
  }
4250
4410
 
4411
+ function readStringValue(s: string, from: number, quote: string): string | null {
4412
+ let value = ''
4413
+ let escaped = false
4414
+ for (let i = from; i < s.length; i++) {
4415
+ const ch = s[i]!
4416
+ if (escaped) {
4417
+ value += ESCAPE_REPLACEMENTS[ch] ?? ch
4418
+ escaped = false
4419
+ continue
4420
+ }
4421
+ if (ch === '\\') {
4422
+ escaped = true
4423
+ continue
4424
+ }
4425
+ if (ch === quote) break
4426
+ value += ch
4427
+ }
4428
+ return value.trim().length > 0 ? value : null
4429
+ }
4430
+
4431
+ const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
4432
+
4251
4433
  function describe(err: unknown): string {
4252
4434
  return err instanceof Error ? err.message : String(err)
4253
4435
  }