synabun 2.0.5 → 2.0.7
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/CHANGELOG.md +40 -0
- package/README.md +2 -1
- package/THIRD-PARTY-LICENSES.md +13 -0
- package/docs/whatsapp.md +151 -16
- package/hooks/claude-code/shared.mjs +6 -3
- package/lib/child-path.js +303 -0
- package/lib/claude-hooks.js +2 -1
- package/lib/data-home-migration.js +2 -0
- package/lib/desktop-app.js +265 -0
- package/lib/external-tools.js +7 -3
- package/lib/log-redact.js +102 -0
- package/lib/packaged-runtime.js +138 -11
- package/mcp-server/dist/http.d.ts +3 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +15 -4
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +9 -6
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
- package/mcp-server/dist/services/budget-judgments.js +153 -0
- package/mcp-server/dist/services/budget-judgments.js.map +1 -0
- package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +41 -4
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +11 -0
- package/mcp-server/dist/services/identity.d.ts.map +1 -1
- package/mcp-server/dist/services/identity.js +24 -0
- package/mcp-server/dist/services/identity.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +13 -0
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +34 -2
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
- package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
- package/mcp-server/dist/services/typesafe-config.js +4 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +10 -7
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +34 -13
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
- package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
- package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
- package/mcp-server/dist/tui.js +2 -1
- package/mcp-server/dist/tui.js.map +1 -1
- package/mcp-server/dist/version.d.ts +5 -0
- package/mcp-server/dist/version.d.ts.map +1 -0
- package/mcp-server/dist/version.js +21 -0
- package/mcp-server/dist/version.js.map +1 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/run.mjs +15 -9
- package/mcp-server/src/http.ts +18 -3
- package/mcp-server/src/index.ts +9 -6
- package/mcp-server/src/services/budget-judgments.ts +183 -0
- package/mcp-server/src/services/codex-config-heal.ts +35 -4
- package/mcp-server/src/services/identity.ts +27 -0
- package/mcp-server/src/services/neural-interface.ts +34 -2
- package/mcp-server/src/services/typesafe-config.ts +4 -1
- package/mcp-server/src/tools/agents.ts +33 -14
- package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
- package/mcp-server/src/tui.ts +2 -1
- package/mcp-server/src/version.ts +22 -0
- package/neural-interface/i18n/en.json +627 -57
- package/neural-interface/i18n/pt-BR.json +626 -56
- package/neural-interface/lib/account-usage.js +163 -0
- package/neural-interface/lib/assistant-api.js +255 -52
- package/neural-interface/lib/assistant-brains/claude.js +15 -2
- package/neural-interface/lib/assistant-brains/codex.js +3 -0
- package/neural-interface/lib/assistant-brains/opencode.js +17 -4
- package/neural-interface/lib/assistant-budget-planner.js +390 -0
- package/neural-interface/lib/assistant-budget-split.js +408 -0
- package/neural-interface/lib/assistant-budget.js +94 -95
- package/neural-interface/lib/assistant-config.js +62 -14
- package/neural-interface/lib/assistant-dispatch.js +432 -125
- package/neural-interface/lib/assistant-jev.js +26 -3
- package/neural-interface/lib/assistant-persona.js +68 -11
- package/neural-interface/lib/assistant-plan-permissions.js +8 -1
- package/neural-interface/lib/assistant-route-accounts.js +14 -0
- package/neural-interface/lib/assistant-route-gate.js +19 -3
- package/neural-interface/lib/assistant-router.js +373 -42
- package/neural-interface/lib/assistant-runtime.js +307 -47
- package/neural-interface/lib/assistant-user-writer.js +4 -2
- package/neural-interface/lib/augmented-path.js +317 -53
- package/neural-interface/lib/claude-accounts.js +342 -31
- package/neural-interface/lib/claude-agent-bridge.js +90 -3
- package/neural-interface/lib/claude-executable.js +6 -1
- package/neural-interface/lib/claude-model-catalog.js +3 -3
- package/neural-interface/lib/claude-panel-auth.js +221 -0
- package/neural-interface/lib/claude-profile-api-key.js +62 -0
- package/neural-interface/lib/cli-install-api.js +137 -0
- package/neural-interface/lib/cli-install.js +797 -0
- package/neural-interface/lib/cli-path.js +130 -0
- package/neural-interface/lib/cli-resolver.js +744 -0
- package/neural-interface/lib/cli-update-planner.js +143 -7
- package/neural-interface/lib/codex-account-auth.js +178 -0
- package/neural-interface/lib/codex-accounts.js +489 -0
- package/neural-interface/lib/codex-capabilities.js +2 -1
- package/neural-interface/lib/codex-compat-rpc.js +8 -2
- package/neural-interface/lib/codex-mcp-approvals.js +5 -1
- package/neural-interface/lib/codex-panel-auth.js +62 -0
- package/neural-interface/lib/codex-session-settings.js +1 -1
- package/neural-interface/lib/codex-settings-schema.js +120 -0
- package/neural-interface/lib/desktop/config.js +3 -0
- package/neural-interface/lib/git-bash/bzip2.js +339 -0
- package/neural-interface/lib/git-bash/claude-settings.js +73 -0
- package/neural-interface/lib/git-bash/extract-worker.js +34 -0
- package/neural-interface/lib/git-bash/install.js +529 -0
- package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
- package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
- package/neural-interface/lib/git-bash/manager.js +268 -0
- package/neural-interface/lib/git-bash/pins.js +206 -0
- package/neural-interface/lib/git-bash/pins.json +34 -0
- package/neural-interface/lib/git-bash/resolver.js +194 -0
- package/neural-interface/lib/git-read.js +34 -2
- package/neural-interface/lib/install-details.js +145 -0
- package/neural-interface/lib/mcp-installer.js +14 -2
- package/neural-interface/lib/native-loop-providers.js +16 -3
- package/neural-interface/lib/native-loop-runtime.js +9 -4
- package/neural-interface/lib/os-path.js +319 -0
- package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
- package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
- package/neural-interface/lib/pty-host/core.js +2 -1
- package/neural-interface/lib/pty-host/manager.js +27 -2
- package/neural-interface/lib/remote-policy.js +20 -11
- package/neural-interface/lib/session-title-generator.js +4 -3
- package/neural-interface/lib/system-about.js +340 -0
- package/neural-interface/lib/system-activity.js +66 -0
- package/neural-interface/lib/terminal-env.js +11 -3
- package/neural-interface/lib/terminal-path.js +55 -0
- package/neural-interface/lib/virtual-socket.js +12 -1
- package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
- package/neural-interface/lib/whatsapp/bridge.js +78 -34
- package/neural-interface/lib/whatsapp/commands.js +13 -14
- package/neural-interface/lib/whatsapp/config.js +134 -20
- package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
- package/neural-interface/lib/whatsapp/host-core.js +168 -35
- package/neural-interface/lib/whatsapp/inbound.js +5 -3
- package/neural-interface/lib/whatsapp/local-file.js +524 -0
- package/neural-interface/lib/whatsapp/manager.js +92 -32
- package/neural-interface/lib/whatsapp/paths.js +11 -1
- package/neural-interface/lib/whatsapp/protocol.js +65 -5
- package/neural-interface/lib/whatsapp/redact.js +63 -0
- package/neural-interface/lib/whatsapp/service.js +343 -91
- package/neural-interface/lib/whatsapp/transcription.js +463 -0
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/index.html +2 -0
- package/neural-interface/public/index2d.html +2 -0
- package/neural-interface/public/offline.html +16 -1
- package/neural-interface/public/onboarding.html +96 -2
- package/neural-interface/public/shared/account-i18n.js +7 -0
- package/neural-interface/public/shared/account-menu.js +539 -0
- package/neural-interface/public/shared/account-usage.js +19 -0
- package/neural-interface/public/shared/account-view.js +335 -0
- package/neural-interface/public/shared/agent-runtime-options.js +28 -8
- package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
- package/neural-interface/public/shared/assistant/asst-api.js +32 -2
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
- package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
- package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
- package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
- package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
- package/neural-interface/public/shared/assistant/asst-render.js +38 -7
- package/neural-interface/public/shared/assistant/asst-route.js +72 -14
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
- package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
- package/neural-interface/public/shared/assistant/asst-state.js +1 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
- package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
- package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
- package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
- package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
- package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
- package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
- package/neural-interface/public/shared/claude-account-view.js +3 -0
- package/neural-interface/public/shared/cli-install-view.js +392 -0
- package/neural-interface/public/shared/cli-status.js +282 -22
- package/neural-interface/public/shared/cp/cp-events.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +46 -0
- package/neural-interface/public/shared/desktop-bridge.js +39 -0
- package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
- package/neural-interface/public/shared/desktop-titlebar.js +153 -0
- package/neural-interface/public/shared/desktop-update-view.js +462 -0
- package/neural-interface/public/shared/editor/editor-panel.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +1 -1
- package/neural-interface/public/shared/git-bash-view.js +145 -0
- package/neural-interface/public/shared/html-shell.js +1 -0
- package/neural-interface/public/shared/i18n.js +19 -1
- package/neural-interface/public/shared/install-details-view.js +86 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
- package/neural-interface/public/shared/pwa-install-page.js +3 -1
- package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
- package/neural-interface/public/shared/settings/settings-about.js +254 -0
- package/neural-interface/public/shared/settings/settings-ia.js +15 -0
- package/neural-interface/public/shared/showcase-privacy.js +489 -0
- package/neural-interface/public/shared/storage.js +30 -3
- package/neural-interface/public/shared/styles.css +278 -31
- package/neural-interface/public/shared/synabun-mascot.js +48 -2
- package/neural-interface/public/shared/ui-claude-panel.js +212 -213
- package/neural-interface/public/shared/ui-cli-banner.js +355 -0
- package/neural-interface/public/shared/ui-git-bash.js +124 -0
- package/neural-interface/public/shared/ui-judgments.js +10 -1
- package/neural-interface/public/shared/ui-loading.js +30 -6
- package/neural-interface/public/shared/ui-menubar.js +13 -3
- package/neural-interface/public/shared/ui-navbar.js +3 -1
- package/neural-interface/public/shared/ui-provider-auth.js +104 -0
- package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
- package/neural-interface/public/shared/ui-sessions.js +9 -3
- package/neural-interface/public/shared/ui-settings.js +55 -3
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
- package/neural-interface/public/shared/ui-sync.js +6 -0
- package/neural-interface/public/shared/ui-terminal.js +17 -0
- package/neural-interface/public/shared/ui-tool-updates.js +5 -0
- package/neural-interface/public/shared/ui-update.js +594 -5
- package/neural-interface/public/shared/ui-whatsapp.js +21 -6
- package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
- package/neural-interface/public/shared/worker-end.js +2 -0
- package/neural-interface/public/sw.js +1 -1
- package/neural-interface/public/variant/2d/main.js +10 -1
- package/neural-interface/public/variant/3d/main.js +10 -1
- package/neural-interface/server.js +1219 -608
- package/package.json +4 -1
- package/setup.js +9 -8
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// WhatsApp Link — voice notes turned into text on this computer.
|
|
2
|
+
//
|
|
3
|
+
// Runs inside the WhatsApp host (host.js → host-core.js), the only process
|
|
4
|
+
// that loads Baileys: the adapter hands over the bytes of a voice note the
|
|
5
|
+
// owner sent (the owner and duplicate checks already passed) and gets its
|
|
6
|
+
// transcript back. Local only, nothing is ever downloaded: FFmpeg decodes the
|
|
7
|
+
// note to 16 kHz mono PCM, whisper.cpp's CLI (`whisper-cli`) and a ggml model
|
|
8
|
+
// turn that into text. The user installs all three (docs/whatsapp.md → Voice
|
|
9
|
+
// notes); SynaBun ships none of them.
|
|
10
|
+
//
|
|
11
|
+
// - Both programs run with execFile (never a shell), windowsHide, a timeout,
|
|
12
|
+
// arguments only and a minimal environment. FFmpeg reads local files and
|
|
13
|
+
// pipes only, only the audio containers WhatsApp sends and only audio
|
|
14
|
+
// decoders (-protocol_whitelist, -format_whitelist, -codec_whitelist: a
|
|
15
|
+
// forwarded file is third-party data). A program found on PATH is never
|
|
16
|
+
// one inside SynaBun, a dependency's node_modules or a Windows app alias.
|
|
17
|
+
// - The decoded duration is what counts (600 s at most), whatever the
|
|
18
|
+
// message said about itself.
|
|
19
|
+
// - The audio and every copy of it live in a private scratch folder (0700,
|
|
20
|
+
// one mkdtemp per note) that is removed in `finally`; folders a crash left
|
|
21
|
+
// behind are removed when the next host starts.
|
|
22
|
+
// - No output of either program is logged or returned: it can hold the
|
|
23
|
+
// transcript. A failure is a reason and a stage word, nothing more.
|
|
24
|
+
// - One note at a time (a queue), so several notes never saturate the CPU.
|
|
25
|
+
//
|
|
26
|
+
// The main process imports the pure parts only (resolveTranscriptionTools,
|
|
27
|
+
// checkToolPath, settingsFromPrefs): Settings shows what is missing and
|
|
28
|
+
// config.js refuses a path that cannot work. It never runs a tool.
|
|
29
|
+
|
|
30
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
31
|
+
import * as nodeFs from 'node:fs';
|
|
32
|
+
import * as nodeFsp from 'node:fs/promises';
|
|
33
|
+
import { availableParallelism } from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { LIMITS, TRANSCRIPTION_LANGUAGE_RE } from './protocol.js';
|
|
37
|
+
|
|
38
|
+
export const TRANSCRIPTION_LIMITS = Object.freeze({
|
|
39
|
+
maxBytes: LIMITS.MAX_AUDIO_BYTES,
|
|
40
|
+
maxSeconds: LIMITS.MAX_AUDIO_SECONDS,
|
|
41
|
+
decodeTimeoutMs: 30_000,
|
|
42
|
+
runTimeoutMs: 180_000,
|
|
43
|
+
// Availability is looked at again on every configure, and otherwise at most this often.
|
|
44
|
+
recheckMs: 60_000,
|
|
45
|
+
maxThreads: 8,
|
|
46
|
+
// A transcript longer than this is cut (the inbound message holds 8,192 characters anyway).
|
|
47
|
+
maxTranscriptChars: 65_536,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The demuxers FFmpeg may use: the containers WhatsApp sends audio in (verified with FFmpeg 9.0.1 on macOS). */
|
|
51
|
+
export const AUDIO_FORMATS = Object.freeze(['ogg', 'mp3', 'mov', 'aac', 'amr', 'amrnb', 'amrwb', 'wav', 'matroska', 'flac']);
|
|
52
|
+
/** The decoders FFmpeg may open (probing included): the audio codecs those containers carry, FFmpeg's own names. */
|
|
53
|
+
export const AUDIO_DECODERS = Object.freeze([
|
|
54
|
+
'opus', 'vorbis', 'mp3float', 'mp3', 'aac', 'aac_fixed', 'alac', 'amrnb', 'amrwb', 'flac',
|
|
55
|
+
'pcm_s16le', 'pcm_s16be', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le', 'pcm_u8', 'pcm_alaw', 'pcm_mulaw',
|
|
56
|
+
]);
|
|
57
|
+
/** What the programs get of the environment: where to look and where temporary files go, nothing else (no keys, no proxies). */
|
|
58
|
+
const PROGRAM_ENV = Object.freeze(['PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'LC_CTYPE', 'SYSTEMROOT', 'WINDIR']);
|
|
59
|
+
// SynaBun's own folder (neural-interface/lib/whatsapp → the package root): never searched for a program.
|
|
60
|
+
const SYNABUN_ROOT = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)));
|
|
61
|
+
export const TOOL_IDS = Object.freeze(['engine', 'model', 'ffmpeg']);
|
|
62
|
+
export const MODEL_FILE = 'ggml-small.bin';
|
|
63
|
+
const SAMPLE_RATE = 16_000;
|
|
64
|
+
const PCM_BYTES_PER_SECOND = SAMPLE_RATE * 2; // mono, 16-bit
|
|
65
|
+
const SCRATCH = 'scratch';
|
|
66
|
+
const SCRATCH_PREFIX = 'vn-';
|
|
67
|
+
// What whisper.cpp writes for a stretch without speech: not words.
|
|
68
|
+
const NON_SPEECH = /\[BLANK_AUDIO\]/gi;
|
|
69
|
+
|
|
70
|
+
const pathApi = (platform) => (platform === 'win32' ? path.win32 : path.posix);
|
|
71
|
+
const programName = (name, platform) => (platform === 'win32' ? `${name}.exe` : name);
|
|
72
|
+
|
|
73
|
+
/** The variable `name` of `env`, by any letter case on Windows (Path, PATH). */
|
|
74
|
+
function envValue(env, name, platform) {
|
|
75
|
+
if (!env || typeof env !== 'object') return '';
|
|
76
|
+
if (platform !== 'win32') return typeof env[name] === 'string' ? env[name] : '';
|
|
77
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === name);
|
|
78
|
+
return key && typeof env[key] === 'string' ? env[key] : '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A UNC path or another network location (`\\server\share`, `//server/share`): not on this computer. */
|
|
82
|
+
function isNetworkPath(value) {
|
|
83
|
+
return /^[\\/]{2}/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A PATH folder that is not the user's own tools (the rules of lib/cli-resolver.js): a dependency's
|
|
88
|
+
* node_modules (an npm-launched server has node_modules/.bin on PATH), a Windows app alias folder, or
|
|
89
|
+
* SynaBun's own folder.
|
|
90
|
+
*/
|
|
91
|
+
export function isForeignProgramDir(dir, { platform = process.platform, root = SYNABUN_ROOT } = {}) {
|
|
92
|
+
const text = String(dir || '');
|
|
93
|
+
if (/(?:^|[\\/])node_modules(?:[\\/]|$)/i.test(text)) return true;
|
|
94
|
+
if (/[\\/]microsoft[\\/]windowsapps(?:[\\/]|$)/i.test(text)) return true;
|
|
95
|
+
const p = pathApi(platform);
|
|
96
|
+
const fold = (value) => p.resolve(value).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
97
|
+
const own = fold(root);
|
|
98
|
+
const here = fold(text);
|
|
99
|
+
return here === own || here.startsWith(`${own}/`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The environment a program runs with: PROGRAM_ENV only (any letter case on Windows). */
|
|
103
|
+
export function programEnv(env = process.env, platform = process.platform) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
106
|
+
if (typeof value !== 'string') continue;
|
|
107
|
+
if (PROGRAM_ENV.includes(platform === 'win32' ? key.toUpperCase() : key)) out[key] = value;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether `value` names a tool SynaBun may use: an absolute path on this
|
|
114
|
+
* computer to an existing regular file (links are followed), executable when
|
|
115
|
+
* `kind` is 'program' (an .exe on Windows), readable and not empty when it is
|
|
116
|
+
* 'file'. Never looked up on PATH.
|
|
117
|
+
* @returns {null | 'not_absolute'|'network'|'missing'|'not_file'|'not_executable'|'unreadable'|'empty'} null when it can be used
|
|
118
|
+
*/
|
|
119
|
+
export function checkToolPath(value, kind = 'program', { platform = process.platform, fs = nodeFs } = {}) {
|
|
120
|
+
const p = pathApi(platform);
|
|
121
|
+
if (typeof value !== 'string' || !value || !p.isAbsolute(value)) return 'not_absolute';
|
|
122
|
+
if (isNetworkPath(value)) return 'network';
|
|
123
|
+
let stat;
|
|
124
|
+
try { stat = fs.statSync(value); } catch { return 'missing'; }
|
|
125
|
+
if (!stat?.isFile?.()) return 'not_file';
|
|
126
|
+
if (kind === 'program') {
|
|
127
|
+
if (platform === 'win32') return /\.exe$/i.test(value) ? null : 'not_executable';
|
|
128
|
+
try { fs.accessSync(value, fs.constants?.X_OK ?? 1); } catch { return 'not_executable'; }
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!(Number(stat.size) > 0)) return 'empty';
|
|
132
|
+
try { fs.accessSync(value, fs.constants?.R_OK ?? 4); } catch { return 'unreadable'; }
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The folders a program is looked for in: PATH (absolute entries on this computer only, never a
|
|
138
|
+
* dependency's or SynaBun's own: isForeignProgramDir), then Homebrew's on macOS.
|
|
139
|
+
*/
|
|
140
|
+
export function programDirs({ env = process.env, platform = process.platform, root = SYNABUN_ROOT } = {}) {
|
|
141
|
+
const p = pathApi(platform);
|
|
142
|
+
const dirs = envValue(env, 'PATH', platform)
|
|
143
|
+
.split(platform === 'win32' ? ';' : ':')
|
|
144
|
+
.map((dir) => dir.trim().replace(/^"(.*)"$/, '$1'))
|
|
145
|
+
// A relative entry would resolve against whatever the current folder is.
|
|
146
|
+
.filter((dir) => dir && p.isAbsolute(dir) && !isNetworkPath(dir) && !isForeignProgramDir(dir, { platform, root }));
|
|
147
|
+
// A server started from the Dock or Finder may not have Homebrew on PATH.
|
|
148
|
+
if (platform === 'darwin') dirs.push('/opt/homebrew/bin', '/usr/local/bin');
|
|
149
|
+
return [...new Set(dirs)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Where the engine, the model and FFmpeg are, in this order:
|
|
154
|
+
* engine the override; else <home>/bin/whisper-cli(.exe), <home>/bin/Release/whisper-cli(.exe)
|
|
155
|
+
* (the layout of the official Windows zip), then whisper-cli on PATH and, on macOS,
|
|
156
|
+
* in /opt/homebrew/bin and /usr/local/bin
|
|
157
|
+
* ffmpeg the override; else <home>/bin/ffmpeg(.exe), then PATH and the Homebrew folders
|
|
158
|
+
* model the override; else <home>/ggml-small.bin
|
|
159
|
+
* An override is used as it is or not at all: one that cannot be used is missing (and `invalid`),
|
|
160
|
+
* never replaced by a search.
|
|
161
|
+
* @returns {{engine:string|null, model:string|null, ffmpeg:string|null, missing:string[], invalid:string[], available:boolean}}
|
|
162
|
+
*/
|
|
163
|
+
export function resolveTranscriptionTools({
|
|
164
|
+
home = null, enginePath = '', modelPath = '', ffmpegPath = '', env = process.env, platform = process.platform, fs = nodeFs, root = SYNABUN_ROOT,
|
|
165
|
+
} = {}) {
|
|
166
|
+
const p = pathApi(platform);
|
|
167
|
+
const usable = (candidate, kind) => checkToolPath(candidate, kind, { platform, fs }) === null;
|
|
168
|
+
const invalid = [];
|
|
169
|
+
const pick = (id, override, kind, candidates) => {
|
|
170
|
+
if (override) {
|
|
171
|
+
if (usable(override, kind)) return override;
|
|
172
|
+
invalid.push(id);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return candidates.find((candidate) => usable(candidate, kind)) || null;
|
|
176
|
+
};
|
|
177
|
+
const homeDir = typeof home === 'string' && home && p.isAbsolute(home) ? home : null;
|
|
178
|
+
const inHome = (...parts) => (homeDir ? [p.join(homeDir, ...parts)] : []);
|
|
179
|
+
const dirs = programDirs({ env, platform, root });
|
|
180
|
+
const onPath = (name) => dirs.map((dir) => p.join(dir, programName(name, platform)));
|
|
181
|
+
const engine = pick('engine', enginePath, 'program', [
|
|
182
|
+
...inHome('bin', programName('whisper-cli', platform)),
|
|
183
|
+
...inHome('bin', 'Release', programName('whisper-cli', platform)),
|
|
184
|
+
...onPath('whisper-cli'),
|
|
185
|
+
]);
|
|
186
|
+
const model = pick('model', modelPath, 'file', inHome(MODEL_FILE));
|
|
187
|
+
const ffmpeg = pick('ffmpeg', ffmpegPath, 'program', [...inHome('bin', programName('ffmpeg', platform)), ...onPath('ffmpeg')]);
|
|
188
|
+
const found = { engine, model, ffmpeg };
|
|
189
|
+
const missing = TOOL_IDS.filter((id) => !found[id]);
|
|
190
|
+
return { ...found, missing, invalid, available: missing.length === 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The transcription preferences of a configure (protocol.js PREFS_SPEC); a host never configured transcribes nothing. */
|
|
194
|
+
export function settingsFromPrefs(prefs = {}) {
|
|
195
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
196
|
+
return {
|
|
197
|
+
enabled: prefs?.transcriptionEnabled === true,
|
|
198
|
+
language: TRANSCRIPTION_LANGUAGE_RE.test(text(prefs?.transcriptionLanguage)) ? prefs.transcriptionLanguage : 'auto',
|
|
199
|
+
enginePath: text(prefs?.transcriptionEnginePath),
|
|
200
|
+
modelPath: text(prefs?.transcriptionModelPath),
|
|
201
|
+
ffmpegPath: text(prefs?.transcriptionFfmpegPath),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A 44-byte RIFF/WAVE header for 16 kHz mono 16-bit PCM of `dataBytes` bytes. */
|
|
206
|
+
export function wavHeader(dataBytes) {
|
|
207
|
+
const header = Buffer.alloc(44);
|
|
208
|
+
header.write('RIFF', 0, 'ascii');
|
|
209
|
+
header.writeUInt32LE(36 + dataBytes, 4);
|
|
210
|
+
header.write('WAVE', 8, 'ascii');
|
|
211
|
+
header.write('fmt ', 12, 'ascii');
|
|
212
|
+
header.writeUInt32LE(16, 16); // fmt chunk size
|
|
213
|
+
header.writeUInt16LE(1, 20); // PCM
|
|
214
|
+
header.writeUInt16LE(1, 22); // mono
|
|
215
|
+
header.writeUInt32LE(SAMPLE_RATE, 24);
|
|
216
|
+
header.writeUInt32LE(PCM_BYTES_PER_SECOND, 28);
|
|
217
|
+
header.writeUInt16LE(2, 32); // block align
|
|
218
|
+
header.writeUInt16LE(16, 34); // bits per sample
|
|
219
|
+
header.write('data', 36, 'ascii');
|
|
220
|
+
header.writeUInt32LE(dataBytes, 40);
|
|
221
|
+
return header;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// `file:` keeps a path from ever reading as an option or another protocol.
|
|
225
|
+
const fileUrl = (target) => `file:${target}`;
|
|
226
|
+
|
|
227
|
+
/** FFmpeg: local files only, WhatsApp's audio containers and audio decoders only, at most maxSeconds + 1 s of 16 kHz mono s16le. */
|
|
228
|
+
export function ffmpegArgs(input, output, { maxSeconds = TRANSCRIPTION_LIMITS.maxSeconds } = {}) {
|
|
229
|
+
return [
|
|
230
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-n',
|
|
231
|
+
'-protocol_whitelist', 'file,pipe', '-format_whitelist', AUDIO_FORMATS.join(','), '-codec_whitelist', AUDIO_DECODERS.join(','),
|
|
232
|
+
'-i', fileUrl(input),
|
|
233
|
+
'-vn', '-t', String(maxSeconds + 1), '-ac', '1', '-ar', String(SAMPLE_RATE), '-f', 's16le', fileUrl(output),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** whisper-cli: greedy decoding (-bs 1 -bo 1), text without timestamps into `<outBase>.txt`. No -ng: a Metal or CUDA build may use its GPU. */
|
|
238
|
+
export function whisperArgs({ model, wav, language, threads, outBase }) {
|
|
239
|
+
return ['-m', model, '-f', wav, '-l', language, '-t', String(threads), '-bs', '1', '-bo', '1', '-nt', '-otxt', '-of', outBase];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** whisper.cpp's text file as one line: whitespace collapsed, non-speech markers dropped. */
|
|
243
|
+
export function cleanTranscript(raw, max = TRANSCRIPTION_LIMITS.maxTranscriptChars) {
|
|
244
|
+
return String(raw ?? '').replace(NON_SPEECH, ' ').replace(/\s+/g, ' ').trim().slice(0, max).trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const STAGE_TEXT = Object.freeze({
|
|
248
|
+
missing_tool: 'a program or the model it needs is no longer there',
|
|
249
|
+
bad_audio: 'the audio was empty or too large',
|
|
250
|
+
download: 'the audio could not be stored for decoding',
|
|
251
|
+
decode_failed: 'FFmpeg could not decode the audio',
|
|
252
|
+
decode_timeout: 'FFmpeg took longer than 30 s',
|
|
253
|
+
no_audio: 'the decoded audio was empty',
|
|
254
|
+
too_long: 'the audio is longer than 10 minutes',
|
|
255
|
+
engine_failed: 'whisper-cli failed',
|
|
256
|
+
engine_timeout: 'whisper-cli took longer than 3 minutes',
|
|
257
|
+
no_text: 'whisper-cli found no words',
|
|
258
|
+
stopped: 'the WhatsApp connector stopped',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function stageError(stage) {
|
|
262
|
+
const error = new Error(stage);
|
|
263
|
+
error.stage = stage;
|
|
264
|
+
return error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {object} o
|
|
269
|
+
* @param {string|null} o.home the transcription home (paths.js transcriptionDir)
|
|
270
|
+
* @param {Function} [o.execFile] child_process.execFile-shaped (tests pass a fake)
|
|
271
|
+
* @param {object} [o.fs] sync stat/access (resolution and checks)
|
|
272
|
+
* @param {object} [o.files] fs/promises-shaped (scratch folders and files)
|
|
273
|
+
* @param {(level:string, message:string) => void} [o.log] never given subprocess output
|
|
274
|
+
*/
|
|
275
|
+
export function createTranscriber({
|
|
276
|
+
home = null,
|
|
277
|
+
env = process.env,
|
|
278
|
+
platform = process.platform,
|
|
279
|
+
execFile = nodeExecFile,
|
|
280
|
+
fs = nodeFs,
|
|
281
|
+
files = nodeFsp,
|
|
282
|
+
parallelism = () => availableParallelism(),
|
|
283
|
+
now = () => Date.now(),
|
|
284
|
+
limits = {},
|
|
285
|
+
log = () => {},
|
|
286
|
+
} = {}) {
|
|
287
|
+
const L = { ...TRANSCRIPTION_LIMITS, ...(limits || {}) };
|
|
288
|
+
const p = pathApi(platform);
|
|
289
|
+
let settings = settingsFromPrefs({});
|
|
290
|
+
let tools = null; // { at, value } of resolveTranscriptionTools
|
|
291
|
+
let chain = Promise.resolve();
|
|
292
|
+
let waiting = 0; // notes queued or running
|
|
293
|
+
let child = null; // the program running now
|
|
294
|
+
let currentDir = null; // the scratch folder of the note being transcribed
|
|
295
|
+
let stopped = false;
|
|
296
|
+
const childEnv = programEnv(env, platform);
|
|
297
|
+
const scratchRoot = typeof home === 'string' && home && p.isAbsolute(home) ? p.join(home, SCRATCH) : null;
|
|
298
|
+
|
|
299
|
+
const say = (level, message) => { try { log(level, `[whatsapp] ${message}`); } catch {} };
|
|
300
|
+
|
|
301
|
+
// What a crash left behind (the folder of a note whose `finally` never ran).
|
|
302
|
+
if (scratchRoot) {
|
|
303
|
+
try {
|
|
304
|
+
for (const name of fs.readdirSync(scratchRoot)) {
|
|
305
|
+
if (name.startsWith(SCRATCH_PREFIX)) { try { fs.rmSync(p.join(scratchRoot, name), { recursive: true, force: true }); } catch {} }
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function check({ force = false } = {}) {
|
|
311
|
+
if (!force && tools && now() - tools.at < L.recheckMs) return tools.value;
|
|
312
|
+
const value = resolveTranscriptionTools({ home, enginePath: settings.enginePath, modelPath: settings.modelPath, ffmpegPath: settings.ffmpegPath, env, platform, fs });
|
|
313
|
+
tools = { at: now(), value };
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** New preferences (every configure): the tools are looked at again at once. */
|
|
318
|
+
function configure(prefs = {}) {
|
|
319
|
+
settings = settingsFromPrefs(prefs);
|
|
320
|
+
tools = null;
|
|
321
|
+
check({ force: true });
|
|
322
|
+
return status();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function status() {
|
|
326
|
+
const v = check();
|
|
327
|
+
return { enabled: settings.enabled, available: v.available, missing: [...v.missing], invalid: [...v.invalid], language: settings.language, pending: waiting };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Text ready to be asked for: on, and the engine, the model and FFmpeg found (looked at again at most every minute). */
|
|
331
|
+
function available() {
|
|
332
|
+
return settings.enabled && !!scratchRoot && check().available;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Right before a program runs: the file it was resolved to is still a usable one. */
|
|
336
|
+
function ensure(file, kind) {
|
|
337
|
+
if (checkToolPath(file, kind, { platform, fs }) !== null) throw stageError('missing_tool');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function run(file, args, { timeoutMs, cwd, timeoutStage, failStage }) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
if (stopped) { reject(stageError('stopped')); return; }
|
|
343
|
+
let proc;
|
|
344
|
+
try {
|
|
345
|
+
proc = execFile(file, args, {
|
|
346
|
+
cwd, env: childEnv, timeout: timeoutMs, killSignal: 'SIGKILL', windowsHide: true, maxBuffer: 8 * 1024 * 1024, encoding: 'buffer',
|
|
347
|
+
}, (error) => {
|
|
348
|
+
if (child === proc) child = null;
|
|
349
|
+
// Neither stdout nor stderr is read: they can hold the transcript.
|
|
350
|
+
if (!error) { resolve(); return; }
|
|
351
|
+
if (stopped) reject(stageError('stopped'));
|
|
352
|
+
else if (error.code === 'ENOENT' || error.code === 'EACCES') reject(stageError('missing_tool'));
|
|
353
|
+
else if (error.killed && error.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') reject(stageError(timeoutStage));
|
|
354
|
+
else reject(stageError(failStage));
|
|
355
|
+
});
|
|
356
|
+
} catch {
|
|
357
|
+
reject(stageError(failStage));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
child = proc;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function transcribeNow(audio) {
|
|
365
|
+
if (stopped) throw stageError('stopped');
|
|
366
|
+
if (!settings.enabled) return { ok: false, reason: 'transcription_disabled', stage: 'disabled' };
|
|
367
|
+
const bytes = Buffer.isBuffer(audio) ? audio : Buffer.from(audio || []);
|
|
368
|
+
if (!bytes.length) throw stageError('bad_audio');
|
|
369
|
+
if (bytes.length > L.maxBytes) return { ok: false, reason: 'too_large', stage: 'too_large' };
|
|
370
|
+
// Checked again right before anything runs: an override (or a found program) may be gone or replaced.
|
|
371
|
+
const found = check({ force: true });
|
|
372
|
+
if (!found.available || !scratchRoot) throw stageError('missing_tool');
|
|
373
|
+
let dir = null;
|
|
374
|
+
try {
|
|
375
|
+
try {
|
|
376
|
+
await files.mkdir(scratchRoot, { recursive: true, mode: 0o700 });
|
|
377
|
+
await files.chmod(scratchRoot, 0o700).catch(() => {});
|
|
378
|
+
dir = await files.mkdtemp(p.join(scratchRoot, SCRATCH_PREFIX));
|
|
379
|
+
currentDir = dir;
|
|
380
|
+
await files.chmod(dir, 0o700).catch(() => {});
|
|
381
|
+
} catch {
|
|
382
|
+
throw stageError('download');
|
|
383
|
+
}
|
|
384
|
+
const input = p.join(dir, 'audio.in');
|
|
385
|
+
const pcmPath = p.join(dir, 'audio.pcm');
|
|
386
|
+
const wavPath = p.join(dir, 'audio.wav');
|
|
387
|
+
const outBase = p.join(dir, 'transcript');
|
|
388
|
+
try { await files.writeFile(input, bytes, { mode: 0o600, flag: 'wx' }); } catch { throw stageError('download'); }
|
|
389
|
+
ensure(found.ffmpeg, 'program');
|
|
390
|
+
await run(found.ffmpeg, ffmpegArgs(input, pcmPath, { maxSeconds: L.maxSeconds }), { timeoutMs: L.decodeTimeoutMs, cwd: dir, timeoutStage: 'decode_timeout', failStage: 'decode_failed' });
|
|
391
|
+
let pcm;
|
|
392
|
+
try { pcm = await files.readFile(pcmPath); } catch { throw stageError('decode_failed'); }
|
|
393
|
+
// The decoded duration counts, whatever the message declared about itself.
|
|
394
|
+
if (pcm.length > L.maxSeconds * PCM_BYTES_PER_SECOND) return { ok: false, reason: 'too_long', stage: 'too_long' };
|
|
395
|
+
const samples = pcm.length - (pcm.length % 2);
|
|
396
|
+
if (samples <= 0) throw stageError('no_audio');
|
|
397
|
+
try { await files.writeFile(wavPath, Buffer.concat([wavHeader(samples), pcm.subarray(0, samples)]), { mode: 0o600, flag: 'wx' }); } catch { throw stageError('decode_failed'); }
|
|
398
|
+
pcm = null;
|
|
399
|
+
const threads = Math.max(1, Math.min(L.maxThreads, Number(parallelism()) || 1));
|
|
400
|
+
ensure(found.engine, 'program');
|
|
401
|
+
ensure(found.model, 'file');
|
|
402
|
+
await run(found.engine, whisperArgs({ model: found.model, wav: wavPath, language: settings.language, threads, outBase }), { timeoutMs: L.runTimeoutMs, cwd: dir, timeoutStage: 'engine_timeout', failStage: 'engine_failed' });
|
|
403
|
+
let raw;
|
|
404
|
+
try { raw = await files.readFile(`${outBase}.txt`, 'utf8'); } catch { throw stageError('engine_failed'); }
|
|
405
|
+
const text = cleanTranscript(raw, L.maxTranscriptChars);
|
|
406
|
+
if (!text) throw stageError('no_text');
|
|
407
|
+
return { ok: true, text, seconds: Math.round((samples / PCM_BYTES_PER_SECOND) * 10) / 10 };
|
|
408
|
+
} finally {
|
|
409
|
+
if (currentDir === dir) currentDir = null;
|
|
410
|
+
if (dir) await files.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One voice note → { ok: true, text, seconds } | { ok: false, reason, stage }. Never rejects.
|
|
416
|
+
* reason: 'too_long' (decoded audio over the limit), 'too_large', 'transcription_disabled' or
|
|
417
|
+
* 'transcription_failed' (a program or file missing, a failed or timed-out run, no words).
|
|
418
|
+
* Notes wait their turn: one transcription at a time.
|
|
419
|
+
*/
|
|
420
|
+
function transcribe(audio) {
|
|
421
|
+
waiting += 1;
|
|
422
|
+
const job = async () => {
|
|
423
|
+
try {
|
|
424
|
+
const result = await transcribeNow(audio);
|
|
425
|
+
if (!result.ok && result.stage !== 'disabled') say('warn', `a voice note was not transcribed: ${STAGE_TEXT[result.stage] || result.stage}`);
|
|
426
|
+
return result;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const stage = typeof error?.stage === 'string' ? error.stage : 'engine_failed';
|
|
429
|
+
say('warn', `a voice note was not transcribed: ${STAGE_TEXT[stage] || 'it failed'}`);
|
|
430
|
+
return { ok: false, reason: 'transcription_failed', stage };
|
|
431
|
+
} finally {
|
|
432
|
+
waiting -= 1;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const result = chain.then(job, job);
|
|
436
|
+
chain = result.then(() => {}, () => {});
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The host is going away: the program running now is killed, its scratch folder removed at once
|
|
442
|
+
* (the process may exit before the note's own finally runs), and the notes waiting fail.
|
|
443
|
+
*/
|
|
444
|
+
function stop() {
|
|
445
|
+
stopped = true;
|
|
446
|
+
const proc = child;
|
|
447
|
+
child = null;
|
|
448
|
+
if (proc) { try { proc.kill('SIGKILL'); } catch {} }
|
|
449
|
+
const dir = currentDir;
|
|
450
|
+
currentDir = null;
|
|
451
|
+
if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
configure,
|
|
456
|
+
status,
|
|
457
|
+
available,
|
|
458
|
+
transcribe,
|
|
459
|
+
stop,
|
|
460
|
+
get enabled() { return settings.enabled; },
|
|
461
|
+
get pending() { return waiting; },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neural-memory-interface",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "neural-memory-interface",
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.7",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"dependencies": {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
6
6
|
<title>SynApp</title>
|
|
7
7
|
<meta name="theme-color" content="#0e0e10">
|
|
8
|
+
<!-- Inside the SynaBun desktop app the navbar is the window's title bar (shared/desktop-titlebar.js). -->
|
|
9
|
+
<meta name="synabun-titlebar" content="navbar">
|
|
8
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
9
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
10
12
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
6
6
|
<title>SynApp</title>
|
|
7
7
|
<meta name="theme-color" content="#0e0e10">
|
|
8
|
+
<!-- Inside the SynaBun desktop app the navbar is the window's title bar (shared/desktop-titlebar.js). -->
|
|
9
|
+
<meta name="synabun-titlebar" content="navbar">
|
|
8
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
9
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
10
12
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
@@ -511,6 +511,12 @@
|
|
|
511
511
|
const hasBridge = typeof createStartBridge === 'function';
|
|
512
512
|
const windows = hasBridge ? isWindowsPlatform(navigator) : /Win/.test(navigator.platform);
|
|
513
513
|
const mac = hasBridge ? isMacPlatform(navigator) : /Mac/.test(navigator.platform);
|
|
514
|
+
// Inside the SynaBun desktop app (its preload puts window.synabunDesktop on every page of its server) the app
|
|
515
|
+
// starts and restarts the server itself: no Start button and no command to type, one sentence instead, and the
|
|
516
|
+
// page reconnects when the server is back. The sentence is DESKTOP_COPY.offline.hint of
|
|
517
|
+
// shared/desktop-update-view.js, written out here because this page can load nothing.
|
|
518
|
+
const desktopApp = !!(window.synabunDesktop && window.synabunDesktop.updates);
|
|
519
|
+
const DESKTOP_HINT = 'The SynaBun app restarts its server by itself. If it stays offline, quit SynaBun and open it again.';
|
|
514
520
|
|
|
515
521
|
// --- Commands to type instead ---
|
|
516
522
|
const commands = hasBridge
|
|
@@ -578,7 +584,16 @@
|
|
|
578
584
|
setTimeout(() => location.reload(), delay);
|
|
579
585
|
}
|
|
580
586
|
|
|
581
|
-
if (
|
|
587
|
+
if (desktopApp) {
|
|
588
|
+
$('start').hidden = true;
|
|
589
|
+
$('manual').hidden = true;
|
|
590
|
+
$('subtext').textContent = DESKTOP_HINT;
|
|
591
|
+
const interval = setInterval(async () => {
|
|
592
|
+
if (!(await serverAnswers())) return;
|
|
593
|
+
clearInterval(interval);
|
|
594
|
+
reconnect(150);
|
|
595
|
+
}, 3000);
|
|
596
|
+
} else if (!hasBridge) {
|
|
582
597
|
// An older server served this page without the bridge: commands and reconnect only.
|
|
583
598
|
const interval = setInterval(async () => {
|
|
584
599
|
if (!(await serverAnswers())) return;
|