@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,28 @@
|
|
|
1
|
+
// Hand-written declarations for the generated schema/helpers.js. assistant's own
|
|
2
|
+
// tsconfig uses allowJs + checkJs:false, but strict consumers (app-runtime,
|
|
3
|
+
// mobile) without allowJs need these types to import the module without TS7016.
|
|
4
|
+
// The `*Map` helpers are hyperdb map-reduce emitters: they return an array of
|
|
5
|
+
// plain record rows (e.g. `[{ chatId, type, seq }]`), never a Map. Rows stay
|
|
6
|
+
// `Record<string, any>` to match the previously-inferred shape and keep existing
|
|
7
|
+
// .ts callers permissive.
|
|
8
|
+
export function chunkTypeOrdinal(type: string): number
|
|
9
|
+
export function chunksByTypeMap(record: any): ReadonlyArray<Record<string, any>>
|
|
10
|
+
export function activeAgentsMap(record: any): ReadonlyArray<Record<string, any>>
|
|
11
|
+
export function activeChatsMap(record: any): ReadonlyArray<Record<string, any>>
|
|
12
|
+
export function activeChatsByAgentMap(record: any): ReadonlyArray<Record<string, any>>
|
|
13
|
+
export function activeChatGroupsMap(record: any): ReadonlyArray<Record<string, any>>
|
|
14
|
+
export function activeKnowledgeByChatMap(record: any): ReadonlyArray<Record<string, any>>
|
|
15
|
+
export function activeToolConfigsByAgentMap(record: any): ReadonlyArray<Record<string, any>>
|
|
16
|
+
export function toolActivityByAgentMap(record: any): ReadonlyArray<Record<string, any>>
|
|
17
|
+
export function runRequestsMap(record: any): ReadonlyArray<Record<string, any>>
|
|
18
|
+
export const MEDIA_KINDS: readonly ['audio', 'image', 'video', 'other']
|
|
19
|
+
export const AUDIO_EXTENSIONS: readonly string[]
|
|
20
|
+
export function mediaKindOf(
|
|
21
|
+
mimeType?: string,
|
|
22
|
+
fileName?: string
|
|
23
|
+
): 'audio' | 'image' | 'video' | 'other'
|
|
24
|
+
export function mediaKindOrdinal(kind: string): number
|
|
25
|
+
export function attachmentsByMediaKindMap(record: any): ReadonlyArray<Record<string, any>>
|
|
26
|
+
export function attachmentsByMediaKindOriginMap(record: any): ReadonlyArray<Record<string, any>>
|
|
27
|
+
export function historyChunksMap(record: any): ReadonlyArray<Record<string, any>>
|
|
28
|
+
export function liveWritersByMostRecentMap(record: any): ReadonlyArray<Record<string, any>>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// imported by the generated spec at runtime (dbns.require embeds a relative path) — stays plain .js
|
|
2
|
+
const historyChunkTypes = ['user-message', 'run-request', 'content', 'attachment', 'compaction']
|
|
3
|
+
|
|
4
|
+
// index keys hold the ordinal, kept in sync by hand with CHUNK_TYPES order in content.ts
|
|
5
|
+
export function chunkTypeOrdinal(type) {
|
|
6
|
+
switch (type) {
|
|
7
|
+
case 'user-message':
|
|
8
|
+
return 1
|
|
9
|
+
case 'content':
|
|
10
|
+
return 2
|
|
11
|
+
case 'thinking':
|
|
12
|
+
return 3
|
|
13
|
+
case 'tool-call':
|
|
14
|
+
return 4
|
|
15
|
+
case 'tool-result':
|
|
16
|
+
return 5
|
|
17
|
+
case 'attachment':
|
|
18
|
+
return 6
|
|
19
|
+
case 'approval-request':
|
|
20
|
+
return 7
|
|
21
|
+
case 'run-status':
|
|
22
|
+
return 8
|
|
23
|
+
case 'run-request':
|
|
24
|
+
return 9
|
|
25
|
+
case 'sources':
|
|
26
|
+
return 10
|
|
27
|
+
case 'compaction':
|
|
28
|
+
return 11
|
|
29
|
+
default:
|
|
30
|
+
throw new Error('Unknown chunk type: ' + type)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function chunksByTypeMap(record) {
|
|
35
|
+
if (record.type === undefined) return []
|
|
36
|
+
return [{ chatId: record.chatId, type: chunkTypeOrdinal(record.type), seq: record.seq }]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function activeAgentsMap(record) {
|
|
40
|
+
if (record.deletedAt) return []
|
|
41
|
+
return [{ createdAt: record.createdAt }]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function activeChatsMap(record) {
|
|
45
|
+
if (record.deletedAt) return []
|
|
46
|
+
return [{ createdAt: record.createdAt }]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function activeChatGroupsMap(record) {
|
|
50
|
+
if (record.deletedAt) return []
|
|
51
|
+
return [{ createdAt: record.createdAt }]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function activeChatsByAgentMap(record) {
|
|
55
|
+
if (record.deletedAt) return []
|
|
56
|
+
return [{ agentId: record.agentId, createdAt: record.createdAt }]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function activeKnowledgeByChatMap(record) {
|
|
60
|
+
if (record.deletedAt || !record.chatId) return []
|
|
61
|
+
return [{ chatId: record.chatId }]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function activeToolConfigsByAgentMap(record) {
|
|
65
|
+
if (record.deletedAt) return []
|
|
66
|
+
return [{ agentId: record.agentId }]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// append-only tool-call log keyed by agent then apply-assigned seq — one range query lists an
|
|
70
|
+
// agent's whole activity across every chat and device, in a peer-consistent order (seq, not wall
|
|
71
|
+
// clock). No tombstone filter: activity is immutable history.
|
|
72
|
+
export function toolActivityByAgentMap(record) {
|
|
73
|
+
if (!record.agentId) return []
|
|
74
|
+
return [{ agentId: record.agentId, seq: record.seq }]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function runRequestsMap(record) {
|
|
78
|
+
if (record.type !== 'run-request') return []
|
|
79
|
+
return [{ chatId: record.chatId, seq: record.seq }]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// media kinds for the attachments-by-media-kind index. The ordinal rides in the key as a plain
|
|
83
|
+
// uint (same pattern as chunksByTypeMap); MEDIA_KINDS is the name↔ordinal source of truth, and
|
|
84
|
+
// the wire media-kind enum in engine.ts is registered from this same array so the two never drift.
|
|
85
|
+
export const MEDIA_KINDS = ['audio', 'image', 'video', 'other']
|
|
86
|
+
|
|
87
|
+
// `.mp4` is deliberately audio, not video: audio recordings ship in the mp4 container and are
|
|
88
|
+
// transcribed from their audio track (parity with isAudioAttachment). audio-attachment.ts derives
|
|
89
|
+
// isAudioAttachment from mediaKindOf, so this list is the one source of truth for "what is audio".
|
|
90
|
+
export const AUDIO_EXTENSIONS = ['.m4a', '.mp3', '.wav', '.ogg', '.flac', '.aac', '.mp4']
|
|
91
|
+
const IMAGE_EXTENSIONS = [
|
|
92
|
+
'.png',
|
|
93
|
+
'.jpg',
|
|
94
|
+
'.jpeg',
|
|
95
|
+
'.gif',
|
|
96
|
+
'.webp',
|
|
97
|
+
'.bmp',
|
|
98
|
+
'.svg',
|
|
99
|
+
'.heic',
|
|
100
|
+
'.heif',
|
|
101
|
+
'.avif'
|
|
102
|
+
]
|
|
103
|
+
const VIDEO_EXTENSIONS = ['.mov', '.webm', '.mkv', '.avi', '.m4v']
|
|
104
|
+
|
|
105
|
+
function endsWithAny(name, extensions) {
|
|
106
|
+
return extensions.some((extension) => name.endsWith(extension))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// classify an attachment by mime first, then filename extension. Broad by design so a generated
|
|
110
|
+
// `audio/wav` and a user `recording.m4a` both land under 'audio'.
|
|
111
|
+
export function mediaKindOf(mimeType, fileName) {
|
|
112
|
+
const mime = (mimeType || '').toLowerCase()
|
|
113
|
+
const name = (fileName || '').toLowerCase()
|
|
114
|
+
if (mime.startsWith('audio/') || endsWithAny(name, AUDIO_EXTENSIONS)) return 'audio'
|
|
115
|
+
if (mime.startsWith('image/') || endsWithAny(name, IMAGE_EXTENSIONS)) return 'image'
|
|
116
|
+
if (mime.startsWith('video/') || endsWithAny(name, VIDEO_EXTENSIONS)) return 'video'
|
|
117
|
+
return 'other'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mediaKindOrdinal(kind) {
|
|
121
|
+
const ordinal = MEDIA_KINDS.indexOf(kind)
|
|
122
|
+
if (ordinal === -1) throw new Error('Unknown media kind: ' + kind)
|
|
123
|
+
return ordinal + 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// every non-deleted attachment chunk, keyed by media kind then createdAt — NOT chatId-prefixed,
|
|
127
|
+
// so one range query lists all audio (uploaded or generated) across every chat in the mesh. Drops
|
|
128
|
+
// non-attachment chunks and tombstoned attachments so a gallery never scans-and-filters.
|
|
129
|
+
export function attachmentsByMediaKindMap(record) {
|
|
130
|
+
if (record.type !== 'attachment') return []
|
|
131
|
+
const attachment = record.attachment
|
|
132
|
+
if (!attachment || attachment.deletedAt) return []
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
mediaKind: mediaKindOrdinal(mediaKindOf(attachment.mimeType, attachment.fileName)),
|
|
136
|
+
createdAt: record.createdAt
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// generated tool output always carries a runId; a user upload never does (attachment-knowledge.ts
|
|
142
|
+
// only routes runId-less uploads to knowledge). So runId presence IS the origin, kept out of the
|
|
143
|
+
// key as a plain 0/1 uint the way mediaKind is.
|
|
144
|
+
function attachmentGenerated(record) {
|
|
145
|
+
return record.runId ? 1 : 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// the same attachments as attachmentsByMediaKindMap, but with origin (generated vs uploaded) folded
|
|
149
|
+
// into the key so "only generated audio" / "only uploaded audio" is a range query, not a post-filter.
|
|
150
|
+
export function attachmentsByMediaKindOriginMap(record) {
|
|
151
|
+
if (record.type !== 'attachment') return []
|
|
152
|
+
const attachment = record.attachment
|
|
153
|
+
if (!attachment || attachment.deletedAt) return []
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
mediaKind: mediaKindOrdinal(mediaKindOf(attachment.mimeType, attachment.fileName)),
|
|
157
|
+
generated: attachmentGenerated(record),
|
|
158
|
+
createdAt: record.createdAt
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function devicesByWriterKeyMap(record) {
|
|
164
|
+
if (!record.writerKey) return []
|
|
165
|
+
return [{ writerKey: record.writerKey }]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function historyChunksMap(record) {
|
|
169
|
+
if (!historyChunkTypes.includes(record.type)) return []
|
|
170
|
+
return [{ chatId: record.chatId, seq: record.seq }]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function liveWritersByMostRecentMap(record) {
|
|
174
|
+
if (record.revoked) return []
|
|
175
|
+
return [{ clock: record.clock }]
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qvac/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Peer-to-peer AI across your devices",
|
|
6
6
|
"exports": {
|
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
"default": "./dist-lib/client.js"
|
|
70
70
|
},
|
|
71
71
|
"./capabilities": "./dist-lib/spec/capabilities.d.ts",
|
|
72
|
+
"./wire-codec": {
|
|
73
|
+
"types": "./dist-lib/spec/hrpc/messages.d.ts",
|
|
74
|
+
"default": "./dist-lib/spec/hrpc/messages.js"
|
|
75
|
+
},
|
|
72
76
|
"./log": {
|
|
73
77
|
"types": "./dist-lib/lib/log.d.ts",
|
|
74
78
|
"default": "./dist-lib/lib/log.js"
|
|
@@ -133,11 +137,13 @@
|
|
|
133
137
|
},
|
|
134
138
|
"./dist/assistant.cjs": "./dist/assistant.cjs"
|
|
135
139
|
},
|
|
140
|
+
"bin": {
|
|
141
|
+
"duck": "./tui/index.mjs"
|
|
142
|
+
},
|
|
136
143
|
"scripts": {
|
|
137
|
-
"build:tui": "node tui/build.mjs",
|
|
138
144
|
"build:splash": "node tui/build-splash.mjs",
|
|
139
145
|
"build:schema": "bare schema/build.ts",
|
|
140
|
-
"duck": "node
|
|
146
|
+
"duck": "node scripts/build-bundle.mjs && bare tui/index.mjs",
|
|
141
147
|
"duck:warm": "bare tui/index.mjs",
|
|
142
148
|
"mesh-peer": "bare examples/mesh-peer.mjs",
|
|
143
149
|
"mesh-lab": "bare examples/mesh-lab.mjs",
|
|
@@ -213,6 +219,7 @@
|
|
|
213
219
|
},
|
|
214
220
|
"dependencies": {
|
|
215
221
|
"@qvac/harness": "^0.2.1",
|
|
222
|
+
"@qvac/skills": "^0.1.3",
|
|
216
223
|
"asenion": "^0.1.0",
|
|
217
224
|
"autobee": "2.6.0",
|
|
218
225
|
"b4a": "^1.8.1",
|
|
@@ -239,6 +246,7 @@
|
|
|
239
246
|
"bare-subprocess": "^6.1.0",
|
|
240
247
|
"bare-supervisor": "^0.1.1",
|
|
241
248
|
"bare-system-logger": "^1.0.3",
|
|
249
|
+
"bare-tui": "^0.0.3",
|
|
242
250
|
"bare-url": "^2.4.5",
|
|
243
251
|
"blind-peer": "^3.10.2",
|
|
244
252
|
"blind-peering": "^2.7.0",
|
|
@@ -280,7 +288,6 @@
|
|
|
280
288
|
"bare-runtime": "^1.30.0",
|
|
281
289
|
"bare-sidecar": "^0.5.2",
|
|
282
290
|
"bare-stow": "^0.1.4",
|
|
283
|
-
"bare-tui": "^0.0.3",
|
|
284
291
|
"brittle": "^4.0.2",
|
|
285
292
|
"holepunch-types": "^0.2.1",
|
|
286
293
|
"hyperdht": "^6.32.0",
|
package/tui/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# duck
|
|
2
|
+
|
|
3
|
+
The interactive chat TUI over `@qvac/core` — the real llama.cpp harness (via
|
|
4
|
+
`@qvac/sdk`), the real chunk stream, a real mesh.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
`duck` ships in the `@qvac/core` package, so an install puts it on PATH:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm i -g @qvac/core # duck
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
In the repo:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bun run duck # build the assistant bundle, then run
|
|
18
|
+
bun run duck:warm # reuse the last-built bundle
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`duck` runs the SAME `dist/assistant.bundle` the desktop app ships — the stow of
|
|
22
|
+
`worker/assistant-sidecar.mjs` that `scripts/build-bundle.mjs` builds. `bun run
|
|
23
|
+
duck` rebuilds it first, because a stale bundle decodes with the old wire format
|
|
24
|
+
and dies on the first call whose encoding changed; a freshly written bundle also
|
|
25
|
+
pays a one-time cold start on its first spawn (OS page-cache miss + the
|
|
26
|
+
platform's security assessment of the new binary and its native addons). The TUI
|
|
27
|
+
host (`index.mjs`, `app.mjs`) is not in that bundle, so while iterating on the
|
|
28
|
+
TUI use `duck:warm`. Rebuild after changing any `worker/`, wire, or `lib/`
|
|
29
|
+
source.
|
|
30
|
+
|
|
31
|
+
The bundle carries its JS but not its native addons: `build-bundle.mjs` offloads
|
|
32
|
+
those beside the bundle and leaves the forest out of the tarball, shipping
|
|
33
|
+
`dist/prebuilds-manifest.json` instead. `lib/prebuilds.mjs` links this host's
|
|
34
|
+
addons back into place from the installed `node_modules` on first run — which is
|
|
35
|
+
why an installed `duck` needs no postinstall step.
|
|
36
|
+
|
|
37
|
+
`index.mjs` is the `duck` bin — a `#!/usr/bin/env bare` script, so `bare` has to
|
|
38
|
+
be on PATH (`bare-runtime` puts it in `node_modules/.bin`). `bare-tui` needs
|
|
39
|
+
Bare's native TTY; the TUI will not run under node.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
duck [--storage <dir>] [--temp] [--headless]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Flags may appear in any order. Joining a mesh is `/join qvac://mesh/…` inside
|
|
48
|
+
the app, not a boot argument.
|
|
49
|
+
|
|
50
|
+
- `--storage <dir>` — a self-contained root (its own corestore and SDK home
|
|
51
|
+
under `<dir>`). `QVAC_STORAGE` is the env fallback.
|
|
52
|
+
- `--temp` — the same, in a throwaway dir. `QVAC_TEMP=1` is the env fallback.
|
|
53
|
+
- `--headless` — brings the same stack up with no TUI: prints the mesh invite,
|
|
54
|
+
then streams logs (including the sidecar child's own output) to stdout until
|
|
55
|
+
ctrl+C. A mesh peer to point other instances at, and the only live view of
|
|
56
|
+
the boot the TUI hides.
|
|
57
|
+
|
|
58
|
+
Storage defaults to the desktop app's release root (`resolveStorage`, i.e.
|
|
59
|
+
`<platform-persistent>/qvac/dev`), so the TUI resumes the same mesh and chats
|
|
60
|
+
the desktop persists there — one at a time (the corestore is single-writer, so
|
|
61
|
+
quit the desktop first) and only across matching Core/schema versions. Models
|
|
62
|
+
stay in the shared global `~/.qvac/models`; `QVAC_ENV` picks another channel.
|
|
63
|
+
|
|
64
|
+
To run alongside another qvac app that already holds the SDK registry lock, use
|
|
65
|
+
an explicit root — it puts the SDK home and its lock on a scratch
|
|
66
|
+
`<dir>/sdk-home`. Models are still shared; `QVAC_MODEL_CACHE_PATH` isolates
|
|
67
|
+
those too.
|
|
68
|
+
|
|
69
|
+
The TUI copies its mesh invite to the clipboard on ctrl+y; a running TUI moves
|
|
70
|
+
mesh with `/join qvac://mesh/…`. See [../docs/tui-commands.md](../docs/tui-commands.md)
|
|
71
|
+
for the full command set.
|
|
72
|
+
|
|
73
|
+
## Environment
|
|
74
|
+
|
|
75
|
+
| Variable | Effect |
|
|
76
|
+
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
+
| `QVAC_MODEL` | Overrides the demo agent's model — a registry name, or a local path/URL |
|
|
78
|
+
| `QVAC_MMPROJ` | Pairs models with their mmproj projections so ctrl+V'd images reach the model natively. One value applies to every model; `model=mmproj,model2=mmproj2` pairs are per-model. `QVAC_INPROCESS=1` only |
|
|
79
|
+
| `QVAC_IMAGE_MODEL` | Enables the `generate_image`/`edit_image` tools — a catalog choice (`sd2` \| `sdxl` \| `flux2-klein`), or a local diffusion model |
|
|
80
|
+
| `QVAC_IMAGE_LLM`, `QVAC_IMAGE_VAE`, `QVAC_IMAGE_PREDICTION` | FLUX.2 is a companion set: the Qwen3 text encoder, the VAE, and `flux2_flow` (required for img2img) |
|
|
81
|
+
| `QVAC_LOG_FILE`, `QVAC_LOG_LEVEL` | Move the log file / pick the level (`debug` \| `info` \| `warn` \| `error`, default `info`) |
|
|
82
|
+
| `QVAC_BOOT_TIMING=1` | Prints the total connect time after the TUI exits. Per-stage stamps exist only under `QVAC_INPROCESS=1` — the shared assistant sidecar carries no boot timer |
|
|
83
|
+
| `QVAC_INPROCESS=1` | Runs Core and Client in this process, no sidecar — the only topology that can inject a live harness object |
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
QVAC_MODEL=~/.qvac/models/3a65a2a3c6a30a47_Qwen3.5-4B-Q4_K_M.gguf \
|
|
87
|
+
QVAC_MMPROJ=~/.qvac/models/0e037fa9ec5dddcf_Qwen3.5-4B.mmproj-Q8_0.gguf \
|
|
88
|
+
duck
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Logs (Core's own lines, and the sidecar child's piped stdout/stderr — the
|
|
92
|
+
llama.cpp and SDK output) go to `<storage.root>/assistant.log`, as they do on
|
|
93
|
+
desktop. Nothing is printed: the TUI owns the tty.
|
|
94
|
+
|
|
95
|
+
## Layout
|
|
96
|
+
|
|
97
|
+
| Path | Responsibility |
|
|
98
|
+
| ----------- | ------------------------------------------------------------------------------------------ |
|
|
99
|
+
| `index.mjs` | The `duck` bin — CLI, storage resolution, and the two topologies (sidecar, in-process) |
|
|
100
|
+
| `app.mjs` | The TUI itself — state, keys, commands, rendering |
|
|
101
|
+
| `lib/` | Leaf helpers: wrapping, selection, rendering, export, knowledge import, uploads, prebuilds |
|
|
102
|
+
|
|
103
|
+
## Splash
|
|
104
|
+
|
|
105
|
+
An empty transcript — first boot, and a fresh chat — shows `../ducks.png` as
|
|
106
|
+
half-block truecolor art, centered, with that state's own caption beneath it
|
|
107
|
+
(`starting up…`, `connecting to the mesh…`, `(say hello)`).
|
|
108
|
+
|
|
109
|
+
`lib/splash-art.mjs` is generated and committed; regenerate it when the image
|
|
110
|
+
changes:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
bun run build:splash
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
It carries four cuts — 64x32, 48x24, 32x16 and 24x12 cells. `splash()` takes
|
|
117
|
+
the widest that fits the body and falls back to the bare caption when none
|
|
118
|
+
does, so a roomy window gets more of the illustration and a 24-row terminal
|
|
119
|
+
still gets a duck. The ladder tops out well short of a full-screen window: this
|
|
120
|
+
is a splash above the prompt, not a wallpaper.
|
|
121
|
+
|
|
122
|
+
## Busy words
|
|
123
|
+
|
|
124
|
+
A run with no model progress to report shows a duck-themed gerund instead of
|
|
125
|
+
`thinking…` — `Dabbling…`, `Pondering…`, `Quackompiling…`. The word is a pure
|
|
126
|
+
function of the run id (`lib/busy-words.mjs`), so it holds for that whole run
|
|
127
|
+
rather than flickering on every repaint, and two runs rarely land on the same
|
|
128
|
+
one. States that _do_ have something to report keep saying it (`loading X…`,
|
|
129
|
+
`downloading X: 40%`).
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
Apache-2.0
|