@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/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(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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
+ }