@trazum/core 1.86.0 → 2.1.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/src/segment.ts CHANGED
@@ -23,8 +23,102 @@ interface PatternDef {
23
23
  const PATTERNS: PatternDef[] = [
24
24
  // Fenced code blocks with ``` or ~~~ (unclosed blocks run to the end).
25
25
  { kind: 'fenced-code', regex: /(?:```|~~~)[\s\S]*?(?:```|~~~|$)/g },
26
- // Indented blocks of 4+ spaces are left to the rules: they are ambiguous in markdown.
26
+ /*
27
+ Indented code blocks, which this file used to leave to the rules on the
28
+ grounds that they are *"ambiguous in markdown"*. The ambiguity is real. The
29
+ consequence of acting on it was not proportionate to it.
30
+
31
+ **What the rules did to four lines of indented code**, at the aggressive
32
+ level, every one measured rather than imagined:
33
+
34
+ const label = "please keep"; -> Const label = " keep";
35
+ def run(x): -> Def run(x):
36
+ SELECT … WHERE note = 'please refund'; -> … = ' refund';
37
+ {"reason": "please cancel"} -> {"reason": " cancel"}
38
+
39
+ Three separate failures at once. The indentation goes, which by itself
40
+ makes the Python a syntax error. Keywords get sentence-capitalised, which
41
+ makes the rest of them syntax errors. And **string literals are edited** —
42
+ that SQL now matches a different value and that payload carries a different
43
+ reason. The report says tokens were saved.
44
+
45
+ The blank line before the block is what makes this checkable rather than a
46
+ guess: CommonMark says an indented code block cannot interrupt a paragraph,
47
+ so a run of indented lines after a blank line is code by the specification
48
+ rather than by anybody's judgement here.
49
+
50
+ **What it still gets wrong, stated rather than discovered later.** Content
51
+ indented four or more spaces inside a deeply nested list is continuation,
52
+ not code, and this protects it. That costs unsaved tokens in a rare shape.
53
+ Not protecting code costs a broken prompt in a common one, and those are not
54
+ the same kind of wrong.
55
+
56
+ Every quantifier is bounded or consumes a whole line exactly once, for the
57
+ reason the email mask below now carries in full.
58
+ */
59
+ {
60
+ kind: 'indented-code',
61
+ /*
62
+ **The lookbehind requires real content before the blank line, and the
63
+ fuzzer is why.** Its first version asked only for a blank line, which a
64
+ document *beginning* with one satisfies — and `optimize` ends with a
65
+ `.trim()` on the whole reassembled string, outside every mask, which then
66
+ removes the leading newlines and the first line's indentation. The block
67
+ stopped being a block, the second pass no longer protected it, and 54 of
68
+ 1,500 corpus inputs failed the idempotence property.
69
+
70
+ The trim is fixed at its source now — `optimize` trims the *masked* string,
71
+ where a protected span is a placeholder nothing can edit — so the mask can
72
+ also claim a block at the very start of a document. It has to: at the
73
+ aggressive level a rule can delete the sentence before the blank line, and
74
+ a block that was protected on the first pass has to still be protected on
75
+ the second or the output never settles.
76
+ */
77
+ regex: /(?<=\S[ \t]*\n[ \t]*\n)(?:(?: {4,}|\t)[^\n]*(?:\n|$))+/g,
78
+ },
27
79
  { kind: 'url', regex: /\b(?:https?|ftp):\/\/[^\s<>"')\]]+/g, trimTrailing: /[.,;:!?]+$/ },
80
+ /*
81
+ Email addresses, for the reason at the top of this file.
82
+
83
+ **Five of ten realistic addresses came out corrupted before this existed.**
84
+ `please@example.com` became `@example.com`, and so did `thanks@`,
85
+ `basically@`, `essentially.ops@` and `very.important@`: the politeness,
86
+ filler and intensifier rules match the local part as ordinary prose and cut
87
+ it out. What is left is not a wrong address, it is not an address — and the
88
+ report says tokens were saved.
89
+
90
+ That is the failure this module's own first paragraph names. A code block, a
91
+ URL and a placeholder were on the list; the thing every support prompt in
92
+ the world carries was not. `support@` and `no-reply@` survived, and
93
+ `por.favor@ejemplo.es` survived only because the Spanish politeness entry is
94
+ written with a space — luck, not design, which is what made it invisible.
95
+
96
+ After the URL pattern so a `mailto:` link is claimed as a URL first, and
97
+ trailing punctuation is trimmed for the same reason it is there: the full
98
+ stop in "write to a@b.com." belongs to the sentence.
99
+ */
100
+ {
101
+ kind: 'email',
102
+ /*
103
+ **Every quantifier here is bounded, and the first version of this was not.**
104
+ The local part accepts `.`, so on a long dotted run with no `@` in it —
105
+ a dotted run behind a scheme, which is a fixture in `security.test.js`
106
+ for exactly this reason — an unbounded `+` matches the whole run from every starting
107
+ position and then fails. Quadratic: **897ms on 40,008 characters**, and
108
+ `optimize` runs this alongside every other pattern, which took the total
109
+ past the 5-second cliff detector on a CI runner while passing on a faster
110
+ machine. That test's own note had already recorded the lesson from the
111
+ last time — *"quadratic until the label quantifiers were bounded"* — and
112
+ this pattern arrived a week later without them.
113
+
114
+ The bounds are RFC 5321's, not invented: 64 octets for a local part, 63
115
+ for a domain label. The TLD's 24 is generous against the longest real one
116
+ (`.travelersinsurance`, 19). Bounded, the same input takes **8ms**, and no
117
+ address in the corpus is lost.
118
+ */
119
+ regex: /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})*\.[A-Za-z]{2,24}/g,
120
+ trimTrailing: /[.,;:!?]+$/,
121
+ },
28
122
  // Template placeholders: {{var}}, {var}, ${var}, {% tag %}, <<VAR>>, %(var)s
29
123
  {
30
124
  kind: 'placeholder',
package/src/structure.ts CHANGED
@@ -54,6 +54,31 @@ interface AxisDefinition {
54
54
  */
55
55
  const RESPOND = String.raw`(?:respond|reply|answer|write|speak|output|responde|contesta|responder|escribe|redacta)`;
56
56
 
57
+ /**
58
+ * How a prompt refers to the reader having produced their message.
59
+ *
60
+ * `used|wrote|speaks` missed on a tense: *"respond in the same language the
61
+ * user writes in"* — present, which is how a standing instruction is normally
62
+ * written — was invisible, so a prompt pinning English and mirroring the user
63
+ * in the same breath reported no conflict at all.
64
+ */
65
+ const SPOKE = String.raw`(?:used?|uses|wrote|writes|writing|speaks|spoke|sent|asked)`;
66
+
67
+ /** Nouns that mean *the thing the model produces*, as opposed to any other text. */
68
+ const THE_ANSWER = String.raw`(?:answers?|responses?|repl(?:y|ies)|explanations?|respuestas?)`;
69
+
70
+ /**
71
+ * A numeric ceiling on length, without the unit.
72
+ *
73
+ * Kept separate because it is only ever safe next to an anchor: *"the title
74
+ * field takes at most five words"* is the same phrase about a form field, and
75
+ * reading it as an instruction about the answer is how a widening turns a
76
+ * detector into a nuisance.
77
+ */
78
+ const REQUIRE_SHORT = String.raw`keep\s+(?:it|them|${THE_ANSWER})\s+(?:short|brief|concise)`;
79
+
80
+ const AT_MOST = String.raw`(?:at\s+most|no\s+more\s+than|under|within|máximo\s+de?)\s+(?:one|two|three|four|five|\d+)\s+(?:sentences?|words?|frases?|palabras?)`;
81
+
57
82
  const AXES: readonly AxisDefinition[] = [
58
83
  {
59
84
  axis: 'response-language',
@@ -71,7 +96,7 @@ const AXES: readonly AxisDefinition[] = [
71
96
  // The qualifier slot ("own", "native", "preferred") is what makes
72
97
  // this survive real prompts: "the customer's own language" is at
73
98
  // least as common as the bare form.
74
- String.raw`\bin\s+the\s+(?:same\s+)?language\s+(?:of|as|the\s+\w+\s+(?:used|wrote|speaks))|\bin\s+(?:the\s+(?:user|customer|client)'?s?|their)\s+(?:own\s+|native\s+|preferred\s+)?language\b|\bmatch\s+the\s+language\b|\ben\s+(?:el\s+)?(?:mismo\s+)?idioma\s+(?:del?\s+|en\s+que\s+)?(?:usuario|cliente|consulta|mensaje|escrib)`,
99
+ String.raw`\bin\s+the\s+(?:same\s+)?language\s+(?:of|as|the\s+\w+\s+(?:used|wrote|speaks))|\bin\s+(?:the\s+(?:user|customer|client)'?s?|their)\s+(?:own\s+|native\s+|preferred\s+)?language\b|\bmatch\s+the\s+language\b|\b${RESPOND}\b[^.!?\n]{0,40}?\bin\s+(?:the\s+(?:same\s+)?|whatever\s+|whichever\s+)language\s+(?:of|as|that\s+|the\s+\w+\s+)?\w*\s*${SPOKE}|\ben\s+(?:el\s+)?(?:mismo\s+)?idioma\s+(?:del?\s+|en\s+que\s+)?(?:usuario|cliente|consulta|mensaje|escrib)`,
75
100
  'i',
76
101
  ),
77
102
  ],
@@ -109,14 +134,14 @@ const AXES: readonly AxisDefinition[] = [
109
134
  [
110
135
  'length-brief',
111
136
  new RegExp(
112
- String.raw`\b(?:be\s+(?:brief|concise|succinct|terse)|keep\s+it\s+(?:short|brief)|as\s+short\s+as\s+possible|in\s+(?:one|a\s+single)\s+sentence|s[ée]\s+(?:breve|conciso)|de\s+forma\s+(?:breve|concisa)|brevemente)\b`,
137
+ String.raw`\b(?:be\s+(?:brief|concise|succinct|terse)|keep\s+it\s+(?:short|brief)|as\s+short\s+as\s+possible|${REQUIRE_SHORT}|\b${RESPOND}\b[^.!?\n]{0,40}?${AT_MOST}|in\s+(?:one|a\s+single)\s+sentence|s[ée]\s+(?:breve|conciso)|de\s+forma\s+(?:breve|concisa)|brevemente)\b`,
113
138
  'i',
114
139
  ),
115
140
  ],
116
141
  [
117
142
  'length-detailed',
118
143
  new RegExp(
119
- String.raw`\b(?:be\s+(?:detailed|comprehensive|thorough|exhaustive)|in\s+(?:great\s+)?depth|as\s+much\s+detail\s+as\s+possible|s[ée]\s+(?:exhaustivo|detallado)|de\s+forma\s+(?:detallada|exhaustiva)|con\s+todo\s+detalle|detalladamente)\b`,
144
+ String.raw`\b(?:be\s+(?:detailed|comprehensive|thorough|exhaustive)|in\s+(?:great\s+)?depth|(?:detailed|comprehensive|thorough|exhaustive|in-depth)[^.!?\n]{0,30}?\b${THE_ANSWER}\b|as\s+much\s+detail\s+as\s+possible|s[ée]\s+(?:exhaustivo|detallado)|de\s+forma\s+(?:detallada|exhaustiva)|con\s+todo\s+detalle|detalladamente)\b`,
120
145
  'i',
121
146
  ),
122
147
  ],
@@ -128,14 +153,14 @@ const AXES: readonly AxisDefinition[] = [
128
153
  [
129
154
  'reasoning-shown',
130
155
  new RegExp(
131
- String.raw`\b(?:explain\s+your\s+(?:reasoning|answer|thinking)|show\s+your\s+(?:work|reasoning|thinking)|think\s+step[\s-]by[\s-]step|justify\s+your\s+answer|explica\s+tu\s+razonamiento|razona\s+paso\s+a\s+paso|justifica\s+tu\s+respuesta)\b`,
156
+ String.raw`\b(?:explain\s+your\s+(?:reasoning|answer|thinking)|show\s+your\s+(?:work|reasoning|thinking)|show\s+your\s+chain\s+of\s+thought|walk\s+(?:me\s+)?through\s+your\s+(?:reasoning|thinking|logic)|think\s+step[\s-]by[\s-]step|justify\s+your\s+answer|explica\s+tu\s+razonamiento|razona\s+paso\s+a\s+paso|justifica\s+tu\s+respuesta)\b`,
132
157
  'i',
133
158
  ),
134
159
  ],
135
160
  [
136
161
  'reasoning-hidden',
137
162
  new RegExp(
138
- String.raw`\b(?:no\s+(?:explanation|commentary|preamble)|without\s+(?:explanation|commentary)|do\s+not\s+explain|don'?t\s+explain|no\s+expliques|sin\s+(?:explicaciones|comentarios)|sin\s+preámbulo)\b`,
163
+ String.raw`\b(?:no\s+(?:explanation|commentary|preamble)|without\s+(?:explanation|commentary|justification|reasoning)|no\s+justification|do\s+not\s+explain|don'?t\s+explain|no\s+expliques|sin\s+(?:explicaciones|comentarios)|sin\s+preámbulo)\b`,
139
164
  'i',
140
165
  ),
141
166
  ],
@@ -249,10 +274,83 @@ const LABEL_SUFFIX = String.raw`[ \t]{0,4}\d{0,6}[ \t]{0,4}[:.)]`;
249
274
  * indistinguishable from ordinary prose, and guessing would make the advisory
250
275
  * fire on prompts containing no examples at all.
251
276
  */
277
+ /**
278
+ * The label on the side that **asks**, in the wordings prompts actually use.
279
+ *
280
+ * **This list was three entries long and it made six analyses blind.** A
281
+ * support prompt labelled `Customer:` / `Agent:` — the single most common
282
+ * shape there is — found zero examples, so `prune` had nothing to cut,
283
+ * `profile` reported the examples as costing nothing, and the redundant-example
284
+ * advisory never fired. Measured across fourteen labellings real prompts use,
285
+ * nine found nothing: `Customer`, `Human`, `Question`, `Client`, `Prompt`,
286
+ * `Query`, `Cliente`, `Pregunta`, `REQUEST`. `Human:` is Anthropic's own
287
+ * historical convention and `Q:` worked while `Question:` did not, which is
288
+ * the arbitrariness that gives the fault away.
289
+ *
290
+ * The failure was silent, which is what made it survive: an unrecognised label
291
+ * produces no examples, and no examples produces no finding — the same shape
292
+ * as a gate that passes because it is checking nothing.
293
+ *
294
+ * Only openers belong here. `Answer:` and `Assistant:` close a turn, and
295
+ * splitting on those would cut every example between its question and its
296
+ * answer and then compare the halves.
297
+ */
298
+ const ASKER_LABELS = [
299
+ 'user',
300
+ 'usuario',
301
+ 'human',
302
+ 'humano',
303
+ 'customer',
304
+ 'cliente',
305
+ 'client',
306
+ 'q',
307
+ 'question',
308
+ 'pregunta',
309
+ 'query',
310
+ 'consulta',
311
+ 'request',
312
+ 'petición',
313
+ 'peticion',
314
+ 'prompt',
315
+ ] as const;
316
+
317
+ /**
318
+ * The label on the side that **answers**.
319
+ *
320
+ * Never a tier: these appear inside an example rather than opening one. They
321
+ * are here so `EXAMPLE_FIELD_LINE` can be built from the same two lists the
322
+ * splitter uses, which is what stops the pair drifting apart again — the field
323
+ * detector already knew `question` and `answer` while the splitter did not,
324
+ * and that disagreement is precisely how `Q:` came to work and `Question:` not.
325
+ */
326
+ const ANSWERER_LABELS = [
327
+ 'assistant',
328
+ 'asistente',
329
+ 'a',
330
+ 'answer',
331
+ 'respuesta',
332
+ 'output',
333
+ 'salida',
334
+ 'response',
335
+ 'reply',
336
+ 'agent',
337
+ 'agente',
338
+ 'support',
339
+ 'soporte',
340
+ 'bot',
341
+ 'result',
342
+ 'resultado',
343
+ ] as const;
344
+
345
+ /** Labels that name an example as a whole, rather than one side of it. */
346
+ const BLOCK_LABELS = ['example', 'ejemplo', 'input', 'entrada'] as const;
347
+
348
+ const anyOf = (labels: readonly string[]) => labels.join('|');
349
+
252
350
  const EXAMPLE_HEADER_TIERS: readonly RegExp[] = [
253
351
  new RegExp(`${LABEL_PREFIX}(?:example|ejemplo)${LABEL_SUFFIX}`, 'i'),
254
352
  new RegExp(`${LABEL_PREFIX}(?:input|entrada)${LABEL_SUFFIX}`, 'i'),
255
- new RegExp(`${LABEL_PREFIX}(?:user|usuario|q)${LABEL_SUFFIX}`, 'i'),
353
+ new RegExp(`${LABEL_PREFIX}(?:${anyOf(ASKER_LABELS)})${LABEL_SUFFIX}`, 'i'),
256
354
  ];
257
355
 
258
356
  /**
@@ -265,7 +363,7 @@ const EXAMPLE_HEADER_TIERS: readonly RegExp[] = [
265
363
  * repetition it removed.
266
364
  */
267
365
  export const EXAMPLE_FIELD_LINE = new RegExp(
268
- `${LABEL_PREFIX}(?:input|output|entrada|salida|example|ejemplo|user|usuario|assistant|asistente|q|a|question|answer|pregunta|respuesta)${LABEL_SUFFIX}`,
366
+ `${LABEL_PREFIX}(?:${anyOf([...BLOCK_LABELS, ...ASKER_LABELS, ...ANSWERER_LABELS])})${LABEL_SUFFIX}`,
269
367
  'i',
270
368
  );
271
369
 
@@ -307,6 +405,30 @@ export interface ExampleAnalysis {
307
405
  * "arrived fast" teach the same thing needs a model, not a word-set overlap;
308
406
  * that belongs to the optional LLM pass, not here.
309
407
  */
408
+ /**
409
+ * How alike two examples must be before one is called redundant.
410
+ *
411
+ * **Do not lower this hoping to catch paraphrases: it was measured and it does
412
+ * not work.** Four support examples whose answers were *"Let me look that up
413
+ * for you. Could you share your order number?"*, *"I can check that for you.
414
+ * What's your order number?"*, *"Happy to help. May I have your order
415
+ * number?"* and *"Of course. What is your order number?"* are one example
416
+ * wearing four coats — any reader says so instantly. Their word overlap is
417
+ * 0.29–0.43, and comparing only the answers makes it **worse**, 0.21–0.39,
418
+ * because the shared content is three words (`your`, `order`, `number`) while
419
+ * the openers are all different. There is no threshold that separates those
420
+ * from genuinely different examples: at 0.4 this fires on every prompt whose
421
+ * examples share a domain vocabulary.
422
+ *
423
+ * That similarity is a semantic judgement, and this repository has a place for
424
+ * those — `trazum semantic`, which says what it costs and asks first. What
425
+ * word overlap can honestly find is a paragraph somebody pasted twice and
426
+ * edited lightly, which is what 0.7 is for.
427
+ *
428
+ * So the deterministic half reports **what the examples cost**, which is a
429
+ * fact, and leaves *whether one should go* to `prune`, which answers it by
430
+ * running the cases rather than by counting words.
431
+ */
310
432
  const EXAMPLE_SIMILARITY = 0.7;
311
433
 
312
434
  /** Splits the prompt on one header style. */
@@ -356,10 +478,70 @@ function trimToExample(block: string): string {
356
478
  return kept.join('\n\n').trim();
357
479
  }
358
480
 
481
+ /**
482
+ * Examples delimited by an `<example>` tag rather than by a label.
483
+ *
484
+ * **This is Anthropic's own documented convention for few-shot prompting**, and
485
+ * the product whose headline model is Claude could not see it: a code-review
486
+ * prompt wrapping three demonstrations in `<example>` found zero examples, and
487
+ * the same six analyses went quiet that `Customer:` had silenced. A shape a
488
+ * provider tells people to use is not an edge case.
489
+ *
490
+ * Tried before the label tiers because it is the least ambiguous thing in this
491
+ * file. A label is a word that might be prose; `<example>` is a delimiter, so
492
+ * there is no threshold and no judgement — either the tag is there or it is not.
493
+ *
494
+ * Only `example` and `examples`, which are what the documentation names.
495
+ * `<sample>` and `<demonstration>` appear in the wild and are not added: the
496
+ * list would then be a guess about what people might write, and this splitter
497
+ * has already been burnt once by a vocabulary somebody thought was wide enough.
498
+ * A tag arriving here should arrive with a prompt that uses it.
499
+ *
500
+ * A tag inside a fenced block is documentation about the tag rather than a use
501
+ * of it — a prompt explaining how to write examples is not a prompt containing
502
+ * them — so fenced regions are removed before matching.
503
+ */
504
+ function xmlExamples(prompt: string): string[] {
505
+ const outsideFences = prompt.replace(/^([ \t]{0,3})(`{3,}|~{3,})[\s\S]*?^\1\2[ \t]*$/gm, '');
506
+ const tag = /<example(?:\s[^>]*)?>([\s\S]*?)<\/example>/gi;
507
+ return [...outsideFences.matchAll(tag)].map(([, body]) => (body ?? '').trim()).filter(Boolean);
508
+ }
509
+
510
+ /**
511
+ * **A shape this deliberately does not detect, and the reason is the point.**
512
+ *
513
+ * Inline mappings are everywhere — `1. "package never arrived" -> shipping`,
514
+ * `- "Acme raised a round" => Acme` — and on the probes that found the two
515
+ * faults above they are the remaining silent case. They are not added, because
516
+ * the only thing separating a demonstration from an instruction here is
517
+ * judgement: `if the ticket is about delivery -> use the shipping category` is
518
+ * the same line shape and is a rule, not an example.
519
+ *
520
+ * A detector that got that wrong would not merely report a wrong number.
521
+ * `prune` *removes* examples, so a rule misread as an example becomes a
522
+ * proposal to delete an instruction — and it would arrive with the same
523
+ * confidence as a correct one.
524
+ *
525
+ * The tempting narrowing is to require the left side to be quoted. It was
526
+ * considered and refused: both probes are quoted **because the author of the
527
+ * probes wrote them that way**, and a threshold fitted to one's own fixture is
528
+ * a threshold that has been measured against nothing. What would settle it is
529
+ * real prompts using the shape, not a rule invented here.
530
+ *
531
+ * Until then the honest behaviour is the one `prune` now has: say what was
532
+ * looked for, so a prompt this cannot read reports that it was not seen rather
533
+ * than that it does not exist. That sentence used to read "this prompt has
534
+ * fewer than two few-shot examples", which was a confident false statement
535
+ * about a prompt with four of them.
536
+ */
537
+
359
538
  /** Splits the prompt into labelled example blocks. */
360
539
  export function findExamples(prompt: string, count: TokenCounter): ExampleBlock[] {
361
540
  const lines = prompt.split('\n');
362
541
 
542
+ const tagged = xmlExamples(prompt);
543
+ if (tagged.length >= 2) return tagged.map((text) => ({ text, tokens: count(text) }));
544
+
363
545
  for (const header of EXAMPLE_HEADER_TIERS) {
364
546
  const blocks = splitOnHeader(lines, header);
365
547
  if (blocks.length >= 2) {
@@ -447,6 +629,90 @@ function fencedBlocks(prompt: string): Array<{ lang: string; body: string }> {
447
629
  * prompts worth checking. Only keys at nesting depth 1 count, so a nested
448
630
  * field name cannot be mistaken for a top-level one.
449
631
  */
632
+ /**
633
+ * Fence languages this reads a schema out of.
634
+ *
635
+ * `ts` and `typescript` are new; the rest were already here and three of them
636
+ * did not work. See `unquotedKeys`.
637
+ */
638
+ const SCHEMA_LANGS = /^(?:json|jsonc|json5|yaml|yml|ts|typescript)$/;
639
+
640
+ /**
641
+ * Keys written without quotes, which is most of them.
642
+ *
643
+ * **The filter above already admitted `yaml`, `yml` and `json5`, and the
644
+ * extractor could not read any of them.** It only accepted a quoted string
645
+ * followed by a colon, which is JSON and nothing else — so a prompt showing its
646
+ * schema as YAML matched a language this module claims to support, produced
647
+ * zero keys, and reported no restatement however thoroughly the prose walked
648
+ * the fields. Not a missing feature: a promise made in one expression and
649
+ * broken by the next.
650
+ *
651
+ * `ts` is added at the same time because it is the same omission wearing a
652
+ * different label — `interface Ticket { category: string }` is how a
653
+ * TypeScript codebase writes the structured output it wants back, and it was
654
+ * not on the list at all.
655
+ *
656
+ * Two shapes, because YAML has no braces:
657
+ *
658
+ * - **Braced** (`json5`, `jsonc`, `ts`): an identifier at depth 1 followed by
659
+ * `:` or `?:`. Depth is what keeps a nested object's fields out.
660
+ * - **Indented** (`yaml`, `yml`): an identifier at the block's own outermost
661
+ * indent, followed by `:`. Anything indented past it belongs to a parent key
662
+ * and is not top-level.
663
+ *
664
+ * Deliberately not applied to a fence with **no** language. An unlabelled block
665
+ * is as likely to be a shell session or a log line as a schema, and
666
+ * `Note: something` reads as a key to any rule this simple. The quoted form
667
+ * stays the only thing read there, exactly as before.
668
+ */
669
+ function unquotedKeys(body: string, lang: string): string[] {
670
+ if (!SCHEMA_LANGS.test(lang)) return [];
671
+
672
+ if (/^(?:yaml|yml)$/.test(lang)) {
673
+ const lines = body
674
+ .split('\n')
675
+ .filter((line) => line.trim() !== '' && !/^\s*#/.test(line));
676
+ const outermost = Math.min(...lines.map((line) => line.length - line.trimStart().length));
677
+ if (!Number.isFinite(outermost)) return [];
678
+ return [
679
+ ...new Set(
680
+ lines
681
+ .filter((line) => line.length - line.trimStart().length === outermost)
682
+ .map((line) => /^\s*([A-Za-z_][\w-]*)\s*:/.exec(line))
683
+ .filter((found): found is RegExpExecArray => found !== null)
684
+ .map((found) => found[1]!),
685
+ ),
686
+ ];
687
+ }
688
+
689
+ /*
690
+ Character by character rather than line by line, and that was found by
691
+ running it: `interface Ticket { category: string; urgency: number }` is one
692
+ line, so a line-oriented walk sees depth 0 where it starts and reads nothing
693
+ at all. Single-line objects are the common case in a `ts` fence and in the
694
+ JSON5 people paste, not the exception.
695
+ */
696
+ const keys: string[] = [];
697
+ let depth = 0;
698
+ let candidate = '';
699
+ for (let i = 0; i < body.length; i++) {
700
+ const ch = body[i]!;
701
+ if (ch === '{' || ch === '[') { depth++; candidate = ''; continue; }
702
+ if (ch === '}' || ch === ']') { depth--; candidate = ''; continue; }
703
+ if (ch === ',' || ch === ';' || ch === '\n') { candidate = ''; continue; }
704
+ if (/[A-Za-z0-9_-]/.test(ch)) { candidate += ch; continue; }
705
+ if (ch === ':' || (ch === '?' && body[i + 1] === ':')) {
706
+ if (depth === 1 && /^[A-Za-z_][\w-]*$/.test(candidate)) keys.push(candidate);
707
+ candidate = '';
708
+ continue;
709
+ }
710
+ // Whitespace between a name and its colon is fine; anything else is not a key.
711
+ if (!/\s/.test(ch)) candidate = '';
712
+ }
713
+ return [...new Set(keys)];
714
+ }
715
+
450
716
  function topLevelKeys(body: string): string[] {
451
717
  const keys: string[] = [];
452
718
  let depth = 0;
@@ -505,8 +771,8 @@ export function findRestatedFormat(prompt: string, count: TokenCounter): Restate
505
771
  const keys = [
506
772
  ...new Set(
507
773
  blocks
508
- .filter((b) => b.lang === '' || /json|jsonc|json5|yaml|yml/.test(b.lang))
509
- .flatMap((b) => topLevelKeys(b.body)),
774
+ .filter((b) => b.lang === '' || SCHEMA_LANGS.test(b.lang))
775
+ .flatMap((b) => [...topLevelKeys(b.body), ...unquotedKeys(b.body, b.lang)]),
510
776
  ),
511
777
  ].filter((k) => k.length >= 3);
512
778
 
package/src/types.ts CHANGED
@@ -363,8 +363,10 @@ export interface Segment {
363
363
 
364
364
  export type ProtectionKind =
365
365
  | 'fenced-code'
366
+ | 'indented-code'
366
367
  | 'inline-code'
367
368
  | 'url'
369
+ | 'email'
368
370
  | 'placeholder'
369
371
  | 'xml-tag';
370
372