pi-spark 0.1.1 → 0.2.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/README.md CHANGED
@@ -6,10 +6,10 @@ A small, opinionated collection of [pi](https://pi.dev/) extensions.
6
6
 
7
7
  ## Extensions
8
8
 
9
- - **Editor**: replaces the default editor with a compact working indicator and current model info.
9
+ - **Editor**: replaces the default editor with a compact working indicator (inspired by [Amp](https://ampcode.com/)) and current model info.
10
10
  - **Footer**: shows the session info, cost, and context usage in one line, followed by extension statuses.
11
11
  - **Fullscreen**: clears the screen and scrollback on session start and pins the editor and footer to the bottom for a full-screen session.
12
- - **Presets**: switches named model presets with `/preset` and quick cycle shortcuts.
12
+ - **Presets**: switches named model presets with `/preset`, `--preset`, and quick cycle shortcuts.
13
13
  - **Recap**: generates a short idle-session recap and exposes a `/recap` command for manual generation, inspired by [Claude Code's session recap](https://code.claude.com/docs/en/interactive-mode#session-recap).
14
14
 
15
15
  ![Screenshot](./assets/screenshot.png)
@@ -64,8 +64,9 @@ Example:
64
64
  Notes:
65
65
 
66
66
  - Set an extension key to `false` to disable it.
67
- - The `editor.spinner` value can be `lights` or `dots`.
67
+ - The `editor.spinner` value can be `dots`, `lights`, or `tildes`.
68
68
  - Presets can be selected with `/preset` or `/preset <key>`.
69
+ - Start pi with a preset using `pi --preset <key>`.
69
70
  - Cycle presets with `ctrl+super+p` and `ctrl+shift+super+p` (`super` is `command` on macOS).
70
71
  - The `recap.idle` value is in milliseconds and must be at least `5000`.
71
72
 
@@ -14,6 +14,7 @@ class Editor extends CustomEditor {
14
14
  private ctx: ExtensionContext;
15
15
 
16
16
  private spinner: Spinner;
17
+ private workingMessage: string | undefined;
17
18
  private slots: { modelBefore: string | undefined };
18
19
 
19
20
  constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner()) {
@@ -24,10 +25,16 @@ class Editor extends CustomEditor {
24
25
 
25
26
  this.spinner = spinner;
26
27
  this.spinner.setTUI(tui);
28
+ this.workingMessage = undefined;
27
29
  this.slots = { modelBefore: undefined };
28
30
  }
29
31
 
30
- setSlot(slot: keyof typeof this.slots, value: string | undefined): void {
32
+ setWorkingMessage(message?: string | undefined): void {
33
+ this.workingMessage = message;
34
+ this.tui.requestRender();
35
+ }
36
+
37
+ setSlot(slot: keyof typeof this.slots, value?: string | undefined): void {
31
38
  this.slots[slot] = value;
32
39
  this.tui.requestRender();
33
40
  }
@@ -58,7 +65,10 @@ class Editor extends CustomEditor {
58
65
  private getLeft(): string {
59
66
  const theme = this.ctx.ui.theme;
60
67
 
61
- return theme.fg("accent", this.spinner.getFrame());
68
+ const spinner = this.spinner.getFrame();
69
+ const workingMessage = this.workingMessage;
70
+
71
+ return [theme.fg("accent", spinner), workingMessage ? theme.fg("dim", workingMessage) : undefined].filter(Boolean).join(" ");
62
72
  }
63
73
 
64
74
  private getRight(): string {
@@ -73,7 +83,10 @@ class Editor extends CustomEditor {
73
83
 
74
84
  export default function (pi: ExtensionAPI) {
75
85
  const events = autoCollectEvents(pi);
86
+
87
+ let editor: Editor | undefined = undefined;
76
88
  let spinner: Spinner | undefined = undefined;
89
+ let runningToolCallIds = new Set<string>();
77
90
 
78
91
  pi.on("session_start", (_event, ctx) => {
79
92
  if (!ctx.hasUI) return;
@@ -85,11 +98,11 @@ export default function (pi: ExtensionAPI) {
85
98
 
86
99
  ctx.ui.setWorkingVisible(false);
87
100
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
88
- const editor = new Editor(pi, ctx, tui, theme, keybindings, spinner);
101
+ editor = new Editor(pi, ctx, tui, theme, keybindings, spinner);
89
102
 
90
103
  events.on(PRESET_CHANGE, (data) => {
91
104
  const payload = parsePresetChange(data);
92
- editor.setSlot("modelBefore", payload ? `preset:${payload}` : undefined);
105
+ editor?.setSlot("modelBefore", payload ? `preset:${payload}` : undefined);
93
106
  });
94
107
 
95
108
  return editor;
@@ -97,14 +110,60 @@ export default function (pi: ExtensionAPI) {
97
110
  });
98
111
 
99
112
  pi.on("agent_start", () => {
113
+ runningToolCallIds.clear();
114
+ editor?.setWorkingMessage();
100
115
  spinner?.start();
101
116
  });
102
117
 
118
+ pi.on("message_update", (event) => {
119
+ if (runningToolCallIds.size > 0) return;
120
+
121
+ switch (event.assistantMessageEvent.type) {
122
+ case "thinking_start":
123
+ case "thinking_delta":
124
+ case "thinking_end":
125
+ editor?.setWorkingMessage("Thinking");
126
+ break;
127
+ case "text_start":
128
+ case "text_delta":
129
+ case "text_end":
130
+ editor?.setWorkingMessage("Streaming");
131
+ break;
132
+ case "toolcall_start":
133
+ case "toolcall_delta":
134
+ case "toolcall_end":
135
+ editor?.setWorkingMessage("Running tools");
136
+ break;
137
+ default:
138
+ editor?.setWorkingMessage();
139
+ break;
140
+ }
141
+ });
142
+
143
+ pi.on("tool_execution_start", (event) => {
144
+ runningToolCallIds.add(event.toolCallId);
145
+ editor?.setWorkingMessage("Running tools");
146
+ });
147
+
148
+ pi.on("tool_call", (event) => {
149
+ runningToolCallIds.add(event.toolCallId);
150
+ editor?.setWorkingMessage("Running tools");
151
+ });
152
+
153
+ pi.on("tool_execution_end", (event) => {
154
+ runningToolCallIds.delete(event.toolCallId);
155
+ editor?.setWorkingMessage(runningToolCallIds.size > 0 ? "Running tools" : undefined);
156
+ });
157
+
103
158
  pi.on("agent_end", () => {
159
+ runningToolCallIds.clear();
160
+ editor?.setWorkingMessage();
104
161
  spinner?.stop();
105
162
  });
106
163
 
107
164
  pi.on("session_shutdown", () => {
165
+ runningToolCallIds.clear();
166
+ editor = undefined;
108
167
  spinner?.dispose();
109
168
  spinner = undefined;
110
169
  });
@@ -2,7 +2,7 @@ import * as z from "zod";
2
2
 
3
3
  import type { TUI } from "@earendil-works/pi-tui";
4
4
 
5
- export const spinnerPresetSchema = z.enum(["lights", "dots"]);
5
+ export const spinnerPresetSchema = z.enum(["dots", "lights", "tildes"]);
6
6
 
7
7
  type SpinnerPreset = z.infer<typeof spinnerPresetSchema>;
8
8
 
@@ -13,19 +13,24 @@ interface SpinnerParams {
13
13
  }
14
14
 
15
15
  const SPINNER_PRESETS: Record<SpinnerPreset, SpinnerParams> = {
16
+ dots: {
17
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
18
+ interval: 80,
19
+ random: false,
20
+ },
16
21
  lights: {
17
- frames: ["○○○○", "●○○○", "○●○○", "○○●○", "○○○●", "●●○○", "●○●○", "●○○●", "○●●○", "○●○●", "○○●●", "●●●○", "●●○●", "●○●●", "○●●●", "●●●●"],
22
+ frames: ["", ""],
18
23
  interval: { min: 120, max: 240 },
19
24
  random: true,
20
25
  },
21
- dots: {
22
- frames: ["", "", "", "", "", "⠴", "⠦", "⠧", "⠇", "⠏"],
23
- interval: 80,
26
+ tildes: {
27
+ frames: ["", "", "", "", ""],
28
+ interval: 200,
24
29
  random: false,
25
30
  },
26
31
  };
27
32
 
28
- const DEFAULT_SPINNER_PRESET = "lights";
33
+ const DEFAULT_SPINNER_PRESET = "tildes";
29
34
 
30
35
  export class Spinner {
31
36
  private tui: TUI | undefined;
@@ -1,5 +1,4 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
2
 
4
3
  import { SplitLine } from "../shared/components/split-line";
5
4
  import { loadConfig } from "../shared/config";
@@ -25,16 +24,10 @@ class FooterComponent implements Component {
25
24
  }
26
25
 
27
26
  render(width: number): string[] {
28
- return [this.renderMainLine(width), this.renderStatusLine(width)].filter(Boolean);
29
- }
30
-
31
- private renderMainLine(width: number): string {
32
27
  const left = this.getLeft();
33
28
  const right = this.getRight();
34
- return new SplitLine(left, right, {
35
- primarySide: "right",
36
- ellipsis: this.theme.fg("dim", "…"),
37
- }).render(width)[0];
29
+
30
+ return new SplitLine(left, right, { primarySide: "right", ellipsis: this.theme.fg("dim", "…") }).render(width);
38
31
  }
39
32
 
40
33
  private getLeft(): string {
@@ -50,10 +43,22 @@ class FooterComponent implements Component {
50
43
  }
51
44
 
52
45
  private getRight(): string {
46
+ const statusesText = this.getStatusesText();
53
47
  const styledCostText = this.getStyledCostText();
54
48
  const styledContextUsageText = this.getStyledContextUsageText();
55
49
 
56
- return `${styledCostText}${this.theme.fg("dim", " • ")}${styledContextUsageText}`;
50
+ return [statusesText, styledCostText, styledContextUsageText].filter(Boolean).join(this.theme.fg("dim", " • "));
51
+ }
52
+
53
+ /** Get extension statuses, sorted by key alphabetically. */
54
+ private getStatusesText(): string {
55
+ const extensionStatuses = this.footerData.getExtensionStatuses();
56
+ if (extensionStatuses.size === 0) return "";
57
+
58
+ return Array.from(extensionStatuses.entries())
59
+ .sort(([a], [b]) => a.localeCompare(b))
60
+ .map(([, text]) => sanitizeText(text))
61
+ .join(this.theme.fg("dim", " • "));
57
62
  }
58
63
 
59
64
  private getStyledCostText(): string {
@@ -64,8 +69,8 @@ class FooterComponent implements Component {
64
69
  }, { subscription: 0, paid: 0 });
65
70
 
66
71
  const isSubscription = this.ctx.model ? this.ctx.modelRegistry.isUsingOAuth(this.ctx.model) : false;
67
- const subscriptionCostText = isSubscription || cost.subscription > 0 ? formatCost(cost.subscription, true) : undefined;
68
- const paidCostText = !isSubscription || cost.paid > 0 ? formatCost(cost.paid, false) : undefined;
72
+ const subscriptionCostText = isSubscription || cost.subscription >= 0.005 ? formatCost(cost.subscription, true) : undefined;
73
+ const paidCostText = !isSubscription || cost.paid >= 0.005 ? formatCost(cost.paid, false) : undefined;
69
74
  const costText = [subscriptionCostText, paidCostText].filter(Boolean).join(" + ");
70
75
 
71
76
  const totalCost = cost.subscription + cost.paid;
@@ -83,19 +88,6 @@ class FooterComponent implements Component {
83
88
  if (percent && percent > 70) return this.theme.fg("warning", contextUsageText);
84
89
  return this.theme.fg("dim", contextUsageText);
85
90
  }
86
-
87
- /** Add extension statues on a single line, sorted by key alphabetically. */
88
- private renderStatusLine(width: number): string {
89
- const extensionStatuses = this.footerData.getExtensionStatuses();
90
- if (extensionStatuses.size === 0) return "";
91
-
92
- const statusLine = Array.from(extensionStatuses.entries())
93
- .sort(([a], [b]) => a.localeCompare(b))
94
- .map(([, text]) => sanitizeText(text))
95
- .join(this.theme.fg("dim", " • "));
96
-
97
- return truncateToWidth(statusLine, width, this.theme.fg("dim", "..."));
98
- }
99
91
  }
100
92
 
101
93
  function formatCwd(cwd: string, home?: string): string {
@@ -9,9 +9,19 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  export default function (pi: ExtensionAPI) {
10
10
  let presetManager: PresetManager | undefined = undefined;
11
11
 
12
- pi.on("session_start", (_event, ctx) => {
12
+ pi.registerFlag("preset", {
13
+ description: "Model preset to use",
14
+ type: "string",
15
+ });
16
+
17
+ pi.on("session_start", async (event, ctx) => {
18
+ const presetFlag = pi.getFlag("preset");
19
+
13
20
  const config = loadConfig(ctx, "presets");
14
- if (!config || Object.keys(config).length === 0) return;
21
+ if (!config || Object.keys(config).length === 0) {
22
+ if (presetFlag) ctx.ui.notify("No presets defined in spark.json", "warning");
23
+ return;
24
+ }
15
25
 
16
26
  presetManager = new PresetManager(pi, config);
17
27
  presetManager.sync(ctx);
@@ -42,6 +52,10 @@ export default function (pi: ExtensionAPI) {
42
52
  }
43
53
  },
44
54
  });
55
+
56
+ if (presetFlag && typeof presetFlag === "string") {
57
+ await presetManager.apply(presetFlag, ctx);
58
+ }
45
59
  });
46
60
 
47
61
  pi.on("model_select", (_event, ctx) => {
@@ -35,7 +35,7 @@ export class PresetManager {
35
35
  async apply(key: string, ctx: ExtensionContext): Promise<boolean> {
36
36
  const preset = this.presets[key];
37
37
  if (!preset) {
38
- ctx.ui.notify(`Unknown preset ${key}`, "error");
38
+ ctx.ui.notify(`Unknown preset ${key} (${this.keys.length ? `available: ${this.keys.join(", ")}` : "none defined"})`, "error");
39
39
  return false;
40
40
  }
41
41
 
@@ -1,7 +1,7 @@
1
1
  import { completeSimple } from "@earendil-works/pi-ai";
2
2
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
3
3
 
4
- import { resolveRecapModel } from "./model";
4
+ import { resolveRecapModelSettings } from "./model";
5
5
  import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
6
6
  import { sanitizeText } from "../shared/format";
7
7
 
@@ -10,14 +10,15 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
10
10
  import type { RecapConfig } from "./config";
11
11
 
12
12
  const SYSTEM_PROMPT = [
13
- "You write concise recaps for an idle terminal coding agent session.",
14
- "Summarize only what is supported by the transcript; do not invent progress, intent, files, or next steps.",
15
- "Focus on the user's goal, completed work, current state, and likely next step.",
16
- "Prefer recent context when the session changed direction.",
17
- "Output one short paragraph of 1-2 sentences. No heading, markdown, bullets, or quotes.",
13
+ "You write concise idle-session recaps for a terminal coding agent.",
14
+ "Use only transcript-supported facts; do not invent progress, intent, files, or next steps.",
15
+ "Prefer the latest active task if the session changed direction.",
16
+ "Summarize the user's goal, what was done, current state, and any clearly supported next step.",
17
+ "Respond in the conversation's primary language.",
18
+ "Output 1-2 plain-text sentences under 80 words. No heading, markdown, bullets, or quotes.",
18
19
  ].join("\n");
19
20
 
20
- const MAX_TOKENS = 120;
21
+ const MAX_TOKENS = 100;
21
22
  const MAX_CONVERSATION_CHARS = 8_000;
22
23
 
23
24
  export class RecapManager {
@@ -39,25 +40,27 @@ export class RecapManager {
39
40
  this.inflight = controller;
40
41
 
41
42
  try {
42
- const recapModel = await resolveRecapModel(this.pi, ctx, this.config);
43
- if (controller.signal.aborted || this.inflight !== controller || !recapModel) return;
43
+ const recapModelSettings = await resolveRecapModelSettings(this.pi, ctx, this.config);
44
+ if (controller.signal.aborted || this.inflight !== controller || !recapModelSettings) return;
44
45
 
45
- setRecapLoadingWidget(ctx, recapModel.warning);
46
+ const { model, thinkingLevel, warning } = recapModelSettings;
47
+
48
+ setRecapLoadingWidget(ctx, warning);
46
49
  this.active = false;
47
50
 
48
- const result = await this.generate(ctx, recapModel.model, recapModel.thinkingLevel, controller.signal);
51
+ const result = await this.generate(ctx, model, thinkingLevel, controller.signal);
49
52
  if (controller.signal.aborted || this.inflight !== controller) return;
50
53
  if (!result.content) {
51
54
  clearRecapWidget(ctx);
52
55
  return;
53
56
  }
54
57
 
55
- setRecapTextWidget(ctx, result.content, recapModel.warning);
58
+ setRecapTextWidget(ctx, result.content, warning);
56
59
  this.active = true;
57
60
 
58
61
  this.pi.appendEntry("recap", {
59
- provider: recapModel.model.provider,
60
- model: recapModel.model.id,
62
+ provider: model.provider,
63
+ model: model.id,
61
64
  usage: result.usage,
62
65
  content: result.content,
63
66
  });
@@ -111,8 +114,8 @@ export class RecapManager {
111
114
  const conversation = text.length > MAX_CONVERSATION_CHARS ? text.slice(-MAX_CONVERSATION_CHARS) : text;
112
115
 
113
116
  return [
114
- "Create a short recap of this coding agent session for the user to see while the agent is idle.",
115
- "The transcript may be truncated from the beginning and may start mid-message; account for that uncertainty.",
117
+ "Create a short recap from this transcript, ordered oldest to newest.",
118
+ "It may be truncated from the beginning; focus on the latest coherent task.",
116
119
  "",
117
120
  "<conversation>",
118
121
  conversation,
@@ -1,61 +1,95 @@
1
1
  import { clampThinkingLevel } from "@earendil-works/pi-ai";
2
2
 
3
+ import { formatModel } from "../shared/format";
4
+
3
5
  import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
4
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
7
  import type { OptionalModelConfig } from "../shared/config/model";
6
8
 
7
9
  const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
8
10
 
9
- type RecapModel = {
11
+ type RecapModelSettings = {
10
12
  model: Model<Api>;
11
13
  thinkingLevel: ModelThinkingLevel;
12
14
  warning: string | undefined;
13
15
  };
14
16
 
15
- export async function resolveRecapModel(pi: ExtensionAPI, ctx: ExtensionContext, config: OptionalModelConfig): Promise<RecapModel | undefined> {
17
+ type ModelSelection = {
18
+ model: Model<Api>;
19
+ warning: string | undefined;
20
+ };
21
+
22
+ type ThinkingLevelSelection = {
23
+ thinkingLevel: ModelThinkingLevel;
24
+ warning: string | undefined;
25
+ };
26
+
27
+ export async function resolveRecapModelSettings(pi: ExtensionAPI, ctx: ExtensionContext, config: OptionalModelConfig): Promise<RecapModelSettings | undefined> {
16
28
  const fallbackModel = ctx.model;
17
29
  if (!fallbackModel) {
18
30
  ctx.ui.notify("No model selected for recap", "warning");
19
31
  return;
20
32
  }
21
33
 
22
- let model = fallbackModel;
23
- let warning: string | undefined;
24
-
25
- if (config.provider || config.model) {
26
- if (!config.provider || !config.model) {
27
- warning = "Both recap.provider and recap.model are required; using the current model.";
28
- } else {
29
- const configuredModel = ctx.modelRegistry.find(config.provider, config.model);
30
- if (configuredModel) {
31
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configuredModel);
32
- if (auth.ok) {
33
- model = configuredModel;
34
- } else {
35
- warning = `Model ${config.provider}/${config.model} unavailable: ${auth.error}; using the current model.`;
36
- }
37
- } else {
38
- warning = `Model ${config.provider}/${config.model} not found; using the current model.`;
39
- }
40
- }
41
- }
42
-
34
+ const { model, warning: modelWarning } = await resolveModelSelection(ctx, config, fallbackModel);
43
35
  const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? pi.getThinkingLevel());
44
36
 
45
37
  return {
46
38
  model,
47
39
  thinkingLevel,
48
- warning: [warning, thinkingLevelWarning].filter(Boolean).join(" ") || undefined,
40
+ warning: [modelWarning, thinkingLevelWarning].filter(Boolean).join(" ") || undefined,
49
41
  };
50
42
  }
51
43
 
52
- function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): { thinkingLevel: ModelThinkingLevel; warning?: string } {
44
+ async function resolveModelSelection(ctx: ExtensionContext, config: OptionalModelConfig, fallbackModel: Model<Api>): Promise<ModelSelection> {
45
+ if (!config.provider && !config.model) {
46
+ return {
47
+ model: fallbackModel,
48
+ warning: undefined,
49
+ };
50
+ }
51
+
52
+ if (!config.provider || !config.model) {
53
+ return {
54
+ model: fallbackModel,
55
+ warning: "Both recap.provider and recap.model are required; using the current model.",
56
+ };
57
+ }
58
+
59
+ const model = ctx.modelRegistry.find(config.provider, config.model);
60
+ if (!model) {
61
+ return {
62
+ model: fallbackModel,
63
+ warning: `Model ${formatModel(config.provider, config.model)} not found; using the current model.`,
64
+ };
65
+ }
66
+
67
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
68
+ if (!auth.ok) {
69
+ return {
70
+ model: fallbackModel,
71
+ warning: `Model ${formatModel(config.provider, config.model)} unavailable: ${auth.error}; using the current model.`,
72
+ };
73
+ }
74
+
75
+ return {
76
+ model,
77
+ warning: undefined,
78
+ };
79
+ }
80
+
81
+ function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): ThinkingLevelSelection {
53
82
  const thinkingLevel = clampThinkingLevel(model, requested);
54
- if (thinkingLevel === requested) return { thinkingLevel };
83
+ if (thinkingLevel === requested) {
84
+ return {
85
+ thinkingLevel,
86
+ warning: undefined,
87
+ };
88
+ }
55
89
 
56
90
  const fallback = clampThinkingLevel(model, DEFAULT_THINKING_LEVEL);
57
91
  return {
58
92
  thinkingLevel: fallback,
59
- warning: `Thinking level ${requested} is not supported by ${model.provider}/${model.id}; using ${fallback}.`,
93
+ warning: `Thinking level ${requested} is not supported by ${formatModel(model.provider, model.id)}; using ${fallback}.`,
60
94
  };
61
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A small, opinionated collection of pi extensions",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -19,7 +19,9 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "pi": {
22
- "extensions": ["./extensions"],
22
+ "extensions": [
23
+ "./extensions"
24
+ ],
23
25
  "image": "https://raw.githubusercontent.com/zlliang/pi-spark/main/assets/cover.png"
24
26
  },
25
27
  "scripts": {