@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/README.md +2 -2
- package/dist/assistant.bundle +6 -5
- package/dist-lib/schema/helpers.d.ts +28 -0
- package/dist-lib/schema/helpers.js +176 -0
- package/dist-lib/schema/vendor.d.ts +5 -0
- package/package.json +11 -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
|
@@ -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
|
+
}
|