@qvac/core 0.1.1 → 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/README.md +2 -2
- package/dist/assistant.bundle +5 -5
- package/package.json +7 -4
- package/tui/README.md +133 -0
- package/tui/app.mjs +4179 -0
- package/tui/index.mjs +650 -0
- package/tui/lib/boot-timing.mjs +41 -0
- package/tui/lib/busy-words.mjs +64 -0
- package/tui/lib/export.mjs +125 -0
- package/tui/lib/knowledge-command.mjs +144 -0
- package/tui/lib/knowledge-import.mjs +135 -0
- package/tui/lib/prebuilds.mjs +81 -0
- package/tui/lib/projection-env.mjs +24 -0
- package/tui/lib/render.mjs +136 -0
- package/tui/lib/save-file.mjs +71 -0
- package/tui/lib/select.mjs +63 -0
- package/tui/lib/setup.mjs +109 -0
- package/tui/lib/sidebar.mjs +52 -0
- package/tui/lib/splash-art.mjs +107 -0
- package/tui/lib/splash.mjs +19 -0
- package/tui/lib/upload-frames.mjs +10 -0
- package/tui/lib/wrap.mjs +114 -0
- package/ducks.png +0 -0
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
|
+
}
|