moqi-tui 0.2.0
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/LICENSE +21 -0
- package/README.md +782 -0
- package/bin/moqi.mjs +40 -0
- package/cordis.patch.yml +41 -0
- package/lib/cross-find.js +217 -0
- package/lib/file-index.js +121 -0
- package/lib/fleet-sources.js +114 -0
- package/lib/index.js +3999 -0
- package/lib/persist.js +194 -0
- package/lib/plugins.js +371 -0
- package/lib/presence.js +144 -0
- package/lib/rename.js +35 -0
- package/lib/rewind.js +94 -0
- package/lib/sessions-store.js +134 -0
- package/lib/startup.js +92 -0
- package/lib/tui/atfile.js +154 -0
- package/lib/tui/export.js +48 -0
- package/lib/tui/fleet.js +346 -0
- package/lib/tui/i18n.js +201 -0
- package/lib/tui/jobs.js +65 -0
- package/lib/tui/keys.js +205 -0
- package/lib/tui/markdown.js +368 -0
- package/lib/tui/mcp.js +95 -0
- package/lib/tui/panels.js +231 -0
- package/lib/tui/screen.js +156 -0
- package/lib/tui/state.js +502 -0
- package/lib/tui/stream.js +109 -0
- package/lib/tui/text.js +173 -0
- package/lib/tui/theme.js +183 -0
- package/lib/tui/themes.js +153 -0
- package/lib/tui/tooldetail.js +140 -0
- package/lib/tui/view.js +830 -0
- package/lib/tui/vim.js +222 -0
- package/lib/tui-host-core.js +141 -0
- package/lib/tui-host.js +48 -0
- package/lib/types/cross-find.d.ts +66 -0
- package/lib/types/file-index.d.ts +34 -0
- package/lib/types/fleet-sources.d.ts +34 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/persist.d.ts +116 -0
- package/lib/types/plugins.d.ts +218 -0
- package/lib/types/presence.d.ts +48 -0
- package/lib/types/rename.d.ts +32 -0
- package/lib/types/rewind.d.ts +75 -0
- package/lib/types/sessions-store.d.ts +46 -0
- package/lib/types/startup.d.ts +45 -0
- package/lib/types/tui/atfile.d.ts +90 -0
- package/lib/types/tui/export.d.ts +18 -0
- package/lib/types/tui/fleet.d.ts +209 -0
- package/lib/types/tui/i18n.d.ts +34 -0
- package/lib/types/tui/jobs.d.ts +28 -0
- package/lib/types/tui/keys.d.ts +52 -0
- package/lib/types/tui/markdown.d.ts +14 -0
- package/lib/types/tui/mcp.d.ts +34 -0
- package/lib/types/tui/panels.d.ts +125 -0
- package/lib/types/tui/screen.d.ts +79 -0
- package/lib/types/tui/state.d.ts +323 -0
- package/lib/types/tui/stream.d.ts +78 -0
- package/lib/types/tui/text.d.ts +28 -0
- package/lib/types/tui/theme.d.ts +87 -0
- package/lib/types/tui/themes.d.ts +70 -0
- package/lib/types/tui/tooldetail.d.ts +45 -0
- package/lib/types/tui/view.d.ts +163 -0
- package/lib/types/tui/vim.d.ts +64 -0
- package/lib/types/tui-host-core.d.ts +62 -0
- package/lib/types/tui-host.d.ts +42 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/voice.d.ts +227 -0
- package/lib/version.js +32 -0
- package/lib/voice.js +405 -0
- package/package.json +119 -0
- package/scripts/harness-root.mjs +88 -0
- package/scripts/install-profile.mjs +133 -0
package/lib/voice.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push-to-talk dictation, transcribed on this machine and nowhere else.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this feature is that a prompt spoken into the composer
|
|
5
|
+
* never becomes somebody else's training data, so there is no cloud endpoint
|
|
6
|
+
* here and no API key to lose: audio is captured by whatever recorder the
|
|
7
|
+
* system already has and handed to a local `whisper.cpp` binary. That also
|
|
8
|
+
* means the app cannot depend on any of it. A machine with no microphone
|
|
9
|
+
* stack, no whisper build, or no model is the normal case, not the error
|
|
10
|
+
* case — so every probe below reports what is missing in one line a person
|
|
11
|
+
* can act on, and the app is otherwise untouched.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in this module is a runtime dependency of the bundle: the recorder
|
|
14
|
+
* and the transcriber are external processes discovered on `PATH`, and the
|
|
15
|
+
* decision logic is pure so it can be tested without either of them.
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import { execFile, spawn } from 'node:child_process';
|
|
19
|
+
import { accessSync, constants } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
22
|
+
/** How long a transcription may run before it is killed, in milliseconds. */
|
|
23
|
+
export const TRANSCRIBE_TIMEOUT_MS = 120_000;
|
|
24
|
+
/** How long a recorder gets to flush its WAV header after a stop, in milliseconds. */
|
|
25
|
+
export const RECORDER_FLUSH_MS = 2000;
|
|
26
|
+
/**
|
|
27
|
+
* Recorders in the order they are preferred.
|
|
28
|
+
*
|
|
29
|
+
* `arecord` comes first because on Linux it is part of alsa-utils, which is
|
|
30
|
+
* already installed anywhere sound works at all, and it talks to ALSA
|
|
31
|
+
* directly. `rec` and `sox` are the same program wearing two names; `rec`
|
|
32
|
+
* defaults to the system input device while `sox` has to be told `-d`, which
|
|
33
|
+
* is why they cannot share one entry.
|
|
34
|
+
*/
|
|
35
|
+
export const RECORDERS = [
|
|
36
|
+
{
|
|
37
|
+
command: 'arecord',
|
|
38
|
+
packageName: 'alsa-utils',
|
|
39
|
+
args: (target) => ['-q', '-f', 'S16_LE', '-r', '16000', '-c', '1', '-t', 'wav', target],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
command: 'rec',
|
|
43
|
+
packageName: 'sox',
|
|
44
|
+
args: (target) => ['-q', '-r', '16000', '-c', '1', '-b', '16', target],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
command: 'sox',
|
|
48
|
+
packageName: 'sox',
|
|
49
|
+
args: (target) => ['-q', '-d', '-r', '16000', '-c', '1', '-b', '16', target],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Names whisper.cpp has shipped its command-line front end under.
|
|
54
|
+
*
|
|
55
|
+
* `whisper-cli` is what upstream builds today and `whisper-cpp` is what most
|
|
56
|
+
* distributions rename it to. Plain `main` is last on purpose: it is the
|
|
57
|
+
* historic name of the build output and still what an in-tree build produces,
|
|
58
|
+
* but it is far too generic to trust ahead of anything else on `PATH`.
|
|
59
|
+
*/
|
|
60
|
+
export const WHISPER_COMMANDS = [
|
|
61
|
+
'whisper-cli',
|
|
62
|
+
'whisper-cpp',
|
|
63
|
+
'whisper.cpp',
|
|
64
|
+
'whisper',
|
|
65
|
+
'main',
|
|
66
|
+
];
|
|
67
|
+
/** Directories searched for a model when none was configured. */
|
|
68
|
+
export const MODEL_DIRECTORIES = [
|
|
69
|
+
'~/.cache/whisper',
|
|
70
|
+
'~/.local/share/whisper',
|
|
71
|
+
'/usr/share/whisper.cpp',
|
|
72
|
+
'/usr/local/share/whisper.cpp',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Model files looked for inside each directory, best first.
|
|
76
|
+
*
|
|
77
|
+
* Dictation is a handful of seconds of one speaker close to the microphone,
|
|
78
|
+
* which `base` already handles, so the ordering trades accuracy for the
|
|
79
|
+
* latency a person is standing there waiting through. English-only weights
|
|
80
|
+
* win their size class because they are measurably better at it.
|
|
81
|
+
*/
|
|
82
|
+
export const MODEL_FILES = [
|
|
83
|
+
'ggml-base.en.bin',
|
|
84
|
+
'ggml-base.bin',
|
|
85
|
+
'ggml-small.en.bin',
|
|
86
|
+
'ggml-small.bin',
|
|
87
|
+
'ggml-medium.en.bin',
|
|
88
|
+
'ggml-medium.bin',
|
|
89
|
+
'ggml-tiny.en.bin',
|
|
90
|
+
'ggml-tiny.bin',
|
|
91
|
+
];
|
|
92
|
+
/** Expand a leading `~` against the home directory the probe reports. */
|
|
93
|
+
function expandHome(path, home) {
|
|
94
|
+
if (path === '~')
|
|
95
|
+
return home;
|
|
96
|
+
if (path.startsWith('~/'))
|
|
97
|
+
return join(home, path.slice(2));
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether voice input can run, and with what.
|
|
102
|
+
*
|
|
103
|
+
* Pure apart from the injected probe, because the interesting part is the
|
|
104
|
+
* order the three dependencies are reported in and that is worth testing
|
|
105
|
+
* without a microphone: the recorder comes first because it is the one a
|
|
106
|
+
* person is most likely to already have, then the binary, then the weights —
|
|
107
|
+
* which is also the order in which they get harder to install.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveVoiceSetup(options, probe) {
|
|
110
|
+
const recorder = RECORDERS.find((candidate) => probe.hasCommand(candidate.command));
|
|
111
|
+
if (recorder === undefined)
|
|
112
|
+
return { ok: false, gap: { kind: 'recorder' } };
|
|
113
|
+
const named = firstConfigured(options.binary, options.envBinary);
|
|
114
|
+
let binary;
|
|
115
|
+
if (named !== undefined) {
|
|
116
|
+
// A path was spelled out, so a miss is a mistake worth naming rather than
|
|
117
|
+
// something to quietly paper over with a `PATH` lookup.
|
|
118
|
+
const path = expandHome(named.value, probe.home);
|
|
119
|
+
const present = isAbsolute(path) || path.includes('/') ? probe.exists(path) : probe.hasCommand(path);
|
|
120
|
+
if (!present) {
|
|
121
|
+
return { ok: false, gap: { kind: 'binary-missing', path, source: named.source } };
|
|
122
|
+
}
|
|
123
|
+
binary = path;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const found = WHISPER_COMMANDS.find((candidate) => probe.hasCommand(candidate));
|
|
127
|
+
if (found === undefined)
|
|
128
|
+
return { ok: false, gap: { kind: 'binary' } };
|
|
129
|
+
binary = found;
|
|
130
|
+
}
|
|
131
|
+
const model = resolveModel(options, probe);
|
|
132
|
+
if (!model.ok)
|
|
133
|
+
return model;
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
setup: {
|
|
137
|
+
recorder,
|
|
138
|
+
binary,
|
|
139
|
+
model: model.path,
|
|
140
|
+
language: options.language === undefined || options.language.trim() === ''
|
|
141
|
+
? undefined
|
|
142
|
+
: options.language.trim(),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The configured value that wins, with where it came from. */
|
|
147
|
+
function firstConfigured(flag, env) {
|
|
148
|
+
// The flag beats the environment: it is the more deliberate of the two, and
|
|
149
|
+
// it is the one a person can change without editing a shell profile.
|
|
150
|
+
if (flag !== undefined && flag.trim() !== '')
|
|
151
|
+
return { value: flag.trim(), source: 'flag' };
|
|
152
|
+
if (env !== undefined && env.trim() !== '')
|
|
153
|
+
return { value: env.trim(), source: 'env' };
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/** Resolve the weights from the flag, the environment, or the default search. */
|
|
157
|
+
export function resolveModel(options, probe) {
|
|
158
|
+
const named = firstConfigured(options.model, options.envModel);
|
|
159
|
+
if (named !== undefined) {
|
|
160
|
+
const path = expandHome(named.value, probe.home);
|
|
161
|
+
if (!probe.exists(path)) {
|
|
162
|
+
return { ok: false, gap: { kind: 'model-missing', path, source: named.source } };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, path };
|
|
165
|
+
}
|
|
166
|
+
// Directory-major: a machine with both a distribution package and a hand
|
|
167
|
+
// downloaded model should use the one it downloaded, which is the one in
|
|
168
|
+
// the home directory, even when the packaged one is the larger weights.
|
|
169
|
+
for (const directory of MODEL_DIRECTORIES) {
|
|
170
|
+
const base = expandHome(directory, probe.home);
|
|
171
|
+
for (const file of MODEL_FILES) {
|
|
172
|
+
const candidate = join(base, file);
|
|
173
|
+
if (probe.exists(candidate))
|
|
174
|
+
return { ok: true, path: candidate };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { ok: false, gap: { kind: 'model-unfound' } };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* One line saying what is missing and how to get it.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately a single sentence with a concrete next step in it: this lands
|
|
183
|
+
* in the footer, which is one line wide, and an error there that only says
|
|
184
|
+
* "voice unavailable" costs the reader a search through the README.
|
|
185
|
+
*/
|
|
186
|
+
export function voiceGapMessage(gap) {
|
|
187
|
+
switch (gap.kind) {
|
|
188
|
+
case 'recorder':
|
|
189
|
+
return 'no recorder — run: npm run setup-voice, or install alsa-utils or sox';
|
|
190
|
+
case 'binary':
|
|
191
|
+
return 'no whisper binary — run: npm run setup-voice, or set MOQI_WHISPER_BIN';
|
|
192
|
+
case 'binary-missing':
|
|
193
|
+
return `no whisper binary at ${gap.path} — fix ${sourceLabel(gap.source, 'bin')}`;
|
|
194
|
+
case 'model-missing':
|
|
195
|
+
return `no model at ${gap.path} — fix ${sourceLabel(gap.source, 'model')}`;
|
|
196
|
+
case 'model-unfound':
|
|
197
|
+
return 'no whisper model — run: npm run setup-voice, or pass --voice-model';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Name the knob that produced a bad path, so the fix is unambiguous. */
|
|
201
|
+
function sourceLabel(source, which) {
|
|
202
|
+
if (source === 'flag')
|
|
203
|
+
return which === 'bin' ? '--voice-bin' : '--voice-model';
|
|
204
|
+
return which === 'bin' ? 'MOQI_WHISPER_BIN' : 'MOQI_WHISPER_MODEL';
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Arguments for one transcription run.
|
|
208
|
+
*
|
|
209
|
+
* `-np` suppresses whisper.cpp's banner and progress so the only thing left
|
|
210
|
+
* on stdout is the transcript; the segment timestamps are deliberately kept,
|
|
211
|
+
* because they are the one marker that reliably separates a result line from
|
|
212
|
+
* whatever a given build still prints alongside it.
|
|
213
|
+
*/
|
|
214
|
+
export function whisperArgs(setup, wavPath) {
|
|
215
|
+
const args = ['-m', setup.model, '-f', wavPath, '-np'];
|
|
216
|
+
if (setup.language !== undefined)
|
|
217
|
+
args.push('-l', setup.language);
|
|
218
|
+
return args;
|
|
219
|
+
}
|
|
220
|
+
/** Segment markers whisper emits for audio that carries no speech. */
|
|
221
|
+
const NON_SPEECH = /^[[(](?:blank_audio|silence|music|sound|noise|inaudible)[\])]$/i;
|
|
222
|
+
/**
|
|
223
|
+
* Turn whisper's stdout into the text a person meant to say.
|
|
224
|
+
*
|
|
225
|
+
* whisper.cpp prints one line per segment, `[00:00:00.000 --> 00:00:02.000]`
|
|
226
|
+
* and then the words. When any such line is present those lines are the whole
|
|
227
|
+
* answer and everything else is noise from a build that ignored `-np`; when
|
|
228
|
+
* none is, the build was asked for plain output and the lines are the text —
|
|
229
|
+
* minus the log chatter, which is recognisable by its `prefix:` shape.
|
|
230
|
+
*/
|
|
231
|
+
export function parseWhisperText(stdout) {
|
|
232
|
+
const timestamped = [];
|
|
233
|
+
const plain = [];
|
|
234
|
+
for (const raw of stdout.split('\n')) {
|
|
235
|
+
const line = raw.trim();
|
|
236
|
+
if (line === '')
|
|
237
|
+
continue;
|
|
238
|
+
const segment = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}\]\s*(.*)$/.exec(line);
|
|
239
|
+
if (segment !== null) {
|
|
240
|
+
const text = (segment[1] ?? '').trim();
|
|
241
|
+
if (text !== '' && !NON_SPEECH.test(text))
|
|
242
|
+
timestamped.push(text);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// A log line from whisper or its loader, e.g. `whisper_init_from_file:` or
|
|
246
|
+
// `main: processing ...`. Real speech can contain a colon, but not one
|
|
247
|
+
// sitting directly after a leading run of identifier characters.
|
|
248
|
+
if (/^[a-z_][a-z0-9_.]*\s*:/i.test(line))
|
|
249
|
+
continue;
|
|
250
|
+
if (line.startsWith('[') || line.startsWith('<'))
|
|
251
|
+
continue;
|
|
252
|
+
if (!NON_SPEECH.test(line))
|
|
253
|
+
plain.push(line);
|
|
254
|
+
}
|
|
255
|
+
const parts = timestamped.length > 0 ? timestamped : plain;
|
|
256
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The text to splice into the composer at `cursor`.
|
|
260
|
+
*
|
|
261
|
+
* Dictation is usually appended to something already typed, and two utterances
|
|
262
|
+
* running together into one word is the kind of small wrongness that makes a
|
|
263
|
+
* feature feel broken, so a separator is added when the character before the
|
|
264
|
+
* cursor is not already one.
|
|
265
|
+
*/
|
|
266
|
+
export function insertionFor(value, cursor, transcript) {
|
|
267
|
+
const text = transcript.trim();
|
|
268
|
+
if (text === '')
|
|
269
|
+
return '';
|
|
270
|
+
const before = value.slice(0, Math.max(cursor, 0));
|
|
271
|
+
if (before === '' || /\s$/.test(before))
|
|
272
|
+
return text;
|
|
273
|
+
return ` ${text}`;
|
|
274
|
+
}
|
|
275
|
+
/** Whether a command resolves to an executable somewhere on `PATH`. */
|
|
276
|
+
export function hasCommand(command, env = process.env) {
|
|
277
|
+
if (command.includes('/'))
|
|
278
|
+
return fileExists(command);
|
|
279
|
+
const path = env['PATH'] ?? '';
|
|
280
|
+
for (const directory of path.split(delimiter)) {
|
|
281
|
+
if (directory === '')
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
accessSync(join(directory, command), constants.X_OK);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Not here, or not executable; keep walking the rest of PATH.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
/** Whether a path exists and is readable. */
|
|
294
|
+
export function fileExists(path) {
|
|
295
|
+
try {
|
|
296
|
+
accessSync(path, constants.R_OK);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** The probe that answers against the real filesystem. */
|
|
304
|
+
export function systemProbe(env = process.env) {
|
|
305
|
+
return {
|
|
306
|
+
hasCommand: (command) => hasCommand(command, env),
|
|
307
|
+
exists: fileExists,
|
|
308
|
+
home: homedir(),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Read the environment half of the configuration. */
|
|
312
|
+
export function voiceOptionsFromEnv(env = process.env) {
|
|
313
|
+
return {
|
|
314
|
+
envModel: env['MOQI_WHISPER_MODEL'],
|
|
315
|
+
envBinary: env['MOQI_WHISPER_BIN'],
|
|
316
|
+
language: env['MOQI_WHISPER_LANG'],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Start the recorder.
|
|
321
|
+
*
|
|
322
|
+
* stdio is fully detached from this process's own: the app owns the alternate
|
|
323
|
+
* screen buffer, and a recorder writing a warning onto it would tear the frame
|
|
324
|
+
* apart with no way to repaint the damage.
|
|
325
|
+
*/
|
|
326
|
+
export function startRecording(setup, wavPath) {
|
|
327
|
+
const child = spawn(setup.recorder.command, setup.recorder.args(wavPath), {
|
|
328
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
329
|
+
});
|
|
330
|
+
return { child, wavPath };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Stop the recorder and wait for the file to be finished.
|
|
334
|
+
*
|
|
335
|
+
* SIGINT rather than SIGTERM because both recorders treat it as "wrap up":
|
|
336
|
+
* they close the WAV and go back and write the real length into the header.
|
|
337
|
+
* Killed harder, the file is left claiming a length of zero and whisper reads
|
|
338
|
+
* nothing out of it. The hard kill is only the fallback for a recorder that
|
|
339
|
+
* ignores the polite request.
|
|
340
|
+
*/
|
|
341
|
+
export function stopRecording(recording) {
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const child = recording.child;
|
|
344
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
345
|
+
resolve();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
try {
|
|
350
|
+
child.kill('SIGKILL');
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Already gone; the exit handler below has the resolve either way.
|
|
354
|
+
}
|
|
355
|
+
resolve();
|
|
356
|
+
}, RECORDER_FLUSH_MS);
|
|
357
|
+
timer.unref?.();
|
|
358
|
+
child.once('exit', () => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
resolve();
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
child.kill('SIGINT');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
resolve();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Transcribe one WAV file and return the text.
|
|
373
|
+
*
|
|
374
|
+
* Rejects with a one-line message rather than an exec error, because the
|
|
375
|
+
* caller puts whatever comes back straight into a status line that is one
|
|
376
|
+
* line wide.
|
|
377
|
+
*/
|
|
378
|
+
export function transcribe(setup, wavPath) {
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
execFile(setup.binary, whisperArgs(setup, wavPath), { timeout: TRANSCRIBE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
381
|
+
if (error !== null) {
|
|
382
|
+
reject(new Error(transcribeError(error, String(stderr))));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
resolve(parseWhisperText(String(stdout)));
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/** A one-line reason a transcription run failed. */
|
|
390
|
+
export function transcribeError(error, stderr) {
|
|
391
|
+
const code = error?.code;
|
|
392
|
+
if (code === 'ENOENT')
|
|
393
|
+
return 'whisper binary vanished mid-run';
|
|
394
|
+
if (code === 'ETIMEDOUT')
|
|
395
|
+
return 'whisper timed out';
|
|
396
|
+
// whisper says why on stderr; node's own message is the whole command line
|
|
397
|
+
// echoed back, which is useless in a footer.
|
|
398
|
+
const detail = stderr
|
|
399
|
+
.split('\n')
|
|
400
|
+
.map((line) => line.trim())
|
|
401
|
+
.find((line) => line !== '' && /error|failed|cannot|unable/i.test(line));
|
|
402
|
+
if (detail !== undefined)
|
|
403
|
+
return `whisper: ${detail}`;
|
|
404
|
+
return error instanceof Error ? error.message.split('\n')[0] ?? 'whisper failed' : 'whisper failed';
|
|
405
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moqi-tui",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Moqi — the unspoken understanding between you and your harness. A terminal app for DeepSeek Harness: bordered composer, live token counter, slash palette, markdown transcript with syntax-highlighted code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "JWE24-code <292770342+JWE24-code@users.noreply.github.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JWE24-code/moqi.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"moqi": "bin/moqi.mjs"
|
|
14
|
+
},
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"types": "lib/types/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./lib/types/index.d.ts",
|
|
20
|
+
"default": "./lib/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./startup": {
|
|
23
|
+
"types": "./lib/types/startup.d.ts",
|
|
24
|
+
"default": "./lib/startup.js"
|
|
25
|
+
},
|
|
26
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
27
|
+
"./package.json": "./package.json",
|
|
28
|
+
"./tui-host": {
|
|
29
|
+
"types": "./lib/types/tui-host.d.ts",
|
|
30
|
+
"default": "./lib/tui-host.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/**/*.js",
|
|
35
|
+
"lib/types/**/*.d.ts",
|
|
36
|
+
"bin/*.mjs",
|
|
37
|
+
"scripts/install-profile.mjs",
|
|
38
|
+
"scripts/harness-root.mjs",
|
|
39
|
+
"cordis.patch.yml"
|
|
40
|
+
],
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
49
|
+
"link-types": "node scripts/link-harness-types.mjs",
|
|
50
|
+
"install-profile": "node scripts/install-profile.mjs",
|
|
51
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test && npm run test:package",
|
|
52
|
+
"test": "node tests/harness-root-smoke.mjs && node --experimental-strip-types tests/offline-imports-smoke.ts && node --experimental-strip-types tests/keys-smoke.ts && node --experimental-strip-types tests/render-smoke.ts && node --experimental-strip-types tests/atfile-smoke.ts && node --experimental-strip-types tests/panels-smoke.ts && node --experimental-strip-types tests/selection-smoke.ts && node --experimental-strip-types tests/rewind-smoke.ts && node --experimental-strip-types tests/jobs-smoke.ts && node --experimental-strip-types tests/cross-find-smoke.ts && node --experimental-strip-types tests/tui-host-smoke.ts && node --experimental-strip-types tests/mcp-smoke.ts && node --experimental-strip-types tests/i18n-smoke.ts && node --experimental-strip-types tests/render-cache-smoke.ts && node --experimental-strip-types tests/vim-smoke.ts && node --experimental-strip-types tests/queue-smoke.ts && node --experimental-strip-types tests/persist.ts && node --experimental-strip-types tests/stream-smoke.ts && node --experimental-strip-types tests/tool-detail-smoke.ts && node --experimental-strip-types tests/export-smoke.ts && node --experimental-strip-types tests/sessions-store-smoke.ts && node --experimental-strip-types tests/rename-smoke.ts && node --experimental-strip-types tests/fleet-smoke.ts && node --experimental-strip-types tests/theme-smoke.ts && node --experimental-strip-types tests/plugins-smoke.ts && node --experimental-strip-types tests/voice-smoke.ts && node --experimental-strip-types tests/patch-smoke.ts && node --experimental-strip-types tests/pty.ts",
|
|
53
|
+
"preview": "node --experimental-strip-types tests/preview.ts",
|
|
54
|
+
"test:pty": "node --experimental-strip-types tests/pty.ts",
|
|
55
|
+
"setup-voice": "node scripts/install-voice.mjs",
|
|
56
|
+
"bench": "node --experimental-strip-types scripts/bench-render.ts",
|
|
57
|
+
"test:live": "MOQI_LIVE=1 node --experimental-strip-types tests/live-pty.ts",
|
|
58
|
+
"test:package": "node scripts/verify-package.mjs"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"commander": "^15.0.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"typescript": "^5.6.0",
|
|
65
|
+
"@types/node": "^22.0.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@deepseek-ai/cordis": "*",
|
|
69
|
+
"@deepseek-ai/dsh-agent": "*",
|
|
70
|
+
"@deepseek-ai/dsh-agent-default-model": "*",
|
|
71
|
+
"@deepseek-ai/dsh-attachment": "*",
|
|
72
|
+
"@deepseek-ai/dsh-brand": "*",
|
|
73
|
+
"@deepseek-ai/dsh-cmdline": "*",
|
|
74
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
75
|
+
"@deepseek-ai/dsh-session": "*",
|
|
76
|
+
"@deepseek-ai/dsh-session-query": "*",
|
|
77
|
+
"@deepseek-ai/schemastery": "*"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"@deepseek-ai/cordis": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@deepseek-ai/dsh-agent": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@deepseek-ai/dsh-agent-default-model": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"@deepseek-ai/dsh-attachment": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"@deepseek-ai/dsh-brand": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"@deepseek-ai/dsh-cmdline": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"@deepseek-ai/dsh-llm": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"@deepseek-ai/dsh-session": {
|
|
102
|
+
"optional": true
|
|
103
|
+
},
|
|
104
|
+
"@deepseek-ai/dsh-session-query": {
|
|
105
|
+
"optional": true
|
|
106
|
+
},
|
|
107
|
+
"@deepseek-ai/schemastery": {
|
|
108
|
+
"optional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"keywords": [
|
|
112
|
+
"moqi",
|
|
113
|
+
"deepseek",
|
|
114
|
+
"dsh",
|
|
115
|
+
"tui",
|
|
116
|
+
"terminal",
|
|
117
|
+
"agent"
|
|
118
|
+
]
|
|
119
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the installed Harness and make its `@deepseek-ai` packages resolvable.
|
|
3
|
+
*
|
|
4
|
+
* A plugin resolves `@deepseek-ai/*` from the dsh installation anchor at
|
|
5
|
+
* runtime. That works when the plugin lives inside the profile's own
|
|
6
|
+
* `node_modules` — but this app is linked from wherever it was installed, so
|
|
7
|
+
* Node resolves imports from the *package's* directory and never sees the
|
|
8
|
+
* anchor. Linking the harness packages into the package's own `node_modules`
|
|
9
|
+
* is what closes that gap, and it keeps exactly one copy of each package (the
|
|
10
|
+
* harness's), which matters: a second `@deepseek-ai/cordis` would be a
|
|
11
|
+
* different `Service` class.
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, realpathSync, symlinkSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The installed dsh package root.
|
|
21
|
+
*
|
|
22
|
+
* `DSH_INSTALL_ROOT` wins (a test or a non-standard install), else the `dsh`
|
|
23
|
+
* binary on PATH is resolved and walked up to the directory owning a
|
|
24
|
+
* `package.json`.
|
|
25
|
+
*
|
|
26
|
+
* @returns the root, or `undefined` with no throw — callers decide whether
|
|
27
|
+
* that is fatal (the installer warns) or fatal-with-help (the type linker).
|
|
28
|
+
*/
|
|
29
|
+
export function findDshRoot() {
|
|
30
|
+
const fromEnvironment = process.env['DSH_INSTALL_ROOT']
|
|
31
|
+
if (fromEnvironment !== undefined && existsSync(join(fromEnvironment, 'package.json'))) {
|
|
32
|
+
return fromEnvironment
|
|
33
|
+
}
|
|
34
|
+
let binary = ''
|
|
35
|
+
try {
|
|
36
|
+
binary = execFileSync('sh', ['-c', 'command -v dsh'], { encoding: 'utf8' }).trim()
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
if (binary === '') return undefined
|
|
41
|
+
let current = dirname(realpathSync(binary))
|
|
42
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
43
|
+
if (existsSync(join(current, 'package.json'))) return current
|
|
44
|
+
current = dirname(current)
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Where the harness's own packages live inside an installation. */
|
|
50
|
+
export function harnessPackageDir(dshRoot) {
|
|
51
|
+
return join(dshRoot, 'node_modules', '@deepseek-ai')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Make every harness `@deepseek-ai` package resolvable from `packageRoot`.
|
|
56
|
+
*
|
|
57
|
+
* Existing entries are replaced, so re-running is idempotent. Failures are
|
|
58
|
+
* collected rather than thrown: a read-only global install should still leave
|
|
59
|
+
* a working profile behind if the harness packages were resolvable some other
|
|
60
|
+
* way, and the caller reports what it could not link.
|
|
61
|
+
*
|
|
62
|
+
* @returns the names linked and the names that failed.
|
|
63
|
+
*/
|
|
64
|
+
export function linkHarnessPackages(packageRoot, dshRoot) {
|
|
65
|
+
const source = harnessPackageDir(dshRoot)
|
|
66
|
+
const linked = []
|
|
67
|
+
const failed = []
|
|
68
|
+
if (!existsSync(source)) return { linked, failed }
|
|
69
|
+
|
|
70
|
+
const target = join(packageRoot, 'node_modules', '@deepseek-ai')
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(target, { recursive: true })
|
|
73
|
+
} catch {
|
|
74
|
+
return { linked, failed }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const name of readdirSync(source)) {
|
|
78
|
+
const to = join(target, name)
|
|
79
|
+
try {
|
|
80
|
+
rmSync(to, { recursive: true, force: true })
|
|
81
|
+
symlinkSync(join(source, name), to, 'dir')
|
|
82
|
+
linked.push(name)
|
|
83
|
+
} catch {
|
|
84
|
+
failed.push(name)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { linked, failed }
|
|
88
|
+
}
|