arcane-os 0.57.0 → 0.59.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 (60) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -2
  3. package/browser-runtime/ai/core-onnx.mjs +96 -0
  4. package/browser-runtime/ai/onnx-tensors.mjs +85 -0
  5. package/browser-runtime/core/client.mjs +152 -17
  6. package/docs/architecture.md +5 -2
  7. package/docs/platform-targets.md +30 -13
  8. package/docs/reference/README.md +22 -3
  9. package/docs/reference/availability-and-normalization.md +14 -7
  10. package/docs/reference/cli.md +22 -10
  11. package/docs/reference/core-client.md +87 -0
  12. package/docs/reference/core-native-packaging.md +129 -9
  13. package/docs/reference/core-preferences.md +49 -0
  14. package/docs/reference/diarization.md +206 -0
  15. package/docs/reference/git-text-snapshot.md +104 -0
  16. package/docs/reference/inventory/package-api.json +205 -23
  17. package/docs/reference/local-ai.md +145 -11
  18. package/docs/reference/protocols.md +28 -19
  19. package/docs/reference/pwa.md +74 -4
  20. package/docs/reference/sdk-api.md +589 -39
  21. package/package.json +11 -3
  22. package/schemas/arcane-app.schema.json +3 -3
  23. package/schemas/arcane-package.schema.json +2 -2
  24. package/schemas/native-build-plan.schema.json +27 -1
  25. package/schemas/target-adapter.schema.json +2 -0
  26. package/src/app-descriptor.mjs +1 -1
  27. package/src/cli/main.mjs +14 -4
  28. package/src/constants.mjs +2 -0
  29. package/src/core/host.mjs +14 -5
  30. package/src/core/hosts/macos/ArcaneHost.h +86 -0
  31. package/src/core/hosts/macos/ArcaneHost.m +455 -0
  32. package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
  33. package/src/core/hosts/windows/ArcaneLauncher.cs +4 -1
  34. package/src/core/services/local-ai.mjs +82 -4
  35. package/src/core/services/packaged-web.mjs +89 -0
  36. package/src/core/services/preferences.mjs +201 -0
  37. package/src/core-classic-source.mjs +2 -2
  38. package/src/dev-server.mjs +2 -5
  39. package/src/diarization/build.mjs +35 -0
  40. package/src/diarization/index.mjs +432 -0
  41. package/src/diarization/native/CMakeLists.txt +9 -0
  42. package/src/diarization/native/main.cpp +269 -0
  43. package/src/diarization/service.mjs +129 -0
  44. package/src/git-text-snapshot.mjs +265 -0
  45. package/src/import-map.mjs +9 -4
  46. package/src/index.mjs +1 -0
  47. package/src/local-ai/config.mjs +1 -1
  48. package/src/local-ai/install.mjs +134 -19
  49. package/src/local-ai/onnx-worker.mjs +85 -0
  50. package/src/local-ai/onnx.mjs +368 -0
  51. package/src/native/macos-provider.mjs +291 -0
  52. package/src/native/portable-layout.mjs +10 -2
  53. package/src/native/portable-provider.mjs +10 -5
  54. package/src/native-plan.mjs +9 -1
  55. package/src/native-provider-loader.mjs +3 -1
  56. package/src/process.mjs +123 -17
  57. package/src/scaffold.mjs +1 -1
  58. package/src/targets/index.mjs +11 -1
  59. package/src/templates/workspace-template.mjs +6 -5
  60. package/src/toolchain.mjs +42 -13
@@ -0,0 +1,269 @@
1
+ #include <nemo_speech/diar.h>
2
+
3
+ #include <cstdint>
4
+ #include <cstring>
5
+ #include <iomanip>
6
+ #include <iostream>
7
+ #include <limits>
8
+ #include <map>
9
+ #include <memory>
10
+ #include <sstream>
11
+ #include <stdexcept>
12
+ #include <string>
13
+ #include <vector>
14
+
15
+ #ifdef _WIN32
16
+ #include <fcntl.h>
17
+ #include <io.h>
18
+ #include <windows.h>
19
+ #endif
20
+
21
+ namespace {
22
+
23
+ using StreamHandle = std::unique_ptr<nemo_speech_diar_stream, decltype(&nemo_speech_diar_stream_close)>;
24
+ using ModelHandle = std::unique_ptr<nemo_speech_diar_model, decltype(&nemo_speech_diar_destroy)>;
25
+
26
+ struct Stream {
27
+ StreamHandle handle;
28
+ int sample_rate;
29
+ bool probabilities;
30
+ bool finished = false;
31
+ int64_t delivered_frames = 0;
32
+ };
33
+
34
+ void check(nemo_speech_asr_status status) {
35
+ if (status != NEMO_SPEECH_ASR_OK) {
36
+ throw std::runtime_error(nemo_speech_asr_last_error());
37
+ }
38
+ }
39
+
40
+ std::string json_string(const std::string& value) {
41
+ std::ostringstream output;
42
+ output << '"';
43
+ for (const unsigned char character : value) {
44
+ switch (character) {
45
+ case '"': output << "\\\""; break;
46
+ case '\\': output << "\\\\"; break;
47
+ case '\n': output << "\\n"; break;
48
+ case '\r': output << "\\r"; break;
49
+ case '\t': output << "\\t"; break;
50
+ default:
51
+ if (character < 0x20) {
52
+ output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
53
+ << static_cast<unsigned int>(character) << std::dec;
54
+ } else {
55
+ output << character;
56
+ }
57
+ }
58
+ }
59
+ output << '"';
60
+ return output.str();
61
+ }
62
+
63
+ void emit_error(uint64_t request, const std::string& message) {
64
+ std::cout << "{\"request\":" << request << ",\"error\":" << json_string(message)
65
+ << "}\n" << std::flush;
66
+ }
67
+
68
+ void emit_ack(uint64_t request) {
69
+ std::cout << "{\"request\":" << request << ",\"ok\":true}\n" << std::flush;
70
+ }
71
+
72
+ // Only transport framing uses sample count * sizeof(float). Samples are decoded
73
+ // losslessly from little-endian float32; the caller's audio is never normalized.
74
+ std::vector<float> read_audio(size_t samples) {
75
+ std::vector<float> audio(samples);
76
+ std::vector<unsigned char> encoded(samples * sizeof(float));
77
+ if (!std::cin.read(reinterpret_cast<char*>(encoded.data()), encoded.size())) {
78
+ throw std::runtime_error("Diarization input ended during an audio frame.");
79
+ }
80
+ for (size_t index = 0; index < samples; ++index) {
81
+ const auto* value = encoded.data() + index * sizeof(float);
82
+ const uint32_t bits = static_cast<uint32_t>(value[0])
83
+ | (static_cast<uint32_t>(value[1]) << 8)
84
+ | (static_cast<uint32_t>(value[2]) << 16)
85
+ | (static_cast<uint32_t>(value[3]) << 24);
86
+ std::memcpy(&audio[index], &bits, sizeof(float));
87
+ }
88
+ return audio;
89
+ }
90
+
91
+ void emit_result(uint64_t request, Stream& stream, int speakers, double seconds_per_frame) {
92
+ const int64_t frame_count = nemo_speech_diar_frame_count(stream.handle.get());
93
+ if (!stream.finished && frame_count == stream.delivered_frames) {
94
+ // Many small caller pushes only fill the native right-context buffer.
95
+ // Do not re-scan and serialize unchanged history for those pushes.
96
+ emit_ack(request);
97
+ return;
98
+ }
99
+ size_t count = 0;
100
+ check(nemo_speech_diar_segments(stream.handle.get(), nullptr, nullptr, 0, &count));
101
+ std::vector<nemo_speech_diar_segment> segments(count);
102
+ if (count != 0) {
103
+ check(nemo_speech_diar_segments(stream.handle.get(), nullptr, segments.data(), count, &count));
104
+ }
105
+
106
+ const int64_t retained_start = nemo_speech_diar_frame_probs_start(stream.handle.get());
107
+ std::vector<float> probabilities;
108
+ if (stream.probabilities) {
109
+ if (retained_start > stream.delivered_frames) {
110
+ throw std::runtime_error("Upstream compacted probability frames before they could be delivered.");
111
+ }
112
+ probabilities.resize(static_cast<size_t>(frame_count - retained_start) * speakers);
113
+ if (!probabilities.empty()) {
114
+ check(nemo_speech_diar_frame_probs(stream.handle.get(), probabilities.data(), probabilities.size()));
115
+ }
116
+ }
117
+
118
+ // Build one complete protocol record before writing it. A C-ABI failure
119
+ // therefore cannot leave a partially emitted result on stdout.
120
+ std::ostringstream output;
121
+ output << std::setprecision(std::numeric_limits<double>::max_digits10);
122
+ output << "{\"request\":" << request << ",\"result\":{\"final\":"
123
+ << (stream.finished ? "true" : "false")
124
+ << ",\"speakers\":" << speakers
125
+ << ",\"secondsPerFrame\":" << seconds_per_frame
126
+ << ",\"frameCount\":" << frame_count << ",\"segments\":[";
127
+ for (size_t index = 0; index < count; ++index) {
128
+ const auto& segment = segments[index];
129
+ if (index != 0) output << ',';
130
+ output << "{\"speaker\":" << segment.speaker << ",\"startTime\":" << segment.start_time
131
+ << ",\"endTime\":" << segment.end_time << '}';
132
+ }
133
+ output << ']';
134
+ if (stream.probabilities) {
135
+ output << ",\"probabilities\":{\"startFrame\":" << stream.delivered_frames
136
+ << ",\"values\":[";
137
+ const size_t first = static_cast<size_t>(stream.delivered_frames - retained_start) * speakers;
138
+ for (size_t index = first; index < probabilities.size(); ++index) {
139
+ if (index != first) output << ',';
140
+ output << probabilities[index];
141
+ }
142
+ output << "]}";
143
+ }
144
+ output << "}}\n";
145
+ std::cout << output.str() << std::flush;
146
+ stream.delivered_frames = frame_count;
147
+ }
148
+
149
+ int serve(const std::string& model_path) {
150
+ #ifdef _WIN32
151
+ _setmode(_fileno(stdin), _O_BINARY);
152
+ _setmode(_fileno(stdout), _O_BINARY);
153
+ #endif
154
+ nemo_speech_diar_model_config configuration = {};
155
+ configuration.size = sizeof(configuration);
156
+ configuration.model_path = model_path.c_str();
157
+ configuration.gpu = -1;
158
+ configuration.preset = "v3-streaming";
159
+ configuration.left_context_frames = -1;
160
+ nemo_speech_diar_model* raw_model = nullptr;
161
+ check(nemo_speech_diar_create(&configuration, &raw_model));
162
+ const ModelHandle model(raw_model, nemo_speech_diar_destroy);
163
+ const int speakers = nemo_speech_diar_num_speakers(model.get());
164
+ const double seconds_per_frame = nemo_speech_diar_seconds_per_frame(model.get());
165
+
166
+ // Streams are destroyed before their retained model. The C ABI serializes
167
+ // model compute internally; one command loop also preserves each stream's
168
+ // required single-threaded access without duplicating the native scheduler.
169
+ std::map<uint64_t, Stream> streams;
170
+ std::cout << "{\"ready\":true,\"speakers\":" << speakers
171
+ << ",\"secondsPerFrame\":" << std::setprecision(17) << seconds_per_frame
172
+ << "}\n" << std::flush;
173
+
174
+ std::string line;
175
+ while (std::getline(std::cin, line)) {
176
+ uint64_t request = 0;
177
+ uint64_t id = 0;
178
+ std::string operation;
179
+ std::istringstream command(line);
180
+ if (!(command >> operation >> request >> id)) {
181
+ emit_error(request, "Unreadable diarization command.");
182
+ return 1;
183
+ }
184
+ try {
185
+ if (operation == "open") {
186
+ int sample_rate = 0;
187
+ int probabilities = 0;
188
+ if (!(command >> sample_rate >> probabilities)) {
189
+ throw std::runtime_error("Unreadable stream configuration.");
190
+ }
191
+ if (streams.count(id)) throw std::runtime_error("The stream is already open.");
192
+ nemo_speech_diar_stream* raw_stream = nullptr;
193
+ check(nemo_speech_diar_stream_open(model.get(), &raw_stream));
194
+ streams.emplace(id, Stream{
195
+ StreamHandle(raw_stream, nemo_speech_diar_stream_close), sample_rate, probabilities != 0
196
+ });
197
+ emit_ack(request);
198
+ } else if (operation == "close") {
199
+ streams.erase(id);
200
+ emit_ack(request);
201
+ } else {
202
+ // Consume the complete framed audio before looking up its owner,
203
+ // so an operation error cannot desynchronize the command stream.
204
+ std::vector<float> audio;
205
+ if (operation == "push") {
206
+ size_t samples = 0;
207
+ if (!(command >> samples)) throw std::runtime_error("Unreadable audio sample count.");
208
+ audio = read_audio(samples);
209
+ }
210
+ Stream& stream = streams.at(id);
211
+ if (stream.finished) throw std::runtime_error("The stream has already finished.");
212
+ if (operation == "push") {
213
+ check(nemo_speech_diar_stream_push_f32(
214
+ stream.handle.get(), audio.data(), audio.size(), stream.sample_rate
215
+ ));
216
+ } else if (operation == "finish") {
217
+ check(nemo_speech_diar_stream_finish(stream.handle.get()));
218
+ stream.finished = true;
219
+ } else {
220
+ throw std::runtime_error("Unknown diarization operation.");
221
+ }
222
+ emit_result(request, stream, speakers, seconds_per_frame);
223
+ }
224
+ } catch (const std::exception& error) {
225
+ emit_error(request, error.what());
226
+ if (!std::cin) return 1;
227
+ }
228
+ }
229
+ // EOF is the owner's graceful shutdown. No stream outlives the model, and
230
+ // there is no claim that EOF interrupts an active native inference call.
231
+ return 0;
232
+ }
233
+
234
+ int run(const std::string& model_path) {
235
+ try {
236
+ return serve(model_path);
237
+ } catch (const std::exception& error) {
238
+ emit_error(0, error.what());
239
+ return 1;
240
+ }
241
+ }
242
+
243
+ } // namespace
244
+
245
+ #ifdef _WIN32
246
+ int wmain(int argc, wchar_t** argv) {
247
+ if (argc != 2) {
248
+ emit_error(0, "Usage: arcane-diarization MODEL.gguf");
249
+ return 1;
250
+ }
251
+ const int length = WideCharToMultiByte(CP_UTF8, 0, argv[1], -1, nullptr, 0, nullptr, nullptr);
252
+ if (length == 0) {
253
+ emit_error(0, "Windows could not encode the selected model path as UTF-8.");
254
+ return 1;
255
+ }
256
+ std::string model_path(length, '\0');
257
+ WideCharToMultiByte(CP_UTF8, 0, argv[1], -1, model_path.data(), length, nullptr, nullptr);
258
+ model_path.pop_back();
259
+ return run(model_path);
260
+ }
261
+ #else
262
+ int main(int argc, char** argv) {
263
+ if (argc != 2) {
264
+ emit_error(0, "Usage: arcane-diarization MODEL.gguf");
265
+ return 1;
266
+ }
267
+ return run(argv[1]);
268
+ }
269
+ #endif
@@ -0,0 +1,129 @@
1
+ import path from 'node:path';
2
+ import Is from 'strong-type';
3
+ import {createDiarization} from './index.mjs';
4
+ import {serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
5
+
6
+ const is = new Is(false);
7
+
8
+ /** Synchronous Core service factory; loading waits only at diarization calls. */
9
+ export function createDiarizationService(options = {}, launch = {}) {
10
+ const workspaceRoot = launch.workspaceRoot ?? launch.appRoot ?? process.cwd();
11
+ const streams = new Map();
12
+ let nextStream = 0;
13
+ let model = null;
14
+ let context = null;
15
+ let closing = null;
16
+ let unsubscribe = null;
17
+
18
+ function current() {
19
+ const state = model?.current() ?? {state: 'unloaded', model: 'nvidia/Nemotron-3-Diarization'};
20
+ return {...state, error: state.error ? serializeCoreError(state.error) : null};
21
+ }
22
+
23
+ function load() {
24
+ if (closing) throw new Error('The diarization service is closing.');
25
+ if (!model) {
26
+ model = createDiarization({
27
+ executable: resolveSelectedPath(options.executable, workspaceRoot, 'executable'),
28
+ modelPath: resolveSelectedPath(options.modelPath, workspaceRoot, 'modelPath'),
29
+ runtime: options.runtime,
30
+ onEvent: options.onEvent
31
+ });
32
+ unsubscribe = model.subscribe(function modelState() {
33
+ context?.emit('diarization.state', current());
34
+ });
35
+ }
36
+ return model.ready;
37
+ }
38
+
39
+ function streamFor(id) {
40
+ const stream = streams.get(id);
41
+ if (!stream) throw new Error(`Diarization stream ${id} is not open.`);
42
+ return stream;
43
+ }
44
+
45
+ async function open({sampleRate = 16000, probabilities = false} = {}) {
46
+ await load();
47
+ const streamId = ++nextStream;
48
+ const stream = await model.openStream({
49
+ sampleRate,
50
+ onUpdate: function update(result) {
51
+ context?.emit('diarization.update', {streamId, result});
52
+ },
53
+ onProbabilities: probabilities ? function probabilityFrames(result) {
54
+ context?.emit('diarization.probabilities', {streamId, ...result});
55
+ } : undefined
56
+ });
57
+ streams.set(streamId, stream);
58
+ return {streamId, sampleRate};
59
+ }
60
+
61
+ async function push({streamId, audio}) {
62
+ return streamFor(streamId).push(decodeAudio(audio));
63
+ }
64
+
65
+ async function finish({streamId}) {
66
+ const stream = streamFor(streamId);
67
+ try { return await stream.finish(); } finally { streams.delete(streamId); }
68
+ }
69
+
70
+ async function cancel({streamId}) {
71
+ const stream = streams.get(streamId);
72
+ if (stream) {
73
+ try { await stream.cancel(); } finally { streams.delete(streamId); }
74
+ }
75
+ return {streamId, cancelled: true};
76
+ }
77
+
78
+ async function recording({audio, sampleRate = 16000}, {signal}) {
79
+ await load();
80
+ return model.diarize({audio: decodeAudio(audio), sampleRate, signal});
81
+ }
82
+
83
+ function drain() {
84
+ if (closing) return closing;
85
+ closing = (async function drainService() {
86
+ try { await model?.close(); } finally {
87
+ unsubscribe?.();
88
+ streams.clear();
89
+ }
90
+ })();
91
+ return closing;
92
+ }
93
+
94
+ return {
95
+ name: 'diarization',
96
+ start: function attachRuntime(owner) { context = owner; },
97
+ methods: {
98
+ 'diarization.status': current,
99
+ 'diarization.load': {lifetime: 'service', handle: load},
100
+ 'diarization.open': {lifetime: 'service', handle: open},
101
+ 'diarization.push': {lifetime: 'service', handle: push},
102
+ 'diarization.finish': {lifetime: 'service', handle: finish},
103
+ 'diarization.cancel': {lifetime: 'service', handle: cancel},
104
+ 'diarization.recording': recording
105
+ },
106
+ drain,
107
+ dispose: drain
108
+ };
109
+ }
110
+
111
+ function resolveSelectedPath(value, workspaceRoot, name) {
112
+ if (!is.string(value)) throw new TypeError(`Diarization requires ${name}.`);
113
+ return path.resolve(workspaceRoot, value);
114
+ }
115
+
116
+ function decodeAudio(audio) {
117
+ if (audio?.encoding !== 'f32le' || !is.string(audio.data)) {
118
+ throw new TypeError('Core audio requires {encoding: "f32le", data: BASE64}.');
119
+ }
120
+ const encoded = Buffer.from(audio.data, 'base64');
121
+ // Framing belongs to the transport. A partial sample is not usable audio.
122
+ if (encoded.length % 4 !== 0) throw new TypeError('The f32le transport ends during a sample.');
123
+ const samples = new Float32Array(encoded.length / 4);
124
+ const bits = new Uint32Array(samples.buffer);
125
+ for (let index = 0; index < samples.length; index++) bits[index] = encoded.readUInt32LE(index * 4);
126
+ return samples;
127
+ }
128
+
129
+ export default createDiarizationService;
@@ -0,0 +1,265 @@
1
+ import Is from 'strong-type';
2
+ import {mkdir, readdir} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {runProcess} from './process.mjs';
5
+ import {ArcaneError, ERROR_CODES, throwIfAborted} from './errors.mjs';
6
+ import {createEventQueue} from './event-queue.mjs';
7
+
8
+ const is = new Is(false);
9
+ const snapshotRef = 'refs/arcane/text-snapshot';
10
+
11
+ /**
12
+ * Read complete selected text from a fetched revision, never a working tree.
13
+ * One application service owns each dedicated cache directory. Construction
14
+ * performs no I/O; refresh coalesces callers and close drains accepted work.
15
+ */
16
+ export function createGitTextSnapshot({cacheDirectory, remote, ref, selectPath, onEvent, run = runProcess} = {}) {
17
+ for (const [name, value] of Object.entries({cacheDirectory, remote, ref})) {
18
+ if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
19
+ }
20
+ if (!is.function(selectPath)) throw new TypeError('selectPath must be a function.');
21
+ const directory = path.resolve(cacheDirectory);
22
+ let prepared = false;
23
+ let retained = null;
24
+ let active = null;
25
+ let closing = null;
26
+
27
+ async function git(args, options = {}) {
28
+ let outputFailure;
29
+ const execution = {...options};
30
+ if (options.onOutput) {
31
+ execution.onOutput = async function consumeGitOutput(record) {
32
+ try { await options.onOutput(record); }
33
+ catch (error) { outputFailure ??= error; throw error; }
34
+ };
35
+ }
36
+ try {
37
+ return await run('git', ['--git-dir', directory, ...args], execution);
38
+ } catch (error) {
39
+ // runProcess first drains its child and preserves complete transport
40
+ // failures. Keep the Git parser's public code with that full cause.
41
+ if (outputFailure instanceof ArcaneError) {
42
+ throw new ArcaneError(outputFailure.code, outputFailure.message,
43
+ {details: outputFailure.details, cause: error});
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async function prepare(events) {
50
+ if (prepared) return;
51
+ await mkdir(directory, {recursive: true});
52
+ if ((await readdir(directory)).length === 0) {
53
+ await run('git', ['init', '--bare', directory], {onEvent: events.send});
54
+ }
55
+ const result = await git(['rev-parse', '--is-bare-repository'], {onEvent: events.send});
56
+ // This is the selected cache's functional role: never reinterpret an
57
+ // existing working checkout as disposable object-cache storage.
58
+ if (result.stdout.trim() !== 'true') {
59
+ throw new ArcaneError('ARCANE_GIT_SNAPSHOT_CACHE_INVALID', 'The snapshot cache must be a bare Git repository.');
60
+ }
61
+ prepared = true;
62
+ }
63
+
64
+ async function acquire() {
65
+ const events = createEventQueue(onEvent);
66
+ try {
67
+ await events.send({type: 'git.snapshot.refreshing', message: 'Refreshing the selected repository revision.', data: {ref}});
68
+ await prepare(events);
69
+ await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remote, `+${ref}:${snapshotRef}`], {onEvent: events.send});
70
+ const selected = await git(['rev-parse', '--verify', `${snapshotRef}^{commit}`], {onEvent: events.send});
71
+ const revision = selected.stdout.trim();
72
+ if (retained?.revision === revision) {
73
+ await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.', data: {revision, reused: true}});
74
+ await events.drain();
75
+ return retained;
76
+ }
77
+
78
+ const entries = [];
79
+ let treeParts = [];
80
+ await git(['ls-tree', '-r', '-z', '--full-tree', revision], {
81
+ outputEncoding: {stdout: null},
82
+ captureOutput: {stdout: false},
83
+ emitOutputEvents: {stdout: false},
84
+ onEvent: events.send,
85
+ onOutput: async function readTree({stream, chunk}) {
86
+ if (stream !== 'stdout') return;
87
+ let start = 0;
88
+ for (let end = chunk.indexOf(0); end !== -1; end = chunk.indexOf(0, start)) {
89
+ treeParts.push(chunk.subarray(start, end));
90
+ const record = Buffer.concat(treeParts);
91
+ treeParts = [];
92
+ const tab = record.indexOf(9);
93
+ if (tab < 0) throw protocolError('Git returned an unreadable tree record.', {record});
94
+ const [mode, type, object] = record.subarray(0, tab).toString('ascii').split(' ');
95
+ const filename = decodeText(record.subarray(tab + 1), 'repository path');
96
+ if (await selectPath(filename)) {
97
+ if (type !== 'blob') throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT',
98
+ `The selected path is a ${type}, not a text blob.`, {details: {path: filename, mode, type}});
99
+ entries.push({path: filename, object});
100
+ }
101
+ start = end + 1;
102
+ }
103
+ if (start < chunk.length) treeParts.push(chunk.subarray(start));
104
+ }
105
+ });
106
+ if (treeParts.length) throw protocolError('Git ended before its tree record delimiter.', {record: Buffer.concat(treeParts)});
107
+
108
+ const reader = createBlobReader(entries);
109
+ if (entries.length) {
110
+ async function* requestedObjects() {
111
+ for (const entry of entries) yield `${entry.object}\n`;
112
+ }
113
+ await git(['cat-file', '--batch'], {
114
+ input: requestedObjects(),
115
+ outputEncoding: {stdout: null},
116
+ captureOutput: {stdout: false},
117
+ emitOutputEvents: {stdout: false},
118
+ onEvent: events.send,
119
+ onOutput: function readBlobs({stream, chunk}) {
120
+ if (stream === 'stdout') reader.write(chunk);
121
+ }
122
+ });
123
+ }
124
+ const result = {revision, files: reader.finish()};
125
+ await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.', data: {revision, reused: false}});
126
+ await events.drain();
127
+ retained = result;
128
+ return result;
129
+ } catch (error) {
130
+ try {
131
+ await events.send({type: 'git.snapshot.failed', message: 'Repository text snapshot failed.', data: {error}});
132
+ await events.drain();
133
+ } catch (observerError) {
134
+ if (observerError !== error) throw new AggregateError([error, observerError], 'Snapshot operation and event delivery failed.');
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ function refresh({signal} = {}) {
141
+ try {
142
+ throwIfAborted(signal);
143
+ if (closing) throw new ArcaneError('CORE_CLOSING', 'The Git snapshot owner is closing.');
144
+ } catch (error) {
145
+ return Promise.reject(error);
146
+ }
147
+ if (!active) {
148
+ // Install ownership before an observer can issue another refresh.
149
+ active = Promise.resolve().then(acquire);
150
+ const accepted = active;
151
+ function settled() { if (active === accepted) active = null; }
152
+ accepted.then(settled, settled);
153
+ }
154
+ return waitForSnapshot(active, signal).then(function copyResult(snapshot) {
155
+ return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; })};
156
+ });
157
+ }
158
+
159
+ function close() {
160
+ if (!closing) {
161
+ closing = (active ?? Promise.resolve()).then(function drained() { retained = null; });
162
+ }
163
+ return closing;
164
+ }
165
+
166
+ return {refresh, close, drain: close, dispose: close};
167
+ }
168
+
169
+ function waitForSnapshot(operation, signal) {
170
+ if (!signal) return operation;
171
+ return new Promise(function wait(resolve, reject) {
172
+ function abort() {
173
+ signal.removeEventListener('abort', abort);
174
+ reject(new ArcaneError(ERROR_CODES.cancelled, 'The snapshot caller was cancelled.', {cause: signal.reason, exitCode: 130}));
175
+ }
176
+ signal.addEventListener('abort', abort, {once: true});
177
+ operation.then(function completed(result) {
178
+ signal.removeEventListener('abort', abort);
179
+ if (!signal.aborted) resolve(result);
180
+ }, function failed(error) {
181
+ signal.removeEventListener('abort', abort);
182
+ if (!signal.aborted) reject(error);
183
+ });
184
+ if (signal.aborted) abort();
185
+ });
186
+ }
187
+
188
+ function decodeText(input, filename) {
189
+ try {
190
+ // ignoreBOM preserves an authored leading U+FEFF instead of consuming it.
191
+ return new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(input);
192
+ } catch (cause) {
193
+ throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git content cannot be represented as UTF-8 text.',
194
+ {cause, details: {path: filename}});
195
+ }
196
+ }
197
+
198
+ function protocolError(message, details) {
199
+ return new ArcaneError('ARCANE_GIT_SNAPSHOT_PROTOCOL', message, {details});
200
+ }
201
+
202
+ function createBlobReader(entries) {
203
+ const files = [];
204
+ let headerParts = [];
205
+ let state = 'header';
206
+ let remaining = 0n;
207
+ let content = [];
208
+ let decoder;
209
+
210
+ function write(chunk) {
211
+ let offset = 0;
212
+ while (offset < chunk.length) {
213
+ if (state === 'header') {
214
+ const newline = chunk.indexOf(10, offset);
215
+ if (newline === -1) { headerParts.push(chunk.subarray(offset)); return; }
216
+ headerParts.push(chunk.subarray(offset, newline));
217
+ const header = Buffer.concat(headerParts).toString('ascii');
218
+ headerParts = [];
219
+ const [object, type, extent] = header.split(' ');
220
+ const entry = entries[files.length];
221
+ if (!entry || object !== entry.object || type !== 'blob' || !/^\d+$/u.test(extent)) {
222
+ throw protocolError('Git returned an unexpected blob response.', {header, path: entry?.path});
223
+ }
224
+ // Git requires this encoded extent to delimit the raw body.
225
+ // It never enters product metadata, admission or progress.
226
+ remaining = BigInt(extent);
227
+ decoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true});
228
+ content = [];
229
+ offset = newline + 1;
230
+ state = remaining === 0n ? 'delimiter' : 'body';
231
+ } else if (state === 'body') {
232
+ const available = chunk.length - offset;
233
+ const taking = remaining < BigInt(available) ? Number(remaining) : available;
234
+ try {
235
+ content.push(decoder.decode(chunk.subarray(offset, offset + taking), {stream: true}));
236
+ } catch (cause) {
237
+ throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git blob is not UTF-8 text.',
238
+ {cause, details: {path: entries[files.length].path}});
239
+ }
240
+ offset += taking;
241
+ remaining -= BigInt(taking);
242
+ if (remaining === 0n) state = 'delimiter';
243
+ } else {
244
+ if (chunk[offset] !== 10) throw protocolError('Git returned an unreadable blob delimiter.', {path: entries[files.length].path});
245
+ try { content.push(decoder.decode()); }
246
+ catch (cause) {
247
+ throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git blob ended inside a UTF-8 character.',
248
+ {cause, details: {path: entries[files.length].path}});
249
+ }
250
+ files.push({path: entries[files.length].path, content: content.join('')});
251
+ offset += 1;
252
+ state = 'header';
253
+ }
254
+ }
255
+ }
256
+
257
+ function finish() {
258
+ if (state !== 'header' || headerParts.length || files.length !== entries.length) {
259
+ throw protocolError('Git ended before all requested text blobs were returned.', {paths: entries.map(function filename(entry) { return entry.path; })});
260
+ }
261
+ return files;
262
+ }
263
+
264
+ return {write, finish};
265
+ }