@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.
@@ -0,0 +1,64 @@
1
+ // What the busy line says while a run is in flight, in place of `thinking…`.
2
+ // Every word has to read as a mind at work first and a duck second — so the
3
+ // real words here already mean thinking or grinding (Pondering, Brooding,
4
+ // Fathoming, Sifting) and merely happen to be pond-, egg-, or water-flavoured,
5
+ // and the invented ones are a duck noise wrapped around a verb that is plainly
6
+ // work (Quackompiling, Quacksmithing). Things a duck simply does — bobbing,
7
+ // drifting, moulting, honking — are not busy words and do not belong here.
8
+ export const WORDS = [
9
+ 'Angling',
10
+ 'Beakstorming',
11
+ 'Bill-shuffling',
12
+ 'Brooding',
13
+ 'Charting',
14
+ 'Dabbling',
15
+ 'Deliberating',
16
+ 'Diving',
17
+ 'Dredging',
18
+ 'Fathoming',
19
+ 'Featherweighing',
20
+ 'Foraging',
21
+ 'Hatching',
22
+ 'Incubating',
23
+ 'Marinating',
24
+ 'Marshalling',
25
+ 'Mulling',
26
+ 'Navigating',
27
+ 'Noodling',
28
+ 'Paddling',
29
+ 'Percolating',
30
+ 'Plotting',
31
+ 'Pondering',
32
+ 'Pondulating',
33
+ 'Preening',
34
+ 'Puzzling',
35
+ 'Quackogitating',
36
+ 'Quackompiling',
37
+ 'Quackrunching',
38
+ 'Quacksmithing',
39
+ 'Quackstorming',
40
+ 'Quackulating',
41
+ 'Ruminating',
42
+ 'Scheming',
43
+ 'Sifting',
44
+ 'Simmering',
45
+ 'Skimming',
46
+ 'Sounding',
47
+ 'Steeping',
48
+ 'Trawling',
49
+ 'Untangling',
50
+ 'Waddlewrangling',
51
+ 'Weighing',
52
+ 'Wrangling'
53
+ ]
54
+
55
+ // Keyed off the run so the word holds for that whole run instead of flickering
56
+ // on every repaint, and so two runs rarely land on the same one. No key (a
57
+ // state with no run behind it) is the plain word.
58
+ export function busyWord(runId) {
59
+ if (!runId) return 'Thinking…'
60
+ const key = typeof runId === 'string' ? runId : runId.toString('hex')
61
+ let hash = 0
62
+ for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) >>> 0
63
+ return `${WORDS[hash % WORDS.length]}…`
64
+ }
@@ -0,0 +1,125 @@
1
+ // The transcript as markdown: one section per run, thinking and answer clearly
2
+ // marked so an exported file can be read (and reported on) away from the TUI.
3
+
4
+ const STATUS = { completed: 'completed', failed: 'failed', canceled: 'canceled' }
5
+
6
+ function quote(text) {
7
+ return text
8
+ .split('\n')
9
+ .map((line) => `> ${line}`)
10
+ .join('\n')
11
+ }
12
+
13
+ function json(value) {
14
+ return ['```json', JSON.stringify(value ?? null, null, 2), '```'].join('\n')
15
+ }
16
+
17
+ // A model is a registry name or a path/URL to the weights — the file is what
18
+ // identifies the run either way, so the directories never reach the report.
19
+ function modelLabel(model) {
20
+ return model.split(/[?#]/)[0].split(/[\\/]/).pop() || model
21
+ }
22
+
23
+ function metricsLine(turn) {
24
+ const metrics = turn.statusDetail?.metrics
25
+ if (!metrics) return null
26
+ const parts = [`${metrics.totalTokens} tokens`]
27
+ if (metrics.tokensPerSecond) parts.push(`${metrics.tokensPerSecond.toFixed(1)} tok/s`)
28
+ if (metrics.timeToFirstToken) parts.push(`ttft ${Math.round(metrics.timeToFirstToken)}ms`)
29
+ if (metrics.cacheTokens) parts.push(`${metrics.cacheTokens} cached`)
30
+ return `_${parts.join(' · ')}_`
31
+ }
32
+
33
+ // Retrieval returns CHUNKS, so one document can appear many times over — say
34
+ // how many, and give each its index, score and the text the model actually read.
35
+ function sourcesSection(detail) {
36
+ const rows = detail.sources ?? []
37
+ if (!rows.length) return ['### Sources', '', detail.notice ?? '(none)']
38
+ const documents = new Set(rows.map((source) => source.documentId ?? source.title)).size
39
+ const chunks = `${rows.length} ${rows.length === 1 ? 'chunk' : 'chunks'}`
40
+ const from = `${documents} ${documents === 1 ? 'document' : 'documents'}`
41
+ const lines = [`### Sources — ${chunks} from ${from}`, '']
42
+ for (const source of rows) {
43
+ const score = typeof source.score === 'number' ? ` · score ${source.score.toFixed(2)}` : ''
44
+ lines.push(`- **${source.title}** — chunk ${source.chunkIndex}${score}`)
45
+ if (source.url) lines.push(` ${source.url}`)
46
+ if (source.excerpt) lines.push('', quote(source.excerpt.trim()).replace(/^/gm, ' '), '')
47
+ }
48
+ return lines
49
+ }
50
+
51
+ function segmentSection(segment) {
52
+ const { type, detail } = segment
53
+ if (type === 'thinking') return ['### Thinking', '', quote(segment.text.trim())]
54
+ if (type === 'content') return ['### Answer', '', segment.text.trim()]
55
+ if (type === 'tool-call') {
56
+ return [`### Tool call — \`${detail.name}\``, '', json(detail.arguments)]
57
+ }
58
+ if (type === 'tool-result') {
59
+ return [`### Tool result — \`${detail.name}\``, '', json(detail.result)]
60
+ }
61
+ if (type === 'attachment') {
62
+ return [`- **attachment:** ${detail.fileName} (${detail.mimeType}, ${detail.byteLength}B)`]
63
+ }
64
+ if (type === 'sources') return sourcesSection(detail)
65
+ if (type === 'approval-request') {
66
+ const call = detail.resource
67
+ ? `${detail.name} → ${detail.resource.kind} ${detail.resource.label}`
68
+ : `${detail.name}(${JSON.stringify(detail.arguments)})`
69
+ return [`### Approval — ${detail.status}`, '', call]
70
+ }
71
+ return ['### Answer', '', (segment.text ?? '').trim()]
72
+ }
73
+
74
+ function agentSection(turn, index) {
75
+ const status = STATUS[turn.status] ?? turn.status ?? 'in flight'
76
+ const lines = [`## Run ${index} — ${status}`, '']
77
+ for (const segment of turn.segments) lines.push(...segmentSection(segment), '')
78
+ if (turn.status === 'failed') lines.push(`> **failed:** ${turn.statusReason ?? 'unknown'}`, '')
79
+ const metrics = metricsLine(turn)
80
+ if (metrics) lines.push(metrics, '')
81
+ return lines
82
+ }
83
+
84
+ function userSection(turn) {
85
+ const lines = ['## Prompt', '']
86
+ for (const segment of turn.segments) {
87
+ if (segment.type === 'attachment') lines.push(...segmentSection(segment), '')
88
+ else lines.push((segment.text ?? '').trim(), '')
89
+ }
90
+ return lines
91
+ }
92
+
93
+ // The turn index the export starts at: the nth-from-last run, plus whatever the
94
+ // user said right before it.
95
+ function startOfRuns(turns, runs) {
96
+ const runAt = []
97
+ for (const [index, turn] of turns.entries()) {
98
+ if (turn.from === 'agent' && (turn.segments.length || turn.status)) runAt.push(index)
99
+ }
100
+ if (!runAt.length) return -1
101
+ let start = runAt[Math.max(0, runAt.length - runs)]
102
+ while (start > 0 && turns[start - 1].from === 'user') start--
103
+ return start
104
+ }
105
+
106
+ export function exportMarkdown(turns, runs = 1, meta = {}) {
107
+ const start = startOfRuns(turns, runs)
108
+ if (start < 0) return null
109
+ const lines = [`# ${meta.title ?? 'Chat'}`, '']
110
+ if (meta.chatId) lines.push(`- **chat:** \`${meta.chatId}\``)
111
+ if (meta.agentName) lines.push(`- **agent:** ${meta.agentName}`)
112
+ if (meta.modelName) lines.push(`- **model:** ${modelLabel(meta.modelName)}`)
113
+ lines.push(`- **exported:** ${new Date(meta.at ?? Date.now()).toISOString()}`, '')
114
+
115
+ let index = 0
116
+ for (const turn of turns.slice(start)) {
117
+ lines.push('---', '')
118
+ if (turn.from === 'user') lines.push(...userSection(turn))
119
+ else lines.push(...agentSection(turn, ++index))
120
+ }
121
+ return `${lines
122
+ .join('\n')
123
+ .replace(/\n{3,}/g, '\n\n')
124
+ .trim()}\n`
125
+ }
@@ -0,0 +1,144 @@
1
+ import b4a from 'b4a'
2
+ import { importKnowledgeDirectory, importKnowledgeFile } from './knowledge-import.mjs'
3
+ import { saveUniqueStream } from './save-file.mjs'
4
+
5
+ const USAGE =
6
+ 'usage: /knowledge [list] · /knowledge add <file> · /knowledge add-dir <directory> · /knowledge download <n|id> · /knowledge remove <n|id>'
7
+
8
+ export async function knowledgeCommand(engine, { chatId, saveDir }, input) {
9
+ try {
10
+ return { type: 'notice', text: await execute(engine, chatId, saveDir, input) }
11
+ } catch (error) {
12
+ return { type: 'run.error', error }
13
+ }
14
+ }
15
+
16
+ async function execute(engine, chatId, saveDir, input) {
17
+ const { action, argument } = parseInput(input)
18
+ if ((!action || action === 'list') && !argument) {
19
+ const [{ knowledge }, { statuses }] = await Promise.all([
20
+ engine.listKnowledge({ chatId }),
21
+ engine.listIndexing({ chatId })
22
+ ])
23
+ return formatKnowledge(sortKnowledge(knowledge ?? []), statuses ?? [])
24
+ }
25
+ if (action === 'add' || action === 'add-dir') {
26
+ if (!argument) return USAGE
27
+ if (action === 'add') {
28
+ const added = await importKnowledgeFile(engine, chatId, argument)
29
+ return `uploaded ${singleLine(added.fileName)} (${added.byteLength}B) · run /knowledge to track indexing`
30
+ }
31
+ return formatDirectoryImport(await importKnowledgeDirectory(engine, chatId, argument))
32
+ }
33
+
34
+ const [selector, ...extra] = argument.split(/\s+/)
35
+ if ((action !== 'download' && action !== 'remove') || !selector || extra.length) return USAGE
36
+
37
+ const { knowledge } = await engine.listKnowledge({ chatId })
38
+ const selected = resolveKnowledge(sortKnowledge(knowledge ?? []), selector)
39
+ if (selected.error) return selected.error
40
+ const row = selected.knowledge
41
+ if (action === 'remove') {
42
+ await engine.removeKnowledge({ knowledgeId: row.id, at: Date.now() })
43
+ return `removed ${singleLine(row.fileName)} (${row.id.slice(0, 8)})`
44
+ }
45
+
46
+ const saved = await saveUniqueStream(
47
+ saveDir,
48
+ row.fileName,
49
+ frameData(engine.downloadKnowledge({ blobId: row.blobId }))
50
+ )
51
+ if (saved) return `saved ${singleLine(row.fileName)} (${saved.bytesWritten}B) to ${saved.file}`
52
+ return `not saved: 100 files named ${singleLine(row.fileName)} already exist in ${saveDir}`
53
+ }
54
+
55
+ function parseInput(input) {
56
+ const value = input.trim()
57
+ const separator = value.search(/\s/)
58
+ if (separator === -1) return { action: value, argument: '' }
59
+ return {
60
+ action: value.slice(0, separator),
61
+ argument: value.slice(separator).trimStart()
62
+ }
63
+ }
64
+
65
+ function formatDirectoryImport(result) {
66
+ if (!result.added.length && !result.failed.length) {
67
+ return `no supported knowledge files in ${result.directory}`
68
+ }
69
+ const lines = [
70
+ `uploaded ${result.added.length} knowledge file${result.added.length === 1 ? '' : 's'}`
71
+ ]
72
+ for (const row of result.added) {
73
+ lines.push(` + ${singleLine(row.fileName)} (${row.byteLength}B)`)
74
+ }
75
+ for (const row of result.failed) {
76
+ lines.push(` ! ${singleLine(row.fileName)} · ${singleLine(row.error)}`)
77
+ }
78
+ if (result.skipped.length) {
79
+ lines.push(
80
+ ` - skipped ${result.skipped.length} unsupported ${result.skipped.length === 1 ? 'entry' : 'entries'}`
81
+ )
82
+ }
83
+ if (result.added.length) lines.push('run /knowledge to track indexing')
84
+ return lines.join('\n')
85
+ }
86
+
87
+ function formatKnowledge(knowledge, statuses) {
88
+ if (!knowledge.length) return 'no knowledge in this chat'
89
+ const byKnowledge = new Map()
90
+ for (const row of statuses) {
91
+ const rows = byKnowledge.get(row.knowledgeId) ?? []
92
+ rows.push(row)
93
+ byKnowledge.set(row.knowledgeId, rows)
94
+ }
95
+ return knowledge
96
+ .map((row, index) => formatKnowledgeRow(row, index, byKnowledge.get(row.id) ?? []))
97
+ .join('\n')
98
+ }
99
+
100
+ function formatKnowledgeRow(knowledge, index, statuses) {
101
+ const name = singleLine(knowledge.fileName) || '(unnamed)'
102
+ const lines = [`${index + 1}. ${name} (${knowledge.byteLength}B) · ${knowledge.id.slice(0, 8)}`]
103
+ const sorted = [...statuses].sort((a, b) => a.id.localeCompare(b.id))
104
+ if (!sorted.length) lines.push(' - not indexed')
105
+ for (const row of sorted) lines.push(` - ${formatStatus(row)}`)
106
+ return lines.join('\n')
107
+ }
108
+
109
+ function formatStatus(row) {
110
+ const status = row.status === 'indexing' && !row.claimLive ? 'expired' : row.status
111
+ let detail = `${status} · artifact ${row.id.slice(0, 8)}`
112
+ if (row.status === 'indexing' && row.claimLive) {
113
+ detail += ` · claimed by ${b4a.toString(row.claimedBy, 'hex').slice(0, 8)}`
114
+ }
115
+ if (row.status === 'failed' && row.error) detail += ` · ${singleLine(row.error)}`
116
+ return detail
117
+ }
118
+
119
+ function sortKnowledge(rows) {
120
+ return [...rows].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
121
+ }
122
+
123
+ function resolveKnowledge(rows, selector) {
124
+ const exact = rows.find((row) => row.id === selector)
125
+ if (exact) return { knowledge: exact }
126
+ if (/^\d+$/.test(selector)) {
127
+ const index = Number(selector) - 1
128
+ return rows[index] ? { knowledge: rows[index] } : { error: `no knowledge #${selector}` }
129
+ }
130
+ const matches = rows.filter((row) => row.id.startsWith(selector))
131
+ if (matches.length === 1) return { knowledge: matches[0] }
132
+ if (matches.length > 1) return { error: `ambiguous knowledge id: ${selector}` }
133
+ return { error: `no knowledge matching: ${selector}` }
134
+ }
135
+
136
+ function singleLine(value) {
137
+ return String(value).replace(/\s+/g, ' ').trim()
138
+ }
139
+
140
+ async function* frameData(frames) {
141
+ for await (const frame of frames) {
142
+ if (frame.data) yield frame.data
143
+ }
144
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { RAG_ACCEPT_EXTENSIONS } from '../../dist-lib/lib/core/files/rag/extract/document-types.js'
6
+ import { UPLOAD_FRAME_BYTES, writeFrame } from './upload-frames.mjs'
7
+ const ACCEPTED = new Set(RAG_ACCEPT_EXTENSIONS)
8
+ const MIME_TYPES = new Map([
9
+ ['.pdf', 'application/pdf'],
10
+ ['.txt', 'text/plain'],
11
+ ['.md', 'text/markdown'],
12
+ ['.markdown', 'text/markdown'],
13
+ ['.rtf', 'application/rtf'],
14
+ ['.html', 'text/html'],
15
+ ['.htm', 'text/html'],
16
+ ['.xhtml', 'application/xhtml+xml'],
17
+ ['.log', 'text/plain'],
18
+ ['.json', 'application/json'],
19
+ ['.jsonl', 'application/x-ndjson'],
20
+ ['.csv', 'text/csv'],
21
+ ['.tsv', 'text/tab-separated-values'],
22
+ ['.xml', 'application/xml'],
23
+ ['.yaml', 'application/yaml'],
24
+ ['.yml', 'application/yaml'],
25
+ // accepted everywhere: a device without the engine waits for a peer to publish the index
26
+ ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
27
+ ['.doc', 'application/msword'],
28
+ ['.odt', 'application/vnd.oasis.opendocument.text']
29
+ ])
30
+
31
+ export async function importKnowledgeFile(engine, chatId, inputPath) {
32
+ const file = resolvePath(inputPath)
33
+ const stat = await fs.promises.stat(file)
34
+ if (!stat.isFile()) throw new Error(`not a file: ${file}`)
35
+ if (!supported(file)) throw new Error(`unsupported knowledge file: ${path.basename(file)}`)
36
+ return upload(engine, chatId, file, stat.size)
37
+ }
38
+
39
+ export async function importKnowledgeDirectory(engine, chatId, inputPath) {
40
+ const directory = resolvePath(inputPath)
41
+ const stat = await fs.promises.stat(directory)
42
+ if (!stat.isDirectory()) throw new Error(`not a directory: ${directory}`)
43
+
44
+ const added = []
45
+ const failed = []
46
+ const skipped = []
47
+ const names = (await fs.promises.readdir(directory)).sort()
48
+ for (const name of names) {
49
+ const file = path.join(directory, name)
50
+ let fileStat
51
+ try {
52
+ fileStat = await fs.promises.lstat(file)
53
+ } catch (error) {
54
+ failed.push({ fileName: name, error: errorMessage(error) })
55
+ continue
56
+ }
57
+ if (!fileStat.isFile() || !supported(file)) {
58
+ skipped.push(name)
59
+ continue
60
+ }
61
+ try {
62
+ added.push(await upload(engine, chatId, file, fileStat.size))
63
+ } catch (error) {
64
+ failed.push({ fileName: name, error: errorMessage(error) })
65
+ }
66
+ }
67
+ return { directory, added, failed, skipped }
68
+ }
69
+
70
+ function resolvePath(input) {
71
+ const value = input.trim()
72
+ if (value.startsWith('file://')) return fileURLToPath(value)
73
+ if (value === '~') return os.homedir()
74
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2))
75
+ return path.resolve(value)
76
+ }
77
+
78
+ function supported(file) {
79
+ return ACCEPTED.has(path.extname(file).toLowerCase())
80
+ }
81
+
82
+ async function upload(engine, chatId, file, byteLength) {
83
+ const fileName = path.basename(file)
84
+ const extension = path.extname(file).toLowerCase()
85
+ const stream = engine.uploadKnowledge()
86
+ let responseError = null
87
+ const response = readFinal(stream).catch((error) => {
88
+ responseError = error
89
+ safeDestroy(stream)
90
+ return null
91
+ })
92
+ const source = fs.createReadStream(file, { highWaterMark: UPLOAD_FRAME_BYTES })
93
+
94
+ try {
95
+ const metadataWritten = await writeFrame(stream, {
96
+ chatId,
97
+ fileName,
98
+ mimeType: MIME_TYPES.get(extension) ?? 'application/octet-stream',
99
+ totalBytes: byteLength
100
+ })
101
+ if (!metadataWritten) throw responseError ?? new Error('knowledge upload closed while writing')
102
+ for await (const data of source) {
103
+ if (responseError) throw responseError
104
+ const dataWritten = await writeFrame(stream, { data })
105
+ if (!dataWritten) throw responseError ?? new Error('knowledge upload closed while writing')
106
+ }
107
+ stream.end()
108
+ const final = await response
109
+ if (responseError) throw responseError
110
+ if (!final?.knowledge) throw new Error('knowledge upload ended without a knowledge row')
111
+ return { fileName, byteLength, knowledge: final.knowledge }
112
+ } catch (error) {
113
+ source.destroy()
114
+ safeDestroy(stream)
115
+ throw error
116
+ }
117
+ }
118
+
119
+ async function readFinal(stream) {
120
+ let final = null
121
+ for await (const frame of stream) final = frame
122
+ return final
123
+ }
124
+
125
+ function safeDestroy(stream) {
126
+ try {
127
+ stream.destroy()
128
+ } catch {
129
+ // Cleanup must not replace the file or transport error.
130
+ }
131
+ }
132
+
133
+ function errorMessage(error) {
134
+ return error instanceof Error ? error.message : String(error)
135
+ }
@@ -0,0 +1,81 @@
1
+ // A stowed bundle carries its JS inline but NOT its native addons: bare-stow
2
+ // offloads every `.bare` to real files beside the bundle (its collectOffloaded
3
+ // writes them relative to the bundle's own directory) because dlopen cannot
4
+ // read a path inside a bundle. scripts/build-bundle.mjs deliberately leaves
5
+ // that forest out of the published tarball — it is per-host binaries the
6
+ // consumer's node_modules already carries — and ships
7
+ // dist/prebuilds-manifest.json naming what to put back.
8
+ //
9
+ // Putting it back is what this does, and it is why `duck` can run the same
10
+ // dist/assistant.bundle the desktop app ships. A repo build leaves the real
11
+ // forest in dist/node_modules, so every link is already there and this costs
12
+ // one readdir; a published install has the manifest and nothing else.
13
+ import fs from 'bare-fs'
14
+ import os from 'bare-os'
15
+ import path from 'path'
16
+
17
+ const HOST = `${os.platform()}-${os.arch()}`
18
+
19
+ // Only this machine's prebuilds — the manifest names all six hosts the bundle
20
+ // was built to serve, and the other five are dead weight on a consumer's disk.
21
+ export function ensurePrebuilds(bundle) {
22
+ const dir = path.dirname(bundle)
23
+ const manifest = path.join(dir, 'prebuilds-manifest.json')
24
+ if (!fs.existsSync(manifest)) return
25
+ const { packages } = JSON.parse(fs.readFileSync(manifest, 'utf8'))
26
+ const missing = []
27
+ for (const [pkgPath, hosts] of Object.entries(packages)) {
28
+ if (!hosts.includes(HOST)) continue
29
+ const dest = path.join(dir, 'node_modules', pkgPath, 'prebuilds', HOST)
30
+ if (fs.existsSync(dest)) continue
31
+ const source = resolvePrebuilds(dir, pkgPath)
32
+ if (source === null) {
33
+ missing.push(pkgPath)
34
+ continue
35
+ }
36
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
37
+ place(source, dest)
38
+ }
39
+ // A missing addon is a model load that fails much later with nothing pointing
40
+ // here, so refuse to spawn instead.
41
+ if (missing.length > 0) {
42
+ throw new Error(
43
+ `prebuilds for ${HOST} not found in node_modules: ${missing.join(', ')} — ` +
44
+ 'reinstall the package (a partial install cannot serve the assistant bundle)'
45
+ )
46
+ }
47
+ }
48
+
49
+ // The same upward walk bare's own resolver does, so a nested manifest key
50
+ // (bare-tty/node_modules/bare-signals) finds the exact copy it names before the
51
+ // hoisted one. That precedence is the whole point: bare-signals is installed
52
+ // twice at different major versions, and linking the nested slot to the hoisted
53
+ // binary loads an addon whose ABI its JS does not match — the failure reads as
54
+ // the addon's constants being undefined, nowhere near here.
55
+ //
56
+ // `from` itself is skipped because its node_modules IS the forest being built:
57
+ // searching it would resolve a key to a link this same run just made, which is
58
+ // how the hoisted binary reached the nested slot in the first place.
59
+ function resolvePrebuilds(from, pkgPath) {
60
+ const nested = pkgPath.lastIndexOf('node_modules/')
61
+ const candidates =
62
+ nested === -1 ? [pkgPath] : [pkgPath, pkgPath.slice(nested + 'node_modules/'.length)]
63
+ for (let dir = path.dirname(from); ; dir = path.dirname(dir)) {
64
+ for (const candidate of candidates) {
65
+ const prebuilds = path.join(dir, 'node_modules', candidate, 'prebuilds', HOST)
66
+ if (fs.existsSync(prebuilds)) return prebuilds
67
+ }
68
+ if (dir === path.dirname(dir)) return null
69
+ }
70
+ }
71
+
72
+ // A link keeps one copy of each addon on disk and makes the forest free to
73
+ // rebuild; a filesystem that refuses one (Windows without the privilege, a
74
+ // crossed mount) still gets a working forest, just a fatter one.
75
+ function place(source, dest) {
76
+ try {
77
+ fs.symlinkSync(source, dest, os.platform() === 'win32' ? 'junction' : 'dir')
78
+ } catch {
79
+ fs.cpSync(source, dest, { recursive: true })
80
+ }
81
+ }
@@ -0,0 +1,24 @@
1
+ // QVAC_MMPROJ: one mmproj for every model, or per-model pairs
2
+ // "model=mmproj,model2=mmproj2" (keys match the agent's model name/path)
3
+ export function parseProjectionEnv(value) {
4
+ if (!value) return undefined
5
+ const eq = value.indexOf('=')
6
+ if (eq < 0) return value
7
+ const scheme = value.indexOf('://')
8
+ // '=' appearing only inside a URL (…?token=x) is one global source, not pairs
9
+ if (scheme >= 0 && scheme < eq) return value
10
+ const map = {}
11
+ let key = null
12
+ for (const segment of value.split(',')) {
13
+ const i = segment.indexOf('=')
14
+ if (i > 0) {
15
+ key = segment.slice(0, i).trim()
16
+ map[key] = segment.slice(i + 1).trim()
17
+ } else if (key) {
18
+ // no '=' means the comma belonged to the previous value (a url like
19
+ // …?token=a,b); a comma followed by k=v stays ambiguous and unsupported
20
+ map[key] += ',' + segment.trim()
21
+ }
22
+ }
23
+ return map
24
+ }