@agfpd/voicetalk-runtime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +135 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -73,6 +73,20 @@ function isIntelligence(value: unknown): value is Intelligence {
73
73
  return value === 'natural' || value === 'artificial' || value === 'absent'
74
74
  }
75
75
 
76
+ // Read-compat map for legacy intelligence values coming off the wire (mirrors the core
77
+ // codec's normalizeIntelligenceValue): human → natural · scripted → absent. A genuinely
78
+ // unknown value is dropped, not invented.
79
+ const LEGACY_INTELLIGENCE: Readonly<Record<string, Intelligence>> = {
80
+ human: 'natural',
81
+ scripted: 'absent',
82
+ }
83
+
84
+ function normalizeIntelligenceValue(value: unknown): Intelligence | undefined {
85
+ if (typeof value !== 'string') return undefined
86
+ if (isIntelligence(value)) return value
87
+ return LEGACY_INTELLIGENCE[value]
88
+ }
89
+
76
90
  const HUMAN_RUNTIMES = new Set(['telegram', 'voicetalk', 'discord', 'matrix', 'email', 'web'])
77
91
  const SCRIPTED_RUNTIMES = new Set(['webhook', 'api', 'cron'])
78
92
 
@@ -105,6 +119,9 @@ type IapEnvelope = {
105
119
  fromPersonality: string
106
120
  fromRuntime: string
107
121
  fromIntelligence?: Intelligence
122
+ /** Sender's dispatch instant (`ts` attribute, envelope-compaction F, core 0.4.86) —
123
+ * full local ISO on the wire; absent on legacy envelopes. */
124
+ sentAt?: string
108
125
  topic?: string
109
126
  attachments: string[]
110
127
  message: string
@@ -367,80 +384,111 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
367
384
  }
368
385
 
369
386
  // ─────────────────────────────────────────────────────────────────────────────
370
- // IAP envelope parsing (CDATA-aware) — mirrors the foundation codec / telegram-runtime
387
+ // IAP envelope parsing — port of the foundation codec (iapeer src/codec/index.ts,
388
+ // envelope-compaction F, 0.4.86): CDATA-aware on BOTH tag sides (В37), В38
389
+ // false-start resync, READ-BOTH attr/tag names (legacy wire + compact
390
+ // presentation) and the additive `ts` attribute.
371
391
  // ─────────────────────────────────────────────────────────────────────────────
372
392
 
373
393
  function unescapeAttr(value: string): string {
394
+ // Reverse of the encoder's escapeAttr. Order matters: &amp; LAST so an escaped
395
+ // "&amp;lt;" in the source does not get double-decoded.
374
396
  return value.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
375
397
  }
376
398
 
399
+ /** NAME-ANCHORED attribute lookup. The anchor `(?:^|\s)` is load-bearing: an unanchored
400
+ * regex lets `runtime="` match the TAIL of `from-runtime="…"` (and `intelligence="` the
401
+ * tail of `from-intelligence="…"`) — coincidentally right under today's attr order, a
402
+ * mine under read-both (the short-name lookup must NOT be satisfied by a legacy long
403
+ * name, or the fromRuntime modality cue reads the wrong value). */
377
404
  function attrValue(attrs: string, name: string): string | undefined {
378
- const m = new RegExp(`${name}="([^"]*)"`).exec(attrs)
405
+ const m = new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attrs)
379
406
  return m ? unescapeAttr(m[1]) : undefined
380
407
  }
381
408
 
382
- function decodeCdata(inner: string): string {
383
- if (!inner.startsWith('<![CDATA[') || !inner.endsWith(']]>')) return inner
384
- return inner.slice('<![CDATA['.length, -']]>'.length).replaceAll(']]]]><![CDATA[>', ']]>')
385
- }
386
-
387
- /** Find `needle` at/after `from`, ignoring any occurrence INSIDE a <![CDATA[ … ]]>
388
- * section, so a message body that literally contains "</message>"/"</iap>" never
389
- * truncates the envelope. With no CDATA present this behaves exactly like indexOf. */
390
- function indexOfOutsideCdata(s: string, needle: string, from = 0): number {
391
- let i = from
392
- let inCdata = false
393
- while (i < s.length) {
394
- if (inCdata) {
395
- if (s.startsWith(']]>', i)) {
396
- inCdata = false
397
- i += 3
398
- continue
399
- }
400
- i++
401
- continue
402
- }
403
- if (s.startsWith('<![CDATA[', i)) {
404
- inCdata = true
405
- i += '<![CDATA['.length
409
+ const CDATA_OPEN = '<![CDATA['
410
+ const CDATA_CLOSE = ']]>'
411
+
412
+ /** Find `needle` at/after `start`, skipping CDATA sections WHOLESALE — `]]>` inside a
413
+ * section is its terminator, never a match — so a message body literally containing
414
+ * "</message>"/"</iap>" never truncates the envelope. Returns -1 when not found OR when
415
+ * an unterminated CDATA section is hit (incomplete buffer → the caller waits). */
416
+ function indexOfOutsideCdata(buffer: string, needle: string, start = 0): number {
417
+ let i = start
418
+ while (i < buffer.length) {
419
+ if (buffer.startsWith(CDATA_OPEN, i)) {
420
+ const term = buffer.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
421
+ if (term < 0) return -1 // unterminated CDATA → incomplete buffer
422
+ i = term + CDATA_CLOSE.length
406
423
  continue
407
424
  }
408
- if (s.startsWith(needle, i)) return i
425
+ if (buffer.startsWith(needle, i)) return i
409
426
  i++
410
427
  }
411
428
  return -1
412
429
  }
413
430
 
431
+ /** Extract and decode the content of `<tag>…</tag>`, treating the body as a sequence of
432
+ * CDATA sections (and any stray raw text) and CONCATENATING them — which both ignores
433
+ * quoted `</tag>`/`</iap>` inside CDATA and reverses the encoder's
434
+ * `]]>` → `]]]]><![CDATA[>` split. В37: the OPEN tag is located CDATA-aware too (the
435
+ * close side already was) — a message whose CDATA body QUOTES `<attachments>…</attachments>`
436
+ * otherwise mints phantom attachments, and a quoted `<message>` hijacks the real body;
437
+ * for this runtime the corrupted text would go straight to TTS. */
414
438
  function tagContent(xml: string, tag: string): string | undefined {
415
439
  const open = `<${tag}>`
416
- const start = xml.indexOf(open)
417
- if (start < 0) return undefined
418
- const innerStart = start + open.length
419
- const end = indexOfOutsideCdata(xml, `</${tag}>`, innerStart)
420
- if (end < 0) return undefined
421
- return decodeCdata(xml.slice(innerStart, end))
440
+ const close = `</${tag}>`
441
+ const openIdx = indexOfOutsideCdata(xml, open, 0)
442
+ if (openIdx < 0) return undefined
443
+ let i = openIdx + open.length
444
+ let out = ''
445
+ while (i < xml.length) {
446
+ if (xml.startsWith(CDATA_OPEN, i)) {
447
+ const term = xml.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
448
+ if (term < 0) {
449
+ // Unterminated CDATA — malformed; treat the remainder as raw content.
450
+ return out + xml.slice(i + CDATA_OPEN.length)
451
+ }
452
+ out += xml.slice(i + CDATA_OPEN.length, term)
453
+ i = term + CDATA_CLOSE.length
454
+ continue
455
+ }
456
+ if (xml.startsWith(close, i)) return out
457
+ out += xml[i]
458
+ i++
459
+ }
460
+ return undefined // close tag never reached outside CDATA
422
461
  }
423
462
 
424
463
  export function parseIapEnvelope(xml: string): IapEnvelope {
425
464
  // Fold \r\n and lone \r → \n once (defensive: a raw-mode pty stdin can surface bare
426
- // CRs), so multi-line messages and attachment splits are LF-terminated.
465
+ // CRs). DELIBERATE deviation from the core codec, which leaves the fold to the
466
+ // transport: here the parse boundary IS the transport boundary — this function is
467
+ // only ever fed envelopes sliced off this process's stdin.
427
468
  xml = xml.replace(/\r\n?/g, '\n')
428
469
  const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
429
470
  if (!open) throw new VoicetalkRuntimeError('invalid IAP envelope: missing <iap ...>')
430
- const fromPersonality = attrValue(open[1], 'from-personality')
431
- const fromRuntime = attrValue(open[1], 'from-runtime')
471
+ // READ-BOTH (envelope-compaction F): the legacy wire names AND the compact
472
+ // presentation names (`from`/`runtime`/`intelligence`, `<msg>`, `ts`) both decode,
473
+ // short names winning when both are present — so a future wire flip to the compact
474
+ // form cannot blank fromRuntime (the spoken-modality cue) on this side.
475
+ const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
476
+ const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
432
477
  if (!fromPersonality || !fromRuntime) {
433
478
  throw new VoicetalkRuntimeError('invalid IAP envelope: missing from-personality/from-runtime')
434
479
  }
435
- const fromIntelligenceRaw = attrValue(open[1], 'from-intelligence')
436
- const fromIntelligence = isIntelligence(fromIntelligenceRaw) ? fromIntelligenceRaw : undefined
437
- const message = tagContent(xml, 'message')
480
+ const fromIntelligence = normalizeIntelligenceValue(
481
+ attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
482
+ )
483
+ const message = tagContent(xml, 'message') ?? tagContent(xml, 'msg')
438
484
  if (message === undefined) throw new VoicetalkRuntimeError('invalid IAP envelope: missing message')
439
485
  const attachmentsRaw = tagContent(xml, 'attachments')
486
+ const sentAt = attrValue(open[1], 'ts')
440
487
  return {
441
488
  fromPersonality,
442
489
  fromRuntime,
443
490
  ...(fromIntelligence ? { fromIntelligence } : {}),
491
+ ...(sentAt ? { sentAt } : {}),
444
492
  topic: attrValue(open[1], 'topic'),
445
493
  attachments: attachmentsRaw
446
494
  ? attachmentsRaw.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
@@ -449,21 +497,64 @@ export function parseIapEnvelope(xml: string): IapEnvelope {
449
497
  }
450
498
  }
451
499
 
500
+ // В38 — a real envelope's open tag is bounded: personality/runtime ≤32 chars each,
501
+ // topic ≤200, intelligence one word, plus attr names and escaping. 1 KiB is far above
502
+ // any legitimate open tag; a longer '>'-less run after `<iap ` is prose, not a tag.
503
+ const MAX_OPEN_TAG_LEN = 1024
504
+
505
+ /** Classify the text at a `<iap ` occurrence: a complete VALID open tag (required attrs
506
+ * present under either naming — legacy wire or compact presentation), a
507
+ * complete-but-INVALID one / overlong tagless run (prose that merely contains the
508
+ * marker), or INCOMPLETE (no '>' yet within bounds — wait for the next chunk). */
509
+ function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
510
+ const m = /^<iap\s+([^>]*)>/.exec(candidate)
511
+ if (!m) {
512
+ const unclosed = candidate.indexOf('>') < 0
513
+ return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
514
+ }
515
+ if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
516
+ const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
517
+ const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
518
+ return hasPersonality && hasRuntime ? 'valid' : 'invalid'
519
+ }
520
+
452
521
  /** Slice complete <iap>…</iap> envelopes out of a streaming buffer, returning the leftover
453
- * tail (a partial envelope still being written). CDATA-aware close matching. */
522
+ * tail (a partial envelope still being written). CDATA-aware close matching.
523
+ *
524
+ * В38 — a FALSE start (prose that merely contains `<iap `, e.g. a quoted tool
525
+ * description) is never committed to: the open tag is validated first, and a candidate
526
+ * whose decode fails RESYNCS one char forward instead of being kept (which used to
527
+ * either swallow the NEXT real envelope into a mis-attributed blob, or park the buffer
528
+ * forever waiting for a close that never comes). */
454
529
  export function extractIapEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
455
530
  const envelopes: string[] = []
456
531
  let rest = buffer
457
532
  while (true) {
458
533
  const start = rest.indexOf('<iap ')
459
534
  if (start < 0) {
460
- return { envelopes, rest: rest.slice(Math.max(0, rest.length - 8)) }
535
+ // Keep a small tail in case `<iap ` is split across chunk boundaries.
536
+ return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
461
537
  }
462
538
  if (start > 0) rest = rest.slice(start)
463
- const end = indexOfOutsideCdata(rest, '</iap>')
464
- if (end < 0) return { envelopes, rest }
539
+ const verdict = openTagVerdict(rest)
540
+ if (verdict === 'incomplete') return { envelopes, rest } // open tag split across chunks → wait
541
+ if (verdict === 'invalid') {
542
+ rest = rest.slice(1) // false start: skip past this '<' and rescan for the next '<iap '
543
+ continue
544
+ }
545
+ const end = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
546
+ if (end < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
465
547
  const envelopeEnd = end + '</iap>'.length
466
- envelopes.push(rest.slice(0, envelopeEnd))
548
+ const candidate = rest.slice(0, envelopeEnd)
549
+ try {
550
+ parseIapEnvelope(candidate)
551
+ } catch {
552
+ // Structurally envelope-shaped but undecodable → a false start after all;
553
+ // resync so a REAL envelope inside/behind the candidate is still found.
554
+ rest = rest.slice(1)
555
+ continue
556
+ }
557
+ envelopes.push(candidate)
467
558
  rest = rest.slice(envelopeEnd)
468
559
  }
469
560
  }