@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.
- package/package.json +1 -1
- package/src/cli.ts +135 -44
package/package.json
CHANGED
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
|
|
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: & LAST so an escaped
|
|
395
|
+
// "&lt;" in the source does not get double-decoded.
|
|
374
396
|
return value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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(
|
|
405
|
+
const m = new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attrs)
|
|
379
406
|
return m ? unescapeAttr(m[1]) : undefined
|
|
380
407
|
}
|
|
381
408
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
* section
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 (
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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)
|
|
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
|
-
|
|
431
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
|
464
|
-
if (
|
|
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
|
-
|
|
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
|
}
|