@agfpd/iapeer 0.4.18 → 0.4.19
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/CHANGELOG.md +22 -1
- package/package.json +1 -1
- package/src/cli/index.ts +20 -4
- package/src/onboard/voice.ts +204 -0
- package/src/status/index.ts +85 -9
- package/src/tui/onboard/app.tsx +32 -7
- package/src/tui/onboard/run.tsx +32 -4
- package/src/update/index.ts +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.19] - 2026-06-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Voice provider slot** (`@agfpd/voice-connect`) — a second declarative provider slot
|
|
18
|
+
alongside memory, integrated into the host lifecycle:
|
|
19
|
+
- `iapeer onboard` now offers an optional **DEFAULT-YES** voice step (after memory) in
|
|
20
|
+
both the linear path (`--no-voice` / `--voice <pkg>`) and the interactive wizard. It
|
|
21
|
+
installs the voice provider's **HOST BACKEND only** (a self-managed launchd HTTP
|
|
22
|
+
TTS/STT service) via the provider's own `init` (inherited stdio) — per-peer voice
|
|
23
|
+
tooling stays the separate explicit `iapeer enable voice-connect <peer>`. Report-only
|
|
24
|
+
for the exit code (an empty slot is a valid state).
|
|
25
|
+
- `iapeer update` cascades to the voice provider (best-effort, version-gated) via its
|
|
26
|
+
own `npm exec --package=@agfpd/voice-connect@latest -- voice-connect update` verb —
|
|
27
|
+
after foundation + runtimes + memory.
|
|
28
|
+
- `iapeer status` shows a `voice:` line (`<provider> <version> @ <endpoint>` + heartbeat
|
|
29
|
+
freshness, or `none`). The voice slot is read-only to the core, fail-open; unlike
|
|
30
|
+
memory it carries no provision/unprovision (voice is opt-in per-peer, not at birth)
|
|
31
|
+
and adds an `endpoint` for HTTP-facade discovery (telegram-STT / voicetalk).
|
|
32
|
+
|
|
13
33
|
## [0.4.18] - 2026-06-22
|
|
14
34
|
|
|
15
35
|
### Changed
|
|
@@ -274,7 +294,8 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
|
|
|
274
294
|
- **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
|
|
275
295
|
(timer = cron, watcher = event) are all first-class and addressed the same way.
|
|
276
296
|
|
|
277
|
-
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.
|
|
297
|
+
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.19...HEAD
|
|
298
|
+
[0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
|
|
278
299
|
[0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
|
|
279
300
|
[0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
|
|
280
301
|
[0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1088,10 +1088,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1088
1088
|
{ sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
|
|
1089
1089
|
{ sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
|
|
1090
1090
|
{
|
|
1091
|
-
sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]',
|
|
1092
|
-
desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
|
|
1091
|
+
sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--infra <csv>]',
|
|
1092
|
+
desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
|
|
1093
1093
|
},
|
|
1094
|
-
{ sig: 'status', desc: 'host snapshot: version, daemon health, memory
|
|
1094
|
+
{ sig: 'status', desc: 'host snapshot: version, daemon health, memory + voice slots (<provider> | none)' },
|
|
1095
1095
|
{ sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
|
|
1096
1096
|
{ sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
|
|
1097
1097
|
{ sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
|
|
@@ -1231,7 +1231,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1231
1231
|
// Interactive TUI wizard (Фаза TUI-редизайн) — the DEFAULT for an
|
|
1232
1232
|
// interactive `iapeer onboard`. Routed ONLY in a real interactive terminal
|
|
1233
1233
|
// and never for an automation path (--accept-risk / --dry-run / --no-memory
|
|
1234
|
-
// / --infra), which stay on the deterministic linear flow below. Escape
|
|
1234
|
+
// / --no-voice / --infra), which stay on the deterministic linear flow below. Escape
|
|
1235
1235
|
// hatch: IAPEER_ONBOARD_WIZARD=0 forces the linear path (recovery / scripts
|
|
1236
1236
|
// that want the old flow). The wizard owns its own gate screen, so we route
|
|
1237
1237
|
// BEFORE the readline gate. It fails closed: no real TTY →
|
|
@@ -1241,6 +1241,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1241
1241
|
flags['accept-risk'] === true ||
|
|
1242
1242
|
flags['dry-run'] === true ||
|
|
1243
1243
|
flags['no-memory'] === true ||
|
|
1244
|
+
flags['no-voice'] === true ||
|
|
1244
1245
|
typeof flags.infra === 'string'
|
|
1245
1246
|
if (!wizardOptOut && !automationFlag && process.stdin.isTTY === true && process.stdout.isTTY === true) {
|
|
1246
1247
|
const { runOnboardWizard, WIZARD_NOT_INTERACTIVE } = await import('../tui/onboard/run.tsx')
|
|
@@ -1342,6 +1343,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1342
1343
|
})
|
|
1343
1344
|
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
1344
1345
|
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
1346
|
+
// Voice slot (host backend only — per-peer voice tooling is the separate
|
|
1347
|
+
// `iapeer enable voice-connect <peer>`): optional DEFAULT-YES install of the
|
|
1348
|
+
// voice provider; its own init runs with INHERITED stdio (it owns the TTS-key
|
|
1349
|
+
// prompts). REPORT-ONLY for the exit code — an empty slot is a valid state.
|
|
1350
|
+
const { onboardVoiceProvider } = await import('../onboard/voice.ts')
|
|
1351
|
+
const voice = await onboardVoiceProvider({
|
|
1352
|
+
skip: flags['no-voice'] === true,
|
|
1353
|
+
package: typeof flags.voice === 'string' ? flags.voice : undefined,
|
|
1354
|
+
dryRun: flags['dry-run'] === true,
|
|
1355
|
+
env,
|
|
1356
|
+
})
|
|
1357
|
+
const voiceLabel = voice.provider ? `${voice.provider.provider} ${voice.provider.version}` : 'none'
|
|
1358
|
+
out(`voice: ${voice.state}${voice.detail ? ` — ${voice.detail}` : ''} (slot: ${voiceLabel})\n`)
|
|
1345
1359
|
// Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
|
|
1346
1360
|
// interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
|
|
1347
1361
|
// CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
|
|
@@ -1945,9 +1959,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1945
1959
|
// ── Cascade tail: runtimes + memory provider (best-effort) ──
|
|
1946
1960
|
const { updateAllRuntimes } = await import('../runtime/update.ts')
|
|
1947
1961
|
const { updateMemoryProvider } = await import('../onboard/memory.ts')
|
|
1962
|
+
const { updateVoiceProvider } = await import('../onboard/voice.ts')
|
|
1948
1963
|
const tail = await cascadeTail({
|
|
1949
1964
|
runtimes: () => updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) }),
|
|
1950
1965
|
memory: () => updateMemoryProvider({ env }),
|
|
1966
|
+
voice: () => updateVoiceProvider({ env }),
|
|
1951
1967
|
out,
|
|
1952
1968
|
})
|
|
1953
1969
|
return foundationExit === 0 && !tail.failed ? 0 : 1
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Voice-slot onboard step (the voice provider = @agfpd/voice-connect).
|
|
2
|
+
// Host-phase, optional, DEFAULT-YES: install the voice provider package and run ITS
|
|
3
|
+
// OWN init-verb — the provider deploys its HOST BACKEND (a self-managed launchd HTTP
|
|
4
|
+
// service), writes the slot declaration, and prompts for any TTS keys over our
|
|
5
|
+
// inherited tty. The core never writes the slot itself.
|
|
6
|
+
//
|
|
7
|
+
// SCOPE — host backend ONLY (the PIN, confirmed with voice-connect): init wires NO
|
|
8
|
+
// host MCP server. Per-peer voice tooling (the voice_create / tts MCP surface) is the
|
|
9
|
+
// SEPARATE explicit `iapeer enable voice-connect <peer>` (a marketplace capability
|
|
10
|
+
// plugin whose per-peer identity resolves from the MCP stdio child's cwd — a single
|
|
11
|
+
// host-wide server cannot resolve per-peer voices). So onboard ≠ per-peer wiring; the
|
|
12
|
+
// backend it installs is independently valuable (telegram-STT / voicetalk consume the
|
|
13
|
+
// HTTP facade with no MCP at all). Unlike memory there is therefore NO per-peer birth
|
|
14
|
+
// hook and the slot carries NO provision/unprovision.
|
|
15
|
+
//
|
|
16
|
+
// Outcome semantics: this step NEVER fails the onboard exit code — an empty slot is a
|
|
17
|
+
// fully valid state regardless of why (skipped / not yet published / non-tty refusal).
|
|
18
|
+
// Outcomes are reported, not enforced. Mirrors onboard/memory.ts deliberately (same
|
|
19
|
+
// vocabulary), minus memory's --human/--runtime init args (voice init takes none).
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'child_process'
|
|
22
|
+
import { readVoiceProvider, type VoiceProvider } from '../status/index.ts'
|
|
23
|
+
|
|
24
|
+
/** The distribution default voice provider. */
|
|
25
|
+
export const DEFAULT_VOICE_PACKAGE = '@agfpd/voice-connect'
|
|
26
|
+
|
|
27
|
+
export interface VoiceOnboardOptions {
|
|
28
|
+
/** --no-voice: skip the step entirely. */
|
|
29
|
+
skip?: boolean
|
|
30
|
+
/** --voice <pkg>: override the provider package (default @agfpd/voice-connect). */
|
|
31
|
+
package?: string
|
|
32
|
+
dryRun?: boolean
|
|
33
|
+
env?: NodeJS.ProcessEnv
|
|
34
|
+
/** Wall-clock backstop (ms) for the provider-init subprocess — a hung provider must
|
|
35
|
+
* NEVER wedge onboard (voice is optional). The interactive wizard passes a generous
|
|
36
|
+
* value; the linear path leaves it unset (no timeout). */
|
|
37
|
+
timeoutMs?: number
|
|
38
|
+
/** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init`
|
|
39
|
+
* with inherited stdio. */
|
|
40
|
+
runInit?: (
|
|
41
|
+
pkg: string,
|
|
42
|
+
args: string[],
|
|
43
|
+
env: NodeJS.ProcessEnv,
|
|
44
|
+
timeoutMs?: number,
|
|
45
|
+
) => { status: number | null; unavailable: boolean }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VoiceOnboardResult {
|
|
49
|
+
state:
|
|
50
|
+
| 'installed' // provider init succeeded and declared the slot
|
|
51
|
+
| 'already' // slot already occupied by the SAME package — idempotent no-op
|
|
52
|
+
| 'skipped-flag' // --no-voice
|
|
53
|
+
| 'skipped-unavailable' // package not published / no network — soft skip (contract)
|
|
54
|
+
| 'refused-foreign' // slot occupied by ANOTHER provider — explicit refusal
|
|
55
|
+
| 'provider-init-failed' // provider init ran and exited non-zero / declared nothing
|
|
56
|
+
| 'dry-run'
|
|
57
|
+
provider: VoiceProvider | null
|
|
58
|
+
detail?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultRunInit(
|
|
62
|
+
pkg: string,
|
|
63
|
+
args: string[],
|
|
64
|
+
env: NodeJS.ProcessEnv,
|
|
65
|
+
timeoutMs?: number,
|
|
66
|
+
): { status: number | null; unavailable: boolean } {
|
|
67
|
+
// Availability probe FIRST (cheap, side-effect-free, bounded): an unpublished package
|
|
68
|
+
// / no network → soft-skip per the contract (the provider's release order must never
|
|
69
|
+
// block the core's onboard).
|
|
70
|
+
const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
|
|
71
|
+
if (probe.status !== 0) return { status: probe.status, unavailable: true }
|
|
72
|
+
// INHERITED stdio — the provider owns its install questions (TTS-key prompts happen
|
|
73
|
+
// here, once). The provider bin (`voice-connect`) is an ARG-DISPATCHER: a subcommand
|
|
74
|
+
// (`init`) routes to its lifecycle path, NOT the backward-compatible MCP-stdio path
|
|
75
|
+
// (no-subcommand) — so `npx -y <pkg> init` correctly hits init.
|
|
76
|
+
const r = spawnSync('npx', ['-y', pkg, ...args], {
|
|
77
|
+
stdio: 'inherit',
|
|
78
|
+
env,
|
|
79
|
+
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
80
|
+
})
|
|
81
|
+
return { status: r.status, unavailable: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function onboardVoiceProvider(opts: VoiceOnboardOptions = {}): Promise<VoiceOnboardResult> {
|
|
85
|
+
const env = opts.env ?? process.env
|
|
86
|
+
const pkg = opts.package?.trim() || DEFAULT_VOICE_PACKAGE
|
|
87
|
+
const existing = readVoiceProvider(env)
|
|
88
|
+
if (opts.skip) return { state: 'skipped-flag', provider: existing }
|
|
89
|
+
if (existing) {
|
|
90
|
+
if (existing.package === pkg) return { state: 'already', provider: existing }
|
|
91
|
+
// Never silently install over an occupied slot (contract: FORBIDDEN).
|
|
92
|
+
return {
|
|
93
|
+
state: 'refused-foreign',
|
|
94
|
+
provider: existing,
|
|
95
|
+
detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// voice init takes NO core-known facts (no --human/--runtime — the host backend is
|
|
99
|
+
// peer-agnostic; per-peer voice is a separate `iapeer enable`).
|
|
100
|
+
const args = ['init']
|
|
101
|
+
if (opts.dryRun) {
|
|
102
|
+
return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
|
|
103
|
+
}
|
|
104
|
+
const run = opts.runInit ?? defaultRunInit
|
|
105
|
+
const r = run(pkg, args, env, opts.timeoutMs)
|
|
106
|
+
if (r.unavailable) {
|
|
107
|
+
return {
|
|
108
|
+
state: 'skipped-unavailable',
|
|
109
|
+
provider: null,
|
|
110
|
+
detail: `${pkg} is not available (not published yet / no network) — install later: npx ${pkg} init`,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (r.status !== 0) {
|
|
114
|
+
return {
|
|
115
|
+
state: 'provider-init-failed',
|
|
116
|
+
provider: readVoiceProvider(env),
|
|
117
|
+
detail: `provider init exited ${r.status ?? 'null'}`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The PROVIDER declares the slot; verify it actually did (its contract duty).
|
|
121
|
+
const after = readVoiceProvider(env)
|
|
122
|
+
if (!after) {
|
|
123
|
+
return {
|
|
124
|
+
state: 'provider-init-failed',
|
|
125
|
+
provider: null,
|
|
126
|
+
detail: 'provider init succeeded but did not declare the slot (voice-provider.json missing)',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { state: 'installed', provider: after }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// updateVoiceProvider — the voice leg of the cascade `iapeer update` (FU12)
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export interface VoiceUpdateResult {
|
|
137
|
+
state:
|
|
138
|
+
| 'updated' // provider update ran and the slot version advanced
|
|
139
|
+
| 'already-latest' // ran but the slot version is unchanged
|
|
140
|
+
| 'no-slot' // no provider claimed — nothing to update
|
|
141
|
+
| 'skipped-unavailable' // package not published / no network — soft skip
|
|
142
|
+
| 'failed' // provider update exited non-zero
|
|
143
|
+
package?: string
|
|
144
|
+
from?: string
|
|
145
|
+
to?: string
|
|
146
|
+
detail?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The provider's CLI bin name = the unscoped package name (`@agfpd/voice-connect` →
|
|
150
|
+
* `voice-connect`). That bin is the arg-dispatcher; `<bin> update` routes to its
|
|
151
|
+
* lifecycle update path. */
|
|
152
|
+
function voiceBinName(pkg: string): string {
|
|
153
|
+
return pkg.replace(/^@[^/]+\//, '')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function defaultRunVoiceUpdate(pkg: string, env: NodeJS.ProcessEnv): { status: number | null; unavailable: boolean } {
|
|
157
|
+
// Availability probe FIRST (cheap, bounded): unpublished / no network → soft-skip.
|
|
158
|
+
const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
|
|
159
|
+
if (probe.status !== 0) return { status: probe.status, unavailable: true }
|
|
160
|
+
// `npm exec --package=<pkg>@latest -- <bin> update` fetches the latest published
|
|
161
|
+
// package into a fresh temp and runs ITS update verb from there — bypassing any
|
|
162
|
+
// PATH name-shadow (the 0.2.9 pitfall: a bare `npx <bin>` could re-run a stale
|
|
163
|
+
// installed binary). voice-connect's update then re-copies its runtime tree into the
|
|
164
|
+
// STABLE provider home (~/.iapeer/providers/voice-connect/, never the GC'd npx cache),
|
|
165
|
+
// bootout→bootstraps its HTTP service, and bumps slot.version — so the before/after
|
|
166
|
+
// version-gate this leg reads is real. The provider OWNS update + slot-write + its own
|
|
167
|
+
// service restart.
|
|
168
|
+
const r = spawnSync(
|
|
169
|
+
'npm',
|
|
170
|
+
['exec', '--yes', `--package=${pkg}@latest`, '--', voiceBinName(pkg), 'update'],
|
|
171
|
+
{ stdio: 'inherit', env },
|
|
172
|
+
)
|
|
173
|
+
return { status: r.status, unavailable: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update the CLAIMED voice provider via ITS OWN update verb (slot contract: the
|
|
178
|
+
* provider owns install/update + slot-write + service restart; the core only invokes +
|
|
179
|
+
* reads the slot). No slot → nothing to update. Reports from→to by reading the slot
|
|
180
|
+
* version before/after. BEST-EFFORT by the caller (the cascade never aborts on it).
|
|
181
|
+
*/
|
|
182
|
+
export function updateVoiceProvider(opts: {
|
|
183
|
+
env?: NodeJS.ProcessEnv
|
|
184
|
+
dryRun?: boolean
|
|
185
|
+
runUpdate?: (pkg: string, env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
|
|
186
|
+
} = {}): VoiceUpdateResult {
|
|
187
|
+
const env = opts.env ?? process.env
|
|
188
|
+
const slot = readVoiceProvider(env)
|
|
189
|
+
if (!slot) return { state: 'no-slot', detail: 'no voice provider claimed — nothing to update' }
|
|
190
|
+
const pkg = slot.package
|
|
191
|
+
const from = slot.version
|
|
192
|
+
if (opts.dryRun) {
|
|
193
|
+
return { state: 'updated', package: pkg, from, detail: `would run: npm exec --package=${pkg}@latest -- ${voiceBinName(pkg)} update` }
|
|
194
|
+
}
|
|
195
|
+
const run = opts.runUpdate ?? defaultRunVoiceUpdate
|
|
196
|
+
const r = run(pkg, env)
|
|
197
|
+
if (r.unavailable) {
|
|
198
|
+
return { state: 'skipped-unavailable', package: pkg, from, detail: `${pkg}@latest not reachable (no network / unpublished)` }
|
|
199
|
+
}
|
|
200
|
+
if (r.status !== 0) return { state: 'failed', package: pkg, from, detail: `provider update exited ${r.status ?? 'null'}` }
|
|
201
|
+
const to = readVoiceProvider(env)?.version
|
|
202
|
+
if (from && to && from === to) return { state: 'already-latest', package: pkg, from, to }
|
|
203
|
+
return { state: 'updated', package: pkg, from, to }
|
|
204
|
+
}
|
package/src/status/index.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Status — the host-snapshot verb: installed-binary version, daemon health, and
|
|
2
|
-
// the
|
|
3
|
-
// is DECLARATIVE: a root file `~/.iapeer/memory-provider.json`
|
|
4
|
-
// by the PROVIDER's own init/uninstall — the core only ever
|
|
5
|
-
// unreadable file is the EMPTY slot — a fully valid state
|
|
6
|
-
// error (fail-open). The core never acts on heartbeat
|
|
7
|
-
// it (healing the provider's daemon is the provider's
|
|
2
|
+
// the provider-SLOT lines (docs/Слот памяти — контракт memory provider.md). A slot
|
|
3
|
+
// is DECLARATIVE: a root file (`~/.iapeer/memory-provider.json`, `voice-provider.json`)
|
|
4
|
+
// written (atomically) by the PROVIDER's own init/uninstall — the core only ever
|
|
5
|
+
// READS it. An absent or unreadable file is the EMPTY slot — a fully valid state
|
|
6
|
+
// (bare core), never an error (fail-open). The core never acts on heartbeat
|
|
7
|
+
// staleness — it only REPORTS it (healing the provider's daemon is the provider's
|
|
8
|
+
// job). Two slots share the same declarative contract: MEMORY (auto-at-birth, carries
|
|
9
|
+
// provision/unprovision) and VOICE (host-level backend only; per-peer voice tooling is
|
|
10
|
+
// the separate `iapeer enable voice-connect <peer>` — the voice slot carries NO
|
|
11
|
+
// provision/unprovision, and adds an `endpoint` for HTTP-facade discovery).
|
|
8
12
|
|
|
9
13
|
import { closeSync, openSync, readFileSync, statSync } from 'fs'
|
|
10
14
|
import { join } from 'path'
|
|
@@ -97,8 +101,9 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
/** Age (s) of
|
|
101
|
-
|
|
104
|
+
/** Age (s) of a provider's heartbeat file, or null (none declared / unreadable).
|
|
105
|
+
* Generic over any slot carrying an optional `heartbeat` path (memory + voice). */
|
|
106
|
+
export function heartbeatAgeSecs(provider: { heartbeat?: string }, nowMs: number = Date.now()): number | null {
|
|
102
107
|
if (!provider.heartbeat) return null
|
|
103
108
|
try {
|
|
104
109
|
return Math.max(0, Math.floor((nowMs - statSync(provider.heartbeat).mtimeMs) / 1000))
|
|
@@ -107,6 +112,63 @@ export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.
|
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// VOICE slot — the voice-connect provider declaration (the same declarative
|
|
117
|
+
// contract as memory, two differences by design: NO provision/unprovision (voice
|
|
118
|
+
// is opt-in PER-PEER via `iapeer enable`, not auto-at-birth), and an `endpoint`
|
|
119
|
+
// (+ a named `routes` object the core does NOT parse) for the HTTP-facade that
|
|
120
|
+
// telegram-STT / voicetalk consume with no MCP at all). The core only READS it.
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** The voice-slot declaration filename in the storage root. */
|
|
124
|
+
export const VOICE_PROVIDER_FILE = 'voice-provider.json'
|
|
125
|
+
|
|
126
|
+
export interface VoiceProvider {
|
|
127
|
+
/** Provider name occupying the slot (e.g. "voice-connect"). */
|
|
128
|
+
provider: string
|
|
129
|
+
/** npm package of the provider (e.g. "@agfpd/voice-connect"). */
|
|
130
|
+
package: string
|
|
131
|
+
version: string
|
|
132
|
+
registeredAt: string
|
|
133
|
+
/** Optional liveness proxy: an absolute path whose mtime the provider's HTTP
|
|
134
|
+
* daemon refreshes. status reports its age; the core takes NO action on staleness. */
|
|
135
|
+
heartbeat?: string
|
|
136
|
+
/** Base URL of the provider's HTTP facade (e.g. "http://127.0.0.1:PORT") — shown
|
|
137
|
+
* in status for discovery. Opaque to the core. */
|
|
138
|
+
endpoint?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function voiceProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
142
|
+
return join(resolveGlobalRoot(env), VOICE_PROVIDER_FILE)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read the voice-slot declaration. null = EMPTY slot (absent / unreadable /
|
|
147
|
+
* schema-invalid file) — a valid state, so this NEVER throws (fail-open to bare).
|
|
148
|
+
* Self-management extras the provider may add (label/managed/host/port/routes) are
|
|
149
|
+
* ignored — the core reads only the contract fields it acts/reports on.
|
|
150
|
+
*/
|
|
151
|
+
export function readVoiceProvider(env: NodeJS.ProcessEnv = process.env): VoiceProvider | null {
|
|
152
|
+
try {
|
|
153
|
+
const raw = JSON.parse(readFileSync(voiceProviderPath(env), 'utf8')) as unknown
|
|
154
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
155
|
+
const o = raw as Record<string, unknown>
|
|
156
|
+
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
157
|
+
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
158
|
+
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
159
|
+
return {
|
|
160
|
+
provider: o.provider.trim(),
|
|
161
|
+
package: o.package.trim(),
|
|
162
|
+
version: o.version.trim(),
|
|
163
|
+
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
164
|
+
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
165
|
+
...(typeof o.endpoint === 'string' && o.endpoint.trim() ? { endpoint: o.endpoint.trim() } : {}),
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
return null // empty slot — bare core is valid
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
110
172
|
/**
|
|
111
173
|
* Probe whether the iapeer binary holds Full Disk Access (macOS TCC). Reads the
|
|
112
174
|
* user TCC.db — a canonical FDA-gated resource that is NEVER promptable (silent
|
|
@@ -137,6 +199,7 @@ export interface HostStatus {
|
|
|
137
199
|
version: string
|
|
138
200
|
daemon: { healthy: boolean; url: string | null; sock: string | null }
|
|
139
201
|
memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
|
|
202
|
+
voice: { provider: VoiceProvider | null; heartbeatAgeSecs: number | null }
|
|
140
203
|
/** Full Disk Access of the iapeer binary: true granted / false not-granted /
|
|
141
204
|
* null undeterminable (non-macOS or TCC.db unreadable for a non-TCC reason). */
|
|
142
205
|
fda: boolean | null
|
|
@@ -165,11 +228,13 @@ export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStat
|
|
|
165
228
|
}
|
|
166
229
|
const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
|
|
167
230
|
const provider = readMemoryProvider(env)
|
|
231
|
+
const voice = readVoiceProvider(env)
|
|
168
232
|
const fda = (opts.fdaProbe ?? (() => probeFullDiskAccess(env)))()
|
|
169
233
|
return {
|
|
170
234
|
version: IAPEER_VERSION,
|
|
171
235
|
daemon: { healthy: health.healthy, url, sock },
|
|
172
236
|
memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
|
|
237
|
+
voice: { provider: voice, heartbeatAgeSecs: voice ? heartbeatAgeSecs(voice) : null },
|
|
173
238
|
fda,
|
|
174
239
|
}
|
|
175
240
|
}
|
|
@@ -190,6 +255,17 @@ export function formatHostStatus(s: HostStatus): string {
|
|
|
190
255
|
: ' (daemon not running — no heartbeat file)'
|
|
191
256
|
}
|
|
192
257
|
const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
|
|
258
|
+
// Voice slot — same heartbeat semantics as memory; also show the HTTP endpoint
|
|
259
|
+
// (discovery target for telegram-STT / voicetalk). 'none' when the slot is empty.
|
|
260
|
+
let vhb = ''
|
|
261
|
+
if (s.voice.provider?.heartbeat) {
|
|
262
|
+
vhb = s.voice.heartbeatAgeSecs !== null
|
|
263
|
+
? ` (heartbeat ${s.voice.heartbeatAgeSecs}s ago)`
|
|
264
|
+
: ' (daemon not running — no heartbeat file)'
|
|
265
|
+
}
|
|
266
|
+
const voice = s.voice.provider
|
|
267
|
+
? `${s.voice.provider.provider} ${s.voice.provider.version}${s.voice.provider.endpoint ? ` @ ${s.voice.provider.endpoint}` : ''}${vhb}`
|
|
268
|
+
: 'none'
|
|
193
269
|
// FDA line: granted → terse OK; NOT granted → the actionable hint (a fresh host
|
|
194
270
|
// without FDA silently meets TCC prompts on every new protected class — peers
|
|
195
271
|
// read protected folders + foreign app containers, attributed to this binary;
|
|
@@ -204,5 +280,5 @@ export function formatHostStatus(s: HostStatus): string {
|
|
|
204
280
|
} else {
|
|
205
281
|
fda = 'fda: unknown (not macOS / undeterminable)'
|
|
206
282
|
}
|
|
207
|
-
return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n${fda}\n`
|
|
283
|
+
return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\nvoice: ${voice}\n${fda}\n`
|
|
208
284
|
}
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import { iapeerBinPath } from '../../install/index.ts'
|
|
|
18
18
|
export interface WizardResult {
|
|
19
19
|
code: number
|
|
20
20
|
memoryConsent: boolean
|
|
21
|
+
voiceConsent: boolean
|
|
21
22
|
advisories: string[]
|
|
22
23
|
summary: string[]
|
|
23
24
|
telegramConsent: boolean
|
|
@@ -27,7 +28,7 @@ export interface WizardResult {
|
|
|
27
28
|
hostRuntime?: OnboardRuntime
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'declined' | 'finishing'
|
|
31
|
+
type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'declined' | 'finishing'
|
|
31
32
|
type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
|
|
32
33
|
interface Step {
|
|
33
34
|
key: string
|
|
@@ -96,16 +97,17 @@ export function OnboardApp({
|
|
|
96
97
|
const [advisories, setAdvisories] = useState<string[]>([])
|
|
97
98
|
const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
|
|
98
99
|
const [telegramConsent, setTelegramConsent] = useState(false)
|
|
100
|
+
const [memoryConsent, setMemoryConsent] = useState(false)
|
|
99
101
|
const spin = useSpinnerFrame(phase === 'running')
|
|
100
102
|
|
|
101
|
-
const finish = (
|
|
103
|
+
const finish = (voiceConsent: boolean): void => {
|
|
102
104
|
const failed = steps.some(s => s.status === 'fail')
|
|
103
105
|
const summary = steps.map(s => {
|
|
104
106
|
const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
|
|
105
107
|
return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
|
|
106
108
|
})
|
|
107
109
|
summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
|
|
108
|
-
onResult({ code: failed ? 1 : 0, memoryConsent, telegramConsent, advisories, summary, hostRuntime })
|
|
110
|
+
onResult({ code: failed ? 1 : 0, memoryConsent, voiceConsent, telegramConsent, advisories, summary, hostRuntime })
|
|
109
111
|
setPhase('finishing')
|
|
110
112
|
setTimeout(() => exit(), 30)
|
|
111
113
|
}
|
|
@@ -135,19 +137,34 @@ export function OnboardApp({
|
|
|
135
137
|
{ isActive: phase === 'telegram' },
|
|
136
138
|
)
|
|
137
139
|
|
|
138
|
-
// Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
|
|
140
|
+
// Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install). Records the decision
|
|
141
|
+
// and advances to the voice consent (the install itself runs post-Ink). Then → voice.
|
|
142
|
+
useInput(
|
|
143
|
+
(input, key) => {
|
|
144
|
+
if (input === 'n' || input === 'N') {
|
|
145
|
+
setMemoryConsent(false)
|
|
146
|
+
setPhase('voice')
|
|
147
|
+
} else if (input === 'y' || input === 'Y' || key.return) {
|
|
148
|
+
setMemoryConsent(true)
|
|
149
|
+
setPhase('voice')
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{ isActive: phase === 'memory' },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install). The LAST consent → finish.
|
|
139
156
|
useInput(
|
|
140
157
|
(input, key) => {
|
|
141
158
|
if (input === 'n' || input === 'N') finish(false)
|
|
142
159
|
else if (input === 'y' || input === 'Y' || key.return) finish(true)
|
|
143
160
|
},
|
|
144
|
-
{ isActive: phase === '
|
|
161
|
+
{ isActive: phase === 'voice' },
|
|
145
162
|
)
|
|
146
163
|
|
|
147
164
|
// Declined → exit 1 (no host mutation).
|
|
148
165
|
useEffect(() => {
|
|
149
166
|
if (phase !== 'declined') return
|
|
150
|
-
onResult({ code: 1, memoryConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
|
|
167
|
+
onResult({ code: 1, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
|
|
151
168
|
const t = setTimeout(() => exit(), 30)
|
|
152
169
|
return () => clearTimeout(t)
|
|
153
170
|
}, [phase, onResult, exit])
|
|
@@ -305,7 +322,7 @@ export function OnboardApp({
|
|
|
305
322
|
return <Text color="red">onboard aborted — risk not accepted.</Text>
|
|
306
323
|
}
|
|
307
324
|
|
|
308
|
-
// running | telegram | memory | finishing
|
|
325
|
+
// running | telegram | memory | voice | finishing
|
|
309
326
|
return (
|
|
310
327
|
<Box flexDirection="column">
|
|
311
328
|
<Box marginBottom={1}>
|
|
@@ -352,6 +369,14 @@ export function OnboardApp({
|
|
|
352
369
|
<Text dimColor> Gives your agents a shared knowledge vault. [Y/n]</Text>
|
|
353
370
|
</Box>
|
|
354
371
|
) : null}
|
|
372
|
+
{phase === 'voice' ? (
|
|
373
|
+
<Box marginTop={1} flexDirection="column">
|
|
374
|
+
<Text bold color="cyan">
|
|
375
|
+
? Install the voice provider backend (@agfpd/voice-connect)?
|
|
376
|
+
</Text>
|
|
377
|
+
<Text dimColor> A local text-to-speech / speech-to-text HTTP service. Per-agent voice tools are a separate `iapeer enable`. [Y/n]</Text>
|
|
378
|
+
</Box>
|
|
379
|
+
) : null}
|
|
355
380
|
</Box>
|
|
356
381
|
)
|
|
357
382
|
}
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -71,7 +71,7 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
71
71
|
// wizard only runs on one), off under NO_COLOR.
|
|
72
72
|
const a = makeAnsi(colorEnabled(env, process.stdout))
|
|
73
73
|
|
|
74
|
-
let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
74
|
+
let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
75
75
|
try {
|
|
76
76
|
const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
|
|
77
77
|
await app.waitUntilExit()
|
|
@@ -79,9 +79,10 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
79
79
|
return WIZARD_NOT_INTERACTIVE
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Post-Ink interactive provisioning — telegram THEN memory (the canonical
|
|
83
|
-
// telegram creates the human peer that memory's --human resolves from
|
|
84
|
-
// the terminal (inherited stdio / readline),
|
|
82
|
+
// Post-Ink interactive provisioning — telegram THEN memory THEN voice (the canonical
|
|
83
|
+
// order: telegram creates the human peer that memory's --human resolves from; voice is
|
|
84
|
+
// an independent host backend). These own the terminal (inherited stdio / readline),
|
|
85
|
+
// so they run AFTER Ink unmounts (no
|
|
85
86
|
// contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
|
|
86
87
|
// CURRENT step's child/readline and onboard continues to the summary, instead of
|
|
87
88
|
// the whole flow dying. The no-op handler is queued past the blocking spawnSync.
|
|
@@ -136,6 +137,33 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
136
137
|
'\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
|
|
137
138
|
)
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
if (result.voiceConsent) {
|
|
142
|
+
const { onboardVoiceProvider } = await import('../../onboard/voice.ts')
|
|
143
|
+
process.stdout.write(
|
|
144
|
+
'\n' +
|
|
145
|
+
a.bold('Installing the voice provider backend (@agfpd/voice-connect)…') +
|
|
146
|
+
'\n' +
|
|
147
|
+
a.dim(
|
|
148
|
+
'A local TTS/STT HTTP service. Voice is OPTIONAL: press Ctrl-C to skip it\n' +
|
|
149
|
+
'(set it up later with `npx @agfpd/voice-connect init`).',
|
|
150
|
+
) +
|
|
151
|
+
'\n\n',
|
|
152
|
+
)
|
|
153
|
+
try {
|
|
154
|
+
const v = await onboardVoiceProvider({ env, timeoutMs: 12 * 60_000 })
|
|
155
|
+
const label = v.provider ? `${v.provider.provider} ${v.provider.version}` : 'none'
|
|
156
|
+
const rest = `${v.detail ? ` — ${v.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
|
|
157
|
+
process.stdout.write('\n' + paintResult('voice', v.state, rest, a) + '\n')
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(set up later: `npx @agfpd/voice-connect init`)')}`
|
|
160
|
+
process.stdout.write('\n' + paintResult('voice', 'step skipped', rest, a) + '\n')
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
process.stdout.write(
|
|
164
|
+
'\n' + paintResult('voice', 'skipped', ' — set it up later with `npx @agfpd/voice-connect init`', a) + '\n',
|
|
165
|
+
)
|
|
166
|
+
}
|
|
139
167
|
} finally {
|
|
140
168
|
process.removeListener('SIGINT', ignore)
|
|
141
169
|
for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
|
package/src/update/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
|
51
51
|
// shapes but receives the component-updaters as injected deps.
|
|
52
52
|
import type { UpdateRuntimeResult } from '../runtime/update.ts'
|
|
53
53
|
import type { MemoryUpdateResult } from '../onboard/memory.ts'
|
|
54
|
+
import type { VoiceUpdateResult } from '../onboard/voice.ts'
|
|
54
55
|
|
|
55
56
|
/** The npm package the foundation publishes / updates from. */
|
|
56
57
|
export const IAPEER_PACKAGE = '@agfpd/iapeer'
|
|
@@ -320,10 +321,10 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
320
321
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
321
322
|
// cascadeTail — the runtimes + memory legs of the cascade `iapeer update` (FU12).
|
|
322
323
|
// Runs AFTER a healthy foundation update (the CLI owns that, abort-on-hard-fail);
|
|
323
|
-
// here we update every installed runtime then the memory provider,
|
|
324
|
-
// component failure is reported, never aborts the rest. Pure
|
|
325
|
-
// injected component-updaters (no value-import of runtime/onboard →
|
|
326
|
-
// is hermetically testable. Returns whether anything failed (→ exit≠0).
|
|
324
|
+
// here we update every installed runtime, then the memory provider, then the voice
|
|
325
|
+
// provider, BEST-EFFORT — a component failure is reported, never aborts the rest. Pure
|
|
326
|
+
// orchestration over injected component-updaters (no value-import of runtime/onboard →
|
|
327
|
+
// no cycle), so it is hermetically testable. Returns whether anything failed (→ exit≠0).
|
|
327
328
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
328
329
|
|
|
329
330
|
export interface CascadeTailDeps {
|
|
@@ -331,6 +332,8 @@ export interface CascadeTailDeps {
|
|
|
331
332
|
runtimes: () => Promise<UpdateRuntimeResult[]>
|
|
332
333
|
/** = updateMemoryProvider (onboard/memory.ts). */
|
|
333
334
|
memory: () => MemoryUpdateResult | Promise<MemoryUpdateResult>
|
|
335
|
+
/** = updateVoiceProvider (onboard/voice.ts). */
|
|
336
|
+
voice: () => VoiceUpdateResult | Promise<VoiceUpdateResult>
|
|
334
337
|
out: (s: string) => void
|
|
335
338
|
}
|
|
336
339
|
|
|
@@ -353,5 +356,10 @@ export async function cascadeTail(deps: CascadeTailDeps): Promise<{ failed: bool
|
|
|
353
356
|
deps.out(`memory: ${m.state}${m.package ? ` (${m.package}${mver})` : ''}${m.detail ? ` — ${m.detail}` : ''}\n`)
|
|
354
357
|
if (m.state === 'failed') failed = true
|
|
355
358
|
|
|
359
|
+
const v = await deps.voice()
|
|
360
|
+
const vver = v.from || v.to ? ` ${v.from ?? '?'} → ${v.to ?? '?'}` : ''
|
|
361
|
+
deps.out(`voice: ${v.state}${v.package ? ` (${v.package}${vver})` : ''}${v.detail ? ` — ${v.detail}` : ''}\n`)
|
|
362
|
+
if (v.state === 'failed') failed = true
|
|
363
|
+
|
|
356
364
|
return { failed }
|
|
357
365
|
}
|