@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,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
+ }
@@ -0,0 +1,5 @@
1
+ // these builder packages ship no types — blank ambient modules, schema-authoring-time only
2
+ declare module 'hrpc'
3
+ declare module 'hyperdispatch'
4
+ declare module 'hyperschema'
5
+ declare module 'hyperdb/builder'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/core",
3
- "version": "0.1.0",
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 tui/build.mjs && bare tui/index.mjs",
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