@agfpd/voicetalk-runtime 0.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/LICENSE +202 -0
- package/README.md +81 -0
- package/bin/voicetalk-runtime +12 -0
- package/docs/README.md +31 -0
- package/docs/ru/README.md +30 -0
- package/package.json +29 -0
- package/src/cli.ts +1076 -0
- package/src/constants.ts +35 -0
- package/src/fsAtomic.ts +17 -0
- package/src/hostDocs.ts +87 -0
- package/src/manifest.ts +109 -0
- package/src/selfConfig.ts +129 -0
- package/src/selfInstall.ts +172 -0
- package/tsconfig.json +15 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// voicetalk-runtime — the voice presence-runtime router (Фаза 2: text caркас).
|
|
3
|
+
//
|
|
4
|
+
// voicetalk is the third *-runtime after telegram and notifier: it makes the host's
|
|
5
|
+
// microphone + speaker one more runtime through which the OWNER (a human, natural peer)
|
|
6
|
+
// talks to the fleet, exactly as telegram-runtime makes a person a peer through Telegram.
|
|
7
|
+
// It is a long-running ROUTER (foundation adapter kind:'router'), pumping IAP envelopes
|
|
8
|
+
// both directions through its stdio + a local inbound channel:
|
|
9
|
+
//
|
|
10
|
+
// • OUTBOUND (fleet → owner): the supervisor writes <iap>…</iap> envelopes to this
|
|
11
|
+
// process's STDIN; the reader parses each and hands it to the AudioSink — which in
|
|
12
|
+
// Фаза 3 is TTS → the owner's speaker, and in Фаза 2 is a STUB that logs the spoken
|
|
13
|
+
// text (and appends it to spoken.log) so the path is exercised without audio.
|
|
14
|
+
// • INBOUND (owner → fleet): in Фаза 3 the mic + wake-word + STT turn the owner's
|
|
15
|
+
// speech into an utterance; in Фаза 2 that audio source is STUBBED by a local unix
|
|
16
|
+
// socket (the `say` subcommand is the mic+STT stand-in). Either way the utterance is
|
|
17
|
+
// framed for the spoken modality and delivered to the target peer through the daemon
|
|
18
|
+
// (`iapeer send`) FROM the owner's identity (PEER_RUNTIME=voicetalk).
|
|
19
|
+
//
|
|
20
|
+
// Speech ↔ text is NOT in this package — STT/TTS live in the shared voice-connect service
|
|
21
|
+
// (a pure HTTP backend this runtime will CONSUME in Фаза 3). The two audio touchpoints
|
|
22
|
+
// here (AudioSink + the socket inbound source) are the seams voice-connect plugs into.
|
|
23
|
+
//
|
|
24
|
+
// NO sender-guard (decision: «Sender-guard: NO for the MVP»): the speaker is a single
|
|
25
|
+
// local endpoint of the owner, so any fleet peer may answer by voice — there is no
|
|
26
|
+
// separate "voicetalk-face" sender to distinguish, unlike telegram's bot_username.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
appendFileSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
unlinkSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'fs'
|
|
37
|
+
import { createConnection, createServer, type Server, type Socket } from 'net'
|
|
38
|
+
import { homedir, tmpdir } from 'os'
|
|
39
|
+
import { basename, dirname, join } from 'path'
|
|
40
|
+
import { randomUUID } from 'crypto'
|
|
41
|
+
import { spawn } from 'child_process'
|
|
42
|
+
import { BIN_NAME, IAPEER_DIR, NAME_RE, PACKAGE_NAME, PEER_PROFILE_FILE, RUNTIME, VERSION } from './constants.ts'
|
|
43
|
+
import { selfInstall } from './selfInstall.ts'
|
|
44
|
+
import { runSelfConfig } from './selfConfig.ts'
|
|
45
|
+
import { writeJsonAtomic } from './fsAtomic.ts'
|
|
46
|
+
|
|
47
|
+
// Inbound (owner → fleet) delivery is synchronous from the speaker's point of view: if
|
|
48
|
+
// the core router refuses the send (ok=false/err) or our local waiter times out, the
|
|
49
|
+
// utterance is reported as not-delivered — no auto-retry, no silent loss. Kept shorter
|
|
50
|
+
// than the daemon wake-deadline so the channel never wedges behind a stuck CLI child.
|
|
51
|
+
const IAP_SEND_TIMEOUT_MS = Number(process.env.VOICETALK_IAP_SEND_TIMEOUT_MS ?? '') || 60_000
|
|
52
|
+
// `say` waits this long for the running router to ack a queued utterance over the socket.
|
|
53
|
+
const SAY_TIMEOUT_MS = Number(process.env.VOICETALK_SAY_TIMEOUT_MS ?? '') || 65_000
|
|
54
|
+
// Modality frame prepended to every inbound utterance so a naive agent answers in the
|
|
55
|
+
// right modality (spoken, brief). Cheap to remove: one env (VOICETALK_FRAME=0), one
|
|
56
|
+
// const, one call site. The daemon ALSO stamps from-runtime=voicetalk on the envelope;
|
|
57
|
+
// the text frame is the belt-and-suspenders that survives an agent ignoring the runtime.
|
|
58
|
+
const VOICETALK_FRAME_ENABLED = process.env.VOICETALK_FRAME !== '0'
|
|
59
|
+
const VOICETALK_FRAME =
|
|
60
|
+
'[via voicetalk — the owner is speaking aloud, live, by voice. Reply briefly and ' +
|
|
61
|
+
'plainly for text-to-speech: short sentences, no markdown, code blocks, tables, or links.]'
|
|
62
|
+
|
|
63
|
+
type Flags = Record<string, string | boolean | string[]>
|
|
64
|
+
|
|
65
|
+
// IAP identity-contract vocabulary: natural (human peers) / artificial (LLM agents) /
|
|
66
|
+
// absent (programmatic sources). The foundation normalizes any legacy value at its
|
|
67
|
+
// boundary before an envelope or a provisioned profile reaches this runtime; values
|
|
68
|
+
// round-trip verbatim through readPeerProfile/writePeerProfile (preserve, never
|
|
69
|
+
// re-derive identity).
|
|
70
|
+
type Intelligence = 'natural' | 'artificial' | 'absent'
|
|
71
|
+
|
|
72
|
+
function isIntelligence(value: unknown): value is Intelligence {
|
|
73
|
+
return value === 'natural' || value === 'artificial' || value === 'absent'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const HUMAN_RUNTIMES = new Set(['telegram', 'voicetalk', 'discord', 'matrix', 'email', 'web'])
|
|
77
|
+
const SCRIPTED_RUNTIMES = new Set(['webhook', 'api', 'cron'])
|
|
78
|
+
|
|
79
|
+
function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
80
|
+
if (HUMAN_RUNTIMES.has(runtime)) return 'natural'
|
|
81
|
+
if (SCRIPTED_RUNTIMES.has(runtime)) return 'absent'
|
|
82
|
+
return 'artificial'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The voicetalk peer binding (narrowed schema, design dialog 21.06). STT/TTS/voice
|
|
86
|
+
* config is DELIBERATELY absent — it lives in voice-connect, not the passport. */
|
|
87
|
+
type VoicetalkInterface = {
|
|
88
|
+
wake_word?: string
|
|
89
|
+
device?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type PeerProfile = {
|
|
93
|
+
personality: string
|
|
94
|
+
runtime: string
|
|
95
|
+
runtimes: string[]
|
|
96
|
+
description: string
|
|
97
|
+
intelligence: Intelligence
|
|
98
|
+
interfaces?: {
|
|
99
|
+
voicetalk?: VoicetalkInterface
|
|
100
|
+
[key: string]: unknown
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type IapEnvelope = {
|
|
105
|
+
fromPersonality: string
|
|
106
|
+
fromRuntime: string
|
|
107
|
+
fromIntelligence?: Intelligence
|
|
108
|
+
topic?: string
|
|
109
|
+
attachments: string[]
|
|
110
|
+
message: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The outbound audio touchpoint (fleet → owner's speaker). Фаза 2 = stub (log + a
|
|
114
|
+
* spoken.log artifact); Фаза 3 = TTS via voice-connect → the speaker device. The
|
|
115
|
+
* interface IS the seam: swapping the sink is the whole Фаза-3 outbound change. */
|
|
116
|
+
interface AudioSink {
|
|
117
|
+
speak(envelope: IapEnvelope): Promise<void>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type RuntimeContext = {
|
|
121
|
+
cwd: string
|
|
122
|
+
owner: PeerProfile
|
|
123
|
+
binding: VoicetalkInterface
|
|
124
|
+
iapBin: string
|
|
125
|
+
sink: AudioSink
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
class VoicetalkRuntimeError extends Error {}
|
|
129
|
+
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// argv + small utilities
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function usage(): string {
|
|
135
|
+
return `Usage:
|
|
136
|
+
${BIN_NAME} # self-install (npx contract): bin on PATH + manifest
|
|
137
|
+
${BIN_NAME} self-install # explicit self-install (idempotent)
|
|
138
|
+
${BIN_NAME} self-config # per-peer self-config hook (foundation-invoked)
|
|
139
|
+
${BIN_NAME} interface --wake-word <w> [--device <d>] # set this peer's voicetalk binding
|
|
140
|
+
${BIN_NAME} run # router loop (Фаза 2: text caркас, audio stubbed)
|
|
141
|
+
${BIN_NAME} say --peer <personality> <text…> # inject an utterance (Фаза-2 mic+STT stand-in)
|
|
142
|
+
${BIN_NAME} doctor [--json] # health: binding + manifest
|
|
143
|
+
${BIN_NAME} --version
|
|
144
|
+
${BIN_NAME} --help`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setFlag(flags: Flags, key: string, value: string | boolean): void {
|
|
148
|
+
const previous = flags[key]
|
|
149
|
+
if (previous === undefined) {
|
|
150
|
+
flags[key] = value
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(previous)) {
|
|
154
|
+
previous.push(String(value))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
flags[key] = [String(previous), String(value)]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Minimal argv parser: positionals + `--flag` / `--flag=value` / `--flag value`. Pure
|
|
161
|
+
* and exported so dispatch is unit-testable without spawning a process. */
|
|
162
|
+
export function parseFlags(argv: string[]): { positional: string[]; flags: Flags } {
|
|
163
|
+
const positional: string[] = []
|
|
164
|
+
const flags: Flags = {}
|
|
165
|
+
for (let i = 0; i < argv.length; i++) {
|
|
166
|
+
const arg = argv[i]
|
|
167
|
+
if (!arg.startsWith('--')) {
|
|
168
|
+
positional.push(arg)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const raw = arg.slice(2)
|
|
172
|
+
const eq = raw.indexOf('=')
|
|
173
|
+
if (eq >= 0) {
|
|
174
|
+
setFlag(flags, raw.slice(0, eq), raw.slice(eq + 1))
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const next = argv[i + 1]
|
|
178
|
+
if (!next || next.startsWith('--')) {
|
|
179
|
+
setFlag(flags, raw, true)
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
i++
|
|
183
|
+
setFlag(flags, raw, next)
|
|
184
|
+
}
|
|
185
|
+
return { positional, flags }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stringFlag(flags: Flags, key: string): string | undefined {
|
|
189
|
+
const value = flags[key]
|
|
190
|
+
if (Array.isArray(value)) return value.at(-1)
|
|
191
|
+
return typeof value === 'string' ? value : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function arrayFlag(flags: Flags, key: string): string[] {
|
|
195
|
+
const value = flags[key]
|
|
196
|
+
if (Array.isArray(value)) return value
|
|
197
|
+
if (typeof value === 'string') return [value]
|
|
198
|
+
return []
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printJson(value: unknown): void {
|
|
202
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
206
|
+
return [...new Set(values)]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeName(value: string): string {
|
|
210
|
+
return value.trim().toLowerCase()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function assertName(value: string, source: string): void {
|
|
214
|
+
if (!NAME_RE.test(value)) {
|
|
215
|
+
throw new VoicetalkRuntimeError(`${source} must match /^[a-z][a-z0-9-]{0,31}$/, got "${value}"`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatError(err: unknown): string {
|
|
220
|
+
return err instanceof Error ? err.message : String(err)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function nowIso(): string {
|
|
224
|
+
return new Date().toISOString()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Structured event line to stderr — the supervisor captures it into the pane-log, so a
|
|
228
|
+
* forensic grep on `evt:` reconstructs every routing decision. */
|
|
229
|
+
function logEvt(evt: string, fields: Record<string, unknown>): void {
|
|
230
|
+
process.stderr.write(`${BIN_NAME} ${JSON.stringify({ ts: nowIso(), evt, ...fields })}\n`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function oneLine(text: string, max = 200): string {
|
|
234
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
235
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
// root paths + scaffold
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/** IAPEER_ROOT-aware (mirror of the foundation's resolveGlobalRoot): the env override
|
|
243
|
+
* wins, else ~/.iapeer. In lockstep with manifest.ts resolveIapeerRoot. */
|
|
244
|
+
function iapeerRoot(): string {
|
|
245
|
+
const override = process.env.IAPEER_ROOT?.trim()
|
|
246
|
+
if (override) return override
|
|
247
|
+
return join(homedir(), '.iapeer')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function globalVoicetalkRoot(): string {
|
|
251
|
+
return join(iapeerRoot(), 'runtimes', RUNTIME)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function inboxRoot(): string {
|
|
255
|
+
return join(globalVoicetalkRoot(), 'inbox')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The local inbound channel — a per-owner unix socket the running router listens on
|
|
259
|
+
* for utterances (Фаза-2 stand-in for the mic+STT source). `say` connects here. Kept
|
|
260
|
+
* short (sun_path is ~104 bytes on macOS); under the global root it stays well within. */
|
|
261
|
+
function inboundSocketPath(personality: string): string {
|
|
262
|
+
return join(inboxRoot(), `${personality}.sock`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Durable artifact the AudioSink stub appends spoken envelopes to — a deterministic
|
|
266
|
+
* assertion target for the text E2E and a natural seam for Фаза-3 TTS. */
|
|
267
|
+
function spokenLogPath(): string {
|
|
268
|
+
return join(globalVoicetalkRoot(), 'spoken.log')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function peerProfilePath(cwd = process.cwd()): string {
|
|
272
|
+
return join(cwd, IAPEER_DIR, PEER_PROFILE_FILE)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function ensureScaffold(cwd = process.cwd()): void {
|
|
276
|
+
mkdirSync(join(cwd, IAPEER_DIR, 'runtimes', RUNTIME), { recursive: true, mode: 0o700 })
|
|
277
|
+
mkdirSync(globalVoicetalkRoot(), { recursive: true, mode: 0o700 })
|
|
278
|
+
mkdirSync(inboxRoot(), { recursive: true, mode: 0o700 })
|
|
279
|
+
mkdirSync(join(globalVoicetalkRoot(), 'logs'), { recursive: true, mode: 0o700 })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// peer profile
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function readJsonFile<T>(path: string): T | null {
|
|
287
|
+
if (!existsSync(path)) return null
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T
|
|
290
|
+
} catch (err) {
|
|
291
|
+
throw new VoicetalkRuntimeError(`${path} is invalid JSON: ${formatError(err)}`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Read the local peer profile, resolving identity defensively (prefer `default_runtime`,
|
|
296
|
+
* fall back to the legacy `runtime` mirror) and preserving `interfaces` verbatim. */
|
|
297
|
+
function readPeerProfile(cwd = process.cwd()): PeerProfile | null {
|
|
298
|
+
const raw = readJsonFile<Partial<PeerProfile> & { default_runtime?: string; runtime?: string }>(
|
|
299
|
+
peerProfilePath(cwd),
|
|
300
|
+
)
|
|
301
|
+
if (!raw) return null
|
|
302
|
+
if (typeof raw.personality !== 'string') {
|
|
303
|
+
throw new VoicetalkRuntimeError(`${peerProfilePath(cwd)} personality is required`)
|
|
304
|
+
}
|
|
305
|
+
const effectiveRuntime =
|
|
306
|
+
typeof raw.default_runtime === 'string' && raw.default_runtime
|
|
307
|
+
? raw.default_runtime
|
|
308
|
+
: typeof raw.runtime === 'string' && raw.runtime
|
|
309
|
+
? raw.runtime
|
|
310
|
+
: undefined
|
|
311
|
+
if (!effectiveRuntime) {
|
|
312
|
+
throw new VoicetalkRuntimeError(`${peerProfilePath(cwd)} default_runtime (or legacy runtime) is required`)
|
|
313
|
+
}
|
|
314
|
+
const personality = normalizeName(raw.personality)
|
|
315
|
+
assertName(personality, 'personality')
|
|
316
|
+
const runtimes = Array.isArray(raw.runtimes)
|
|
317
|
+
? raw.runtimes.filter((item): item is string => typeof item === 'string')
|
|
318
|
+
: [effectiveRuntime]
|
|
319
|
+
const intelligence: Intelligence = isIntelligence(raw.intelligence)
|
|
320
|
+
? raw.intelligence
|
|
321
|
+
: defaultIntelligenceForRuntime(effectiveRuntime)
|
|
322
|
+
return {
|
|
323
|
+
personality,
|
|
324
|
+
runtime: effectiveRuntime,
|
|
325
|
+
runtimes: unique([effectiveRuntime, ...runtimes]),
|
|
326
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
327
|
+
intelligence,
|
|
328
|
+
...(raw.interfaces && typeof raw.interfaces === 'object' && !Array.isArray(raw.interfaces)
|
|
329
|
+
? { interfaces: raw.interfaces }
|
|
330
|
+
: {}),
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function voicetalkInterface(profile: PeerProfile): VoicetalkInterface {
|
|
335
|
+
const iface = profile.interfaces?.voicetalk
|
|
336
|
+
return iface && typeof iface === 'object' ? iface : {}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function ensureCurrentProfile(cwd = process.cwd()): PeerProfile {
|
|
340
|
+
const profile = readPeerProfile(cwd)
|
|
341
|
+
if (!profile) {
|
|
342
|
+
throw new VoicetalkRuntimeError(`missing ${peerProfilePath(cwd)} — provision the peer first`)
|
|
343
|
+
}
|
|
344
|
+
return profile
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Set interfaces.voicetalk on the LOCAL profile, preserving every other field (RAW
|
|
348
|
+
* read-merge-write so the foundation-provisioned identity round-trips untouched). */
|
|
349
|
+
function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<string, unknown> {
|
|
350
|
+
const path = peerProfilePath(cwd)
|
|
351
|
+
const raw = (readJsonFile<Record<string, unknown>>(path) ?? {}) as Record<string, unknown>
|
|
352
|
+
const interfaces =
|
|
353
|
+
raw.interfaces && typeof raw.interfaces === 'object' && !Array.isArray(raw.interfaces)
|
|
354
|
+
? { ...(raw.interfaces as Record<string, unknown>) }
|
|
355
|
+
: {}
|
|
356
|
+
const voicetalk =
|
|
357
|
+
(interfaces as { voicetalk?: unknown }).voicetalk &&
|
|
358
|
+
typeof (interfaces as { voicetalk?: unknown }).voicetalk === 'object'
|
|
359
|
+
? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
|
|
360
|
+
: {}
|
|
361
|
+
if (patch.wake_word !== undefined) voicetalk.wake_word = patch.wake_word
|
|
362
|
+
if (patch.device !== undefined) voicetalk.device = patch.device
|
|
363
|
+
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
364
|
+
const merged = { ...raw, interfaces }
|
|
365
|
+
writeJsonAtomic(path, merged)
|
|
366
|
+
return merged
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
// IAP envelope parsing (CDATA-aware) — mirrors the foundation codec / telegram-runtime
|
|
371
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function unescapeAttr(value: string): string {
|
|
374
|
+
return value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function attrValue(attrs: string, name: string): string | undefined {
|
|
378
|
+
const m = new RegExp(`${name}="([^"]*)"`).exec(attrs)
|
|
379
|
+
return m ? unescapeAttr(m[1]) : undefined
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
if (s.startsWith(needle, i)) return i
|
|
409
|
+
i++
|
|
410
|
+
}
|
|
411
|
+
return -1
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function tagContent(xml: string, tag: string): string | undefined {
|
|
415
|
+
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))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function parseIapEnvelope(xml: string): IapEnvelope {
|
|
425
|
+
// 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.
|
|
427
|
+
xml = xml.replace(/\r\n?/g, '\n')
|
|
428
|
+
const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
|
|
429
|
+
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')
|
|
432
|
+
if (!fromPersonality || !fromRuntime) {
|
|
433
|
+
throw new VoicetalkRuntimeError('invalid IAP envelope: missing from-personality/from-runtime')
|
|
434
|
+
}
|
|
435
|
+
const fromIntelligenceRaw = attrValue(open[1], 'from-intelligence')
|
|
436
|
+
const fromIntelligence = isIntelligence(fromIntelligenceRaw) ? fromIntelligenceRaw : undefined
|
|
437
|
+
const message = tagContent(xml, 'message')
|
|
438
|
+
if (message === undefined) throw new VoicetalkRuntimeError('invalid IAP envelope: missing message')
|
|
439
|
+
const attachmentsRaw = tagContent(xml, 'attachments')
|
|
440
|
+
return {
|
|
441
|
+
fromPersonality,
|
|
442
|
+
fromRuntime,
|
|
443
|
+
...(fromIntelligence ? { fromIntelligence } : {}),
|
|
444
|
+
topic: attrValue(open[1], 'topic'),
|
|
445
|
+
attachments: attachmentsRaw
|
|
446
|
+
? attachmentsRaw.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
|
|
447
|
+
: [],
|
|
448
|
+
message,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 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. */
|
|
454
|
+
export function extractIapEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
|
|
455
|
+
const envelopes: string[] = []
|
|
456
|
+
let rest = buffer
|
|
457
|
+
while (true) {
|
|
458
|
+
const start = rest.indexOf('<iap ')
|
|
459
|
+
if (start < 0) {
|
|
460
|
+
return { envelopes, rest: rest.slice(Math.max(0, rest.length - 8)) }
|
|
461
|
+
}
|
|
462
|
+
if (start > 0) rest = rest.slice(start)
|
|
463
|
+
const end = indexOfOutsideCdata(rest, '</iap>')
|
|
464
|
+
if (end < 0) return { envelopes, rest }
|
|
465
|
+
const envelopeEnd = end + '</iap>'.length
|
|
466
|
+
envelopes.push(rest.slice(0, envelopeEnd))
|
|
467
|
+
rest = rest.slice(envelopeEnd)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
472
|
+
// inbound delivery — the send_to_peer wrapper (owner → fleet, via the daemon CLI)
|
|
473
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
/** Namespaced env identifying the OWNER as the sender, so the daemon stamps the envelope
|
|
476
|
+
* with from-runtime=voicetalk (the modality cue) and from-personality=<owner>. The three
|
|
477
|
+
* must be coherent — the daemon enforces PEER_IDENTITY === PEER_RUNTIME-PEER_PERSONALITY. */
|
|
478
|
+
function iapEnv(owner: PeerProfile): NodeJS.ProcessEnv {
|
|
479
|
+
return {
|
|
480
|
+
...process.env,
|
|
481
|
+
PEER_PERSONALITY: owner.personality,
|
|
482
|
+
PEER_RUNTIME: RUNTIME,
|
|
483
|
+
PEER_IDENTITY: `${RUNTIME}-${owner.personality}`,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Prepend the spoken-modality frame to an inbound utterance (env-gated, cheap to remove). */
|
|
488
|
+
export function frameSpokenInbound(text: string): string {
|
|
489
|
+
return VOICETALK_FRAME_ENABLED ? `${VOICETALK_FRAME}\n\n${text}` : text
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type IapSendResult =
|
|
493
|
+
| { ok: true; stdout: string; stderr: string }
|
|
494
|
+
| { ok: false; detail: string; stdout: string; stderr: string; timedOut?: boolean; timeoutMs?: number }
|
|
495
|
+
|
|
496
|
+
function cleanIapSendDetail(detail: string): string {
|
|
497
|
+
return detail
|
|
498
|
+
.replace(/^\s*iapeer\s+send:\s*/i, '')
|
|
499
|
+
.replace(/^\s*iap\s+send\s+failed:\s*/i, '')
|
|
500
|
+
.replace(/\s+/g, ' ')
|
|
501
|
+
.trim()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseLogfmtValue(line: string, key: string): string | undefined {
|
|
505
|
+
const re = new RegExp(`(?:^|\\s)${key}=(?:"((?:\\\\.|[^"\\\\])*)"|(\\S+))`)
|
|
506
|
+
const m = re.exec(line)
|
|
507
|
+
if (!m) return undefined
|
|
508
|
+
if (m[1] !== undefined) return m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
509
|
+
return m[2]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Accept both the CLI shape (non-zero + stderr "iapeer send: <err>") and the lower-level
|
|
513
|
+
* result vocabulary (ok=false err=…, or a JSON line with ok:false) so the bridge keys on
|
|
514
|
+
* the contract word, not one concrete presentation. */
|
|
515
|
+
function parseIapSendNotOk(stdout: string, stderr: string): string | null {
|
|
516
|
+
const combined = `${stdout}\n${stderr}`
|
|
517
|
+
for (const rawLine of combined.split(/\r?\n/)) {
|
|
518
|
+
const line = rawLine.trim()
|
|
519
|
+
if (!line) continue
|
|
520
|
+
if (line.startsWith('{') && line.endsWith('}')) {
|
|
521
|
+
try {
|
|
522
|
+
const obj = JSON.parse(line) as Record<string, unknown>
|
|
523
|
+
if (obj.ok === false) {
|
|
524
|
+
const detail = obj.err ?? obj.error ?? obj.reason ?? obj.detail
|
|
525
|
+
return cleanIapSendDetail(typeof detail === 'string' && detail ? detail : 'delivery failed')
|
|
526
|
+
}
|
|
527
|
+
} catch {}
|
|
528
|
+
}
|
|
529
|
+
if (/\bok=false\b/.test(line)) {
|
|
530
|
+
const detail =
|
|
531
|
+
parseLogfmtValue(line, 'err') ??
|
|
532
|
+
parseLogfmtValue(line, 'error') ??
|
|
533
|
+
parseLogfmtValue(line, 'reason') ??
|
|
534
|
+
parseLogfmtValue(line, 'detail')
|
|
535
|
+
return cleanIapSendDetail(detail ?? 'delivery failed')
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function formatTimeoutSeconds(ms: number): number {
|
|
542
|
+
return Math.max(1, Math.ceil(ms / 1000))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function iapDeliveryFailureVerdict(result: Extract<IapSendResult, { ok: false }>): string {
|
|
546
|
+
if (result.timedOut) {
|
|
547
|
+
return `not delivered: delivery timed out after ${formatTimeoutSeconds(result.timeoutMs ?? IAP_SEND_TIMEOUT_MS)}s — check the session`
|
|
548
|
+
}
|
|
549
|
+
return `not delivered: ${cleanIapSendDetail(result.detail) || 'delivery failed'}`
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Shell out the daemon's `iapeer send <target> --message-file <tmp>` from the owner's
|
|
553
|
+
* identity (the contract send_to_peer wrapper). Bounded by a timeout; never throws —
|
|
554
|
+
* resolves a tagged result. Exported so a unit test drives it against a faked iap bin. */
|
|
555
|
+
export async function runIapSendCommand(args: {
|
|
556
|
+
bin: string
|
|
557
|
+
cwd: string
|
|
558
|
+
env: NodeJS.ProcessEnv
|
|
559
|
+
targetPersonality: string
|
|
560
|
+
message: string
|
|
561
|
+
attachments: string[]
|
|
562
|
+
timeoutMs?: number
|
|
563
|
+
}): Promise<IapSendResult> {
|
|
564
|
+
const timeoutMs = args.timeoutMs ?? IAP_SEND_TIMEOUT_MS
|
|
565
|
+
const tmp = join(tmpdir(), `${BIN_NAME}-${process.pid}-${Date.now()}-${randomUUID()}.txt`)
|
|
566
|
+
writeFileSync(tmp, args.message || '(message)', { mode: 0o600 })
|
|
567
|
+
try {
|
|
568
|
+
const argv = ['send', args.targetPersonality, '--message-file', tmp]
|
|
569
|
+
for (const attachment of args.attachments) argv.push('--attachment', attachment)
|
|
570
|
+
return await new Promise<IapSendResult>(resolve => {
|
|
571
|
+
let stdout = ''
|
|
572
|
+
let stderr = ''
|
|
573
|
+
let settled = false
|
|
574
|
+
let child: ReturnType<typeof spawn> | undefined
|
|
575
|
+
const finish = (result: IapSendResult) => {
|
|
576
|
+
if (settled) return
|
|
577
|
+
settled = true
|
|
578
|
+
clearTimeout(timer)
|
|
579
|
+
resolve(result)
|
|
580
|
+
}
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
try {
|
|
583
|
+
child?.kill('SIGKILL')
|
|
584
|
+
} catch {}
|
|
585
|
+
finish({
|
|
586
|
+
ok: false,
|
|
587
|
+
detail: `delivery timed out after ${formatTimeoutSeconds(timeoutMs)}s`,
|
|
588
|
+
stdout,
|
|
589
|
+
stderr,
|
|
590
|
+
timedOut: true,
|
|
591
|
+
timeoutMs,
|
|
592
|
+
})
|
|
593
|
+
}, timeoutMs)
|
|
594
|
+
try {
|
|
595
|
+
child = spawn(args.bin, argv, { cwd: args.cwd, env: args.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
596
|
+
} catch (err) {
|
|
597
|
+
finish({ ok: false, detail: formatError(err), stdout, stderr })
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
child.stdout?.on('data', data => (stdout += String(data)))
|
|
601
|
+
child.stderr?.on('data', data => (stderr += String(data)))
|
|
602
|
+
child.on('error', err => finish({ ok: false, detail: err.message, stdout, stderr }))
|
|
603
|
+
child.on('close', status => {
|
|
604
|
+
const notOk = parseIapSendNotOk(stdout, stderr)
|
|
605
|
+
if (notOk) {
|
|
606
|
+
finish({ ok: false, detail: notOk, stdout, stderr })
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (status === 0) {
|
|
610
|
+
finish({ ok: true, stdout, stderr })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
finish({ ok: false, detail: cleanIapSendDetail((stderr || stdout || `exit ${status}`).trim()), stdout, stderr })
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
} finally {
|
|
617
|
+
rmSync(tmp, { force: true })
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
622
|
+
// outbound — the AudioSink (fleet → owner's speaker), Фаза-2 stub
|
|
623
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
/** Фаза-2 AudioSink: instead of TTS → speaker, log the spoken text (human stderr line +
|
|
626
|
+
* structured evt) and append the envelope to spoken.log so the path is observable and
|
|
627
|
+
* the E2E has a deterministic artifact. Фаза 3 replaces this with a voice-connect sink. */
|
|
628
|
+
function makeStubAudioSink(owner: string): AudioSink {
|
|
629
|
+
return {
|
|
630
|
+
async speak(envelope: IapEnvelope): Promise<void> {
|
|
631
|
+
process.stderr.write(
|
|
632
|
+
`🔊 ${BIN_NAME}[tts-stub] ${envelope.fromPersonality} → ${owner}: ${oneLine(envelope.message)}\n`,
|
|
633
|
+
)
|
|
634
|
+
logEvt('speak.stub', {
|
|
635
|
+
to: owner,
|
|
636
|
+
from: envelope.fromPersonality,
|
|
637
|
+
fromRuntime: envelope.fromRuntime,
|
|
638
|
+
chars: envelope.message.length,
|
|
639
|
+
attachments: envelope.attachments.length,
|
|
640
|
+
})
|
|
641
|
+
try {
|
|
642
|
+
appendFileSync(
|
|
643
|
+
spokenLogPath(),
|
|
644
|
+
`${JSON.stringify({
|
|
645
|
+
ts: nowIso(),
|
|
646
|
+
to: owner,
|
|
647
|
+
from: envelope.fromPersonality,
|
|
648
|
+
fromRuntime: envelope.fromRuntime,
|
|
649
|
+
message: envelope.message,
|
|
650
|
+
attachments: envelope.attachments,
|
|
651
|
+
})}\n`,
|
|
652
|
+
{ mode: 0o600 },
|
|
653
|
+
)
|
|
654
|
+
} catch (err) {
|
|
655
|
+
// The artifact is best-effort observability; never let it break delivery.
|
|
656
|
+
logEvt('speak.log-error', { reason: formatError(err) })
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
663
|
+
// run — the bidirectional router loop
|
|
664
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
// A serial queue so outbound envelopes are spoken one at a time, in order (Фаза-3 TTS is
|
|
667
|
+
// async and must not overlap on the speaker). A failed task logs and is dropped — the
|
|
668
|
+
// next envelope still gets its turn.
|
|
669
|
+
let outboundChain: Promise<void> = Promise.resolve()
|
|
670
|
+
function enqueueOutbound(task: () => Promise<void> | void): void {
|
|
671
|
+
outboundChain = outboundChain.then(task).catch(err => {
|
|
672
|
+
process.stderr.write(`${BIN_NAME}: outbound task failed: ${formatError(err)}\n`)
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** OUTBOUND reader: parse <iap>…</iap> envelopes off STDIN (the supervisor writes them
|
|
677
|
+
* here) and hand each to the AudioSink, serialized. */
|
|
678
|
+
function installStdinEnvelopeReader(ctx: RuntimeContext): void {
|
|
679
|
+
let buffer = ''
|
|
680
|
+
const onChunk = (chunk: unknown) => {
|
|
681
|
+
buffer += String(chunk)
|
|
682
|
+
const extracted = extractIapEnvelopes(buffer)
|
|
683
|
+
buffer = extracted.rest
|
|
684
|
+
for (const raw of extracted.envelopes) {
|
|
685
|
+
enqueueOutbound(async () => {
|
|
686
|
+
let envelope: IapEnvelope
|
|
687
|
+
try {
|
|
688
|
+
envelope = parseIapEnvelope(raw)
|
|
689
|
+
} catch (err) {
|
|
690
|
+
logEvt('envelope.drop', { stage: 'parse', reason: formatError(err) })
|
|
691
|
+
throw err
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
await ctx.sink.speak(envelope)
|
|
695
|
+
} catch (err) {
|
|
696
|
+
logEvt('envelope.drop', { stage: 'speak', from: envelope.fromPersonality, reason: formatError(err) })
|
|
697
|
+
throw err
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Raw mode disables the pty line-discipline cap (MAX_CANON ~1024B) that would truncate
|
|
703
|
+
// a long single-line envelope. Guard on isTTY: setRawMode throws on a piped stdin.
|
|
704
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
705
|
+
process.stdin.setEncoding('utf8')
|
|
706
|
+
process.stdin.on('data', onChunk)
|
|
707
|
+
process.stdin.resume()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Frame + deliver one inbound utterance to the fleet through the daemon. Returns a small
|
|
711
|
+
* verdict the socket hands back to `say`. */
|
|
712
|
+
async function handleInboundUtterance(
|
|
713
|
+
ctx: RuntimeContext,
|
|
714
|
+
raw: string,
|
|
715
|
+
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
716
|
+
let req: { target?: unknown; text?: unknown; attachments?: unknown }
|
|
717
|
+
try {
|
|
718
|
+
req = JSON.parse(raw)
|
|
719
|
+
} catch (err) {
|
|
720
|
+
return { ok: false, detail: `invalid utterance JSON: ${formatError(err)}` }
|
|
721
|
+
}
|
|
722
|
+
const target = normalizeName(String(req.target ?? ''))
|
|
723
|
+
if (!NAME_RE.test(target)) return { ok: false, detail: `invalid target "${target}"` }
|
|
724
|
+
const text = typeof req.text === 'string' ? req.text : ''
|
|
725
|
+
if (!text.trim()) return { ok: false, target, detail: 'empty utterance' }
|
|
726
|
+
const attachments = Array.isArray(req.attachments)
|
|
727
|
+
? req.attachments.filter((s): s is string => typeof s === 'string')
|
|
728
|
+
: []
|
|
729
|
+
|
|
730
|
+
logEvt('inbound.utterance', { target, chars: text.length, attachments: attachments.length })
|
|
731
|
+
const result = await runIapSendCommand({
|
|
732
|
+
bin: ctx.iapBin,
|
|
733
|
+
cwd: ctx.cwd,
|
|
734
|
+
env: iapEnv(ctx.owner),
|
|
735
|
+
targetPersonality: target,
|
|
736
|
+
message: frameSpokenInbound(text),
|
|
737
|
+
attachments,
|
|
738
|
+
})
|
|
739
|
+
if (result.ok) {
|
|
740
|
+
logEvt('inbound.delivered', { target })
|
|
741
|
+
return { ok: true, target }
|
|
742
|
+
}
|
|
743
|
+
const detail = iapDeliveryFailureVerdict(result)
|
|
744
|
+
logEvt('inbound.drop', { target, reason: detail })
|
|
745
|
+
return { ok: false, target, detail }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** INBOUND source (Фаза-2 mic+STT stand-in): a unix socket the running router listens on.
|
|
749
|
+
* One connection = one utterance: the client writes a JSON line {target,text,attachments?}
|
|
750
|
+
* then half-closes; the router frames + delivers it and writes back a verdict line. */
|
|
751
|
+
function startInboundServer(ctx: RuntimeContext): Server {
|
|
752
|
+
const sockPath = inboundSocketPath(ctx.owner.personality)
|
|
753
|
+
// Clear a stale socket from a prior (crashed) run; listen() fails on EADDRINUSE otherwise.
|
|
754
|
+
try {
|
|
755
|
+
if (existsSync(sockPath)) unlinkSync(sockPath)
|
|
756
|
+
} catch {}
|
|
757
|
+
mkdirSync(dirname(sockPath), { recursive: true, mode: 0o700 })
|
|
758
|
+
const server = createServer((conn: Socket) => {
|
|
759
|
+
// One newline-terminated JSON utterance per connection. We frame on '\n' rather than
|
|
760
|
+
// the client's half-close so we never depend on half-open socket semantics (a server
|
|
761
|
+
// socket with allowHalfOpen:false would auto-close its writable side on the client's
|
|
762
|
+
// FIN, losing the async verdict). reply() writes the verdict + closes.
|
|
763
|
+
let buf = ''
|
|
764
|
+
let handled = false
|
|
765
|
+
conn.setEncoding('utf8')
|
|
766
|
+
const reply = (result: { ok: boolean; target?: string; detail?: string }) => {
|
|
767
|
+
try {
|
|
768
|
+
conn.end(`${JSON.stringify(result)}\n`)
|
|
769
|
+
} catch {}
|
|
770
|
+
}
|
|
771
|
+
conn.on('data', d => {
|
|
772
|
+
if (handled) return
|
|
773
|
+
buf += String(d)
|
|
774
|
+
const nl = buf.indexOf('\n')
|
|
775
|
+
if (nl < 0) return
|
|
776
|
+
handled = true
|
|
777
|
+
handleInboundUtterance(ctx, buf.slice(0, nl))
|
|
778
|
+
.then(reply)
|
|
779
|
+
.catch(err => reply({ ok: false, detail: formatError(err) }))
|
|
780
|
+
})
|
|
781
|
+
conn.on('error', () => {
|
|
782
|
+
try {
|
|
783
|
+
conn.destroy()
|
|
784
|
+
} catch {}
|
|
785
|
+
})
|
|
786
|
+
})
|
|
787
|
+
server.listen(sockPath)
|
|
788
|
+
return server
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function runCommand(extraArgs: string[]): Promise<void> {
|
|
792
|
+
parseFlags(extraArgs) // tolerate (ignore) extra flags the supervisor may pass through
|
|
793
|
+
ensureScaffold()
|
|
794
|
+
const owner = ensureCurrentProfile()
|
|
795
|
+
if (owner.intelligence !== 'natural') {
|
|
796
|
+
// voicetalk is a HUMAN channel (foundation adapter requiresIntelligence:'natural').
|
|
797
|
+
// Fail loud rather than route the owner's voice endpoint to an agent/script.
|
|
798
|
+
throw new VoicetalkRuntimeError(
|
|
799
|
+
`voicetalk is a human channel — peer "${owner.personality}" has intelligence=${owner.intelligence}, expected natural`,
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
const binding = voicetalkInterface(owner)
|
|
803
|
+
const ctx: RuntimeContext = {
|
|
804
|
+
cwd: process.cwd(),
|
|
805
|
+
owner,
|
|
806
|
+
binding,
|
|
807
|
+
iapBin: process.env.VOICETALK_RUNTIME_IAP_BIN ?? process.env.IAP_BIN ?? 'iapeer',
|
|
808
|
+
sink: makeStubAudioSink(owner.personality),
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const server = startInboundServer(ctx)
|
|
812
|
+
installStdinEnvelopeReader(ctx)
|
|
813
|
+
|
|
814
|
+
const cleanup = () => {
|
|
815
|
+
try {
|
|
816
|
+
server.close()
|
|
817
|
+
} catch {}
|
|
818
|
+
try {
|
|
819
|
+
const p = inboundSocketPath(owner.personality)
|
|
820
|
+
if (existsSync(p)) unlinkSync(p)
|
|
821
|
+
} catch {}
|
|
822
|
+
}
|
|
823
|
+
process.once('exit', cleanup)
|
|
824
|
+
process.once('SIGINT', () => {
|
|
825
|
+
cleanup()
|
|
826
|
+
process.exit(130)
|
|
827
|
+
})
|
|
828
|
+
process.once('SIGTERM', () => {
|
|
829
|
+
cleanup()
|
|
830
|
+
process.exit(143)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
logEvt('run', {
|
|
834
|
+
owner: owner.personality,
|
|
835
|
+
wake_word: binding.wake_word ?? null,
|
|
836
|
+
device: binding.device ?? null,
|
|
837
|
+
iapBin: ctx.iapBin,
|
|
838
|
+
inbound_socket: inboundSocketPath(owner.personality),
|
|
839
|
+
note: 'Фаза 2 — text caркас; audio (mic/wake-word/STT/TTS) is stubbed, lands in Фаза 3',
|
|
840
|
+
})
|
|
841
|
+
process.stderr.write(
|
|
842
|
+
`${BIN_NAME}: running ${RUNTIME}-${owner.personality}` +
|
|
843
|
+
`${binding.wake_word ? ` (wake-word "${binding.wake_word}")` : ' (wake-word unset)'}; ` +
|
|
844
|
+
`audio stubbed — Фаза 2 text caркас\n`,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
// Run forever — the supervisor (launchd KeepAlive) owns the lifecycle; we exit only on
|
|
848
|
+
// a signal. Never resolves.
|
|
849
|
+
await new Promise<never>(() => {})
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
853
|
+
// say — the Фаза-2 inbound injector (mic + wake-word + STT stand-in)
|
|
854
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
/** Connect to a running router's inbound socket, write one utterance, read the verdict. */
|
|
857
|
+
async function sendUtteranceOverSocket(
|
|
858
|
+
sockPath: string,
|
|
859
|
+
payload: { target: string; text: string; attachments: string[] },
|
|
860
|
+
timeoutMs = SAY_TIMEOUT_MS,
|
|
861
|
+
): Promise<{ ok: boolean; target?: string; detail?: string }> {
|
|
862
|
+
return await new Promise(resolve => {
|
|
863
|
+
let buf = ''
|
|
864
|
+
let settled = false
|
|
865
|
+
const finish = (r: { ok: boolean; target?: string; detail?: string }) => {
|
|
866
|
+
if (settled) return
|
|
867
|
+
settled = true
|
|
868
|
+
clearTimeout(timer)
|
|
869
|
+
try {
|
|
870
|
+
conn.destroy()
|
|
871
|
+
} catch {}
|
|
872
|
+
resolve(r)
|
|
873
|
+
}
|
|
874
|
+
const conn = createConnection(sockPath)
|
|
875
|
+
const timer = setTimeout(() => finish({ ok: false, detail: `no ack from router after ${timeoutMs}ms` }), timeoutMs)
|
|
876
|
+
conn.setEncoding('utf8')
|
|
877
|
+
// Write the utterance and KEEP the writable side open (no half-close) — we frame the
|
|
878
|
+
// verdict on the router's '\n', then tear the connection down in finish().
|
|
879
|
+
conn.on('connect', () => conn.write(`${JSON.stringify(payload)}\n`))
|
|
880
|
+
const tryParse = () => {
|
|
881
|
+
const nl = buf.indexOf('\n')
|
|
882
|
+
if (nl < 0) return
|
|
883
|
+
const line = buf.slice(0, nl).trim()
|
|
884
|
+
try {
|
|
885
|
+
finish(JSON.parse(line))
|
|
886
|
+
} catch {
|
|
887
|
+
finish({ ok: false, detail: `bad router response: ${line}` })
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
conn.on('data', d => {
|
|
891
|
+
buf += String(d)
|
|
892
|
+
tryParse()
|
|
893
|
+
})
|
|
894
|
+
conn.on('end', () => {
|
|
895
|
+
if (!buf.trim()) finish({ ok: false, detail: 'router closed without a verdict' })
|
|
896
|
+
else tryParse()
|
|
897
|
+
})
|
|
898
|
+
conn.on('error', err =>
|
|
899
|
+
finish({
|
|
900
|
+
ok: false,
|
|
901
|
+
detail: `cannot reach the voicetalk router at ${sockPath}: ${err.message} — is "voicetalk-runtime run" up?`,
|
|
902
|
+
}),
|
|
903
|
+
)
|
|
904
|
+
})
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function sayCommand(args: string[]): Promise<void> {
|
|
908
|
+
const { positional, flags } = parseFlags(args)
|
|
909
|
+
const target = stringFlag(flags, 'peer')
|
|
910
|
+
if (!target) throw new VoicetalkRuntimeError('say requires --peer <personality>')
|
|
911
|
+
const normalized = normalizeName(target)
|
|
912
|
+
assertName(normalized, 'peer')
|
|
913
|
+
const text = positional.join(' ').trim()
|
|
914
|
+
if (!text) throw new VoicetalkRuntimeError('say requires utterance text (positional)')
|
|
915
|
+
const attachments = arrayFlag(flags, 'attachment')
|
|
916
|
+
|
|
917
|
+
const owner = ensureCurrentProfile()
|
|
918
|
+
const sockPath = inboundSocketPath(owner.personality)
|
|
919
|
+
const verdict = await sendUtteranceOverSocket(sockPath, { target: normalized, text, attachments })
|
|
920
|
+
printJson(verdict)
|
|
921
|
+
if (!verdict.ok) process.exitCode = 1
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
925
|
+
// interface / self-install / self-config / doctor
|
|
926
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
927
|
+
|
|
928
|
+
async function interfaceCommand(args: string[]): Promise<void> {
|
|
929
|
+
const { flags } = parseFlags(args)
|
|
930
|
+
const wakeWord = stringFlag(flags, 'wake-word')
|
|
931
|
+
const device = stringFlag(flags, 'device')
|
|
932
|
+
if (wakeWord === undefined && device === undefined) {
|
|
933
|
+
throw new VoicetalkRuntimeError('interface requires --wake-word <w> and/or --device <d>')
|
|
934
|
+
}
|
|
935
|
+
ensureCurrentProfile() // assert the profile exists before patching
|
|
936
|
+
const merged = setVoicetalkInterface(process.cwd(), {
|
|
937
|
+
...(wakeWord !== undefined ? { wake_word: wakeWord } : {}),
|
|
938
|
+
...(device !== undefined ? { device } : {}),
|
|
939
|
+
})
|
|
940
|
+
process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
|
|
941
|
+
printJson(merged)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// `voicetalk-runtime` (bare) / `self-install`: the npx self-deploy contract. Place the
|
|
945
|
+
// launcher on PATH (a self-contained compiled snapshot, overwriting any legacy symlink) +
|
|
946
|
+
// write the runtime manifest at <IAPEER_ROOT>/runtimes/voicetalk/runtime.json (NO peers[]
|
|
947
|
+
// — operator-add). IDEMPOTENT. After it, `iapeer create <owner> --runtime voicetalk`
|
|
948
|
+
// resolves the launcher, bakes it into the always-on plist (VOICETALK_RUNTIME_BIN), and
|
|
949
|
+
// runs the per-peer self-config hook.
|
|
950
|
+
async function selfInstallCommand(): Promise<void> {
|
|
951
|
+
const r = selfInstall({ env: process.env, sourceEntry: import.meta.path })
|
|
952
|
+
process.stdout.write(`${BIN_NAME} self-install (idempotent)\n`)
|
|
953
|
+
process.stdout.write(` bin: ${r.binPath} (${r.binMode})\n`)
|
|
954
|
+
process.stdout.write(` manifest: ${r.manifestPath}\n`)
|
|
955
|
+
process.stdout.write(` root: ${r.root}\n`)
|
|
956
|
+
process.stdout.write(` docs: ${r.docs.copied ? r.docs.dest : `skipped (${r.docs.reason ?? 'unknown'})`}\n`)
|
|
957
|
+
logEvt('self-install', {
|
|
958
|
+
bin: r.binPath,
|
|
959
|
+
manifest: r.manifestPath,
|
|
960
|
+
binMode: r.binMode,
|
|
961
|
+
root: r.root,
|
|
962
|
+
docs: r.docs,
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// `voicetalk-runtime self-config`: the PER-PEER hook the foundation invokes inside
|
|
967
|
+
// createPeer→initPeer (cwd = peer cwd, IAPEER_PEER_* in env). Merges this owner's voice
|
|
968
|
+
// binding (wake_word + device, from env) into the local peer profile, PRESERVING the
|
|
969
|
+
// foundation-provisioned identity (intelligence=natural). exit 0 = configured.
|
|
970
|
+
async function selfConfigCommand(): Promise<void> {
|
|
971
|
+
const r = runSelfConfig({ env: process.env, cwd: process.cwd() })
|
|
972
|
+
process.stdout.write(`${r.profilePath}\n`)
|
|
973
|
+
process.stdout.write(
|
|
974
|
+
`${BIN_NAME} self-config: configured peer "${r.personality}"` +
|
|
975
|
+
`${r.wakeWord ? ` wake_word="${r.wakeWord}"` : ''}${r.device ? ` device="${r.device}"` : ''}\n`,
|
|
976
|
+
)
|
|
977
|
+
logEvt('self-config', { personality: r.personality, wake_word: r.wakeWord, device: r.device, profile: r.profilePath })
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The contract self-description (runtime id / version / bin / package). Pure + exported
|
|
981
|
+
* so a test asserts the contract values without running the CLI. */
|
|
982
|
+
export function doctorReport(): { runtime: string; version: string; bin: string; package: string } {
|
|
983
|
+
return { runtime: RUNTIME, version: VERSION, bin: BIN_NAME, package: PACKAGE_NAME }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function doctorCommand(args: string[]): Promise<void> {
|
|
987
|
+
const { flags } = parseFlags(args)
|
|
988
|
+
const problems: string[] = []
|
|
989
|
+
let profile: PeerProfile | null = null
|
|
990
|
+
try {
|
|
991
|
+
profile = readPeerProfile(process.cwd())
|
|
992
|
+
} catch (err) {
|
|
993
|
+
problems.push(formatError(err))
|
|
994
|
+
}
|
|
995
|
+
if (!profile) problems.push(`${peerProfilePath(process.cwd())} missing`)
|
|
996
|
+
if (profile && profile.intelligence !== 'natural') {
|
|
997
|
+
problems.push(`peer intelligence is ${profile.intelligence}, voicetalk requires natural`)
|
|
998
|
+
}
|
|
999
|
+
const binding = profile ? voicetalkInterface(profile) : {}
|
|
1000
|
+
if (profile && !binding.wake_word) {
|
|
1001
|
+
// A warning, not a hard fail: Фаза-2 text routing does not need the wake-word, but a
|
|
1002
|
+
// live voice session (Фаза 3) will.
|
|
1003
|
+
problems.push('interfaces.voicetalk.wake_word is unset (needed for live voice in Фаза 3)')
|
|
1004
|
+
}
|
|
1005
|
+
const result = {
|
|
1006
|
+
...doctorReport(),
|
|
1007
|
+
ok: problems.length === 0,
|
|
1008
|
+
cwd: process.cwd(),
|
|
1009
|
+
profile_path: peerProfilePath(process.cwd()),
|
|
1010
|
+
owner: profile ? { personality: profile.personality, intelligence: profile.intelligence } : null,
|
|
1011
|
+
binding,
|
|
1012
|
+
inbound_socket: profile ? inboundSocketPath(profile.personality) : null,
|
|
1013
|
+
problems,
|
|
1014
|
+
}
|
|
1015
|
+
if (flags.json) printJson(result)
|
|
1016
|
+
else {
|
|
1017
|
+
process.stdout.write(`runtime: ${result.runtime}\nversion: ${result.version}\nbin: ${result.bin}\n`)
|
|
1018
|
+
process.stdout.write(`${result.ok ? 'ok' : 'not ok'}\n`)
|
|
1019
|
+
for (const problem of problems) process.stdout.write(`- ${problem}\n`)
|
|
1020
|
+
}
|
|
1021
|
+
if (!result.ok) process.exitCode = 1
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1025
|
+
// entry
|
|
1026
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1027
|
+
|
|
1028
|
+
async function main(): Promise<void> {
|
|
1029
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
1030
|
+
const { flags } = parseFlags(process.argv.slice(2))
|
|
1031
|
+
if (flags.version === true || flags.v === true) {
|
|
1032
|
+
process.stdout.write(`${VERSION}\n`)
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
1035
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help' || (flags.help === true && !cmd)) {
|
|
1036
|
+
process.stdout.write(`${usage()}\n`)
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
if (!cmd) {
|
|
1040
|
+
// Bare invocation = the npx self-install contract (the foundation's defaultNpxRunner
|
|
1041
|
+
// runs `npx -y <package>` with NO args).
|
|
1042
|
+
await selfInstallCommand()
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
switch (cmd) {
|
|
1046
|
+
case 'self-install':
|
|
1047
|
+
await selfInstallCommand()
|
|
1048
|
+
return
|
|
1049
|
+
case 'self-config':
|
|
1050
|
+
await selfConfigCommand()
|
|
1051
|
+
return
|
|
1052
|
+
case 'interface':
|
|
1053
|
+
await interfaceCommand(rest)
|
|
1054
|
+
return
|
|
1055
|
+
case 'run':
|
|
1056
|
+
await runCommand(rest)
|
|
1057
|
+
return
|
|
1058
|
+
case 'say':
|
|
1059
|
+
await sayCommand(rest)
|
|
1060
|
+
return
|
|
1061
|
+
case 'doctor':
|
|
1062
|
+
await doctorCommand(rest)
|
|
1063
|
+
return
|
|
1064
|
+
default:
|
|
1065
|
+
process.stderr.write(`unknown command: ${cmd}\n\n${usage()}\n`)
|
|
1066
|
+
process.exitCode = 2
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (import.meta.main) {
|
|
1072
|
+
main().catch(err => {
|
|
1073
|
+
process.stderr.write(`${BIN_NAME}: ${formatError(err)}\n`)
|
|
1074
|
+
process.exit(1)
|
|
1075
|
+
})
|
|
1076
|
+
}
|