opencode-usage-coach 0.7.0 → 0.7.1
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/dist/index.js +186 -16
- package/dist/tui.js +10 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -114,6 +114,8 @@ function saveInvestigationResult(keywords, result, source) {
|
|
|
114
114
|
// src/index.ts
|
|
115
115
|
var PLUGIN_NAME = "opencode-usage-coach";
|
|
116
116
|
var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
|
|
117
|
+
var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
|
|
118
|
+
var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
|
|
117
119
|
var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
|
|
118
120
|
function pipeLog(msg) {
|
|
119
121
|
try {
|
|
@@ -205,6 +207,40 @@ function writeHarness(sessionID, h) {
|
|
|
205
207
|
} catch {
|
|
206
208
|
}
|
|
207
209
|
}
|
|
210
|
+
function updateSubSession(sessionID, taskId, fields) {
|
|
211
|
+
try {
|
|
212
|
+
const h = readHarness(sessionID);
|
|
213
|
+
if (!h) return;
|
|
214
|
+
const t = h.tasks.find((x) => x.id === taskId);
|
|
215
|
+
if (!t) return;
|
|
216
|
+
Object.assign(t, fields);
|
|
217
|
+
writeHarness(sessionID, h);
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function clearSubSession(sessionID, taskId) {
|
|
222
|
+
try {
|
|
223
|
+
const h = readHarness(sessionID);
|
|
224
|
+
if (!h) return;
|
|
225
|
+
const t = h.tasks.find((x) => x.id === taskId);
|
|
226
|
+
if (!t) return;
|
|
227
|
+
t.subSessionId = void 0;
|
|
228
|
+
t.subStep = void 0;
|
|
229
|
+
t.lastActivity = void 0;
|
|
230
|
+
t.subElapsed = void 0;
|
|
231
|
+
writeHarness(sessionID, h);
|
|
232
|
+
} catch {
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function findActiveTaskId(sessionID, status) {
|
|
236
|
+
try {
|
|
237
|
+
const h = readHarness(sessionID);
|
|
238
|
+
if (!h) return void 0;
|
|
239
|
+
return h.tasks.find((x) => x.status === status)?.id;
|
|
240
|
+
} catch {
|
|
241
|
+
return void 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
208
244
|
function readHarnessCfg(dir) {
|
|
209
245
|
const tryRead = (p) => {
|
|
210
246
|
try {
|
|
@@ -218,8 +254,16 @@ function readHarnessCfg(dir) {
|
|
|
218
254
|
...tryRead(join2(dir, "harness.config.json"))
|
|
219
255
|
};
|
|
220
256
|
}
|
|
221
|
-
async function runModel(client, model, prompt, directory) {
|
|
257
|
+
async function runModel(client, model, prompt, directory, track, maxSteps = DEFAULT_MAX_STEPS) {
|
|
222
258
|
const t0 = Date.now();
|
|
259
|
+
const subStart = Date.now();
|
|
260
|
+
let poller = null;
|
|
261
|
+
let subId = null;
|
|
262
|
+
let timedOut = false;
|
|
263
|
+
let signalTimeout;
|
|
264
|
+
const timeoutSignal = new Promise((resolve2) => {
|
|
265
|
+
signalTimeout = resolve2;
|
|
266
|
+
});
|
|
223
267
|
try {
|
|
224
268
|
const slash = model.indexOf("/");
|
|
225
269
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
@@ -227,12 +271,75 @@ async function runModel(client, model, prompt, directory) {
|
|
|
227
271
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
228
272
|
const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
|
|
229
273
|
if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
|
|
230
|
-
|
|
231
|
-
|
|
274
|
+
subId = id;
|
|
275
|
+
log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars), max_steps=${maxSteps}`);
|
|
276
|
+
poller = setInterval(async () => {
|
|
277
|
+
if (timedOut) return;
|
|
278
|
+
try {
|
|
279
|
+
let step = 0;
|
|
280
|
+
let lastTs = (/* @__PURE__ */ new Date()).toISOString();
|
|
281
|
+
try {
|
|
282
|
+
const msgs = await client.session.messages?.({ path: { id } });
|
|
283
|
+
const msgList = Array.isArray(msgs?.data) ? msgs.data : Array.isArray(msgs) ? msgs : [];
|
|
284
|
+
if (msgList.length) {
|
|
285
|
+
step = msgList.filter((m) => {
|
|
286
|
+
const role = m?.role ?? m?.info?.role;
|
|
287
|
+
return role === "assistant";
|
|
288
|
+
}).length;
|
|
289
|
+
const last = msgList[msgList.length - 1];
|
|
290
|
+
const ts = last?.ts ?? last?.info?.updatedAt ?? last?.info?.completedAt ?? last?.updatedAt;
|
|
291
|
+
if (ts) lastTs = String(ts);
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
if (step > maxSteps) {
|
|
296
|
+
log(`runModel(${model}): STEP LIMIT exceeded (${step} > ${maxSteps}), aborting session ${id}`);
|
|
297
|
+
timedOut = true;
|
|
298
|
+
try {
|
|
299
|
+
await client.session.abort?.({ path: { id } });
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
signalTimeout();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (track) {
|
|
306
|
+
const elapsed2 = Math.round((Date.now() - subStart) / 1e3);
|
|
307
|
+
updateSubSession(track.sessionID, track.taskId, {
|
|
308
|
+
subSessionId: id,
|
|
309
|
+
subStep: step,
|
|
310
|
+
lastActivity: lastTs,
|
|
311
|
+
subElapsed: elapsed2
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} catch (e) {
|
|
315
|
+
log(`runModel poller err: ${String(e)}`);
|
|
316
|
+
}
|
|
317
|
+
}, WATCHDOG_POLL_MS);
|
|
318
|
+
const promptP = client.session.prompt({
|
|
232
319
|
path: { id },
|
|
233
320
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
234
|
-
})
|
|
321
|
+
}).then(
|
|
322
|
+
(r) => r,
|
|
323
|
+
() => null
|
|
324
|
+
// abort causes rejection -> return null (handled via timedOut flag)
|
|
325
|
+
);
|
|
326
|
+
const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
|
|
235
327
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
328
|
+
if (timedOut) {
|
|
329
|
+
try {
|
|
330
|
+
const summary = await client.session.summarize?.({ path: { id } });
|
|
331
|
+
log(`runModel(${model}): TIMED OUT summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await client.session.delete?.({ path: { id } });
|
|
336
|
+
} catch {
|
|
337
|
+
}
|
|
338
|
+
subId = null;
|
|
339
|
+
log(`runModel(${model}): TIMED OUT after ${elapsed}s (${maxSteps} steps exceeded)`);
|
|
340
|
+
return `Task appears too large (exceeded ${maxSteps} steps). Consider splitting into smaller subtasks.
|
|
341
|
+
[usage-coach NEXT] split the original task into smaller subtasks (each should complete within ${maxSteps} steps), then re-run generate for each subtask.`;
|
|
342
|
+
}
|
|
236
343
|
const parts = resp?.data?.parts ?? resp?.parts ?? [];
|
|
237
344
|
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
238
345
|
try {
|
|
@@ -244,12 +351,27 @@ async function runModel(client, model, prompt, directory) {
|
|
|
244
351
|
await client.session.delete?.({ path: { id } });
|
|
245
352
|
} catch {
|
|
246
353
|
}
|
|
354
|
+
subId = null;
|
|
247
355
|
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
|
|
248
356
|
return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
|
|
249
357
|
} catch (e) {
|
|
250
358
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
251
359
|
log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
|
|
252
360
|
return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
|
|
361
|
+
} finally {
|
|
362
|
+
if (poller) clearInterval(poller);
|
|
363
|
+
if (track) {
|
|
364
|
+
try {
|
|
365
|
+
clearSubSession(track.sessionID, track.taskId);
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (subId) {
|
|
370
|
+
try {
|
|
371
|
+
await client.session.delete?.({ path: { id: subId } });
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
}
|
|
253
375
|
}
|
|
254
376
|
}
|
|
255
377
|
var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
@@ -549,12 +671,14 @@ async function UsageCoachPlugin(input) {
|
|
|
549
671
|
// Custom tools for the harness agent mode — report status to the panel.
|
|
550
672
|
tool: {
|
|
551
673
|
harness_start: tool({
|
|
552
|
-
description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins.",
|
|
674
|
+
description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins. IMPORTANT: each generate/generate_batch sub-session is step-limited (default 30). If any task seems too large, split it into smaller subtasks BEFORE starting \u2014 oversized tasks will timeout.",
|
|
553
675
|
args: { name: tool.schema.string(), total: tool.schema.number() },
|
|
554
676
|
async execute(args, ctx) {
|
|
555
677
|
writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
556
678
|
return `Harness '${args.name}' started (${args.total} tasks).
|
|
557
679
|
|
|
680
|
+
STEP LIMIT (default ${DEFAULT_MAX_STEPS}): each generate call creates a sub-session that is automatically aborted if it exceeds ${DEFAULT_MAX_STEPS} assistant steps. Before starting the loop, review each task: can it be completed in a focused, single-pass effort? If a task seems too broad (multiple files, multiple features, open-ended research), SPLIT it now into 2-3 smaller subtasks. A timeout wastes quota \u2014 split upfront.
|
|
681
|
+
|
|
558
682
|
DETERMINISTIC LOOP \u2014 first classify the tasks:
|
|
559
683
|
INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
|
|
560
684
|
DEPENDENT = task B needs task A's output -> use PATH B (sequential)
|
|
@@ -669,7 +793,14 @@ Output a structured root cause:
|
|
|
669
793
|
category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
|
|
670
794
|
explanation: <why it failed>
|
|
671
795
|
evidence: <file/line or specific quote>`;
|
|
672
|
-
const
|
|
796
|
+
const invTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
797
|
+
const out = await runModel(
|
|
798
|
+
input.client,
|
|
799
|
+
cfg.generator,
|
|
800
|
+
domainPrefix + rcaPrompt,
|
|
801
|
+
ctx.directory,
|
|
802
|
+
invTaskId ? { sessionID: ctx.sessionID, taskId: invTaskId } : void 0
|
|
803
|
+
);
|
|
673
804
|
if (domainEmpty && keywords.length) {
|
|
674
805
|
try {
|
|
675
806
|
saveInvestigationResult(keywords, out, "investigate");
|
|
@@ -697,7 +828,14 @@ Grade feedback: ${args.gradeResult}
|
|
|
697
828
|
Diagnosis: ${args.diagnosis}
|
|
698
829
|
Is the diagnosis CORRECT and ACTIONABLE (leads to a useful rule)?
|
|
699
830
|
Output PASS (the diagnosis is right) or FAIL (re-investigate needed), then reason.`;
|
|
700
|
-
const
|
|
831
|
+
const verTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
832
|
+
const out = await runModel(
|
|
833
|
+
input.client,
|
|
834
|
+
model,
|
|
835
|
+
verifyPrompt,
|
|
836
|
+
ctx.directory,
|
|
837
|
+
verTaskId ? { sessionID: ctx.sessionID, taskId: verTaskId } : void 0
|
|
838
|
+
);
|
|
701
839
|
let verdict = "FAIL";
|
|
702
840
|
if (!out.startsWith("ERROR:")) {
|
|
703
841
|
const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
|
|
@@ -724,7 +862,14 @@ Diagnosis: ${args.diagnosis}
|
|
|
724
862
|
Failed task: ${args.task}
|
|
725
863
|
Output a single rule in the form: 'For <task-type> tasks, always <check/do X> because <reason>.'
|
|
726
864
|
Keep it concrete and actionable.`;
|
|
727
|
-
const
|
|
865
|
+
const genRuleTaskId = findActiveTaskId(ctx.sessionID, "revising");
|
|
866
|
+
const out = await runModel(
|
|
867
|
+
input.client,
|
|
868
|
+
cfg.generator,
|
|
869
|
+
genPrompt,
|
|
870
|
+
ctx.directory,
|
|
871
|
+
genRuleTaskId ? { sessionID: ctx.sessionID, taskId: genRuleTaskId } : void 0
|
|
872
|
+
);
|
|
728
873
|
const rule = out;
|
|
729
874
|
try {
|
|
730
875
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
@@ -744,8 +889,8 @@ Origin: ${args.task}
|
|
|
744
889
|
// Per-role model execution (config-driven, quota-aware, same server, no deadlock).
|
|
745
890
|
// P1: quota decision drives model selection + concurrency.
|
|
746
891
|
generate: tool({
|
|
747
|
-
description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
|
|
748
|
-
args: { prompt: tool.schema.string() },
|
|
892
|
+
description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response. Step-limited: aborts after max_steps (default 30) to prevent runaway tasks.",
|
|
893
|
+
args: { prompt: tool.schema.string(), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps before timeout (default 30). Increase for complex tasks, decrease to fail fast on scope creep.") },
|
|
749
894
|
async execute(args, ctx) {
|
|
750
895
|
const cfg = readHarnessCfg(ctx.directory);
|
|
751
896
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
@@ -781,22 +926,33 @@ ${rules}
|
|
|
781
926
|
} catch (e) {
|
|
782
927
|
log(`generate domain query err: ${String(e)}`);
|
|
783
928
|
}
|
|
784
|
-
const
|
|
785
|
-
|
|
929
|
+
const genTaskId = findActiveTaskId(ctx.sessionID, "generating");
|
|
930
|
+
const maxSteps = args.max_steps ?? DEFAULT_MAX_STEPS;
|
|
931
|
+
const out = await runModel(
|
|
932
|
+
input.client,
|
|
933
|
+
model,
|
|
934
|
+
prefix + args.prompt,
|
|
935
|
+
ctx.directory,
|
|
936
|
+
genTaskId ? { sessionID: ctx.sessionID, taskId: genTaskId } : void 0,
|
|
937
|
+
maxSteps
|
|
938
|
+
);
|
|
939
|
+
const isTimeoutOrError = out.startsWith("Task appears too large") || out.startsWith("ERROR:");
|
|
940
|
+
if (domainEmpty && keywords.length && !isTimeoutOrError) {
|
|
786
941
|
try {
|
|
787
942
|
saveInvestigationResult(keywords, out, "generate");
|
|
788
943
|
} catch (e) {
|
|
789
944
|
log(`generate save err: ${String(e)}`);
|
|
790
945
|
}
|
|
791
946
|
}
|
|
947
|
+
if (out.startsWith("Task appears too large")) return out;
|
|
792
948
|
return out + (throttle ? `
|
|
793
949
|
[usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
|
|
794
950
|
[usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
|
|
795
951
|
}
|
|
796
952
|
}),
|
|
797
953
|
generate_batch: tool({
|
|
798
|
-
description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks.",
|
|
799
|
-
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
|
|
954
|
+
description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks. Step-limited: each sub-session aborts after max_steps (default 30).",
|
|
955
|
+
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps per task before timeout (default 30).") },
|
|
800
956
|
async execute(args, ctx) {
|
|
801
957
|
const cfg = readHarnessCfg(ctx.directory);
|
|
802
958
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
@@ -813,7 +969,14 @@ ${rules}
|
|
|
813
969
|
for (let i = 0; i < args.tasks.length; i += limit) {
|
|
814
970
|
const batch = args.tasks.slice(i, i + limit);
|
|
815
971
|
const out = await Promise.all(batch.map(async (t) => {
|
|
816
|
-
const r = await runModel(
|
|
972
|
+
const r = await runModel(
|
|
973
|
+
input.client,
|
|
974
|
+
model,
|
|
975
|
+
t.prompt,
|
|
976
|
+
ctx.directory,
|
|
977
|
+
{ sessionID: ctx.sessionID, taskId: t.id },
|
|
978
|
+
args.max_steps ?? DEFAULT_MAX_STEPS
|
|
979
|
+
);
|
|
817
980
|
return `[task ${t.id}] ${r}`;
|
|
818
981
|
}));
|
|
819
982
|
results.push(...out);
|
|
@@ -830,7 +993,14 @@ ${rules}
|
|
|
830
993
|
const cfg = readHarnessCfg(ctx.directory);
|
|
831
994
|
const model = cfg.grader ?? cfg.generator;
|
|
832
995
|
if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry grade.";
|
|
833
|
-
const
|
|
996
|
+
const gradeTaskId = findActiveTaskId(ctx.sessionID, "grading");
|
|
997
|
+
const out = await runModel(
|
|
998
|
+
input.client,
|
|
999
|
+
model,
|
|
1000
|
+
args.prompt,
|
|
1001
|
+
ctx.directory,
|
|
1002
|
+
gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
|
|
1003
|
+
);
|
|
834
1004
|
let verdict = "FAIL";
|
|
835
1005
|
if (!out.startsWith("ERROR:")) {
|
|
836
1006
|
const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
|
package/dist/tui.js
CHANGED
|
@@ -391,8 +391,14 @@ function initializeTui(api, disposeRoot) {
|
|
|
391
391
|
const lbl = TLABEL[t.status] ?? t.status;
|
|
392
392
|
const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
|
|
393
393
|
const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
|
|
394
|
+
const hasSub = !!t.subSessionId;
|
|
395
|
+
const subStepStr = hasSub && t.subStep !== void 0 && t.subStep > 0 ? ` step:${t.subStep}` : "";
|
|
396
|
+
const subEl = hasSub && t.subElapsed !== void 0 ? ` ${t.subElapsed}s` : "";
|
|
397
|
+
const subWarn = hasSub && (t.subElapsed ?? 0) > 300;
|
|
394
398
|
const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
|
|
395
|
-
const
|
|
399
|
+
const taskEl = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
|
|
400
|
+
const displayEl = hasSub ? subEl : taskEl;
|
|
401
|
+
const lineKey = subWarn ? "warning" : sKey;
|
|
396
402
|
nodes.push((() => {
|
|
397
403
|
var _el$55 = _$createElement("text"), _el$56 = _$createTextNode(` \u25CF `), _el$57 = _$createTextNode(` `), _el$58 = _$createTextNode(` `);
|
|
398
404
|
_$insertNode(_el$55, _el$56);
|
|
@@ -402,9 +408,10 @@ function initializeTui(api, disposeRoot) {
|
|
|
402
408
|
_$insert(_el$55, mdl, _el$57);
|
|
403
409
|
_$insert(_el$55, lbl, _el$58);
|
|
404
410
|
_$insert(_el$55, rev, _el$58);
|
|
405
|
-
_$insert(_el$55,
|
|
411
|
+
_$insert(_el$55, subStepStr, _el$58);
|
|
412
|
+
_$insert(_el$55, displayEl, _el$58);
|
|
406
413
|
_$insert(_el$55, () => t.title, null);
|
|
407
|
-
_$effect((_$p) => _$setProp(_el$55, "style", st(
|
|
414
|
+
_$effect((_$p) => _$setProp(_el$55, "style", st(lineKey), _$p));
|
|
408
415
|
return _el$55;
|
|
409
416
|
})());
|
|
410
417
|
const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
|
package/package.json
CHANGED