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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. 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
+ }