@danypops/pi-jittor 0.3.1 → 0.5.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.
- package/extension/src/index.ts +111 -30
- package/extension/src/jittor-command.ts +110 -0
- package/extension/src/jittor-shell.ts +338 -0
- package/extension/src/observability/cache-economics-view.ts +179 -0
- package/extension/src/observability/status.ts +116 -65
- package/extension/src/optimization/model-selection-panel.ts +61 -32
- package/extension/src/service-client.ts +1 -0
- package/extension/src/settings-tui.ts +112 -34
- package/extension/src/tui-prompts.ts +110 -0
- package/package.json +2 -2
package/extension/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
loadOpenAiTextTokenCounter,
|
|
13
13
|
MAX_DYNAMIC_ROUTES,
|
|
14
14
|
type MetricObservation,
|
|
15
|
+
MILLISECONDS_PER_DAY,
|
|
15
16
|
type ModelCandidate,
|
|
16
17
|
type ModelTaskDomain,
|
|
17
18
|
type ModelTaskType,
|
|
@@ -33,6 +34,8 @@ import {
|
|
|
33
34
|
validateTaskFocusEvent,
|
|
34
35
|
} from "@danypops/jittor";
|
|
35
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { jittorArgumentCompletions, jittorUsageError } from "./jittor-command.ts";
|
|
38
|
+
import { showJittorShell } from "./jittor-shell.ts";
|
|
36
39
|
import {
|
|
37
40
|
basePromptSegment,
|
|
38
41
|
buildBasePromptItems,
|
|
@@ -49,14 +52,12 @@ import { type CompactionProgress, type IntegratedFooterState, installIntegratedF
|
|
|
49
52
|
import { LocalRunTelemetry } from "./observability/model-run.ts";
|
|
50
53
|
import { captureProviderContextSnapshot } from "./observability/provider-context-snapshot.ts";
|
|
51
54
|
import { ProviderResponseTelemetry } from "./observability/provider-response.ts";
|
|
52
|
-
import { buildFooterBudget, providerBudgetMetricQuery
|
|
55
|
+
import { buildFooterBudget, providerBudgetMetricQuery } from "./observability/status.ts";
|
|
53
56
|
import { showUsagePanel } from "./observability/usage.ts";
|
|
54
|
-
import { showBenchmarkPanel } from "./optimization/model-selection-panel.ts";
|
|
55
57
|
import { CodexRecoveryCapability, type CodexRecoveryRuntime, SYSTEM_RECOVERY_RUNTIME } from "./optimization/recovery/codex.ts";
|
|
56
58
|
import { callJittor } from "./service-client.ts";
|
|
57
59
|
import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
|
|
58
60
|
import { type CodexRecoveryControl, type EnforcementControl, persistentEnforcementControl, type UsageBudgetControl } from "./settings.ts";
|
|
59
|
-
import { showSettingsPanel } from "./settings-tui.ts";
|
|
60
61
|
|
|
61
62
|
export { formatFooterStatus } from "./observability/status.ts";
|
|
62
63
|
export type { CodexRecoveryRuntime } from "./optimization/recovery/codex.ts";
|
|
@@ -161,6 +162,23 @@ interface PiRouteModel {
|
|
|
161
162
|
cost?: { input?: number; output?: number };
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the `/scoped-models`
|
|
167
|
+
* command shows) is the authority for which models a session may actually use -- e.g. a "work"
|
|
168
|
+
* profile scoped to one provider/model set versus a "personal" profile scoped to a different one.
|
|
169
|
+
* `ctx.modelRegistry.getAvailable()` enumerates every authenticated model on the host regardless of
|
|
170
|
+
* that restriction (Pi's own docs warn against using it for a model picker for exactly this reason:
|
|
171
|
+
* "instead of enumerating the whole catalogue via ctx.modelRegistry.getAvailable()"). Every Jittor
|
|
172
|
+
* call site that builds a candidate/route set for routing or ranking must prefer the scoped set
|
|
173
|
+
* when one is configured, or a scoped session keeps seeing -- and can be automatically routed onto
|
|
174
|
+
* -- another profile's models. An empty scopedModels list means "no scoping configured" (matching
|
|
175
|
+
* Pi's own semantics), not "scoped to nothing", so it still falls back to the full catalog.
|
|
176
|
+
*/
|
|
177
|
+
function scopedOrAvailableModels(ctx: ExtensionContext): PiRouteModel[] {
|
|
178
|
+
if (ctx.scopedModels.length > 0) return ctx.scopedModels.map((entry) => entry.model as PiRouteModel);
|
|
179
|
+
return ctx.modelRegistry.getAvailable() as PiRouteModel[];
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
const THINKING_DESCENDING = ["max", "xhigh", "high", "medium", "low", "minimal", "off"] as const;
|
|
165
183
|
|
|
166
184
|
function supportsThinking(model: PiRouteModel, level: string): boolean {
|
|
@@ -237,7 +255,7 @@ async function syncAvailableRoutes(pi: ExtensionAPI, client: JittorExtensionClie
|
|
|
237
255
|
await client.call("router.available_routes", { routes: [], session_id, ...secret });
|
|
238
256
|
return;
|
|
239
257
|
}
|
|
240
|
-
const models = ctx
|
|
258
|
+
const models = scopedOrAvailableModels(ctx);
|
|
241
259
|
const routes = routesFromPi(models, ctx.model as PiRouteModel, pi.getThinkingLevel());
|
|
242
260
|
await client.call("router.available_routes", { routes, session_id, ...secret });
|
|
243
261
|
}
|
|
@@ -292,16 +310,24 @@ async function applyDecision(
|
|
|
292
310
|
);
|
|
293
311
|
}
|
|
294
312
|
|
|
313
|
+
let assistantUsageRunSequence = 0;
|
|
314
|
+
|
|
295
315
|
/**
|
|
296
316
|
* taskId, when a Papyrus task is focused, tags the metric for cost-per-task correlation. thinking
|
|
297
317
|
* comes from pi.getThinkingLevel() at message_end time, not from the message itself -- AssistantMessage
|
|
298
|
-
* has no thinking field of its own, and the level can't have changed mid-message.
|
|
318
|
+
* has no thinking field of its own, and the level can't have changed mid-message. sessionId tags every
|
|
319
|
+
* row so cache economics (see @danypops/jittor's cache-economics.ts) can correlate a cache write back
|
|
320
|
+
* to this same session's own context-prefix reset evidence, without ever widening scope by provider/model.
|
|
321
|
+
* runId ties every metric emitted for this one turn together (a counter, not just observedAt, since
|
|
322
|
+
* two turns can share a millisecond) so cache economics can resolve per-turn (e.g. tiered) catalog
|
|
323
|
+
* pricing against this turn's own real size instead of a blended sum across a whole query window.
|
|
299
324
|
*/
|
|
300
325
|
function assistantUsageMetrics(
|
|
301
326
|
message: unknown,
|
|
302
327
|
observedAt: number,
|
|
303
328
|
taskId: string | null = null,
|
|
304
329
|
thinking: string | null = null,
|
|
330
|
+
sessionId: string | null = null,
|
|
305
331
|
): MetricObservation[] {
|
|
306
332
|
if (typeof message !== "object" || message === null || Array.isArray(message)) return [];
|
|
307
333
|
const value = message as Record<string, unknown>;
|
|
@@ -313,11 +339,14 @@ function assistantUsageMetrics(
|
|
|
313
339
|
const provider = typeof value.provider === "string" ? value.provider : "unknown";
|
|
314
340
|
const model = typeof value.model === "string" ? value.model : "unknown";
|
|
315
341
|
const scope = `${provider}:${model}`;
|
|
342
|
+
const runId = `pi-usage-${metricObservedAt}-${++assistantUsageRunSequence}`;
|
|
316
343
|
const attributes = {
|
|
317
344
|
provider,
|
|
318
345
|
model,
|
|
346
|
+
runId,
|
|
319
347
|
...(taskId === null ? {} : { taskId }),
|
|
320
348
|
...(thinking === null || thinking.length === 0 ? {} : { thinking }),
|
|
349
|
+
...(sessionId === null || sessionId.length === 0 ? {} : { sessionId }),
|
|
321
350
|
};
|
|
322
351
|
const metrics: MetricObservation[] = [];
|
|
323
352
|
for (const [field, metric, tokenScope] of [
|
|
@@ -348,9 +377,23 @@ function assistantUsageMetrics(
|
|
|
348
377
|
},
|
|
349
378
|
});
|
|
350
379
|
}
|
|
351
|
-
const
|
|
380
|
+
const costBreakdown = typeof usage.cost === "object" && usage.cost !== null ? (usage.cost as Record<string, unknown>) : undefined;
|
|
381
|
+
const cost = costBreakdown?.total;
|
|
352
382
|
if (typeof cost === "number" && Number.isFinite(cost))
|
|
353
383
|
metrics.push({ source: "pi", scope, metric: "cost", value: cost, unit: "usd", observedAt: metricObservedAt, attributes });
|
|
384
|
+
// Itemized provider-reported cost, when the provider breaks it out -- the real dollar figures cache
|
|
385
|
+
// economics needs (see cache-economics.ts) instead of ever re-deriving them from catalog prices when
|
|
386
|
+
// the provider already told us. Never fabricated: omitted entirely when a field is absent.
|
|
387
|
+
for (const [field, metric] of [
|
|
388
|
+
["input", "input-cost"],
|
|
389
|
+
["output", "output-cost"],
|
|
390
|
+
["cacheRead", "cache-read-cost"],
|
|
391
|
+
["cacheWrite", "cache-write-cost"],
|
|
392
|
+
] as const satisfies ReadonlyArray<readonly [string, string]>) {
|
|
393
|
+
const amount = costBreakdown?.[field];
|
|
394
|
+
if (typeof amount === "number" && Number.isFinite(amount))
|
|
395
|
+
metrics.push({ source: "pi", scope, metric, value: amount, unit: "usd", observedAt: metricObservedAt, attributes });
|
|
396
|
+
}
|
|
354
397
|
return metrics;
|
|
355
398
|
}
|
|
356
399
|
|
|
@@ -529,22 +572,54 @@ export function registerJittorExtension(
|
|
|
529
572
|
};
|
|
530
573
|
|
|
531
574
|
pi.registerCommand("jittor", {
|
|
532
|
-
description: "Jittor settings, routing status, benchmarks, and Codex recovery controls",
|
|
575
|
+
description: "Jittor settings, routing status, benchmarks, cache economics, and Codex recovery controls",
|
|
576
|
+
getArgumentCompletions: jittorArgumentCompletions,
|
|
533
577
|
handler: async (args, ctx) => {
|
|
534
578
|
const action = args.trim().toLowerCase();
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
579
|
+
const usageError = jittorUsageError(action);
|
|
580
|
+
if (usageError) {
|
|
581
|
+
ctx.ui.notify(usageError, "warning");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// One shared JittorShellDeps builder for every /jittor entry point below -- all four tabs
|
|
585
|
+
// (Settings/Status/Benchmarks/Cache) are reachable from a single opened shell regardless of
|
|
586
|
+
// which subcommand opened it, so every entry point needs the full dependency set, not just
|
|
587
|
+
// its own tab's. `benchmarksTask` overrides the domain/type only for an explicit
|
|
588
|
+
// "/jittor benchmarks ..." invocation; every other entry point defaults to general/general
|
|
589
|
+
// (only meaningfully exercised if the user tab-cycles into Benchmarks from elsewhere).
|
|
590
|
+
const buildShellDeps = (benchmarksTask?: {
|
|
591
|
+
domain: ModelTaskDomain;
|
|
592
|
+
type: ModelTaskType;
|
|
593
|
+
}): Parameters<typeof showJittorShell>[1] => ({
|
|
594
|
+
settings: {
|
|
595
|
+
enforcement,
|
|
596
|
+
recovery: codexRecovery,
|
|
597
|
+
budgets: usageBudgets,
|
|
598
|
+
effects: {
|
|
599
|
+
setEnforcement: async (enabled) => (enabled ? enable(ctx) : disable(ctx)),
|
|
600
|
+
setFooter: async (enabled) => {
|
|
601
|
+
await enforcement.setFooterEnabled(enabled);
|
|
602
|
+
showFooter(ctx);
|
|
603
|
+
if (enabled) await refreshFooter(client, footerState, ctx.sessionManager.getSessionId()).catch(() => undefined);
|
|
604
|
+
},
|
|
605
|
+
setRecovery: async (enabled) => {
|
|
606
|
+
if (!enabled) cancelRecovery(true);
|
|
607
|
+
await codexRecovery.setCodexRecoveryEnabled(enabled);
|
|
608
|
+
},
|
|
546
609
|
},
|
|
547
|
-
}
|
|
610
|
+
},
|
|
611
|
+
status: { client },
|
|
612
|
+
benchmarks: {
|
|
613
|
+
client,
|
|
614
|
+
candidates: benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel()),
|
|
615
|
+
currentIdentity: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
|
|
616
|
+
domain: benchmarksTask?.domain ?? "general",
|
|
617
|
+
type: benchmarksTask?.type ?? "general",
|
|
618
|
+
},
|
|
619
|
+
cache: { client, windowMs: 7 * MILLISECONDS_PER_DAY },
|
|
620
|
+
});
|
|
621
|
+
if (action === "" || action === "settings") {
|
|
622
|
+
await showJittorShell(ctx, buildShellDeps(), "settings");
|
|
548
623
|
return;
|
|
549
624
|
}
|
|
550
625
|
if (action === "benchmarks" || action.startsWith("benchmarks ")) {
|
|
@@ -569,17 +644,17 @@ export function registerJittorExtension(
|
|
|
569
644
|
ctx.ui.notify("Usage: /jittor benchmarks [coding|general] [research|planning|general]", "warning");
|
|
570
645
|
return;
|
|
571
646
|
}
|
|
572
|
-
|
|
573
|
-
await showBenchmarkPanel(
|
|
647
|
+
await showJittorShell(
|
|
574
648
|
ctx,
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
`${ctx.model.provider}/${ctx.model.id}`,
|
|
578
|
-
requestedDomain ?? "general",
|
|
579
|
-
requestedType ?? "general",
|
|
649
|
+
buildShellDeps({ domain: requestedDomain ?? "general", type: requestedType ?? "general" }),
|
|
650
|
+
"benchmarks",
|
|
580
651
|
);
|
|
581
652
|
return;
|
|
582
653
|
}
|
|
654
|
+
if (action === "cache") {
|
|
655
|
+
await showJittorShell(ctx, buildShellDeps(), "cache");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
583
658
|
if (action === "outcome accepted" || action === "outcome rejected") {
|
|
584
659
|
const explicitOutcome = action.endsWith("accepted") ? ("accepted" as const) : ("rejected" as const);
|
|
585
660
|
const outcomeMetric = localRunTelemetry.explicitOutcomeMetric(explicitOutcome);
|
|
@@ -653,13 +728,13 @@ export function registerJittorExtension(
|
|
|
653
728
|
);
|
|
654
729
|
return;
|
|
655
730
|
}
|
|
656
|
-
// Reached only for the explicit "status" keyword
|
|
657
|
-
//
|
|
731
|
+
// Reached only for the explicit "status" keyword -- jittorUsageError above already rejected
|
|
732
|
+
// anything else this handler doesn't recognize, so this is never an unrecognized fallback.
|
|
658
733
|
if (!enforcement.isEnabled()) {
|
|
659
734
|
ctx.ui.notify("Jittor is monitor-only. Run /jittor on to re-enable blocking.", "info");
|
|
660
735
|
return;
|
|
661
736
|
}
|
|
662
|
-
await
|
|
737
|
+
await showJittorShell(ctx, buildShellDeps(), "status");
|
|
663
738
|
},
|
|
664
739
|
});
|
|
665
740
|
|
|
@@ -929,7 +1004,13 @@ export function registerJittorExtension(
|
|
|
929
1004
|
event.message.errorMessage,
|
|
930
1005
|
);
|
|
931
1006
|
}
|
|
932
|
-
const metrics = assistantUsageMetrics(
|
|
1007
|
+
const metrics = assistantUsageMetrics(
|
|
1008
|
+
event.message,
|
|
1009
|
+
Date.now(),
|
|
1010
|
+
focusedTaskId,
|
|
1011
|
+
pi.getThinkingLevel(),
|
|
1012
|
+
ctx.sessionManager.getSessionId(),
|
|
1013
|
+
);
|
|
933
1014
|
if (metrics.length > 0) {
|
|
934
1015
|
const amount = (name: string): number =>
|
|
935
1016
|
metrics
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full argument grammar for /jittor, as one small data table -- the single source of truth
|
|
3
|
+
* both jittorUsageError (validation) and jittorArgumentCompletions (tab-completion) read from, so
|
|
4
|
+
* the two can never drift apart (a phrase added to one is automatically valid/completable in the
|
|
5
|
+
* other). /jittor's own registerCommand handler in index.ts owns dispatching a validated phrase to
|
|
6
|
+
* its real behavior; this module only ever answers "is this a real phrase" and "what could this
|
|
7
|
+
* become".
|
|
8
|
+
*
|
|
9
|
+
* `benchmarks` is deliberately excluded from LEAF_PHRASES: its own domain/type combinatorics
|
|
10
|
+
* (TASK_DOMAINS x TASK_TYPES, either order) already have a dedicated, tested malformed-usage
|
|
11
|
+
* branch in index.ts with its own specific error message -- this module defers to it entirely
|
|
12
|
+
* for validation, and only mirrors its real phrase space for completions.
|
|
13
|
+
*/
|
|
14
|
+
import { TASK_DOMAINS, TASK_TYPES } from "@danypops/jittor";
|
|
15
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
export const JITTOR_TOP_LEVEL_COMMANDS = [
|
|
18
|
+
"settings",
|
|
19
|
+
"status",
|
|
20
|
+
"benchmarks",
|
|
21
|
+
"cache",
|
|
22
|
+
"outcome",
|
|
23
|
+
"recovery",
|
|
24
|
+
"on",
|
|
25
|
+
"off",
|
|
26
|
+
"footer",
|
|
27
|
+
"context",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** Every real, currently-supported phrase EXCEPT benchmarks (see module doc comment). */
|
|
31
|
+
const LEAF_PHRASES = [
|
|
32
|
+
"",
|
|
33
|
+
"settings",
|
|
34
|
+
"status",
|
|
35
|
+
"cache",
|
|
36
|
+
"context",
|
|
37
|
+
"on",
|
|
38
|
+
"enable",
|
|
39
|
+
"off",
|
|
40
|
+
"disable",
|
|
41
|
+
"outcome accepted",
|
|
42
|
+
"outcome rejected",
|
|
43
|
+
"recovery",
|
|
44
|
+
"recovery status",
|
|
45
|
+
"recovery on",
|
|
46
|
+
"recovery enable",
|
|
47
|
+
"recovery off",
|
|
48
|
+
"recovery disable",
|
|
49
|
+
"recovery cancel",
|
|
50
|
+
"footer on",
|
|
51
|
+
"footer enable",
|
|
52
|
+
"footer off",
|
|
53
|
+
"footer disable",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** Real benchmarks phrases: bare, one axis alone, or both axes in either accepted word order -- mirrors index.ts's own lenient two-word-any-order parsing, for completions only. */
|
|
57
|
+
function benchmarksPhrases(): string[] {
|
|
58
|
+
const phrases: string[] = ["benchmarks"];
|
|
59
|
+
for (const domain of TASK_DOMAINS) phrases.push(`benchmarks ${domain}`);
|
|
60
|
+
for (const type of TASK_TYPES) phrases.push(`benchmarks ${type}`);
|
|
61
|
+
for (const domain of TASK_DOMAINS) {
|
|
62
|
+
for (const type of TASK_TYPES) {
|
|
63
|
+
phrases.push(`benchmarks ${domain} ${type}`, `benchmarks ${type} ${domain}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return phrases;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* null when `action` (already trimmed/lowercased by the caller, matching index.ts's own
|
|
71
|
+
* convention) is a real, currently-supported phrase; otherwise a human-readable message naming
|
|
72
|
+
* exactly what was wrong and what the real alternatives are -- never a silent fallback to
|
|
73
|
+
* unrelated behavior (the bug class this replaces: an unrecognized word, or a valid command with
|
|
74
|
+
* an unrecognized trailing argument, used to silently open the status panel instead).
|
|
75
|
+
*/
|
|
76
|
+
export function jittorUsageError(action: string): string | null {
|
|
77
|
+
if ((LEAF_PHRASES as readonly string[]).includes(action)) return null;
|
|
78
|
+
if (action === "benchmarks" || action.startsWith("benchmarks ")) return null;
|
|
79
|
+
|
|
80
|
+
const words = action.split(/\s+/).filter(Boolean);
|
|
81
|
+
const firstWord = words[0]!;
|
|
82
|
+
if (!(JITTOR_TOP_LEVEL_COMMANDS as readonly string[]).includes(firstWord)) {
|
|
83
|
+
return `Unknown /jittor command "${firstWord}". Allowed: ${JITTOR_TOP_LEVEL_COMMANDS.join(", ")}.`;
|
|
84
|
+
}
|
|
85
|
+
const subPhrases = LEAF_PHRASES.filter((phrase) => phrase.startsWith(`${firstWord} `));
|
|
86
|
+
if (subPhrases.length === 0) return `/jittor ${firstWord} does not take any arguments.`;
|
|
87
|
+
const subArguments = subPhrases.map((phrase) => phrase.slice(firstWord.length + 1));
|
|
88
|
+
return `Unknown /jittor ${firstWord} argument "${words.slice(1).join(" ")}". Allowed: ${subArguments.join(", ")}.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let cachedCompletionPhrases: string[] | undefined;
|
|
92
|
+
function completionPhrases(): string[] {
|
|
93
|
+
if (!cachedCompletionPhrases) cachedCompletionPhrases = [...LEAF_PHRASES.filter((phrase) => phrase.length > 0), ...benchmarksPhrases()];
|
|
94
|
+
return cachedCompletionPhrases;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every real phrase (see completionPhrases) that could still result from typing more after
|
|
99
|
+
* `argumentPrefix`, matching pi-tui's own SlashCommand.getArgumentCompletions contract: `value` is
|
|
100
|
+
* the FULL replacement for the entire argument text typed so far (pi-tui's own applyCompletion
|
|
101
|
+
* replaces the whole prefix span with it, not just the trailing word), so every candidate here is a
|
|
102
|
+
* complete phrase, not a per-word delta. Matching is case-insensitive on the typed prefix (a human
|
|
103
|
+
* may capitalize while typing); returned values are always canonical lowercase.
|
|
104
|
+
*/
|
|
105
|
+
export function jittorArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
106
|
+
const needle = argumentPrefix.toLowerCase();
|
|
107
|
+
const matches = completionPhrases().filter((phrase) => phrase.startsWith(needle));
|
|
108
|
+
if (matches.length === 0) return null;
|
|
109
|
+
return matches.map((value) => ({ value, label: value }));
|
|
110
|
+
}
|