modelstat 0.0.25 → 0.0.27

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.25",
3
+ "version": "0.0.27",
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",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "scripts/postinstall.mjs",
13
14
  "vendor/tray-mac",
14
15
  "README.md",
15
16
  "LICENSE"
@@ -24,12 +25,14 @@
24
25
  "prepack": "pnpm run build && pnpm run build:tray",
25
26
  "pack:tarball": "pnpm run build && npm pack --pack-destination ../..",
26
27
  "install:local": "bash ./scripts/install-local.sh",
28
+ "postinstall": "node ./scripts/postinstall.mjs",
27
29
  "typecheck": "tsc --noEmit"
28
30
  },
29
31
  "dependencies": {
30
32
  "chokidar": "^4.0.3",
31
33
  "conf": "^13.1.0",
32
34
  "dotenv": "^16.4.7",
35
+ "node-llama-cpp": "^3.18.0",
33
36
  "ulid": "^2.3.0",
34
37
  "undici": "^7.1.0"
35
38
  },
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install hook for the @modelstat/agent CLI.
4
+ *
5
+ * Runs after `npm install -g modelstat` (or `npx modelstat` cache
6
+ * population) and downloads the bundled summariser GGUF (~2.7 GB)
7
+ * with visible progress, so the user sees the heavy download
8
+ * attached to the install they just kicked off — not as a surprise
9
+ * 7-minute hang the first time they run `modelstat scan`.
10
+ *
11
+ * Skipped when:
12
+ * - Running inside this repo's pnpm workspace (we don't want every
13
+ * dev `pnpm install` to pull 2.7 GB; the developer can opt in by
14
+ * running `modelstat connect` once or pre-pulling the model).
15
+ * - `MODELSTAT_SKIP_POSTINSTALL=1` is set (CI escape hatch, or for
16
+ * packagers who handle the model out-of-band).
17
+ * - `CI=true` is set (most CI providers; avoids blocking automated
18
+ * installs on a multi-GB pull).
19
+ * - stdout isn't a TTY AND CI isn't set (likely a scripted
20
+ * install that doesn't want long-running side-effects).
21
+ *
22
+ * In all skip cases the model still downloads lazily on first
23
+ * `modelstat scan` — the agent always preflights the summariser
24
+ * before producing any segments (see src/pipeline.ts), so users in
25
+ * skip-mode just see the download then, instead of now.
26
+ */
27
+
28
+ import { existsSync } from "node:fs";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ function inThisMonorepo() {
33
+ // Walk up from this script looking for the repo's pnpm-workspace.yaml.
34
+ // When npm installs us into a global node_modules, this file isn't an
35
+ // ancestor — so we know we're in a real install and should download.
36
+ let dir = dirname(fileURLToPath(import.meta.url));
37
+ for (let i = 0; i < 10; i++) {
38
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return true;
39
+ const up = resolve(dir, "..");
40
+ if (up === dir) return false;
41
+ dir = up;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ async function main() {
47
+ if (process.env.MODELSTAT_SKIP_POSTINSTALL === "1") {
48
+ console.log(
49
+ "[modelstat] postinstall skipped (MODELSTAT_SKIP_POSTINSTALL=1) — model downloads lazily on first scan",
50
+ );
51
+ return;
52
+ }
53
+ if (process.env.CI === "true" || process.env.CI === "1") {
54
+ console.log(
55
+ "[modelstat] postinstall skipped (CI=1) — model downloads lazily on first scan",
56
+ );
57
+ return;
58
+ }
59
+ if (inThisMonorepo()) {
60
+ // Dev environment — don't pull 2.7 GB on every workspace install.
61
+ return;
62
+ }
63
+ if (!process.stdout.isTTY) {
64
+ console.log(
65
+ "[modelstat] postinstall: non-TTY — model will download lazily on first `modelstat scan`",
66
+ );
67
+ return;
68
+ }
69
+
70
+ // Resolve the helper from the workspace package the bundle uses.
71
+ // companion-core is bundled into dist/cli.mjs by tsup, but we need
72
+ // the helper as a standalone import here (postinstall runs against
73
+ // the unbundled source layout).
74
+ let ensureLlamaModel;
75
+ let defaultLlamaConfig;
76
+ try {
77
+ ({ ensureLlamaModel, defaultLlamaConfig } = await import(
78
+ "@modelstat/companion-core/node"
79
+ ));
80
+ } catch (err) {
81
+ console.warn(
82
+ `[modelstat] postinstall: couldn't import summariser helper (${err && err.message ? err.message : err}); model will download lazily on first scan`,
83
+ );
84
+ return;
85
+ }
86
+
87
+ console.log("");
88
+ console.log("━".repeat(60));
89
+ console.log(" Pre-downloading the local summariser model");
90
+ console.log("");
91
+ console.log(" modelstat ships with a small LLM that runs ON YOUR MACHINE");
92
+ console.log(" to summarise every coding session. The model is ~2.7 GB —");
93
+ console.log(" pulling it now so the first `modelstat scan` is instant.");
94
+ console.log("━".repeat(60));
95
+ console.log("");
96
+
97
+ try {
98
+ await ensureLlamaModel(defaultLlamaConfig());
99
+ console.log("");
100
+ console.log("[modelstat] ✓ summariser ready — run `modelstat connect` next");
101
+ } catch (err) {
102
+ // Don't fail the npm install. The model will retry-download on
103
+ // first scan; better to leave the user with a working binary
104
+ // than to abort because their network blipped.
105
+ console.warn(
106
+ `[modelstat] ⚠ couldn't pre-download summariser (${err && err.message ? err.message : err})`,
107
+ );
108
+ console.warn(
109
+ "[modelstat] the model will download lazily on first `modelstat scan` instead",
110
+ );
111
+ }
112
+ }
113
+
114
+ main().catch((err) => {
115
+ console.warn(
116
+ `[modelstat] postinstall failed: ${err && err.message ? err.message : err}`,
117
+ );
118
+ // Never fail the install for a postinstall problem.
119
+ process.exit(0);
120
+ });
@@ -149,12 +149,17 @@ final class TrayController: NSObject {
149
149
  return
150
150
  }
151
151
  let p = Process()
152
+ // --force: the tray owns the daemon. If a stale lock from a prior
153
+ // run (crash, kill -9, OS reboot mid-write) is left behind, the
154
+ // unforced `start` exits in <1s with "already running" and the
155
+ // tray's terminationHandler retries it forever. With --force we
156
+ // claim the lock unconditionally and become the live daemon.
152
157
  if cli.pathExtension == "mjs" {
153
158
  p.launchPath = "/usr/bin/env"
154
- p.arguments = ["node", cli.path, "start"]
159
+ p.arguments = ["node", cli.path, "start", "--force"]
155
160
  } else {
156
161
  p.launchPath = cli.path
157
- p.arguments = ["start"]
162
+ p.arguments = ["start", "--force"]
158
163
  }
159
164
  // Bolt stdout/stderr onto the same log the launchd plist uses so
160
165
  // `modelstat status` still sees the same tail.
@@ -208,6 +213,10 @@ final class TrayController: NSObject {
208
213
  do {
209
214
  try p.run()
210
215
  } catch {
216
+ // Surface the failure in the menu instead of leaving the title
217
+ // stuck on whatever it was last (e.g. "Starting…" forever). Most
218
+ // likely cause is `node` not being on the launchd-inherited PATH.
219
+ statusMI.title = "stats failed: \(error.localizedDescription)"
211
220
  return
212
221
  }
213
222
  // Run on a background queue so we don't block the main loop.
@@ -336,11 +345,23 @@ final class TrayController: NSObject {
336
345
  // TrayController is @MainActor, so its init must run on the main
337
346
  // actor. We wrap the bootstrap in a main-actor function to satisfy
338
347
  // Swift 6's strict concurrency without bloating the controller.
348
+ //
349
+ // IMPORTANT: nothing in AppKit retains TrayController for us. The
350
+ // NSStatusItem holds the menu, and NSMenuItem.target is a weak
351
+ // reference, so the controller has no strong owners. Without a
352
+ // global anchor, ARC deallocates the controller as soon as init
353
+ // returns — which leaves the timer's `[weak self]` callback firing
354
+ // against nil and the menu title frozen on "Starting…" forever.
355
+ // The `controller` global below is the strong reference that keeps
356
+ // the controller alive for the entire app lifetime.
357
+ @MainActor
358
+ private var controller: TrayController?
359
+
339
360
  @MainActor
340
361
  func bootstrap() {
341
362
  let app = NSApplication.shared
342
363
  app.setActivationPolicy(.accessory)
343
- _ = TrayController()
364
+ controller = TrayController()
344
365
  app.run()
345
366
  }
346
367