@rhizomatics/signalk-einklabel-genai-plugin 0.1.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # eInk Label GenAI Rendering
2
+
3
+ A companion plugin for [`@rhizomatics/signalk-einklabel-plugin`](https://github.com/rhizomatics/signalk-einklabel-plugin) that generates a device's content from an LLM prompt, as an alternative to a hand-authored SVG template.
4
+
5
+ This is deliberately a separate plugin, not a feature of the core one: the core plugin has nothing to do with any LLM SDK or API key, so installing it never pulls in GenAI dependencies unless you also explicitly install and enable this one - useful for anyone on a small/constrained install, or who'd simply rather not have any GenAI code in their install at all.
6
+
7
+ ## How it fits together
8
+
9
+ This plugin registers a `TemplateProvider` with the core plugin (the same extension mechanism a new vendor's hardware driver uses - see the core plugin's README, "Extending"). Once both plugins are installed and enabled:
10
+
11
+ - Its prompts show up as ordinary entries in the core plugin's own "Template" picker, suffixed `(GenAI)` - pick one exactly like you'd pick a `.svg` file. No separate "render mode" setting.
12
+ - All LLM provider/model/API-key configuration lives in **this** plugin's own config screen - the core plugin knows nothing about any of that.
13
+ - If the LLM call fails (network/API error, exhausted retries) or its response isn't a renderable SVG, the **core** plugin pushes its own generic fallback-warning template instead of leaving the previous, possibly now-wrong, content on screen - the same safety net a broken hand-authored template gets. The next scheduled repaint retries automatically.
14
+
15
+ Since each repaint may call a (usually paid) LLM API, a GenAI-backed device should use Repaint Trigger `interval`, not `subscription`.
16
+
17
+ ## Installation
18
+
19
+ Install both plugins from the SignalK AppStore (or `npm install @rhizomatics/signalk-einklabel-plugin @rhizomatics/signalk-einklabel-genai-plugin`), then install whichever LLM provider package(s) you'll actually use - these are `optionalDependencies`, not installed automatically, since a user only ever needs one:
20
+
21
+ ```bash
22
+ npm install ai @ai-sdk/openai # llmProvider: "openai"
23
+ npm install ai @ai-sdk/anthropic # llmProvider: "anthropic"
24
+ npm install ai @ai-sdk/google # llmProvider: "google"
25
+ npm install ai @ai-sdk/xai # llmProvider: "xai"
26
+ npm install ai @ai-sdk/deepseek # llmProvider: "deepseek"
27
+ npm install ai @ai-sdk/moonshotai # llmProvider: "moonshotai"
28
+ npm install ai ai-sdk-ollama # llmProvider: "ollama"
29
+ npm install ai @ai-sdk/openai-compatible # llmProvider: "local" (LM Studio, vLLM, ...)
30
+ ```
31
+
32
+ If the package for your configured provider isn't installed, a GenAI-backed device falls back to the core plugin's warning template with a log message naming exactly which package to `npm install`.
33
+
34
+ ## Configuration
35
+
36
+ - `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `ollama`, or `local` (any other OpenAI-compatible server)
37
+ - `llmApiKey` - not needed for `ollama`/`local` unless your server itself checks one
38
+ - `llmModel` - provider-specific model id, e.g. `gpt-4o`, `claude-sonnet-4-5`, `gemini-2.5-flash`, `grok-4`, or an Ollama/local model tag
39
+ - `llmBaseUrl` - required for `local`; optional for `ollama` (defaults to `http://localhost:11434/v1`)
40
+ - `llmTimeoutSeconds`/`llmRetries` - how long to wait, and how many attempts, before giving up (the core plugin then shows its fallback warning)
41
+ - `promptsDir` - where to look for your own `.md` prompt files, same convention as the core plugin's `templatesDir` (empty for the default, a relative path resolves against `~/.signalk`, absolute used as-is)
42
+
43
+ ### Adding another LLM provider
44
+
45
+ The provider list above is `resolveModel`'s (`src/llmGateway.ts`) built-in switch, but you're not limited to it: call `registerLlmProvider(name, factory)` (exported from this plugin, mirroring the core plugin's own extension points) from your own SignalK plugin's `start()` to add support for a provider this plugin doesn't bundle, without forking it. A registered name takes priority over the built-in switch, so it can even replace one of the names above.
46
+
47
+ ```ts
48
+ import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
49
+ genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
50
+ ```
51
+
52
+ Declare this package as a regular npm `dependency` (**not** a `peerDependency` - see the core plugin's own README for why) plus `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json`.
53
+
54
+ ## Writing Prompts
55
+
56
+ A prompt (`.md` file) is just prose, with the same `{...}` binding syntax as a template's `<desc>` in the core plugin (see its README, "Template Source Specification") - a bare path like `{design.length.overall}`, or the full `source=...,path=...,format=...` grammar, resolve exactly the way they would in a template. One more source is available here, `source=label`, giving facts about the physical label the LLM needs to design for:
57
+
58
+ - `path=width`/`path=height` - exact pixel size the returned SVG must match
59
+ - `path=colours` - the label's colour palette (add `format=csv` for a plain comma-separated list)
60
+ - `path=fonts` - the three font-family keywords the renderer can actually display (also supports `format=csv`)
61
+ - `path=description` - the device's own configured Location/description field
62
+ - `path=manufacturer`/`path=label` - the panel's real-world brand and its own size label (e.g. `3.7"`)
63
+ - `path=position` - the vessel's current GPS position, pre-formatted as decimal degrees with hemisphere letters
64
+
65
+ A placeholder that doesn't resolve to any value becomes `???` in the prompt text - a visible gap the LLM can reason around, rather than a silent, misleading blank. Add `default=<value>` to any binding to use that value instead when the data is missing.
66
+
67
+ Every prompt also has a bundled `.assets/target_guidance.md` fragment appended automatically, telling the model the exact size/colour/font constraints and that it must respond with nothing but a single self-contained `<svg>...</svg>` document. Override it the same way you'd override any bundled prompt - put your own `.assets/target_guidance.md` in your `promptsDir`.
68
+
69
+ ## Testing Prompts from the CLI
70
+
71
+ This plugin contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, loaded via `-r`:
72
+
73
+ ```bash
74
+ esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js prompt forecast -e examples
75
+ esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js generate forecast -e examples -o forecast.png --llm-provider ollama --llm-model llama3.2
76
+ ```
77
+
78
+ - `prompt [name]` - resolve a prompt's placeholders against a live SignalK server (or example data) and print the exact text an LLM would be sent, with no LLM call
79
+ - `generate [name]` - call the configured LLM and write the rendered SVG's PNG, optionally saving the raw SVG too with `--save-svg`
80
+
81
+ The optional `[name]` argument names a prompt the same way the config UI's picker does - a full path also works. Use `--help` on either for the full option list.
82
+
83
+ ## Publishing note
84
+
85
+ This plugin's own `package.json` currently points its `@rhizomatics/signalk-einklabel-plugin` dependency at a local `file:` path for development against an unpublished core plugin - update it to a real semver range once both are published.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * Contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, for testing prompts
8
+ * without a device or a live SignalK server. Load via `esl-cli -r <path-to-this-file's-dist> prompt ...`
9
+ * - the core CLI pre-scans `-r`/`--require` and loads those modules *before* building its own command
10
+ * tree (see its own README/source for why), which is what makes adding a brand-new subcommand from here
11
+ * possible at all, unlike a module that only registers a vendor driver/template provider.
12
+ *
13
+ * A plain top-level `require()` (rather than a static `import`) of the core CLI module is deliberate -
14
+ * it makes the load-time side effect (adding commands to an already-running CLI process) obvious to a
15
+ * reader, and avoids a static import accidentally triggering a *second*, unrelated full execution of
16
+ * that module (including its own `parseAsync(process.argv)`) if this file were ever `require()`'d
17
+ * outside of `esl-cli -r`'s intended flow - within that flow, the core CLI module is already
18
+ * mid-execution and cached, so this just reads back the shared `program`/helpers it already exported.
19
+ */
20
+ const fs_1 = require("fs");
21
+ const promises_1 = require("fs/promises");
22
+ const os_1 = require("os");
23
+ const path_1 = require("path");
24
+ const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
25
+ const config_1 = require("./config");
26
+ const llmGateway_1 = require("./llmGateway");
27
+ const retry_1 = require("./retry");
28
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
29
+ const cli = require("@rhizomatics/signalk-einklabel-plugin/dist/cli/index.js");
30
+ const DEFAULT_GUIDANCE_PATH = (0, path_1.join)(config_1.BUNDLED_PROMPTS_DIR, ".assets", "target_guidance.md");
31
+ /** Shared -p/-g/-w/--height/--colours/-d/--manufacturer/--label/-u/-e options for `prompt` and `generate`. */
32
+ function addPromptOptions(command) {
33
+ return command
34
+ .option("-p, --prompt <path>", "path to a .md prompt file", (0, path_1.join)(config_1.BUNDLED_PROMPTS_DIR, config_1.DEFAULT_PROMPT_NAME))
35
+ .option("-g, --guidance <path>", "path to the target-guidance fragment appended after the prompt", DEFAULT_GUIDANCE_PATH)
36
+ .option("-u, --url <url>", "SignalK server base URL - if omitted, tries each of " + cli.DEFAULT_SIGNALK_URLS.join(", ") + " in turn")
37
+ .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> instead of a live SignalK server")
38
+ .option("-w, --width <px>", "value for {source=label,path=width}", "416")
39
+ .option("--height <px>", "value for {source=label,path=height}", "240")
40
+ .option("--colours <code>", "value for {source=label,path=colours}: BW, BWR, or BWRY", "BWRY")
41
+ .option("-d, --description <text>", "value for {source=label,path=description} (a label's configured location/notes)")
42
+ .option("--manufacturer <name>", "value for {source=label,path=manufacturer}", "preview")
43
+ .option("--label <name>", "value for {source=label,path=label} (the panel's own size label, e.g. '3.7\"')", "preview");
44
+ }
45
+ /**
46
+ * Resolves the optional positional `[name]` argument (e.g. `esl-cli prompt forecast`) to a prompt file
47
+ * path, overriding `-p/--prompt` when given - a bare name (no extension) is resolved as a prompt name
48
+ * via `resolvePromptPath`/`resolvePromptsDir` (the same lookup a real device's prompt gets).
49
+ */
50
+ function resolvePromptArg(name, promptOption) {
51
+ if (!name)
52
+ return promptOption;
53
+ if ((0, fs_1.existsSync)(name))
54
+ return name;
55
+ const fileName = /\.[a-z0-9]+$/i.test(name) ? name : `${name}.md`;
56
+ return (0, config_1.resolvePromptPath)((0, config_1.resolvePromptsDir)(""), fileName);
57
+ }
58
+ /**
59
+ * Resolves a prompt+guidance fragment's placeholders against a live SignalK server (or example data),
60
+ * building the same `context.label` (`esl.buildLabel`) a real repaint would - shared by `prompt` and
61
+ * `generate`. `navigation.position` is always fetched (even if no other binding references it) so
62
+ * `{source=label,path=position}` works without the caller needing to name it explicitly.
63
+ */
64
+ async function resolvePrompt(opts) {
65
+ const promptText = await (0, promises_1.readFile)(opts.prompt, "utf-8");
66
+ const guidanceText = await (0, promises_1.readFile)(opts.guidance, "utf-8");
67
+ const positionBinding = { source: "signalk", context: "self", path: "navigation.position" };
68
+ const bindings = [...signalk_einklabel_plugin_1.default.findBindingsInText(promptText, guidanceText), positionBinding];
69
+ const context = await cli.assembleContext(opts, bindings);
70
+ const rawPosition = context.signalk?.self?.navigation?.position;
71
+ const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
72
+ ? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
73
+ : undefined;
74
+ const label = signalk_einklabel_plugin_1.default.buildLabel({
75
+ manufacturer: opts.manufacturer,
76
+ label: opts.label,
77
+ width: Number(opts.width),
78
+ height: Number(opts.height),
79
+ colours: cli.parseColours(opts.colours),
80
+ description: opts.description,
81
+ position,
82
+ });
83
+ const promptContext = { ...context, label };
84
+ return `${signalk_einklabel_plugin_1.default.substituteBindingsInText(promptText, promptContext).trim()}\n\n${signalk_einklabel_plugin_1.default.substituteBindingsInText(guidanceText, promptContext).trim()}\n`;
85
+ }
86
+ addPromptOptions(cli.program
87
+ .command("prompt")
88
+ .argument("[name]", 'prompt name (e.g. "forecast") or path - overrides -p/--prompt if given')
89
+ .description("Resolve a prompt file's placeholders against a live SignalK server (or example data) and print the exact text an LLM would be sent - no LLM call")).action(async (name, opts) => {
90
+ console.log(await resolvePrompt({ ...opts, prompt: resolvePromptArg(name, opts.prompt) }));
91
+ });
92
+ addPromptOptions(cli.program
93
+ .command("generate")
94
+ .argument("[name]", 'prompt name (e.g. "forecast") or path - overrides -p/--prompt if given')
95
+ .description("Call an LLM with a resolved prompt and write the rendered SVG's PNG, without needing a device"))
96
+ .requiredOption("-o, --output <path>", "output PNG path")
97
+ .option("--save-svg <path>", "also write the LLM's raw SVG response to this path (feed it straight into the core plugin's `paint -t`/`render -t` afterwards)")
98
+ .option("--llm-provider <name>", "openai, anthropic, google, xai, deepseek, moonshotai, ollama, or local", "openai")
99
+ .option("--llm-api-key <key>", "LLM API key - falls back to the provider's usual environment variable if omitted")
100
+ .requiredOption("--llm-model <name>", "provider-specific model id/name, e.g. gpt-4o, claude-sonnet-4-5, gemini-2.5-flash, grok-4, or a local/ollama model tag")
101
+ .option("--llm-base-url <url>", 'OpenAI-compatible endpoint - required if --llm-provider is "local"; for "ollama" defaults to http://localhost:11434/v1')
102
+ .option("--llm-timeout <seconds>", "LLM call timeout", "30")
103
+ .option("--llm-retries <n>", "LLM call attempts before giving up", "2")
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ .action(async (name, opts) => {
106
+ const prompt = await resolvePrompt({ ...opts, prompt: resolvePromptArg(name, opts.prompt) });
107
+ const llmSettings = {
108
+ llmProvider: opts.llmProvider,
109
+ llmApiKey: opts.llmApiKey,
110
+ llmModel: opts.llmModel,
111
+ llmBaseUrl: opts.llmBaseUrl,
112
+ llmTimeoutSeconds: Number(opts.llmTimeout),
113
+ };
114
+ const raw = await (0, retry_1.withRetries)(Number(opts.llmRetries), () => (0, llmGateway_1.callLlm)(llmSettings, prompt));
115
+ const svg = (0, llmGateway_1.extractSvg)(raw);
116
+ const svgPath = opts.saveSvg ?? (0, path_1.join)((0, os_1.tmpdir)(), `esl-cli-generate-${Date.now()}.svg`);
117
+ await (0, promises_1.writeFile)(svgPath, svg);
118
+ const width = Number(opts.width);
119
+ const height = Number(opts.height);
120
+ const renderer = new signalk_einklabel_plugin_1.default.Renderer();
121
+ const bitmap = await renderer.render(svgPath, { meta: { repainted: new Date().toISOString(), description: opts.description ?? "" } }, width, height);
122
+ await (0, promises_1.writeFile)(opts.output, signalk_einklabel_plugin_1.default.bitmapToPng(bitmap));
123
+ console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})${opts.saveSvg ? `, svg saved to ${opts.saveSvg}` : ""}`);
124
+ });
@@ -0,0 +1,50 @@
1
+ export interface PluginConfig {
2
+ /**
3
+ * Which LLM gateway a prompt is sent to - see `./llmGateway.ts`. `"ollama"`/`"local"` both go through
4
+ * the generic OpenAI-compatible provider; any other value not in this list is looked up in the
5
+ * `registerLlmProvider` registry (`./llmProviderRegistry.ts`) before giving up - so someone keen can
6
+ * add support for another provider entirely without forking this plugin. Defaults to `"openai"`.
7
+ */
8
+ llmProvider?: "openai" | "anthropic" | "google" | "xai" | "deepseek" | "moonshotai" | "ollama" | "local";
9
+ /** Ignored for `llmProvider: "ollama"`/`"local"` unless the server itself requires one. */
10
+ llmApiKey?: string;
11
+ /** Provider-specific model id, e.g. `"gpt-4o"`, `"claude-sonnet-4-5"`, `"gemini-2.5-flash"`, or an Ollama/local model tag. */
12
+ llmModel?: string;
13
+ /**
14
+ * An OpenAI-compatible endpoint. Optional for `llmProvider: "ollama"` - defaults to Ollama's own
15
+ * standard local address, `http://localhost:11434/v1`; only needed there to point at a differently
16
+ * configured/remote Ollama instance. Required for `llmProvider: "local"` (any other OpenAI-compatible
17
+ * server, e.g. LM Studio/vLLM), which has no such universal default.
18
+ */
19
+ llmBaseUrl?: string;
20
+ /** How long to wait for the LLM's response before giving up on an attempt. */
21
+ llmTimeoutSeconds: number;
22
+ /** How many times to attempt the LLM call (including the first try) before giving up (the core plugin then shows its own generic fallback warning). */
23
+ llmRetries: number;
24
+ /**
25
+ * Directory this plugin scans for `.md` prompt files - same convention as the core plugin's own
26
+ * `templatesDir`: empty for the default, a relative path resolved against `~/.signalk`, or an
27
+ * absolute path. Use `resolvePromptsDir` to turn this into an actual path.
28
+ */
29
+ promptsDir: string;
30
+ }
31
+ /**
32
+ * This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
33
+ * `files`) - `forecast.md` (the built-in default) and the always-appended
34
+ * `.assets/target_guidance.md` fragment live here. A same-named file in the user's `promptsDir` takes
35
+ * priority, letting users add or override their own prompts.
36
+ */
37
+ export declare const BUNDLED_PROMPTS_DIR: string;
38
+ /** Bundled prompt used when `promptName` is left unset. */
39
+ export declare const DEFAULT_PROMPT_NAME = "forecast.md";
40
+ export declare function defaultConfig(): PluginConfig;
41
+ /** Mirrors the core plugin's `resolveTemplatesDir` - see its own doc comment. */
42
+ export declare function resolvePromptsDir(promptsDir: string | undefined): string;
43
+ /** Local prompts take priority over a same-named bundled one; both show up as options. */
44
+ export declare function promptNameOptions(promptsDir: string): string[];
45
+ /**
46
+ * Resolves a prompt name (or the `.assets/target_guidance.md` fragment, passed as
47
+ * `".assets/target_guidance.md"`) to an actual file - a local file under `promptsDir` overrides the
48
+ * bundled one of the same name.
49
+ */
50
+ export declare function resolvePromptPath(promptsDir: string, name: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PROMPT_NAME = exports.BUNDLED_PROMPTS_DIR = void 0;
4
+ exports.defaultConfig = defaultConfig;
5
+ exports.resolvePromptsDir = resolvePromptsDir;
6
+ exports.promptNameOptions = promptNameOptions;
7
+ exports.resolvePromptPath = resolvePromptPath;
8
+ const fs_1 = require("fs");
9
+ const os_1 = require("os");
10
+ const path_1 = require("path");
11
+ /**
12
+ * This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
13
+ * `files`) - `forecast.md` (the built-in default) and the always-appended
14
+ * `.assets/target_guidance.md` fragment live here. A same-named file in the user's `promptsDir` takes
15
+ * priority, letting users add or override their own prompts.
16
+ */
17
+ exports.BUNDLED_PROMPTS_DIR = (0, path_1.join)(__dirname, "..", "prompts");
18
+ /** Bundled prompt used when `promptName` is left unset. */
19
+ exports.DEFAULT_PROMPT_NAME = "forecast.md";
20
+ const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
21
+ const DEFAULT_PROMPTS_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel-genai", "prompts");
22
+ function defaultConfig() {
23
+ return {
24
+ llmTimeoutSeconds: 30,
25
+ llmRetries: 2,
26
+ promptsDir: "",
27
+ };
28
+ }
29
+ /** Mirrors the core plugin's `resolveTemplatesDir` - see its own doc comment. */
30
+ function resolvePromptsDir(promptsDir) {
31
+ const trimmed = promptsDir?.trim();
32
+ if (!trimmed)
33
+ return DEFAULT_PROMPTS_DIR;
34
+ return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
35
+ }
36
+ function listMarkdownFiles(dir) {
37
+ try {
38
+ return (0, fs_1.readdirSync)(dir).filter((name) => name.endsWith(".md"));
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ /** Local prompts take priority over a same-named bundled one; both show up as options. */
45
+ function promptNameOptions(promptsDir) {
46
+ const local = listMarkdownFiles(promptsDir);
47
+ const bundled = listMarkdownFiles(exports.BUNDLED_PROMPTS_DIR).filter((name) => !local.includes(name));
48
+ return [...local, ...bundled];
49
+ }
50
+ /**
51
+ * Resolves a prompt name (or the `.assets/target_guidance.md` fragment, passed as
52
+ * `".assets/target_guidance.md"`) to an actual file - a local file under `promptsDir` overrides the
53
+ * bundled one of the same name.
54
+ */
55
+ function resolvePromptPath(promptsDir, name) {
56
+ const localPath = (0, path_1.join)(promptsDir, name);
57
+ return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_PROMPTS_DIR, name);
58
+ }
@@ -0,0 +1,23 @@
1
+ import { ServerAPI, Plugin } from "@signalk/server-api";
2
+ import { registerLlmProvider as registerLlmProviderImpl, getLlmProvider as getLlmProviderImpl } from "./llmProviderRegistry";
3
+ import type { LlmProviderFactory as LlmProviderFactoryType, LlmSettings as LlmSettingsType } from "./llmProviderRegistry";
4
+ /**
5
+ * On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
6
+ * (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
7
+ * up in the core plugin's own "Template" picker, suffixed "(GenAI)".
8
+ */
9
+ declare function plugin(app: ServerAPI): Plugin;
10
+ declare namespace plugin {
11
+ /**
12
+ * Public extension point for a package adding support for an LLM provider this plugin doesn't
13
+ * already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
14
+ * full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
15
+ * convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
16
+ * `peerDependency`).
17
+ */
18
+ const registerLlmProvider: typeof registerLlmProviderImpl;
19
+ const getLlmProvider: typeof getLlmProviderImpl;
20
+ type LlmProviderFactory = LlmProviderFactoryType;
21
+ type LlmSettings = LlmSettingsType;
22
+ }
23
+ export = plugin;
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ const plugin_1 = require("./plugin");
3
+ const llmProviderRegistry_1 = require("./llmProviderRegistry");
4
+ /**
5
+ * On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
6
+ * (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
7
+ * up in the core plugin's own "Template" picker, suffixed "(GenAI)".
8
+ */
9
+ function plugin(app) {
10
+ return (0, plugin_1.createPlugin)(app);
11
+ }
12
+ (function (plugin) {
13
+ /**
14
+ * Public extension point for a package adding support for an LLM provider this plugin doesn't
15
+ * already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
16
+ * full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
17
+ * convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
18
+ * `peerDependency`).
19
+ */
20
+ plugin.registerLlmProvider = llmProviderRegistry_1.registerLlmProvider;
21
+ plugin.getLlmProvider = llmProviderRegistry_1.getLlmProvider;
22
+ })(plugin || (plugin = {}));
23
+ module.exports = plugin;
@@ -0,0 +1,30 @@
1
+ import { PluginConfig } from "./config";
2
+ import { LlmSettings as RegistryLlmSettings } from "./llmProviderRegistry";
3
+ export type LlmSettings = RegistryLlmSettings & {
4
+ llmTimeoutSeconds?: number;
5
+ };
6
+ /**
7
+ * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
8
+ * package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
9
+ * force every one of them to install, and an install that fails to fetch one it doesn't even use (a
10
+ * network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
11
+ * for everyone else. That means every reference to one of these packages has to be a `require()` reached
12
+ * only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
13
+ * module loads, throwing before any config is even read. Types are still fully checked via
14
+ * `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
15
+ * compile time and leaves no runtime trace for `tsc` to eagerly require.
16
+ */
17
+ export declare function loadOptional<M>(moduleName: string): M;
18
+ /**
19
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
20
+ * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
21
+ * one genuinely slow/unreliable network call in this plugin.
22
+ */
23
+ export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
24
+ /**
25
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
26
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
27
+ * side, despite the target-guidance fragment telling it not to.
28
+ */
29
+ export declare function extractSvg(raw: string): string;
30
+ export type { PluginConfig };
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadOptional = loadOptional;
4
+ exports.callLlm = callLlm;
5
+ exports.extractSvg = extractSvg;
6
+ const llmProviderRegistry_1 = require("./llmProviderRegistry");
7
+ /**
8
+ * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
9
+ * package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
10
+ * force every one of them to install, and an install that fails to fetch one it doesn't even use (a
11
+ * network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
12
+ * for everyone else. That means every reference to one of these packages has to be a `require()` reached
13
+ * only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
14
+ * module loads, throwing before any config is even read. Types are still fully checked via
15
+ * `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
16
+ * compile time and leaves no runtime trace for `tsc` to eagerly require.
17
+ */
18
+ function loadOptional(moduleName) {
19
+ try {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ return require(moduleName);
22
+ }
23
+ catch (err) {
24
+ throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
25
+ }
26
+ }
27
+ /** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
28
+ const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
29
+ /**
30
+ * Resolves `settings` to a Vercel AI SDK model handle. Checks `registerLlmProvider`'s registry
31
+ * (`./llmProviderRegistry.ts`) first, by exact `llmProvider` name, before falling through to this
32
+ * built-in switch - so a custom-registered provider can even *replace* one of the names below if it
33
+ * wants to. `"ollama"` uses the dedicated `ai-sdk-ollama` package (defaults its own base URL to Ollama's
34
+ * standard local address, so no config is needed beyond a model tag); `"local"` uses the generic
35
+ * `@ai-sdk/openai-compatible` provider for anything else OpenAI-compatible (LM Studio, vLLM, ...), which
36
+ * has no such universal default, so `llmBaseUrl` is required there.
37
+ *
38
+ * Returns `any` deliberately - this bridges several different concrete SDK provider packages' own
39
+ * model-handle types (each an implementation of `ai`'s `LanguageModel`, but not literally the same
40
+ * type reference) plus the registry's own `unknown`-typed `LlmModel`; `generateText` below still
41
+ * validates the value at the one place it's actually used.
42
+ */
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ function resolveModel(settings) {
45
+ const model = settings.llmModel;
46
+ if (!model) {
47
+ throw new Error("no LLM model configured (llmModel)");
48
+ }
49
+ const provider = settings.llmProvider ?? "openai";
50
+ const registered = (0, llmProviderRegistry_1.getLlmProvider)(provider);
51
+ if (registered)
52
+ return registered(settings);
53
+ switch (provider) {
54
+ case "anthropic": {
55
+ const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
56
+ return createAnthropic({ apiKey: settings.llmApiKey })(model);
57
+ }
58
+ case "google": {
59
+ const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
60
+ return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
61
+ }
62
+ case "xai": {
63
+ const { createXai } = loadOptional("@ai-sdk/xai");
64
+ return createXai({ apiKey: settings.llmApiKey })(model);
65
+ }
66
+ case "deepseek": {
67
+ const { createDeepSeek } = loadOptional("@ai-sdk/deepseek");
68
+ return createDeepSeek({ apiKey: settings.llmApiKey })(model);
69
+ }
70
+ case "moonshotai": {
71
+ const { createMoonshotAI } = loadOptional("@ai-sdk/moonshotai");
72
+ return createMoonshotAI({ apiKey: settings.llmApiKey })(model);
73
+ }
74
+ case "ollama": {
75
+ const { createOllama } = loadOptional("ai-sdk-ollama");
76
+ return createOllama({ baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL, apiKey: settings.llmApiKey })(model);
77
+ }
78
+ case "local": {
79
+ if (!settings.llmBaseUrl) {
80
+ throw new Error('llmBaseUrl is required when llmProvider is "local"');
81
+ }
82
+ const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
83
+ return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
84
+ }
85
+ case "openai":
86
+ default: {
87
+ const { createOpenAI } = loadOptional("@ai-sdk/openai");
88
+ return createOpenAI({ apiKey: settings.llmApiKey })(model);
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
94
+ * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
95
+ * one genuinely slow/unreliable network call in this plugin.
96
+ */
97
+ async function callLlm(settings, prompt) {
98
+ const model = resolveModel(settings);
99
+ const { generateText } = loadOptional("ai");
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
102
+ try {
103
+ const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
104
+ return text;
105
+ }
106
+ finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+ /**
111
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
112
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
113
+ * side, despite the target-guidance fragment telling it not to.
114
+ */
115
+ function extractSvg(raw) {
116
+ const start = raw.indexOf("<svg");
117
+ const end = raw.lastIndexOf("</svg>");
118
+ if (start === -1 || end === -1 || end < start) {
119
+ throw new Error('LLM response did not contain a "<svg>...</svg>" document');
120
+ }
121
+ return raw.slice(start, end + "</svg>".length);
122
+ }
@@ -0,0 +1,25 @@
1
+ import { PluginConfig } from "./config";
2
+ /**
3
+ * Settings a registered LLM provider factory needs - a subset of `PluginConfig`, shared with the
4
+ * built-in providers in `./llmGateway.ts`.
5
+ */
6
+ export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl">;
7
+ /** Vercel AI SDK's own `LanguageModel` type, loosened to `unknown` here to avoid a hard dependency on `ai`'s types in this always-loaded module - see `./llmGateway.ts`'s `loadOptional`. */
8
+ export type LlmModel = unknown;
9
+ /** Builds a Vercel AI SDK model handle for one custom provider name - see `registerLlmProvider`. */
10
+ export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
11
+ /**
12
+ * Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
13
+ * `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
14
+ * providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
15
+ * ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
16
+ * wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
17
+ * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
18
+ * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
19
+ * `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
20
+ * extension timing - see its README). Declare this package as a regular npm `dependency` (not a
21
+ * `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
22
+ * resolves a real copy to register into.
23
+ */
24
+ export declare function registerLlmProvider(name: string, factory: LlmProviderFactory): void;
25
+ export declare function getLlmProvider(name: string): LlmProviderFactory | undefined;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerLlmProvider = registerLlmProvider;
4
+ exports.getLlmProvider = getLlmProvider;
5
+ const providers = new Map();
6
+ /**
7
+ * Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
8
+ * `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
9
+ * providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
10
+ * ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
11
+ * wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
12
+ * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
13
+ * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
14
+ * `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
15
+ * extension timing - see its README). Declare this package as a regular npm `dependency` (not a
16
+ * `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
17
+ * resolves a real copy to register into.
18
+ */
19
+ function registerLlmProvider(name, factory) {
20
+ providers.set(name, factory);
21
+ }
22
+ function getLlmProvider(name) {
23
+ return providers.get(name);
24
+ }
@@ -0,0 +1,2 @@
1
+ import { Plugin, ServerAPI } from "@signalk/server-api";
2
+ export declare function createPlugin(app: ServerAPI): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createPlugin = createPlugin;
7
+ const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
8
+ const config_1 = require("./config");
9
+ const templateProvider_1 = require("./templateProvider");
10
+ function createPlugin(app) {
11
+ // Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
12
+ // this closure rather than capturing one fixed config object, so a saved config change takes effect
13
+ // on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
14
+ // know or care that anything changed on this side.
15
+ let currentConfig = (0, config_1.defaultConfig)();
16
+ signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(() => currentConfig));
17
+ return {
18
+ id: "signalk-einklabel-genai-plugin",
19
+ name: "eInk Label GenAI Rendering",
20
+ description: "Contributes LLM-generated prompts as an alternative to hand-authored SVG templates for signalk-einklabel-plugin",
21
+ schema: () => ({
22
+ type: "object",
23
+ properties: {
24
+ llmProvider: {
25
+ type: "string",
26
+ title: "LLM provider",
27
+ description: "Which LLM gateway to call - each (other than local/ollama) needs its own optional npm package installed, e.g. " +
28
+ '`npm install ai @ai-sdk/anthropic` (see README). "Ollama" defaults to a local Ollama server with no further ' +
29
+ 'config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) and needs "LLM base URL" below.',
30
+ enum: ["openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"],
31
+ default: "openai",
32
+ },
33
+ llmApiKey: {
34
+ type: "string",
35
+ title: "LLM API key",
36
+ description: 'Not required for "ollama"/"local" unless the server itself checks one.',
37
+ },
38
+ llmModel: {
39
+ type: "string",
40
+ title: "LLM model",
41
+ description: 'Provider-specific model id/name, e.g. "gpt-4o", "claude-sonnet-4-5", "gemini-2.5-flash", "grok-4", or an Ollama/local model tag.',
42
+ },
43
+ llmBaseUrl: {
44
+ type: "string",
45
+ title: 'LLM base URL (required if LLM provider is "local")',
46
+ description: 'An OpenAI-compatible endpoint. Optional for "ollama" (defaults to "http://localhost:11434/v1") - set this only if Ollama runs elsewhere.',
47
+ },
48
+ llmTimeoutSeconds: {
49
+ type: "number",
50
+ title: "LLM call timeout (seconds)",
51
+ description: "How long to wait for the LLM's response before giving up on an attempt.",
52
+ minimum: 1,
53
+ default: (0, config_1.defaultConfig)().llmTimeoutSeconds,
54
+ },
55
+ llmRetries: {
56
+ type: "number",
57
+ title: "LLM call retries",
58
+ description: "How many times to attempt the LLM call (including the first try) before giving up - the core plugin then shows " +
59
+ "its own generic fallback warning instead of leaving stale content on screen.",
60
+ minimum: 1,
61
+ default: (0, config_1.defaultConfig)().llmRetries,
62
+ },
63
+ promptsDir: {
64
+ type: "string",
65
+ title: "Prompts directory",
66
+ description: "Relative path from ~/.signalk, or an absolute path. Leave empty for the default. Holds .md prompt files - a prompt " +
67
+ "here with the same name as a bundled one takes priority. Prompts show up in the core plugin's own Template " +
68
+ 'picker, suffixed "(GenAI)".',
69
+ default: (0, config_1.defaultConfig)().promptsDir,
70
+ },
71
+ },
72
+ }),
73
+ uiSchema: () => ({ llmApiKey: { "ui:widget": "password" } }),
74
+ start(config) {
75
+ currentConfig = { ...(0, config_1.defaultConfig)(), ...config };
76
+ app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openai"})`);
77
+ },
78
+ stop() { },
79
+ };
80
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
3
+ * mirrors the core plugin's own `withRetries` (`src/devices/bleDiscovery.ts`), duplicated here rather
4
+ * than imported since that one isn't part of the core plugin's public `esl` export surface.
5
+ */
6
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withRetries = withRetries;
4
+ /**
5
+ * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
6
+ * mirrors the core plugin's own `withRetries` (`src/devices/bleDiscovery.ts`), duplicated here rather
7
+ * than imported since that one isn't part of the core plugin's public `esl` export surface.
8
+ */
9
+ async function withRetries(attempts, fn) {
10
+ let lastErr;
11
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
12
+ try {
13
+ return await fn(attempt);
14
+ }
15
+ catch (err) {
16
+ lastErr = err;
17
+ }
18
+ }
19
+ throw lastErr;
20
+ }
@@ -0,0 +1,11 @@
1
+ import esl from "@rhizomatics/signalk-einklabel-plugin";
2
+ import { PluginConfig } from "./config";
3
+ /** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
4
+ export declare const SUFFIX = "(GenAI)";
5
+ /**
6
+ * The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
7
+ * called from `./plugin.ts`'s `start()`). `listTemplates`/`describeBindings`/`render` all resolve the
8
+ * current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
9
+ * file takes effect without a plugin restart - same as the core plugin's own template directory.
10
+ */
11
+ export declare function createTemplateProvider(getConfig: () => PluginConfig): esl.TemplateProvider;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SUFFIX = void 0;
7
+ exports.createTemplateProvider = createTemplateProvider;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
11
+ const retry_1 = require("./retry");
12
+ const config_1 = require("./config");
13
+ const llmGateway_1 = require("./llmGateway");
14
+ /** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
15
+ exports.SUFFIX = "(GenAI)";
16
+ const GUIDANCE_NAME = (0, path_1.join)(".assets", "target_guidance.md");
17
+ /** Strips ` (GenAI)` back off a dropdown entry to get the bare prompt file name, e.g. `"forecast (GenAI)"` -> `"forecast.md"`. */
18
+ function promptNameFor(templateName) {
19
+ return templateName.slice(0, templateName.length - ` ${exports.SUFFIX}`.length);
20
+ }
21
+ /**
22
+ * The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
23
+ * called from `./plugin.ts`'s `start()`). `listTemplates`/`describeBindings`/`render` all resolve the
24
+ * current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
25
+ * file takes effect without a plugin restart - same as the core plugin's own template directory.
26
+ */
27
+ function createTemplateProvider(getConfig) {
28
+ function promptsDir() {
29
+ return (0, config_1.resolvePromptsDir)(getConfig().promptsDir);
30
+ }
31
+ function promptPathFor(templateName) {
32
+ const dir = promptsDir();
33
+ return {
34
+ promptPath: (0, config_1.resolvePromptPath)(dir, promptNameFor(templateName)),
35
+ guidancePath: (0, config_1.resolvePromptPath)(dir, GUIDANCE_NAME),
36
+ };
37
+ }
38
+ return {
39
+ suffix: exports.SUFFIX,
40
+ listTemplates() {
41
+ return (0, config_1.promptNameOptions)(promptsDir()).map((name) => `${name} ${exports.SUFFIX}`);
42
+ },
43
+ describeBindings(templateName) {
44
+ const { promptPath, guidancePath } = promptPathFor(templateName);
45
+ const promptText = (0, fs_1.readFileSync)(promptPath, "utf-8");
46
+ const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
47
+ return signalk_einklabel_plugin_1.default.findBindingsInText(promptText, guidanceText);
48
+ },
49
+ async render(request) {
50
+ const { promptPath, guidancePath } = promptPathFor(request.templateName);
51
+ const promptText = (0, fs_1.readFileSync)(promptPath, "utf-8");
52
+ const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
53
+ const prompt = `${signalk_einklabel_plugin_1.default.substituteBindingsInText(promptText, request.context).trim()}\n\n${signalk_einklabel_plugin_1.default
54
+ .substituteBindingsInText(guidanceText, request.context)
55
+ .trim()}\n`;
56
+ const config = getConfig();
57
+ const llmSettings = {
58
+ llmProvider: config.llmProvider,
59
+ llmApiKey: config.llmApiKey,
60
+ llmModel: config.llmModel,
61
+ llmBaseUrl: config.llmBaseUrl,
62
+ llmTimeoutSeconds: config.llmTimeoutSeconds,
63
+ };
64
+ const raw = await (0, retry_1.withRetries)(config.llmRetries, () => (0, llmGateway_1.callLlm)(llmSettings, prompt));
65
+ const svg = (0, llmGateway_1.extractSvg)(raw);
66
+ const renderer = new signalk_einklabel_plugin_1.default.Renderer();
67
+ // `esl.Renderer.render()` reads its template from a file path, not a string - the LLM's SVG is
68
+ // written out as one so the exact same rasterization path a bundled/local template gets is
69
+ // reused unchanged, rather than needing its own copy of that logic.
70
+ const cacheDir = (0, path_1.join)(promptsDir(), ".genai-cache");
71
+ (0, fs_1.mkdirSync)(cacheDir, { recursive: true });
72
+ const cachePath = (0, path_1.join)(cacheDir, `${Buffer.from(request.templateName).toString("hex")}.svg`);
73
+ (0, fs_1.writeFileSync)(cachePath, svg);
74
+ return renderer.render(cachePath, request.context, request.width, request.height);
75
+ },
76
+ };
77
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@rhizomatics/signalk-einklabel-genai-plugin",
3
+ "version": "0.1.0",
4
+ "description": "GenAI prompt generated labels for the eInk Electronic Shelf Label plugin. Contributes prompts as an alternative to hand-authored SVG templates.",
5
+ "keywords": [
6
+ "eink",
7
+ "esl",
8
+ "genai",
9
+ "llm",
10
+ "signalk-category-hardware",
11
+ "signalk-category-instruments",
12
+ "signalk-node-server-plugin"
13
+ ],
14
+ "homepage": "https://github.com/rhizomatics/signalk-einklabel-genai-plugin",
15
+ "bugs": {
16
+ "url": "https://github.com/rhizomatics/signalk-einklabel-genai-plugin/issues"
17
+ },
18
+ "license": "APACHE-2.0",
19
+ "author": "jey burrows",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/rhizomatics/signalk-einklabel-genai-plugin.git"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "prompts",
27
+ "README.md"
28
+ ],
29
+ "main": "dist/index.js",
30
+ "types": "dist/index.d.ts",
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "watch": "tsc --watch",
34
+ "test": "node scripts/run-tests.js",
35
+ "coverage": "node scripts/run-tests.js --coverage",
36
+ "prepublishOnly": "npm run build",
37
+ "fmt": "oxfmt",
38
+ "fmt:check": "oxfmt --check",
39
+ "lint": "oxlint"
40
+ },
41
+ "dependencies": {
42
+ "@rhizomatics/signalk-einklabel-plugin": "file:../signalk-einklabel-plugin"
43
+ },
44
+ "devDependencies": {
45
+ "@signalk/server-api": "^2.28.0",
46
+ "@types/node": "^20.14.0",
47
+ "commander": "^12.1.0",
48
+ "oxfmt": "^0.59.0",
49
+ "oxlint": "^1.74.0",
50
+ "oxlint-tsgolint": "^7.0.0",
51
+ "ts-node": "^10.9.2",
52
+ "typescript": "^5.5.0"
53
+ },
54
+ "optionalDependencies": {
55
+ "@ai-sdk/anthropic": "^4.0.21",
56
+ "@ai-sdk/deepseek": "^3.0.13",
57
+ "@ai-sdk/google": "^4.0.24",
58
+ "@ai-sdk/moonshotai": "^3.0.17",
59
+ "@ai-sdk/openai": "^4.0.20",
60
+ "@ai-sdk/openai-compatible": "^3.0.14",
61
+ "@ai-sdk/xai": "^4.0.18",
62
+ "ai": "^7.0.37",
63
+ "ai-sdk-ollama": "^4.1.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=18"
67
+ },
68
+ "signalk": {
69
+ "displayName": "eInk Label GenAI Rendering",
70
+ "requires": [
71
+ "@rhizomatics/signalk-einklabel-plugin"
72
+ ]
73
+ }
74
+ }
@@ -0,0 +1,5 @@
1
+ Generate an SVG image for the result suitable for a {source=label,path=manufacturer,default=} {source=label,path=label,default=} eInk Electronic Shelf Label display with pixel height {source=label,path=height} and width {source=label,path=width}. Available colours are {source=label,path=colours,format=csv}. Fonts available are {source=label,path=fonts,format=csv}.
2
+
3
+ Use graphics to make it easier to read and use the space better, and use colour and layout to draw attention to safety critical information. Include in minimal size font the source of the information.
4
+
5
+ {source=label,path=description}
@@ -0,0 +1 @@
1
+ Summarize the maritime weather outlook for the next 24 hours, appropriate for the sailor of a {design.length.overall}m {design.aisShipType.name} craft positioned at latitude {navigation.position.latitude}, longitude {navigation.position.longitude}