@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.
@@ -0,0 +1,136 @@
1
+ import { busyWord } from './busy-words.mjs'
2
+
3
+ const PAYLOAD_FIELDS = {
4
+ content: 'content',
5
+ thinking: 'thinking',
6
+ 'tool-call': 'toolCall',
7
+ 'tool-result': 'toolResult',
8
+ attachment: 'attachment'
9
+ }
10
+
11
+ export function turnsFromChunks(chunks) {
12
+ const turns = []
13
+ const byRun = new Map()
14
+ for (const chunk of chunks) {
15
+ // deviceId (hex of the chunk's originDeviceId) lets a renderer label the
16
+ // human side per device — purely presentational, nothing schema-enforced.
17
+ if (chunk.type === 'user-message') {
18
+ turns.push({
19
+ from: 'user',
20
+ deviceId: chunk.originDeviceId?.toString('hex'),
21
+ segments: [{ type: 'user-message', text: chunk.userMessage.text }]
22
+ })
23
+ continue
24
+ }
25
+ // A user-uploaded attachment has no runId (it isn't part of an agent
26
+ // turn) — an attachment a tool call produced mid-run does, and groups
27
+ // into that run's turn below like any other segment.
28
+ if (chunk.type === 'attachment' && !chunk.runId) {
29
+ turns.push({
30
+ from: 'user',
31
+ deviceId: chunk.originDeviceId?.toString('hex'),
32
+ segments: [{ type: 'attachment', detail: chunk.attachment }]
33
+ })
34
+ continue
35
+ }
36
+ let turn = byRun.get(chunk.runId)
37
+ if (!turn) {
38
+ turn = { from: 'agent', runId: chunk.runId, status: null, segments: [] }
39
+ byRun.set(chunk.runId, turn)
40
+ turns.push(turn)
41
+ }
42
+ // The ask itself materializes the turn (so EVERY peer sees a run is in
43
+ // flight before the host even starts) but renders nothing of its own —
44
+ // the provider's run-status/content chunks are the visible side.
45
+ if (chunk.type === 'run-request') continue
46
+ if (chunk.type === 'run-status') {
47
+ turn.status = chunk.runStatus.state
48
+ turn.statusReason = chunk.runStatus.reason
49
+ turn.statusDetail = chunk.runStatus
50
+ // Status chunks are stamped by the device doing the work (the
51
+ // provider), so progress can be labelled with WHERE it's happening.
52
+ turn.statusDeviceId = chunk.originDeviceId?.toString('hex')
53
+ continue
54
+ }
55
+ // An approval-request chunk IS the approval state (see runAgent's
56
+ // createApprove) — never coalesced with a neighbor, and carries its own
57
+ // id so the UI can resolveApproval() it directly.
58
+ if (chunk.type === 'approval-request') {
59
+ turn.segments.push({ type: 'approval-request', id: chunk.id, detail: chunk.approvalRequest })
60
+ continue
61
+ }
62
+ // recovery appends a fresh sources chunk on the same run - the newest wins (v1 rule)
63
+ if (chunk.type === 'sources') {
64
+ const stale = turn.segments.findIndex((segment) => segment.type === 'sources')
65
+ if (stale >= 0) turn.segments.splice(stale, 1)
66
+ turn.segments.push({ type: 'sources', detail: chunk.sources })
67
+ continue
68
+ }
69
+ const detail = chunk[PAYLOAD_FIELDS[chunk.type]] ?? {}
70
+ const last = turn.segments[turn.segments.length - 1]
71
+ if (last && last.type === chunk.type) last.text += detail.text ?? ''
72
+ else {
73
+ turn.segments.push({
74
+ type: chunk.type,
75
+ text: detail.text ?? '',
76
+ detail
77
+ })
78
+ }
79
+ }
80
+ return turns
81
+ }
82
+
83
+ // The latest still-pending approval-request across every turn, or null —
84
+ // there's at most one live tool call awaiting approval at a time (the
85
+ // harness's own loop blocks on it before doing anything else).
86
+ export function findPendingApproval(turns) {
87
+ for (let i = turns.length - 1; i >= 0; i--) {
88
+ for (const segment of turns[i].segments) {
89
+ if (segment.type === 'approval-request' && segment.detail.status === 'pending') return segment
90
+ }
91
+ }
92
+ return null
93
+ }
94
+
95
+ export function runningAgentTurn(turns) {
96
+ for (let i = turns.length - 1; i >= 0; i--) {
97
+ const turn = turns[i]
98
+ if (turn.from === 'agent' && turn.status === 'executing') return turn
99
+ }
100
+ return null
101
+ }
102
+
103
+ const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled'])
104
+
105
+ export function runInFlight(turns) {
106
+ for (let i = turns.length - 1; i >= 0; i--) {
107
+ if (turns[i].from !== 'agent') continue
108
+ return !TERMINAL_RUN_STATES.has(turns[i].status)
109
+ }
110
+ return false
111
+ }
112
+
113
+ export function busyStatus(modelState, runTurn) {
114
+ if (modelState?.status === 'loading') return { text: `loading ${modelState.model}…`, on: 'local' }
115
+ if (modelState?.status === 'downloading') {
116
+ return {
117
+ text: `downloading ${modelState.model}: ${Math.round(modelState.percentage ?? 0)}%`,
118
+ on: 'local'
119
+ }
120
+ }
121
+ if (modelState?.status === 'unloading') {
122
+ return { text: `unloading ${modelState.model}…`, on: 'local' }
123
+ }
124
+ const progress = runTurn?.statusDetail?.progress
125
+ if (progress?.phase === 'loading') return { text: `loading ${progress.model}…`, on: 'host' }
126
+ if (progress?.phase === 'downloading') {
127
+ return { text: `downloading ${progress.model}: ${progress.percentage ?? 0}%`, on: 'host' }
128
+ }
129
+ if (progress?.phase === 'generating') {
130
+ return { text: `generating image: ${progress.percentage ?? 0}%`, on: 'host' }
131
+ }
132
+ if (progress?.phase === 'queued') {
133
+ return { text: `queued - agent busy in ${progress.busyChatId}`, on: 'host' }
134
+ }
135
+ return { text: busyWord(runTurn?.runId), on: null }
136
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ export async function saveUniqueFile(dir, fileName, bytes, extension) {
5
+ const saved = await saveUnique(dir, fileName, extension, (handle) => write(handle, bytes))
6
+ return saved?.file ?? null
7
+ }
8
+
9
+ export function saveUniqueStream(dir, fileName, source, extension) {
10
+ return saveUnique(dir, fileName, extension, async (handle) => {
11
+ let bytesWritten = 0
12
+ for await (const data of source) bytesWritten += await write(handle, data)
13
+ return bytesWritten
14
+ })
15
+ }
16
+
17
+ async function saveUnique(dir, fileName, extension, writeFile) {
18
+ const tempDir = await fs.promises.mkdtemp(path.join(dir, '.qvac-download-'))
19
+ const tempFile = path.join(tempDir, 'download')
20
+ let handle = await fs.promises.open(tempFile, 'w')
21
+ let file = null
22
+
23
+ try {
24
+ file = await claimUniqueFile(dir, fileName, extension, tempFile)
25
+ if (!file) return null
26
+ const bytesWritten = await writeFile(handle)
27
+ await handle.close()
28
+ handle = null
29
+ return { file, bytesWritten }
30
+ } catch (err) {
31
+ await handle.close().catch(noop)
32
+ handle = null
33
+ if (file) await fs.promises.unlink(file).catch(noop)
34
+ throw err
35
+ } finally {
36
+ // Cleanup must not replace the download error or remove the reserved destination.
37
+ if (handle) await handle.close().catch(noop)
38
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(noop)
39
+ }
40
+ }
41
+
42
+ async function claimUniqueFile(dir, fileName, extension, source) {
43
+ const sanitized = path.basename(fileName).replace(/[^a-zA-Z0-9_.-]/g, '_')
44
+ const safe = !sanitized || sanitized === '.' || sanitized === '..' ? 'download' : sanitized
45
+ const dot = safe.lastIndexOf('.')
46
+ const base = (dot > 0 ? safe.slice(0, dot) : safe) || 'download'
47
+ const ext = extension === undefined ? (dot > 0 ? safe.slice(dot) : '') : extension
48
+
49
+ for (let n = 0; n < 100; n++) {
50
+ const file = path.join(dir, `${base}${n ? `-${n + 1}` : ''}${ext}`)
51
+ try {
52
+ // bare-fs does not preserve O_EXCL for concurrent opens; link is the atomic reservation.
53
+ await fs.promises.link(source, file)
54
+ return file
55
+ } catch (err) {
56
+ if (err.code !== 'EEXIST') throw err
57
+ }
58
+ }
59
+ return null
60
+ }
61
+
62
+ async function write(handle, data) {
63
+ let offset = 0
64
+ while (offset < data.length) {
65
+ const result = await handle.write(offset ? data.subarray(offset) : data)
66
+ offset += result.bytesWritten
67
+ }
68
+ return data.length
69
+ }
70
+
71
+ function noop() {}
@@ -0,0 +1,63 @@
1
+ import { cells } from './wrap.mjs'
2
+
3
+ const REVERSE = '\x1b[7m'
4
+ const REVERSE_OFF = '\x1b[27m'
5
+ // anything that would drop reverse video mid-span, the input's own cursor cell included
6
+ const RESETS = new Set(['\x1b[0m', '\x1b[m', REVERSE_OFF])
7
+
8
+ function ordered(anchor, focus) {
9
+ const [from, to] =
10
+ anchor.row < focus.row || (anchor.row === focus.row && anchor.col <= focus.col)
11
+ ? [anchor, focus]
12
+ : [focus, anchor]
13
+ return { from, to }
14
+ }
15
+
16
+ function span(row, from, to) {
17
+ if (row < from.row || row > to.row) return null
18
+ return {
19
+ start: row === from.row ? from.col : 0,
20
+ end: row === to.row ? to.col + 1 : Infinity
21
+ }
22
+ }
23
+
24
+ function walk(line, start, end) {
25
+ let col = 0
26
+ let text = ''
27
+ let painted = ''
28
+ let on = false
29
+ for (const cell of cells(line)) {
30
+ if (cell.esc) {
31
+ painted += cell.esc
32
+ if (on && RESETS.has(cell.esc)) painted += REVERSE
33
+ continue
34
+ }
35
+ const inside = col >= start && col < end
36
+ if (inside && !on) painted += REVERSE
37
+ if (!inside && on) painted += REVERSE_OFF
38
+ on = inside
39
+ if (inside) text += cell.ch
40
+ painted += cell.ch
41
+ col += cell.w
42
+ }
43
+ if (on) painted += REVERSE_OFF
44
+ return { text: text.replace(/ +$/, ''), painted }
45
+ }
46
+
47
+ export function selectionText(lines, anchor, focus) {
48
+ const { from, to } = ordered(anchor, focus)
49
+ const rows = []
50
+ for (let row = from.row; row <= Math.min(to.row, lines.length - 1); row++) {
51
+ const at = span(row, from, to)
52
+ rows.push(walk(lines[row], at.start, at.end).text)
53
+ }
54
+ return rows.join('\n').replace(/^\n+|\n+$/g, '')
55
+ }
56
+
57
+ export function paintSelection(lines, anchor, focus) {
58
+ const { from, to } = ordered(anchor, focus)
59
+ return lines.map((line, row) => {
60
+ const at = span(row, from, to)
61
+ return at ? walk(line, at.start, at.end).painted : line
62
+ })
63
+ }
@@ -0,0 +1,109 @@
1
+ // What a skill still needs before it can run, expressed as something the TUI can
2
+ // run for you: an OAuth route becomes /connect, a pasted-token route a /cred
3
+ // stub, anything else its own steps. The routes themselves come from the shared
4
+ // integrations registry, so the TUI and the desktop Settings UI agree on what
5
+ // setting a skill up means.
6
+ import { deriveSetupRoutes } from '../../dist-lib/lib/integrations/index.js'
7
+
8
+ const STATUS_LABELS = {
9
+ ready: 'ready',
10
+ 'needs-setup': 'needs setup',
11
+ disconnected: 'reconnect',
12
+ outdated: 'update available',
13
+ failed: 'failed',
14
+ disabled: 'disabled',
15
+ unsupported: 'unsupported here',
16
+ 'incompatible-model': 'needs a bigger model'
17
+ }
18
+
19
+ export function statusLabel(status) {
20
+ return STATUS_LABELS[status] ?? status ?? 'unknown'
21
+ }
22
+
23
+ export function isReady(skill) {
24
+ return skill?.status === 'ready'
25
+ }
26
+
27
+ export function connectSlug(credentialKey) {
28
+ return credentialKey
29
+ .replace(/_access_token$/, '')
30
+ .replace(/_mcp$/, '')
31
+ .replaceAll('_', '-')
32
+ }
33
+
34
+ // Every route a skill exposes, in manifest order. A skill can have more than
35
+ // one way in — Obsidian is either the CLI or a vault folder — and the ones that
36
+ // carry a credential key are also how it is RECONFIGURED later.
37
+ export function setupActions(skill) {
38
+ return (deriveSetupRoutes(skillEntry(skill)).routes ?? []).map((route) =>
39
+ routeAction(route, skill)
40
+ )
41
+ }
42
+
43
+ // What to offer, and what else there is: `primary` is the first unsatisfied
44
+ // route, preferring one the TUI can run over one that only prints instructions;
45
+ // `others` are the remaining ways in, rendered as a one-line "or …" each.
46
+ export function setupPlan(skill) {
47
+ const actions = setupActions(skill)
48
+ const open = actions.filter((action) => !action.satisfied)
49
+ const primary = open.find((action) => action.command || action.prefill) ?? open[0] ?? null
50
+ const others = actions.filter((action) => action !== primary)
51
+ return { primary, others }
52
+ }
53
+
54
+ export function setupAction(skill) {
55
+ return setupPlan(skill).primary
56
+ }
57
+
58
+ // Every route the TUI can run, offered one first. A connected skill keeps
59
+ // these: they are how it is pointed somewhere else (another Obsidian vault,
60
+ // another GitHub token) — a route with a credential key is configuration, not
61
+ // a one-time connect.
62
+ export function configureActions(skill) {
63
+ const { primary, others } = setupPlan(skill)
64
+ return [primary, ...others].filter((action) => action?.command || action?.prefill)
65
+ }
66
+
67
+ function routeAction(route, skill) {
68
+ const action = {
69
+ kind: 'steps',
70
+ label: route.label,
71
+ description: route.description ?? null,
72
+ helpUrl: route.helpUrl ?? null,
73
+ satisfied: route.satisfied === true,
74
+ steps: route.kind === 'install' ? installSteps(skill) : [...(route.steps ?? [])],
75
+ command: undefined,
76
+ prefill: undefined
77
+ }
78
+ if (!route.credentialKey) return action
79
+ if (route.kind === 'oauth') {
80
+ return { ...action, kind: 'connect', command: `/connect ${connectSlug(route.credentialKey)}` }
81
+ }
82
+ return { ...action, kind: 'cred', prefill: `/cred ${route.credentialKey} ` }
83
+ }
84
+
85
+ function installSteps(skill) {
86
+ return (skill.install ?? []).map((s) => (s.command ? `${s.label} → ${s.command}` : s.label))
87
+ }
88
+
89
+ // The engine reports a skill flat (name, status, credentials, …); the route
90
+ // registry reads a catalog entry. `a|b` credential slots list interchangeable
91
+ // keys — each is its own candidate route.
92
+ function skillEntry(skill) {
93
+ const credentials = (skill.requires?.credentials ?? skill.credentials ?? []).flatMap((slot) =>
94
+ slot.split('|')
95
+ )
96
+ return {
97
+ metadata: {
98
+ name: skill.name,
99
+ description: skill.description,
100
+ setup: skill.setup,
101
+ transports: skill.transports,
102
+ requires: { ...skill.requires, credentials },
103
+ install: skill.install
104
+ },
105
+ content: '',
106
+ available: isReady(skill),
107
+ state: skill.status
108
+ }
109
+ }
@@ -0,0 +1,52 @@
1
+ // The chat sidebar's row model: chats under their groups, in the order core
2
+ // presents them (groups first, each chat newest-activity first). Rows are what
3
+ // the pane draws and what its cursor walks; only `chat` rows are selectable.
4
+
5
+ export const SIDEBAR_COLS = 26
6
+
7
+ export function sidebarRows(chats, groups, activeId) {
8
+ const live = [...(chats ?? [])].reverse().filter((chat) => !chat.deletedAt)
9
+ const rows = []
10
+ for (const group of groups ?? []) {
11
+ const held = live.filter((chat) => chat.groupId === group.id)
12
+ rows.push({ kind: 'group', label: group.name })
13
+ if (!held.length) rows.push({ kind: 'empty', label: '(empty)' })
14
+ for (const chat of held) rows.push(chatRow(chat, activeId))
15
+ }
16
+ const loose = live.filter((chat) => !chat.groupId)
17
+ if (loose.length && rows.length) rows.push({ kind: 'group', label: '(ungrouped)' })
18
+ for (const chat of loose) rows.push(chatRow(chat, activeId))
19
+ return rows
20
+ }
21
+
22
+ // The move picker's rows — every group, plus ungrouping, minus the one the chat
23
+ // already sits in.
24
+ export function moveRows(groups, groupId) {
25
+ const rows = (groups ?? [])
26
+ .filter((group) => group.id !== groupId)
27
+ .map((group) => ({ kind: 'target', label: group.name, groupId: group.id }))
28
+ if (groupId) rows.unshift({ kind: 'target', label: '(no group)', groupId: null })
29
+ rows.push({ kind: 'new-group', label: 'New group…' })
30
+ return rows
31
+ }
32
+
33
+ // The next row the cursor may land on, walking `delta` at a time; the headers
34
+ // and the (empty) placeholders are skipped. Stays put at either end.
35
+ export function nextSelectable(rows, from, delta) {
36
+ for (let i = from + delta; i >= 0 && i < rows.length; i += delta) {
37
+ if (selectable(rows[i])) return i
38
+ }
39
+ return selectable(rows[from]) ? from : firstSelectable(rows)
40
+ }
41
+
42
+ export function firstSelectable(rows) {
43
+ return rows.findIndex(selectable)
44
+ }
45
+
46
+ export function selectable(row) {
47
+ return row?.kind === 'chat' || row?.kind === 'target' || row?.kind === 'new-group'
48
+ }
49
+
50
+ function chatRow(chat, activeId) {
51
+ return { kind: 'chat', label: chat.title, chatId: chat.id, active: chat.id === activeId }
52
+ }