modelstat 0.0.30 → 0.0.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelstat",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "modelstat companion — reads local AI-tool usage and ships tokenised events to modelstat.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -167,12 +167,20 @@ async function rebootServiceIfInstalled() {
167
167
  const { spawnSync } = await import("node:child_process");
168
168
  console.log("[modelstat] refreshing background service…");
169
169
 
170
- // Stop first best effort, ignore failures.
170
+ // Stop the SERVICE (launchctl bootout / systemctl --user disable)
171
+ // via the fresh bundle's own knowledge of each platform.
171
172
  spawnSync(process.execPath, [freshBundle, "stop"], {
172
173
  stdio: "ignore",
173
174
  timeout: 30_000,
174
175
  });
175
176
 
177
+ // Belt-and-braces: kill any stray daemon process that the service
178
+ // supervisor didn't reap (stale lock, killed parent, KeepAlive
179
+ // race during the bundle swap below). Lock file at
180
+ // ~/.modelstat/daemon.lock has the live PID; SIGTERM it gently
181
+ // first, escalate to SIGKILL if it's still around 2 s later.
182
+ await killStaleDaemon(stateDir);
183
+
176
184
  // Copy the new bundle over the old `~/.modelstat/bin/modelstat.mjs`
177
185
  // so the service supervisor (launchd/systemd) loads the new code on
178
186
  // next launch. The fresh bundle's `connect` / `start` does this on
@@ -188,6 +196,16 @@ async function rebootServiceIfInstalled() {
188
196
  );
189
197
  }
190
198
 
199
+ // The bundle is portable EXCEPT for `node-llama-cpp` and its
200
+ // platform-specific native sub-packages — they're marked external
201
+ // in tsup so they're resolved at runtime via Node's `node_modules`
202
+ // walk-up. From `~/.modelstat/bin/modelstat.mjs` there's no parent
203
+ // `node_modules` to find them in, so the bundled summariser dies
204
+ // at preflight with "Cannot find package 'node-llama-cpp'". Set up
205
+ // a sibling `node_modules` directory containing the runtime
206
+ // dependencies, sourced from this package's own install location.
207
+ await setupNativeNodeModules(installedBundle);
208
+
191
209
  // Start back up. `modelstat start` re-runs preflight (incl. the
192
210
  // processing-version reconcile that wipes cursors when we ship a
193
211
  // new pipeline), so the upgrade picks up the new behaviour
@@ -201,6 +219,140 @@ async function rebootServiceIfInstalled() {
201
219
  console.log("[modelstat] ✓ background service restarted with new build");
202
220
  }
203
221
 
222
+ /**
223
+ * Make `node-llama-cpp` and its platform-specific native sibling
224
+ * packages reachable from `~/.modelstat/bin/modelstat.mjs` so the
225
+ * bundle's runtime `import("node-llama-cpp")` resolves.
226
+ *
227
+ * Strategy:
228
+ * - Find the source `node_modules` directory in this package's
229
+ * install (npm flat layout puts it at <pkg>/.. for global
230
+ * installs; pnpm/bun layouts vary but the same walk-up works
231
+ * since postinstall always runs from the package root).
232
+ * - For each runtime dep we care about (`node-llama-cpp` plus
233
+ * every `@node-llama-cpp/*` and `@reflink/*` sub-package),
234
+ * symlink it into `~/.modelstat/bin/node_modules/<name>`.
235
+ * Symlinks are free + fast; if symlinking fails (filesystem
236
+ * boundary, permissions) fall back to a recursive copy with
237
+ * `dereference:true` so the resulting tree has no dangling
238
+ * refs into the source location.
239
+ *
240
+ * Idempotent: each upgrade clears the destination first.
241
+ */
242
+ async function setupNativeNodeModules(installedBundle) {
243
+ const here = dirname(fileURLToPath(import.meta.url));
244
+ // Find a parent `node_modules` containing `node-llama-cpp`. The
245
+ // walk handles npm flat (<prefix>/lib/node_modules), pnpm flat
246
+ // (<prefix>/lib/node_modules with peer hoisting), and the npx
247
+ // cache layout (~/.npm/_npx/<hash>/node_modules).
248
+ let srcNodeModules = null;
249
+ for (let dir = here, i = 0; i < 8; i++) {
250
+ const probe = join(dir, "node_modules", "node-llama-cpp", "package.json");
251
+ if (existsSync(probe)) {
252
+ srcNodeModules = join(dir, "node_modules");
253
+ break;
254
+ }
255
+ const up = resolve(dir, "..");
256
+ if (up === dir) break;
257
+ dir = up;
258
+ }
259
+ if (!srcNodeModules) {
260
+ console.warn(
261
+ "[modelstat] couldn't locate node-llama-cpp in this install — bundled summariser will fail at preflight. Re-install with `npm i -g modelstat` or report a bug.",
262
+ );
263
+ return;
264
+ }
265
+
266
+ const fs = await import("node:fs/promises");
267
+ const destDir = join(dirname(installedBundle), "node_modules");
268
+ await fs.mkdir(destDir, { recursive: true });
269
+
270
+ // Discover every native package we need: the main one + every
271
+ // platform-specific sibling (the binary blob is in one of the
272
+ // `@node-llama-cpp/*` packages chosen by node-llama-cpp's
273
+ // optional-dependencies resolution at install time).
274
+ const wanted = new Set(["node-llama-cpp"]);
275
+ const all = await fs
276
+ .readdir(srcNodeModules, { withFileTypes: true })
277
+ .catch(() => []);
278
+ for (const e of all) {
279
+ if (!e.isDirectory()) continue;
280
+ if (!e.name.startsWith("@")) continue;
281
+ if (
282
+ e.name === "@node-llama-cpp" ||
283
+ e.name === "@reflink"
284
+ ) {
285
+ // Scoped dirs contain multiple packages — copy/symlink the
286
+ // whole scope dir so all platform variants are reachable.
287
+ wanted.add(e.name);
288
+ }
289
+ }
290
+
291
+ for (const pkg of wanted) {
292
+ const src = join(srcNodeModules, pkg);
293
+ const dest = join(destDir, pkg);
294
+ if (!existsSync(src)) continue;
295
+ try {
296
+ await fs.rm(dest, { recursive: true, force: true });
297
+ } catch {
298
+ /* ignore */
299
+ }
300
+ try {
301
+ await fs.symlink(src, dest, "dir");
302
+ } catch {
303
+ try {
304
+ await fs.cp(src, dest, { recursive: true, dereference: true });
305
+ } catch (err2) {
306
+ console.warn(
307
+ `[modelstat] couldn't link ${pkg}: ${err2 && err2.message ? err2.message : err2}`,
308
+ );
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Kill any daemon process the service supervisor didn't reap.
316
+ * Reads ~/.modelstat/daemon.lock for the PID, sends SIGTERM, then
317
+ * SIGKILL after a short grace period. Tolerates a missing/stale
318
+ * lock file — we just want to make sure the new bundle is the only
319
+ * thing holding the lock when we restart.
320
+ */
321
+ async function killStaleDaemon(stateDir) {
322
+ const { readFileSync } = await import("node:fs");
323
+ const lockPath = join(stateDir, "daemon.lock");
324
+ let payload;
325
+ try {
326
+ payload = JSON.parse(readFileSync(lockPath, "utf8"));
327
+ } catch {
328
+ return; // no lock file — nothing to do
329
+ }
330
+ const pid = Number(payload?.pid);
331
+ if (!Number.isInteger(pid) || pid <= 0) return;
332
+ if (pid === process.pid) return; // shouldn't happen, but defensive
333
+ try {
334
+ process.kill(pid, "SIGTERM");
335
+ } catch (err) {
336
+ if (err && err.code === "ESRCH") return; // already gone
337
+ // EPERM: not our process — leave it alone.
338
+ return;
339
+ }
340
+ // Wait up to 2 s for graceful exit.
341
+ for (let i = 0; i < 20; i++) {
342
+ try {
343
+ process.kill(pid, 0); // signal 0 = existence check
344
+ } catch {
345
+ return; // exited
346
+ }
347
+ await new Promise((r) => setTimeout(r, 100));
348
+ }
349
+ try {
350
+ process.kill(pid, "SIGKILL");
351
+ } catch {
352
+ /* gone or denied */
353
+ }
354
+ }
355
+
204
356
  main().catch((err) => {
205
357
  console.warn(
206
358
  `[modelstat] postinstall failed: ${err && err.message ? err.message : err}`,