@qvac/core 0.1.0 → 0.1.2

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/tui/index.mjs ADDED
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env bare
2
+ // The duck TUI host: CLI, storage resolution, and the two topologies it can
3
+ // bring the stack up in — a stowed sidecar child (the desktop shape, default)
4
+ // or everything in this process (QVAC_INPROCESS=1, the only way to inject a
5
+ // live harness object). See README.md for flags, env, and layout.
6
+ import fs from 'bare-fs'
7
+ import os from 'bare-os'
8
+ import process from 'bare-process'
9
+ import tui from 'bare-tui'
10
+ import { fileURLToPath } from 'bare-url'
11
+ import { spawn } from 'child_process'
12
+ import path from 'path'
13
+ import { Harness, imageClampsFromEnv, imageModelFromEnv } from '@qvac/harness'
14
+ import { createExecTool } from '@qvac/harness/lib/tools/exec.ts'
15
+ import { createHttpRequestTool } from '@qvac/harness/lib/tools/http-request.ts'
16
+ import { createMcpCallTool } from '@qvac/harness/lib/tools/mcp-call.ts'
17
+ import { compileOperationTools } from '@qvac/harness/lib/tools/operations/compile.ts'
18
+ import { createSkillTool } from '@qvac/harness/lib/tools/skill.ts'
19
+ import { createWebFetchTool } from '@qvac/harness/lib/tools/web-fetch.ts'
20
+ import { createWebSearchTool } from '@qvac/harness/lib/tools/web-search.ts'
21
+ import {
22
+ credentialAllowListFor,
23
+ loadSkillCatalog,
24
+ mcpReadMethods
25
+ } from '@qvac/harness/lib/tools/skills/index.ts'
26
+ import {
27
+ harnessListModels,
28
+ harnessSpeak,
29
+ harnessTranscribe,
30
+ QvacAssistant
31
+ } from '../dist-lib/lib/assistant.js'
32
+ import { Client } from '../dist-lib/lib/client.js'
33
+ import { Core } from '../dist-lib/lib/core.js'
34
+ import { Logger, toLoggerOptions } from '../dist-lib/lib/log.js'
35
+ import { extractOfficeText } from '../dist-lib/lib/core/files/rag/extract/office-collabora.js'
36
+ import { DEFAULT_RAG_MODEL } from '../dist-lib/lib/core/files/rag/models.js'
37
+ import { configureSdkStorage } from '@qvac/harness/sdk-storage'
38
+ import { MODEL_CACHE_PATH_ENV, releaseChannel, resolveStorage } from '@qvac/harness/storage'
39
+ import {
40
+ ASANA_MCP_CREDENTIAL_KEY,
41
+ ASANA_OAUTH_STATE_KEY,
42
+ oauthKeySpec,
43
+ NOTION_MCP_CREDENTIAL_KEY,
44
+ NOTION_OAUTH_STATE_KEY,
45
+ resolveAsanaCredential,
46
+ resolveGoogleCredential,
47
+ resolveNotionCredential,
48
+ resolveSpotifyCredential,
49
+ SPOTIFY_CREDENTIAL_KEY,
50
+ SPOTIFY_OAUTH_STATE_KEY
51
+ } from '../dist-lib/lib/oauth.js'
52
+ import sidecarRunner from 'bare-supervisor/runner/sidecar'
53
+ import { duplexPair } from '@qvac/harness/transport'
54
+ import { bootTimer, readBootTiming } from './lib/boot-timing.mjs'
55
+ import { ensurePrebuilds } from './lib/prebuilds.mjs'
56
+ import { ChatApp, credKey } from './app.mjs'
57
+ import { parseProjectionEnv } from './lib/projection-env.mjs'
58
+
59
+ const skillsDir = path.dirname(
60
+ path.dirname(new URL(import.meta.resolve('@qvac/skills/skills/weather/SKILL.md')).pathname)
61
+ )
62
+ // The harness the assistant sidecar spawns as its own child — the bundle
63
+ // @qvac/harness publishes, resolved through its only dist export's sibling.
64
+ const harnessEntry = path.join(
65
+ path.dirname(new URL(import.meta.resolve('@qvac/harness/dist/harness.cjs')).pathname),
66
+ 'harness.bundle'
67
+ )
68
+
69
+ function createCredentialStore() {
70
+ let engine
71
+ return {
72
+ bind: (readyEngine) => {
73
+ engine = readyEngine
74
+ },
75
+ resolve: async (key, ctx) => {
76
+ if (!engine) return null
77
+ const googleSpec = oauthKeySpec(key)
78
+ if (googleSpec?.provider === 'google') {
79
+ return resolveOAuthRecord(
80
+ resolveGoogleCredential({
81
+ engine,
82
+ agentId: ctx?.agentId,
83
+ credentialKey: key,
84
+ stateKey: googleSpec.stateKey
85
+ }),
86
+ engine,
87
+ googleSpec.stateKey,
88
+ ctx?.agentId
89
+ )
90
+ }
91
+ if (key === SPOTIFY_CREDENTIAL_KEY) {
92
+ return resolveOAuthRecord(
93
+ resolveSpotifyCredential({ engine, agentId: ctx?.agentId }),
94
+ engine,
95
+ SPOTIFY_OAUTH_STATE_KEY,
96
+ ctx?.agentId
97
+ )
98
+ }
99
+ if (key === NOTION_MCP_CREDENTIAL_KEY) {
100
+ return resolveOAuthRecord(
101
+ resolveNotionCredential({ engine, agentId: ctx?.agentId }),
102
+ engine,
103
+ NOTION_OAUTH_STATE_KEY,
104
+ ctx?.agentId
105
+ )
106
+ }
107
+ if (key === ASANA_MCP_CREDENTIAL_KEY) {
108
+ return resolveOAuthRecord(
109
+ resolveAsanaCredential({ engine, agentId: ctx?.agentId }),
110
+ engine,
111
+ ASANA_OAUTH_STATE_KEY,
112
+ ctx?.agentId
113
+ )
114
+ }
115
+ const { value } = await engine.configGet({ key: credKey(ctx?.agentId, key) })
116
+ return typeof value === 'string' ? value : null
117
+ }
118
+ }
119
+ }
120
+
121
+ async function resolveOAuthRecord(tokenPromise, engine, stateKey, agentId) {
122
+ const token = await tokenPromise
123
+ if (!token) return null
124
+ const { value } = await engine.configGet({ key: credKey(agentId, stateKey) })
125
+ let expiresAt
126
+ try {
127
+ const state = typeof value === 'string' ? JSON.parse(value) : null
128
+ expiresAt = typeof state?.expiresAt === 'number' ? state.expiresAt : undefined
129
+ } catch {}
130
+ return { token, ...(expiresAt === undefined ? {} : { expiresAt }) }
131
+ }
132
+
133
+ const { Program } = tui
134
+
135
+ // how long a lone esc waits to see whether it starts a sequence — the terminal
136
+ // writes a real one in a single read, so this only has to beat human perception
137
+ const ESCAPE_TIMEOUT_MS = 50
138
+
139
+ // @qvac/sdk writes its own [sdk:client]/[sdk:server] logs straight to
140
+ // console — bare-tui's Program owns the terminal via cursor-tracked ANSI
141
+ // output, so any other writer to the same fd corrupts the redraw. Silence
142
+ // console output for the TUI's lifetime; errors after it exits still print.
143
+ // (llama.cpp's own native stdout logs are a separate path, not covered by
144
+ // this — see assistant-harness/lib/sdk-loader.ts.)
145
+ function withConsoleSuppressed(fn) {
146
+ const original = {
147
+ log: console.log,
148
+ info: console.info,
149
+ warn: console.warn,
150
+ error: console.error,
151
+ debug: console.debug
152
+ }
153
+ const noop = () => {}
154
+ Object.assign(console, {
155
+ log: noop,
156
+ info: noop,
157
+ warn: noop,
158
+ error: noop,
159
+ debug: noop
160
+ })
161
+ return fn().finally(() => Object.assign(console, original))
162
+ }
163
+
164
+ // llama.cpp writes some of its own init/inference logs straight to the real
165
+ // stdout/stderr fd (see assistant-harness/lib/sdk-loader.ts) — that bypasses
166
+ // both withConsoleSuppressed below AND bare-tui's Renderer, which only
167
+ // repaints rows whose logical content changed and has no idea those rows got
168
+ // clobbered by an external writer. Sending a synthetic resize forces the
169
+ // same full repaint Program already does on a real terminal resize, wiping
170
+ // any leaked lines. `getProgram` is a thunk because this is wired up before
171
+ // Program exists.
172
+ function requestRepaint(getProgram) {
173
+ const program = getProgram()
174
+ if (!program) return
175
+ // || not ??: a size-less pty reports 0 columns, and a 0-width resize turns
176
+ // the style width math into repeat(-2) — 0 means "unknown", not a size
177
+ program.send({
178
+ type: 'resize',
179
+ width: program.output.columns || 80,
180
+ height: program.output.rows || 24
181
+ })
182
+ }
183
+
184
+ // Where an in-process harness's model lifecycle lands: the log always, and the
185
+ // running Program's message loop when there is one — bare-tui's documented
186
+ // pattern for external code pushing messages in.
187
+ function reportModel(state, logger, getProgram) {
188
+ const pct = typeof state.percentage === 'number' ? ` ${state.percentage}%` : ''
189
+ logger.info('model', `${state.model} ${state.status}${pct}`)
190
+ const program = getProgram()
191
+ if (!program) return
192
+ program.send({ type: 'model', state })
193
+ // 'loading'/'downloading' can't have leaked logs yet; force a repaint once
194
+ // the load itself (and its native init logs) has actually happened.
195
+ if (state.status === 'ready' || state.status === 'unloaded') requestRepaint(getProgram)
196
+ }
197
+
198
+ // ctrl+y clipboard write via the platform's own pipe-to-clipboard command —
199
+ // spawned per copy, nothing printed to the tty (safe alongside bare-tui's
200
+ // renderer). Candidates tried in order; the first that exits 0 wins (linux
201
+ // terminals vary on wayland vs x11).
202
+ function copyToClipboard(text) {
203
+ const commands =
204
+ process.platform === 'darwin'
205
+ ? [['pbcopy', []]]
206
+ : process.platform === 'win32'
207
+ ? [['clip', []]]
208
+ : [
209
+ ['wl-copy', []],
210
+ ['xclip', ['-selection', 'clipboard']]
211
+ ]
212
+ return new Promise((resolve, reject) => {
213
+ const attempt = (i) => {
214
+ if (i >= commands.length) {
215
+ reject(new Error('no clipboard command available'))
216
+ return
217
+ }
218
+ const [cmd, args] = commands[i]
219
+ let child
220
+ try {
221
+ child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] })
222
+ } catch {
223
+ attempt(i + 1)
224
+ return
225
+ }
226
+ child.on('error', () => attempt(i + 1))
227
+ child.on('exit', (code) => (code === 0 ? resolve() : attempt(i + 1)))
228
+ child.stdin.write(Buffer.from(text))
229
+ child.stdin.end()
230
+ }
231
+ attempt(0)
232
+ })
233
+ }
234
+
235
+ function readClipboard() {
236
+ const commands =
237
+ process.platform === 'darwin'
238
+ ? [['pbpaste', []]]
239
+ : [
240
+ ['wl-paste', ['--no-newline']],
241
+ ['xclip', ['-o', '-selection', 'clipboard']]
242
+ ]
243
+ return new Promise((resolve, reject) => {
244
+ const attempt = (i) => {
245
+ if (i >= commands.length) {
246
+ reject(new Error('no clipboard command available'))
247
+ return
248
+ }
249
+ const [cmd, args] = commands[i]
250
+ let child
251
+ try {
252
+ child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] })
253
+ } catch {
254
+ attempt(i + 1)
255
+ return
256
+ }
257
+ const out = []
258
+ child.stdout.on('data', (data) => out.push(data))
259
+ child.on('error', () => attempt(i + 1))
260
+ child.on('exit', (code) =>
261
+ code === 0 ? resolve(Buffer.concat(out).toString()) : attempt(i + 1)
262
+ )
263
+ }
264
+ attempt(0)
265
+ })
266
+ }
267
+
268
+ const MIME_TYPES = new Map([
269
+ ['.png', 'image/png'],
270
+ ['.jpg', 'image/jpeg'],
271
+ ['.jpeg', 'image/jpeg'],
272
+ ['.gif', 'image/gif'],
273
+ ['.webp', 'image/webp'],
274
+ ['.heic', 'image/heic'],
275
+ ['.heif', 'image/heif'],
276
+ ['.pdf', 'application/pdf'],
277
+ ['.txt', 'text/plain'],
278
+ ['.md', 'text/markdown'],
279
+ ['.json', 'application/json']
280
+ ])
281
+ const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
282
+
283
+ // ctrl+v resolver: a clipboard holding a file path (or file:// URL — what
284
+ // "copy" in a file manager and drag-to-terminal produce) becomes an
285
+ // attachment; anything else is null and the shortcut just says so. Raw
286
+ // image DATA on the clipboard has no portable tty-side accessor — copy the
287
+ // file, not the pixels.
288
+ async function resolvePastedFile() {
289
+ const text = (await readClipboard()).trim()
290
+ if (!text || text.includes('\n')) return null
291
+ const file = expandFilePath(text)
292
+ let stat
293
+ try {
294
+ stat = fs.statSync(file)
295
+ } catch {
296
+ return null
297
+ }
298
+ if (!stat.isFile()) return null
299
+ return fileToAttachment(file, stat)
300
+ }
301
+
302
+ // `/attach` resolver: a named path -> attachment; throws on a bad path.
303
+ function resolveFileAttachment(pathString) {
304
+ return fileToAttachment(expandFilePath(pathString))
305
+ }
306
+
307
+ function expandFilePath(text) {
308
+ const file = text.startsWith('file://') ? decodeURI(text.slice('file://'.length)) : text
309
+ return file.startsWith('~/') ? path.join(os.homedir(), file.slice(2)) : file
310
+ }
311
+
312
+ function fileToAttachment(file, stat = fs.statSync(file)) {
313
+ if (!stat.isFile()) throw new Error('not a regular file')
314
+ if (stat.size > MAX_ATTACHMENT_BYTES) throw new Error('file too large (32MB cap)')
315
+ return {
316
+ fileName: path.basename(file),
317
+ mimeType: MIME_TYPES.get(path.extname(file).toLowerCase()) ?? 'application/octet-stream',
318
+ data: fs.readFileSync(file)
319
+ }
320
+ }
321
+
322
+ // The QVAC_INPROCESS=1 topology: Core + the harness + the QvacAssistant all
323
+ // live in THIS process, so the harness — a live JS object, which cannot cross
324
+ // the sidecar's process boundary as a spawn arg — can be injected directly.
325
+ // llama.cpp's native logs then share the TUI's tty, the reason for
326
+ // withConsoleSuppressed/requestRepaint above.
327
+ async function startInProcess({ storagePath, deviceName, modelName, logger, onModel }) {
328
+ const timer = bootTimer(storagePath, 'in-process')
329
+ timer.stamp('entry')
330
+ const imageModel = imageModelFromEnv(
331
+ process.env,
332
+ () =>
333
+ console.error(
334
+ 'QVAC_IMAGE_MODEL is a catalog choice — ignoring QVAC_IMAGE_LLM/QVAC_IMAGE_VAE/QVAC_IMAGE_PREDICTION'
335
+ ),
336
+ () =>
337
+ console.error(
338
+ 'QVAC_IMAGE_PREDICTION needs QVAC_IMAGE_LLM and QVAC_IMAGE_VAE (a FLUX set) — ignoring it'
339
+ )
340
+ )
341
+ const credentials = createCredentialStore()
342
+ const skillCatalog = await loadSkillCatalog(skillsDir)
343
+ timer.stamp('skills-catalog')
344
+ const httpRequestTool = createHttpRequestTool({
345
+ credentialStore: credentials.resolve,
346
+ credentialAllowList: credentialAllowListFor(skillCatalog)
347
+ })
348
+ const mcpCallTool = createMcpCallTool({
349
+ credentialStore: credentials.resolve,
350
+ credentialAllowList: credentialAllowListFor(skillCatalog),
351
+ readMethods: mcpReadMethods(skillCatalog)
352
+ })
353
+ const harness = new Harness({
354
+ tools: [
355
+ httpRequestTool,
356
+ createExecTool({ skillsDir }),
357
+ mcpCallTool,
358
+ // typed tools compiled from each skill's operations.json, as the sidecar does —
359
+ // without them a skill that grants sheets_create or notion_create_page is unsupported here
360
+ ...compileOperationTools(skillCatalog, { httpRequestTool, mcpCallTool }),
361
+ createSkillTool({ skillsDir }),
362
+ createWebSearchTool(),
363
+ createWebFetchTool()
364
+ ],
365
+ skillsDir,
366
+ credentialStore: credentials.resolve,
367
+ projectionModel: parseProjectionEnv(process.env.QVAC_MMPROJ),
368
+ imageModel,
369
+ imageClamps: imageClampsFromEnv(process.env)
370
+ })
371
+ await harness.ready()
372
+ timer.stamp('harness-ready')
373
+ const [serverStream, clientStream] = duplexPair()
374
+ // `device` matters: the birth op registers this device's @qvac/devices row
375
+ // (keyed by the device's public key), which the chunk/approval writer
376
+ // guards resolve against — without it, every decision is silently dropped.
377
+ const core = new Core(() => serverStream, {
378
+ logger,
379
+ storagePath,
380
+ device: { name: deviceName, capabilityProfile: 'mid', recommendedModelName: modelName },
381
+ listModels: harnessListModels(harness),
382
+ transcribe: harnessTranscribe(harness),
383
+ speak: harnessSpeak(harness),
384
+ embedder: {
385
+ model: DEFAULT_RAG_MODEL,
386
+ embed: ({ texts, signal }) => harness.embed({ model: DEFAULT_RAG_MODEL, texts, signal })
387
+ },
388
+ extractors: { office: extractOfficeText }
389
+ })
390
+ await core.ready()
391
+ timer.stamp('core-ready')
392
+ const engine = new Client(() => clientStream)
393
+ await engine.ready()
394
+ credentials.bind(engine)
395
+ const assistant = new QvacAssistant(engine, harness)
396
+ await assistant.ready()
397
+ timer.stamp('sup-ready')
398
+ const offDiagnostics = core.registerDiagnostics(() => assistant.diagnosticsSlice())
399
+ // The harness's model lifecycle is a plain EventEmitter event (see Harness's
400
+ // own doc comment); the host decides where it lands — the TUI's message loop,
401
+ // or a log line.
402
+ harness.on('model', onModel)
403
+ return {
404
+ engine,
405
+ skills: (agentId, modelName) => assistant.skills(agentId, modelName),
406
+ close: async () => {
407
+ offDiagnostics()
408
+ await assistant.close()
409
+ await engine.close()
410
+ await core.close()
411
+ await harness.close()
412
+ }
413
+ }
414
+ }
415
+
416
+ // The prod/desktop topology (the default), and the whole integration story:
417
+ // this spawns the SAME bundle the desktop app ships — dist/assistant.bundle,
418
+ // stowed from worker/assistant-sidecar.mjs by scripts/build-bundle.mjs — and
419
+ // speaks to it through a Client. The TUI used to stow a near-copy of that entry
420
+ // of its own (tui/sidecar.mjs), which is how the two drifted: the copy built
421
+ // its harness in-process while prod spawns one as a further child, and only
422
+ // prod ran the credential pusher. One entry means a break here is a break
423
+ // there. The child's stdout/stderr are pipes, so native model logs can never
424
+ // corrupt this tty — the logger is where that output goes instead: the log file
425
+ // always, and stdout too when headless.
426
+ async function startSidecar({ config, logger }) {
427
+ const bundle = fileURLToPath(new URL('../dist/assistant.bundle', import.meta.url))
428
+ if (!fs.existsSync(bundle)) {
429
+ throw new Error('assistant bundle missing — run `npm run build:bundle`')
430
+ }
431
+ // Both bundles: the assistant spawns the harness, and a published install has
432
+ // neither forest on disk until now.
433
+ ensurePrebuilds(bundle)
434
+ ensurePrebuilds(config.harnessEntry)
435
+ const engine = new Client(async () => {
436
+ const { ipc, exit } = sidecarRunner(bundle, [JSON.stringify(config)], logger)
437
+ // the loser resolves rather than rejects: an armed rejection outlives the
438
+ // race, and the child's own clean exit at teardown would then go unhandled
439
+ const died = exit.then((code) => `sidecar exited (${code}) before ready`)
440
+ const failure = await Promise.race([ipc.ready.then(() => null), died])
441
+ if (failure) throw new Error(failure)
442
+ return ipc
443
+ })
444
+ await engine.ready()
445
+ return {
446
+ engine,
447
+ close: () => engine.close(),
448
+ skills: async (agentId, modelName) => (await engine.skills({ agentId, modelName })).skills ?? []
449
+ }
450
+ }
451
+
452
+ // CLI: `duck [--storage <dir>] [--temp] [--headless]`. Flags may appear in any
453
+ // order. Precedence for the storage dir: --storage <dir> > QVAC_STORAGE env >
454
+ // --temp/QVAC_TEMP=1 throwaway > the persistent global default (below).
455
+ // Joining a mesh is /join inside the app, not a boot argument — the engine
456
+ // takes an invite live (engine.joinMesh), so it never needed to be one.
457
+ function parseArgs(argv) {
458
+ let storageDir = null
459
+ let temp = false
460
+ let headless = false
461
+ for (let i = 0; i < argv.length; i++) {
462
+ const arg = argv[i]
463
+ if (arg === '--temp') temp = true
464
+ else if (arg === '--headless') headless = true
465
+ else if (arg === '--storage') storageDir = argv[++i] ?? null
466
+ else if (arg.startsWith('--storage=')) storageDir = arg.slice('--storage='.length)
467
+ }
468
+ return { storageDir, temp, headless }
469
+ }
470
+
471
+ // Boot-timing report (QVAC_BOOT_TIMING=1): per-stage stamps from the
472
+ // sidecar/in-process bring-up (see lib/boot-timing.mjs), plus the host's own
473
+ // connect wall time. The TUI calls this only once it has released the tty, so
474
+ // it never fights bare-tui's live render.
475
+ function printBootTiming(storagePath, connectMs) {
476
+ if (process.env.QVAC_BOOT_TIMING !== '1') return
477
+ const lines = [
478
+ connectMs === null ? null : `host: "starting up…" (connect) = ${connectMs}ms`,
479
+ readBootTiming(storagePath)
480
+ ].filter(Boolean)
481
+ if (lines.length) console.log(`\nboot timing:\n${lines.join('\n')}`)
482
+ }
483
+
484
+ async function main() {
485
+ const args = parseArgs(process.argv.slice(2))
486
+ // Only override the agent's model when QVAC_MODEL is EXPLICITLY set — otherwise
487
+ // resuming a shared mesh would silently rewrite the desktop's agent to this
488
+ // demo default. `recommendedModel` is just the device-row hint on first mint.
489
+ const modelOverride = process.env.QVAC_MODEL || null
490
+ const recommendedModel = modelOverride || 'Qwen3.5-9B-Q4_K_M'
491
+ // Default storage IS the desktop app's own release root, so the TUI resumes
492
+ // the same mesh + chats the desktop persists there. One at a time — quit the
493
+ // desktop first, the corestore is single-writer; only safe across matching
494
+ // Core/schema versions. configureSdkStorage points the SDK at this root's
495
+ // private sdk-home and the shared model cache, before the SDK's first load so
496
+ // spawned children inherit both.
497
+ const useTemp = args.temp || process.env.QVAC_TEMP === '1'
498
+ const explicitRoot =
499
+ args.storageDir ||
500
+ process.env.QVAC_STORAGE ||
501
+ (useTemp ? fs.mkdtempSync(path.join(os.tmpdir(), 'duck-')) : null)
502
+ const environment = process.env.QVAC_ENV || 'local'
503
+ const storage = resolveStorage(releaseChannel(environment), {
504
+ root: explicitRoot,
505
+ models: process.env[MODEL_CACHE_PATH_ENV] ?? null
506
+ })
507
+ const storagePath = storage.assistant
508
+ fs.mkdirSync(storagePath, { recursive: true })
509
+ configureSdkStorage(storage.sdk)
510
+ let program
511
+ // reassigned to the engine's close() once connect resolves; noop until then
512
+ let teardown = () => {}
513
+ const deviceName = `duck-${process.pid}`
514
+ // Every run logs; only the destination differs. Both modes write
515
+ // <storage.root>/assistant.log — the same file the desktop app writes, and
516
+ // the only way to read a TUI session's engine and sidecar output, since
517
+ // anything printed outside bare-tui's renderer corrupts the frame. Headless
518
+ // owns nothing on the tty, so it streams to stdout as well.
519
+ const logFile = process.env.QVAC_LOG_FILE || path.join(storage.root, 'assistant.log')
520
+ const logger = new Logger(
521
+ toLoggerOptions({ logFile, logLevel: process.env.QVAC_LOG_LEVEL, logVerbose: args.headless })
522
+ )
523
+
524
+ // The whole engine bring-up runs INSIDE the TUI (ChatApp calls this from
525
+ // init()), so the terminal shows the app's own "starting up…" frame instead
526
+ // of a pre-render status line — console is suppressed for run()'s lifetime
527
+ // below, so the SDK's boot logs can't corrupt the render. Sidecar is the
528
+ // default (desktop topology); QVAC_INPROCESS=1 keeps everything in this
529
+ // process. The app never mints an agent: a fresh mesh seeds its default at
530
+ // birth (contract/seed-defaults.ts) and a joiner waits for it to replicate;
531
+ // the provider applies the QVAC_MODEL override only when one is set (below).
532
+ // Wall time of the whole "starting up…" frame (connect start → resolve), for
533
+ // the boot-timing report printed after the TUI exits (QVAC_BOOT_TIMING=1).
534
+ let connectMs = null
535
+ const connect = async () => {
536
+ const connectStart = Date.now()
537
+ const { engine, close, skills } =
538
+ process.env.QVAC_INPROCESS === '1'
539
+ ? await startInProcess({
540
+ storagePath,
541
+ deviceName,
542
+ modelName: recommendedModel,
543
+ logger,
544
+ onModel: (state) => reportModel(state, logger, () => program)
545
+ })
546
+ : await startSidecar({
547
+ // worker/assistant-sidecar.mjs's own contract, verbatim: it resolves
548
+ // its storage, logger and image set from this, and spawns the
549
+ // harness child from harnessEntry. Everything it leaves out keeps
550
+ // that entry's default (llm kv-cache, hostsModelSource, eager).
551
+ config: {
552
+ environment,
553
+ // ALREADY resolved, not the overrides this host resolved from.
554
+ // worker/assistant-sidecar.mjs calls resolveStorage again, and by
555
+ // then configureSdkStorage (below) has exported SDK_HOME_ENV,
556
+ // which resolveStorage reads as the base a relative root hangs
557
+ // off: a null root would re-resolve to <root>/sdk-home/qvac/<ch>
558
+ // — a second, empty mesh beside the real one, with an empty model
559
+ // cache to match. Passing both values makes that call a
560
+ // pass-through.
561
+ storage: { root: storage.root, models: storage.sdk.models },
562
+ logFlags: {
563
+ logFile,
564
+ logLevel: process.env.QVAC_LOG_LEVEL,
565
+ logVerbose: args.headless
566
+ },
567
+ skillsDir,
568
+ harnessEntry,
569
+ // only the image keys cross: the arg is argv on a spawned process,
570
+ // readable by anything that can list processes
571
+ imageEnv: Object.fromEntries(
572
+ Object.entries(process.env).filter(([key]) => key.startsWith('QVAC_IMAGE_'))
573
+ ),
574
+ device: {
575
+ name: deviceName,
576
+ capabilityProfile: 'mid',
577
+ recommendedModelName: recommendedModel
578
+ }
579
+ },
580
+ logger
581
+ })
582
+ teardown = close
583
+ // The device id IS the device's public key — the raw 32-byte buffer end to
584
+ // end (schema fixed32), never a hex string.
585
+ const deviceId = (await engine.deviceInfo({})).id
586
+ const meshInvite = (await engine.meshInvite({})).invite ?? null
587
+ connectMs = Date.now() - connectStart
588
+ return { engine, skills, deviceId, meshInvite, modelName: modelOverride }
589
+ }
590
+
591
+ // Headless: the same stack with no TUI at all. Bring the engine up, print the
592
+ // invite another instance joins with, then park — the engine's own handles
593
+ // hold the loop open and its logs stream to stdout until a signal arrives.
594
+ if (args.headless) {
595
+ // armed before connect, not after: a ctrl+C partway through a first-run
596
+ // model download still unwinds cleanly (teardown is a noop until then)
597
+ const shutdown = async () => {
598
+ await teardown()
599
+ Bare.exit(0)
600
+ }
601
+ process.on('SIGINT', () => void shutdown())
602
+ process.on('SIGTERM', () => void shutdown())
603
+ const { deviceId, meshInvite } = await connect()
604
+ console.log(`storage ${storagePath}`)
605
+ console.log(`log ${logFile}`)
606
+ console.log(`device ${deviceName} ${deviceId.toString('hex')}`)
607
+ console.log(`invite ${meshInvite ?? '(none — mesh not writable)'}`)
608
+ printBootTiming(storagePath, connectMs)
609
+ return
610
+ }
611
+
612
+ const app = new ChatApp({
613
+ connect,
614
+ requestRepaint: () => requestRepaint(() => program),
615
+ copyToClipboard,
616
+ resolvePastedFile,
617
+ resolveFileAttachment
618
+ })
619
+ // wheel + drag reports (1002+1006) instead of the terminal turning the wheel
620
+ // into ↑/↓ keys: the transcript scrolls, and it selects its own text (the
621
+ // terminal can't while tracking is on) — see ChatApp._select
622
+ program = new Program(app, { mouse: 'drag' })
623
+ // bracketed paste: a paste arrives as one paste-start/…/paste-end block
624
+ // instead of per-line key events submitting each pasted line
625
+ program.output.write('\x1b[?2004h')
626
+ try {
627
+ await withConsoleSuppressed(() => {
628
+ const running = program.run()
629
+ // run() builds the key decoder synchronously, and bare-tui gives no way to
630
+ // configure it: a lone esc waits out its 500ms sequence timeout, which is
631
+ // the visible beat before an overlay closes.
632
+ if (program._decoder) program._decoder._escapeDelay = ESCAPE_TIMEOUT_MS
633
+ return running
634
+ })
635
+ } finally {
636
+ program.output.write('\x1b[?2004l')
637
+ }
638
+
639
+ await teardown()
640
+ printBootTiming(storagePath, connectMs)
641
+ // Quitting is the goal — don't idle the terminal waiting for the child to
642
+ // unwind a blocking native call (e.g. a model still loading) before its loop
643
+ // drains; close() has already torn the sidecar down.
644
+ Bare.exit(0)
645
+ }
646
+
647
+ main().catch((err) => {
648
+ console.error(err)
649
+ Bare.exit(1)
650
+ })
@@ -0,0 +1,41 @@
1
+ // Opt-in boot-stage timing for the chat demo. Set QVAC_BOOT_TIMING=1 and the
2
+ // bring-up (sidecar child or in-process) stamps each startup milestone to
3
+ // <storagePath>/boot-timing.log; index.mjs reads + prints it after the TUI exits
4
+ // (writing to a file, not the tty, so it can't corrupt bare-tui's live render).
5
+ // A no-op when the env flag is unset, so it never costs anything by default.
6
+ import fs from 'bare-fs'
7
+ import path from 'path'
8
+ import process from 'process'
9
+
10
+ const NOOP = { enabled: false, stamp() {} }
11
+
12
+ export function bootTimer(storagePath, tag) {
13
+ if (process.env.QVAC_BOOT_TIMING !== '1') return NOOP
14
+ const file = path.join(storagePath, 'boot-timing.log')
15
+ const t0 = Date.now()
16
+ try {
17
+ fs.writeFileSync(file, `# ${tag} boot @ ${new Date().toISOString()}\n`)
18
+ } catch {}
19
+ return {
20
+ enabled: true,
21
+ stamp(label) {
22
+ try {
23
+ fs.appendFileSync(file, `+${Date.now() - t0}ms\t${label}\n`)
24
+ } catch {}
25
+ }
26
+ }
27
+ }
28
+
29
+ // Read + consume the log the timer wrote (deleted after reading so each launch
30
+ // starts clean). Null when timing is off or nothing was stamped.
31
+ export function readBootTiming(storagePath) {
32
+ const file = path.join(storagePath, 'boot-timing.log')
33
+ try {
34
+ if (!fs.existsSync(file)) return null
35
+ const text = fs.readFileSync(file, 'utf8').trim()
36
+ fs.unlinkSync(file)
37
+ return text || null
38
+ } catch {
39
+ return null
40
+ }
41
+ }