@modelstatus/cli 0.1.75 → 0.1.77
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 +1 -1
- package/src/index.js +7 -3
- package/src/registry/local.js +31 -0
- package/src/tui/app.js +59 -3
- package/src/tui/feedback.js +121 -0
- package/src/tui/scan-stream.js +4 -1
- package/src/tui/views/account.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
4
4
|
"description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
package/src/index.js
CHANGED
|
@@ -373,9 +373,11 @@ async function ciReport(dir, flags, res) {
|
|
|
373
373
|
const uniq = [...new Set(res.candidates.map((c) => c.model_string))];
|
|
374
374
|
const resolved = uniq.length ? (await client.resolve(uniq)).data || [] : [];
|
|
375
375
|
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
376
|
+
const { dropResolvedFragments } = await import("./registry/local.js");
|
|
377
|
+
const uploadable = dropResolvedFragments(res.candidates, (c) => !!byStr.get(c.model_string.toLowerCase())?.model_id);
|
|
376
378
|
const seen = new Set();
|
|
377
379
|
const usages = [];
|
|
378
|
-
for (const c of
|
|
380
|
+
for (const c of uploadable) {
|
|
379
381
|
const r = byStr.get(c.model_string.toLowerCase());
|
|
380
382
|
const k = `${r?.model_id ?? "custom:" + c.model_string}|${c.location_label}`;
|
|
381
383
|
if (seen.has(k)) continue;
|
|
@@ -685,12 +687,14 @@ function cmdIntegrations(positional, flags) {
|
|
|
685
687
|
async function cmdStatus(positional, flags) {
|
|
686
688
|
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
687
689
|
const { getRegistry } = await import("./registry/fetch.js");
|
|
688
|
-
const { resolveLocal, computeHealth } = await import("./registry/local.js");
|
|
690
|
+
const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
|
|
689
691
|
const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
|
|
690
692
|
|
|
691
|
-
|
|
693
|
+
let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
|
|
692
694
|
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
693
695
|
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
696
|
+
// Suppress detector fragments of resolved aliases (see dropResolvedFragments).
|
|
697
|
+
candidates = dropResolvedFragments(candidates, (c) => !!byStr.get(c.model_string.toLowerCase())?.model_slug);
|
|
694
698
|
|
|
695
699
|
// Aggregate locations per known model / per custom string.
|
|
696
700
|
const known = new Map(); // slug -> { model, count }
|
package/src/registry/local.js
CHANGED
|
@@ -51,3 +51,34 @@ export function needsAttention(snapshot, retiringWindowDays = 90, today = new Da
|
|
|
51
51
|
.filter((m) => m.health !== "ok")
|
|
52
52
|
.sort((a, b) => String(a.retires_date || "9999-99-99").localeCompare(String(b.retires_date || "9999-99-99")));
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop unresolved (custom) candidates that NEST with a resolved match at the
|
|
57
|
+
* same location (either direction of containment). The detector often emits
|
|
58
|
+
* overlapping spans for one id — e.g. from the Bedrock ARN
|
|
59
|
+
* "anthropic.claude-3-sonnet-20240229-v1:0" it emits both
|
|
60
|
+
* "claude-3-sonnet-20240229" (resolves → claude-3-sonnet) and the longer
|
|
61
|
+
* fragment "claude-3-sonnet-20240229-v1" (doesn't resolve) — which would
|
|
62
|
+
* double-count the line as a known model AND a phantom custom id. Both
|
|
63
|
+
* directions are intentional: for variants like "ft:gpt-4:acme", the resolved
|
|
64
|
+
* base model carries the lifecycle signal (a fine-tune dies with its base), so
|
|
65
|
+
* the custom row is noise there too. Customs that share a line with a resolved
|
|
66
|
+
* model WITHOUT string overlap (two different ids on one line) are kept.
|
|
67
|
+
*/
|
|
68
|
+
export function dropResolvedFragments(candidates, isResolved) {
|
|
69
|
+
const locOf = (c) => (c.source_path || c.location_label || "") + ":" + (c.source_line || "");
|
|
70
|
+
const resolvedByLoc = new Map(); // loc → [lowercased resolved strings]
|
|
71
|
+
for (const c of candidates) {
|
|
72
|
+
if (!isResolved(c)) continue;
|
|
73
|
+
const k = locOf(c);
|
|
74
|
+
if (!resolvedByLoc.has(k)) resolvedByLoc.set(k, []);
|
|
75
|
+
resolvedByLoc.get(k).push(c.model_string.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
return candidates.filter((c) => {
|
|
78
|
+
if (isResolved(c)) return true;
|
|
79
|
+
const here = resolvedByLoc.get(locOf(c));
|
|
80
|
+
if (!here) return true;
|
|
81
|
+
const s = c.model_string.toLowerCase();
|
|
82
|
+
return !here.some((rs) => rs !== s && (rs.includes(s) || s.includes(rs)));
|
|
83
|
+
});
|
|
84
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
|
|
|
4
4
|
import { createClient } from "../api.js";
|
|
5
5
|
import { track } from "../telemetry.js";
|
|
6
6
|
import { BUILD_VERSION } from "../version.js";
|
|
7
|
+
import { feedbackEligible, recordFeedbackShown, recordFeedbackSubmitted, submitFeedback, FeedbackStrip, FeedbackCard, ACTION_THRESHOLD } from "./feedback.js";
|
|
7
8
|
import {
|
|
8
9
|
h, C, GLYPH, useAsync, Window, TrafficLights, TabStrip, StatusBar, legendSegments,
|
|
9
10
|
KeyBar, EmptyCard,
|
|
@@ -146,6 +147,12 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
146
147
|
// changes (reset below). Keeps the keybar honest for tabbed views (What's New,
|
|
147
148
|
// Alerts) where the active keys depend on which sub-tab you're on.
|
|
148
149
|
const [dynKeys, setDynKeys] = React.useState(null);
|
|
150
|
+
// Feedback: null | {stage:"strip"} | {stage:"card", score, comment, sending}.
|
|
151
|
+
// The strip auto-shows once ACTION_THRESHOLD real actions happen (30-day
|
|
152
|
+
// cooldown, never after a submit); `!` opens the card from anywhere, always.
|
|
153
|
+
const [feedback, setFeedback] = React.useState(null);
|
|
154
|
+
const actionsRef = React.useRef(0);
|
|
155
|
+
const fbEligibleRef = React.useRef(feedbackEligible());
|
|
149
156
|
const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
|
|
150
157
|
|
|
151
158
|
const { outer, termRows } = useTermDims();
|
|
@@ -187,6 +194,21 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
187
194
|
useInput(
|
|
188
195
|
(input, key) => {
|
|
189
196
|
if (key.ctrl && input === "c") return exit();
|
|
197
|
+
if (input === "!") {
|
|
198
|
+
setFeedback({ stage: "card", score: null, comment: "", sending: false });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Count "real" actions (letter/symbol keys that do work — not tab digits,
|
|
202
|
+
// not bare navigation, not q). Arrows/enter arrive with empty input, so
|
|
203
|
+
// pure browsing never trips the ask.
|
|
204
|
+
if (fbEligibleRef.current && !feedback && input && !key.ctrl && !key.meta && !/^[0-9]$/.test(input) && input !== "q") {
|
|
205
|
+
actionsRef.current += 1;
|
|
206
|
+
if (actionsRef.current >= ACTION_THRESHOLD) {
|
|
207
|
+
fbEligibleRef.current = false;
|
|
208
|
+
recordFeedbackShown();
|
|
209
|
+
setFeedback({ stage: "strip" });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
190
212
|
// Ctrl-L: hard redraw. Clears screen + scrollback, then bumps `redraw` so
|
|
191
213
|
// the frame string changes (ink skips writing identical output) and ink
|
|
192
214
|
// repaints from a clean top. Fixes a display knocked askew by a resize /
|
|
@@ -219,7 +241,37 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
219
241
|
}
|
|
220
242
|
if (input === "q") return exit();
|
|
221
243
|
},
|
|
222
|
-
{ isActive: !prompt && !capturing },
|
|
244
|
+
{ isActive: !prompt && !capturing && feedback?.stage !== "card" },
|
|
245
|
+
);
|
|
246
|
+
// Feedback card is modal: digits score it (until a comment starts), printable
|
|
247
|
+
// chars build the comment, ←→ nudges the score, ↵ sends, esc closes.
|
|
248
|
+
useInput(
|
|
249
|
+
(input, key) => {
|
|
250
|
+
const fb = feedback;
|
|
251
|
+
if (!fb || fb.sending) return;
|
|
252
|
+
if (key.escape) return setFeedback(null);
|
|
253
|
+
if (key.return) {
|
|
254
|
+
if (!fb.score) return showToast("pick a score first (1-5)", "#d97706");
|
|
255
|
+
setFeedback({ ...fb, sending: true });
|
|
256
|
+
submitFeedback({ apiBase, score: fb.score, comment: fb.comment }).then((ok) => {
|
|
257
|
+
if (ok) {
|
|
258
|
+
recordFeedbackSubmitted();
|
|
259
|
+
setFeedback(null);
|
|
260
|
+
showToast("🙏 thanks — feedback sent to the humans");
|
|
261
|
+
} else {
|
|
262
|
+
setFeedback((f) => (f ? { ...f, sending: false } : f));
|
|
263
|
+
showToast("couldn't send feedback — check your connection, try again", "#dc2626");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (key.leftArrow) return setFeedback({ ...fb, score: Math.max(1, (fb.score || 4) - 1) });
|
|
269
|
+
if (key.rightArrow) return setFeedback({ ...fb, score: Math.min(5, (fb.score || 2) + 1) });
|
|
270
|
+
if (key.backspace || key.delete) return setFeedback({ ...fb, comment: fb.comment.slice(0, -1) });
|
|
271
|
+
if (/^[1-5]$/.test(input) && !fb.comment) return setFeedback({ ...fb, score: Number(input) });
|
|
272
|
+
if (input && !key.ctrl && !key.meta) return setFeedback({ ...fb, comment: (fb.comment + input).slice(0, 500) });
|
|
273
|
+
},
|
|
274
|
+
{ isActive: feedback?.stage === "card" },
|
|
223
275
|
);
|
|
224
276
|
useInput(
|
|
225
277
|
(input, key) => {
|
|
@@ -257,9 +309,13 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
257
309
|
});
|
|
258
310
|
keys = GATE_KEYS;
|
|
259
311
|
} else {
|
|
260
|
-
body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt, fresh });
|
|
312
|
+
body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt && feedback?.stage !== "card", fresh });
|
|
261
313
|
keys = dynKeys || (current2.meta && current2.meta.keys) || [];
|
|
262
314
|
}
|
|
315
|
+
if (feedback?.stage === "card") {
|
|
316
|
+
body = h(FeedbackCard, { score: feedback.score, comment: feedback.comment, sending: feedback.sending, width: W });
|
|
317
|
+
keys = [{ k: "1-5", label: "score" }, { k: "↵", label: "send" }, { k: "esc", label: "close" }];
|
|
318
|
+
}
|
|
263
319
|
|
|
264
320
|
// Status bar segments.
|
|
265
321
|
const live = status && status.forKey === current2.key ? status : null;
|
|
@@ -282,7 +338,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
282
338
|
h(Text, {}, ""),
|
|
283
339
|
h(Box, { flexDirection: "column", width: W, minHeight: bodyRows }, body),
|
|
284
340
|
h(PromptRow, { prompt }),
|
|
285
|
-
h(Text, {}, ""),
|
|
341
|
+
feedback?.stage === "strip" ? h(FeedbackStrip, null) : h(Text, {}, ""),
|
|
286
342
|
toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
|
|
287
343
|
h(StatusBar, { segsLeft, segsRight, width: W }),
|
|
288
344
|
h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play 🦍" }], width: W }),
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* In-TUI feedback: a quiet "♥ enjoying mm? press ! to rate it" strip appears
|
|
2
|
+
* once the user has taken a handful of real actions, and `!` (always available
|
|
3
|
+
* on the Account tab too) opens a modal card — score 1–5 (1 = hot garbage,
|
|
4
|
+
* 5 = best thing since sliced bread) + optional comment. Submission goes to
|
|
5
|
+
* POST /api/v1/feedback on our API (works signed-out); the SERVER forwards to
|
|
6
|
+
* PostHog + the ops Slack channel — the public binary never holds a secret.
|
|
7
|
+
*
|
|
8
|
+
* Nag policy (user-chosen): auto-show at most once per 30 days; submitting
|
|
9
|
+
* means never auto-show again. The `!` key keeps working regardless.
|
|
10
|
+
*/
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { Box, Text } from "ink";
|
|
13
|
+
import { h, C } from "./ui.js";
|
|
14
|
+
import { loadConfig, setConfigValue } from "../config.js";
|
|
15
|
+
import { BUILD_VERSION } from "../version.js";
|
|
16
|
+
|
|
17
|
+
const COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
18
|
+
export const ACTION_THRESHOLD = 5; // strip appears after this many real actions
|
|
19
|
+
|
|
20
|
+
/** Should the auto-strip show this session? (The `!` key is always allowed.) */
|
|
21
|
+
export function feedbackEligible(cfg = loadConfig(), now = Date.now()) {
|
|
22
|
+
const f = cfg.feedback || {};
|
|
23
|
+
if (f.submittedAt) return false; // they already told us — never auto-nag again
|
|
24
|
+
if (f.shownAt && now - f.shownAt < COOLDOWN_MS) return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Record that the strip was shown (ignoring it counts as dismissal). */
|
|
29
|
+
export function recordFeedbackShown(now = Date.now()) {
|
|
30
|
+
try {
|
|
31
|
+
setConfigValue("feedback", { ...(loadConfig().feedback || {}), shownAt: now });
|
|
32
|
+
} catch { /* best effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function recordFeedbackSubmitted(now = Date.now()) {
|
|
36
|
+
try {
|
|
37
|
+
setConfigValue("feedback", { ...(loadConfig().feedback || {}), submittedAt: now });
|
|
38
|
+
} catch { /* best effort */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** POST to our API. Returns true on success; never throws. */
|
|
42
|
+
export async function submitFeedback({ apiBase, score, comment }) {
|
|
43
|
+
try {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const res = await fetch(`${(apiBase || "https://llmstatus.ai").replace(/\/$/, "")}/api/v1/feedback`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
score,
|
|
50
|
+
thumbs: score >= 4 ? "up" : score <= 2 ? "down" : undefined,
|
|
51
|
+
comment: comment || undefined,
|
|
52
|
+
version: typeof BUILD_VERSION === "string" ? BUILD_VERSION : "",
|
|
53
|
+
platform: `${process.platform}-${process.arch}`,
|
|
54
|
+
distinct_id: cfg.anonId || undefined,
|
|
55
|
+
}),
|
|
56
|
+
signal: AbortSignal.timeout(5_000),
|
|
57
|
+
});
|
|
58
|
+
return res.ok;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SCORE_LABELS = {
|
|
65
|
+
1: "hot garbage",
|
|
66
|
+
2: "rough",
|
|
67
|
+
3: "it's fine",
|
|
68
|
+
4: "really good",
|
|
69
|
+
5: "best thing since sliced bread",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The one-line invite, rendered by app.js above the keybar. */
|
|
73
|
+
export function FeedbackStrip() {
|
|
74
|
+
return h(
|
|
75
|
+
Text,
|
|
76
|
+
{},
|
|
77
|
+
h(Text, { color: "#fb7185" }, " ♥ "),
|
|
78
|
+
h(Text, { color: C.FG }, "enjoying mm? "),
|
|
79
|
+
h(Text, { color: C.ACCENT, bold: true }, "press ! to rate it"),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The modal card. Pure render — app.js owns the state + key handling. */
|
|
84
|
+
export function FeedbackCard({ score, comment, sending, width = 78 }) {
|
|
85
|
+
const cells = [];
|
|
86
|
+
for (let n = 1; n <= 5; n++) {
|
|
87
|
+
const active = n === score;
|
|
88
|
+
cells.push(
|
|
89
|
+
h(
|
|
90
|
+
Text,
|
|
91
|
+
{ key: `s${n}` },
|
|
92
|
+
h(Text, { color: active ? C.ACCENT : C.FG_DIM, bold: active }, active ? ` ▸${n}◂ ` : ` ${n} `),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const label = score ? SCORE_LABELS[score] : "pick a number";
|
|
97
|
+
return h(
|
|
98
|
+
Box,
|
|
99
|
+
{ flexDirection: "column", paddingX: 1 },
|
|
100
|
+
h(Text, {}, h(Text, { color: C.ACCENT, bold: true }, " ✦ feedback "), h(Text, { color: C.FG_DIM }, "— 30 seconds, straight to the humans")),
|
|
101
|
+
h(Text, {}, ""),
|
|
102
|
+
h(Text, { color: C.FG }, " How do you like mm? (1 = hot garbage · 5 = best thing since sliced bread)"),
|
|
103
|
+
h(Text, {}, ""),
|
|
104
|
+
h(Box, {}, h(Text, {}, " "), ...cells, h(Text, { color: C.FG_DIM }, ` ${label}`)),
|
|
105
|
+
h(Text, {}, ""),
|
|
106
|
+
h(Text, { color: C.FG }, " anything we should know? (optional)"),
|
|
107
|
+
h(
|
|
108
|
+
Text,
|
|
109
|
+
{},
|
|
110
|
+
h(Text, { color: C.FG_DIM }, " │ "),
|
|
111
|
+
h(Text, { color: C.FG }, comment || ""),
|
|
112
|
+
h(Text, { color: C.ACCENT }, "▌"),
|
|
113
|
+
),
|
|
114
|
+
h(Text, {}, ""),
|
|
115
|
+
h(
|
|
116
|
+
Text,
|
|
117
|
+
{ color: C.FG_DIM },
|
|
118
|
+
sending ? " sending…" : " ←→ or 1-5 score · type to comment · ↵ send · esc close",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
package/src/tui/scan-stream.js
CHANGED
|
@@ -11,7 +11,7 @@ import fs from "node:fs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { getRegistry } from "../registry/fetch.js";
|
|
14
|
-
import { resolveLocal, computeHealth } from "../registry/local.js";
|
|
14
|
+
import { resolveLocal, computeHealth, dropResolvedFragments } from "../registry/local.js";
|
|
15
15
|
import { scanFilesystemStreaming } from "../sources/filesystem.js";
|
|
16
16
|
import { compilePatterns } from "../detect/core.js";
|
|
17
17
|
|
|
@@ -76,6 +76,9 @@ export function loadRegistry(log = () => {}) {
|
|
|
76
76
|
export function summarize(snapshot, candidates) {
|
|
77
77
|
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
78
78
|
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
79
|
+
// A custom id that's a fragment of a resolved alias on the same line is a
|
|
80
|
+
// detector artifact, not a finding (e.g. the inner piece of a Bedrock ARN).
|
|
81
|
+
candidates = dropResolvedFragments(candidates, (c) => !!byStr.get(c.model_string.toLowerCase())?.model_slug);
|
|
79
82
|
const known = new Map(); // slug → { model, count }
|
|
80
83
|
const custom = new Map(); // string → count
|
|
81
84
|
const refsBySlug = new Map(); // slug → [candidate]
|
package/src/tui/views/account.js
CHANGED
|
@@ -8,7 +8,7 @@ const FREE_LIMITS = { projects: 1, usages: 15, channels: 1 };
|
|
|
8
8
|
// Background paint only when colors are on (mirrors ui.js BG_ON gate; ui handles the rest).
|
|
9
9
|
const BG_ON = process.env.MM_ASCII !== "1" && process.env.TERM !== "dumb" && process.env.NO_COLOR == null;
|
|
10
10
|
|
|
11
|
-
export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }] };
|
|
11
|
+
export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }, { k: "!", label: "feedback" }] };
|
|
12
12
|
|
|
13
13
|
/** label/value row: padded accent label + value. */
|
|
14
14
|
function Row({ label, value, color = C.FG, bold = false }) {
|