preppergpt 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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/preppergpt.js +8 -0
  4. package/compose/preppergpt.yaml +232 -0
  5. package/docs/hardware.md +15 -0
  6. package/docs/model-sources.md +12 -0
  7. package/docs/preppergpt-local-parity-map.md +16 -0
  8. package/docs/publishing.md +24 -0
  9. package/installer/cli.mjs +225 -0
  10. package/installer/install.sh +18 -0
  11. package/installer/lib/detect.mjs +128 -0
  12. package/installer/lib/paths.mjs +26 -0
  13. package/installer/lib/planner.mjs +175 -0
  14. package/installer/lib/render.mjs +76 -0
  15. package/installer/lib/util.mjs +84 -0
  16. package/package.json +48 -0
  17. package/profiles/models.json +277 -0
  18. package/services/comfyui/flux-kontext-edit-openwebui-nodes.json +46 -0
  19. package/services/comfyui/flux-kontext-edit-openwebui-workflow.json +245 -0
  20. package/services/comfyui/flux-kontext-mask-edit-openwebui-nodes.json +51 -0
  21. package/services/comfyui/flux-kontext-mask-edit-openwebui-workflow.json +322 -0
  22. package/services/comfyui/flux2-klein-9b-openwebui-nodes.json +58 -0
  23. package/services/comfyui/flux2-klein-9b-openwebui-workflow.json +141 -0
  24. package/services/comfyui/image-invert-edit-openwebui-nodes.json +23 -0
  25. package/services/comfyui/image-invert-edit-openwebui-workflow.json +52 -0
  26. package/services/deep-research/Dockerfile +7 -0
  27. package/services/deep-research/app.py +1913 -0
  28. package/services/local-agent/Dockerfile +17 -0
  29. package/services/local-agent/app.py +2311 -0
  30. package/services/local-scheduler/Dockerfile +8 -0
  31. package/services/local-scheduler/app.py +15774 -0
  32. package/services/local-vision/Dockerfile +11 -0
  33. package/services/local-vision/app.py +888 -0
  34. package/services/searxng/settings.yml +16 -0
  35. package/themes/preppergpt/custom.css +15 -0
  36. package/themes/preppergpt/static/favicon.svg +5 -0
  37. package/themes/preppergpt/static/logo.svg +6 -0
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { commandExists, commandResult, gb } from "./util.mjs";
6
+
7
+ const DEFAULT_PORTS = [8080, 11434, 11438, 11441, 18041, 18042, 18043, 18044, 18045, 18080, 8188, 8888, 9998];
8
+
9
+ function parseDf(target) {
10
+ const result = commandResult("df", ["-Pk", target], { timeoutMs: 5000 });
11
+ if (!result.ok) {
12
+ return null;
13
+ }
14
+ const lines = result.stdout.trim().split(/\n/);
15
+ const fields = lines.at(-1)?.trim().split(/\s+/);
16
+ if (!fields || fields.length < 6) {
17
+ return null;
18
+ }
19
+ return {
20
+ path: target,
21
+ filesystem: fields[0],
22
+ sizeGb: Number(fields[1]) / 1024 / 1024,
23
+ usedGb: Number(fields[2]) / 1024 / 1024,
24
+ freeGb: Number(fields[3]) / 1024 / 1024,
25
+ mount: fields[5],
26
+ isNvme: fields[0].includes("nvme") || fields[5].toLowerCase().includes("nvme")
27
+ };
28
+ }
29
+
30
+ function candidateDiskPaths() {
31
+ const candidates = [
32
+ process.env.PREPPERGPT_MODELS_DIR,
33
+ process.env.PREPPERGPT_DATA_DIR,
34
+ path.join(os.homedir(), ".preppergpt"),
35
+ "/models",
36
+ "/data",
37
+ "/mnt",
38
+ "/media",
39
+ process.cwd()
40
+ ].filter(Boolean);
41
+ return [...new Set(candidates)].filter((candidate) => {
42
+ try {
43
+ return fs.existsSync(candidate) || fs.existsSync(path.dirname(candidate));
44
+ } catch {
45
+ return false;
46
+ }
47
+ });
48
+ }
49
+
50
+ function detectGpus() {
51
+ if (!commandExists("nvidia-smi")) {
52
+ return [];
53
+ }
54
+ const result = commandResult("nvidia-smi", [
55
+ "--query-gpu=name,memory.total,memory.free,driver_version",
56
+ "--format=csv,noheader,nounits"
57
+ ]);
58
+ if (!result.ok) {
59
+ return [];
60
+ }
61
+ return result.stdout
62
+ .trim()
63
+ .split(/\n/)
64
+ .map((line, index) => {
65
+ const [name, totalMiB, freeMiB, driver] = line.split(",").map((part) => part.trim());
66
+ return {
67
+ index,
68
+ vendor: "nvidia",
69
+ name,
70
+ totalVramGb: Math.round((Number(totalMiB) / 1024) * 10) / 10,
71
+ freeVramGb: Math.round((Number(freeMiB) / 1024) * 10) / 10,
72
+ usableVramGb: Math.round((Number(totalMiB) / 1024) * 0.82 * 10) / 10,
73
+ driver
74
+ };
75
+ })
76
+ .filter((gpu) => gpu.name && Number.isFinite(gpu.totalVramGb));
77
+ }
78
+
79
+ async function portFree(port) {
80
+ return new Promise((resolve) => {
81
+ const server = net.createServer();
82
+ server.once("error", () => resolve(false));
83
+ server.once("listening", () => {
84
+ server.close(() => resolve(true));
85
+ });
86
+ server.listen(port, "127.0.0.1");
87
+ });
88
+ }
89
+
90
+ async function detectPorts(ports = DEFAULT_PORTS) {
91
+ const entries = await Promise.all(ports.map(async (port) => [port, await portFree(port)]));
92
+ return Object.fromEntries(entries.map(([port, free]) => [String(port), { port, free }]));
93
+ }
94
+
95
+ export async function detectMachine(options = {}) {
96
+ const disks = candidateDiskPaths()
97
+ .map(parseDf)
98
+ .filter(Boolean)
99
+ .sort((a, b) => b.freeGb - a.freeGb);
100
+ const gpus = detectGpus();
101
+ const tools = {
102
+ docker: commandExists("docker"),
103
+ dockerCompose: commandResult("docker", ["compose", "version"], { timeoutMs: 5000 }).ok,
104
+ tmux: commandExists("tmux"),
105
+ curl: commandExists("curl"),
106
+ python3: commandExists("python3"),
107
+ git: commandExists("git"),
108
+ nvidiaSmi: commandExists("nvidia-smi")
109
+ };
110
+ return {
111
+ generatedAt: new Date().toISOString(),
112
+ platform: process.platform,
113
+ arch: process.arch,
114
+ hostname: os.hostname(),
115
+ cpu: {
116
+ model: os.cpus()[0]?.model || "unknown",
117
+ cores: os.cpus().length
118
+ },
119
+ memory: {
120
+ totalGb: gb(os.totalmem()),
121
+ freeGb: gb(os.freemem())
122
+ },
123
+ disks,
124
+ gpus,
125
+ tools,
126
+ ports: options.skipPorts ? {} : await detectPorts(options.ports || DEFAULT_PORTS)
127
+ };
128
+ }
@@ -0,0 +1,26 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
6
+
7
+ export function defaultHome() {
8
+ return path.resolve(process.env.PREPPERGPT_HOME || path.join(os.homedir(), ".preppergpt"));
9
+ }
10
+
11
+ export function runtimePaths(home = defaultHome()) {
12
+ const root = path.resolve(home);
13
+ return {
14
+ root,
15
+ envFile: path.join(root, ".env.preppergpt"),
16
+ dataDir: path.join(root, "data"),
17
+ composeDir: path.join(root, "compose"),
18
+ generatedCompose: path.join(root, "compose", "generated.models.yaml"),
19
+ modelPlan: path.join(root, "data", "preppergpt", "model-plan.json"),
20
+ detectReport: path.join(root, "data", "preppergpt", "hardware-detect.json")
21
+ };
22
+ }
23
+
24
+ export function packagedPath(...parts) {
25
+ return path.join(packageRoot, ...parts);
26
+ }
@@ -0,0 +1,175 @@
1
+ import { packagedPath } from "./paths.mjs";
2
+ import { readJson, unique } from "./util.mjs";
3
+
4
+ export function loadCatalog() {
5
+ return readJson(packagedPath("profiles", "models.json"));
6
+ }
7
+
8
+ export function normalizeProfile(profile) {
9
+ const value = String(profile || "balanced").toLowerCase();
10
+ if (["intelligence", "max-intelligence", "max_intelligence", "smart", "quality"].includes(value)) {
11
+ return "intelligence";
12
+ }
13
+ if (["speed", "max-speed", "max_speed", "fast"].includes(value)) {
14
+ return "speed";
15
+ }
16
+ if (["balanced", "balance", "middle", "middle-ground", "middle_ground"].includes(value)) {
17
+ return "balanced";
18
+ }
19
+ throw new Error(`Unknown profile: ${profile}`);
20
+ }
21
+
22
+ function bestDisk(detection) {
23
+ return detection.disks?.[0] || { freeGb: 0, isNvme: false, path: "" };
24
+ }
25
+
26
+ function bestGpu(detection) {
27
+ return [...(detection.gpus || [])].sort((a, b) => b.usableVramGb - a.usableVramGb)[0] || null;
28
+ }
29
+
30
+ function requirementFailures(model, detection) {
31
+ const requires = model.requires || {};
32
+ const disk = bestDisk(detection);
33
+ const gpu = bestGpu(detection);
34
+ const failures = [];
35
+ if (requires.platforms && !requires.platforms.includes(detection.platform)) {
36
+ failures.push(`requires platform ${requires.platforms.join(", ")}`);
37
+ }
38
+ if (requires.minRamGb && detection.memory.totalGb < requires.minRamGb) {
39
+ failures.push(`requires ${requires.minRamGb} GB RAM`);
40
+ }
41
+ if (requires.diskGb && disk.freeGb < requires.diskGb) {
42
+ failures.push(`requires ${requires.diskGb} GB free disk`);
43
+ }
44
+ if (requires.nvme && disk.freeGb >= (requires.diskGb || 0) && !disk.isNvme) {
45
+ failures.push("strongly prefers NVMe for acceptable load time");
46
+ }
47
+ if (requires.gpu && !gpu) {
48
+ failures.push("requires NVIDIA GPU");
49
+ }
50
+ if (requires.minVramGb && (!gpu || gpu.usableVramGb < requires.minVramGb)) {
51
+ failures.push(`requires about ${requires.minVramGb} GB usable VRAM`);
52
+ }
53
+ return failures;
54
+ }
55
+
56
+ function chooseFirst(candidates, models, detection) {
57
+ const skipped = [];
58
+ for (const id of candidates) {
59
+ const model = models.get(id);
60
+ if (!model) {
61
+ skipped.push({ id, reasons: ["not in catalog"] });
62
+ continue;
63
+ }
64
+ const failures = requirementFailures(model, detection);
65
+ if (failures.length === 0 || model.source?.type === "manual" || model.source?.type === "external") {
66
+ return { model, skipped };
67
+ }
68
+ skipped.push({ id, reasons: failures });
69
+ }
70
+ return { model: null, skipped };
71
+ }
72
+
73
+ export function buildPlan(detection, requestedProfile = "balanced", catalog = loadCatalog()) {
74
+ const profile = normalizeProfile(requestedProfile);
75
+ const models = new Map(catalog.models.map((model) => [model.id, model]));
76
+ const priorities = catalog.profiles[profile];
77
+ const selected = {};
78
+ const skipped = {};
79
+
80
+ for (const [role, candidates] of Object.entries(priorities.roles)) {
81
+ const choice = chooseFirst(candidates, models, detection);
82
+ if (choice.model) {
83
+ selected[role] = {
84
+ ...choice.model,
85
+ requirementWarnings: requirementFailures(choice.model, detection),
86
+ needsManualAssets: ["manual", "external"].includes(choice.model.source?.type)
87
+ };
88
+ }
89
+ if (choice.skipped.length) {
90
+ skipped[role] = choice.skipped;
91
+ }
92
+ }
93
+
94
+ const routeIds = unique([
95
+ priorities.defaultModel,
96
+ selected.chat?.id,
97
+ selected.fast?.id,
98
+ selected.reasoning?.id,
99
+ selected.coding?.id,
100
+ selected.research?.id,
101
+ selected.agent?.id,
102
+ selected.vision?.id,
103
+ selected.image?.id,
104
+ selected.stt?.id
105
+ ]);
106
+
107
+ const manualAssets = Object.values(selected)
108
+ .filter((model) => model.needsManualAssets)
109
+ .map((model) => ({
110
+ id: model.id,
111
+ source: model.source,
112
+ reason: model.source?.description || "manual or external source"
113
+ }));
114
+
115
+ const warnings = [];
116
+ const missingTools = Object.entries(detection.tools || {})
117
+ .filter(([tool, present]) => ["docker", "dockerCompose", "curl", "python3"].includes(tool) && !present)
118
+ .map(([tool]) => tool);
119
+ if (missingTools.length) {
120
+ warnings.push(`Missing required tools: ${missingTools.join(", ")}`);
121
+ }
122
+ const occupiedPorts = Object.values(detection.ports || {})
123
+ .filter((entry) => !entry.free)
124
+ .map((entry) => entry.port);
125
+ if (occupiedPorts.length) {
126
+ warnings.push(`Ports already in use: ${occupiedPorts.join(", ")}`);
127
+ }
128
+ if (!detection.gpus?.length) {
129
+ warnings.push("No NVIDIA GPU detected; CPU fallback will be much slower.");
130
+ }
131
+ if (manualAssets.length) {
132
+ warnings.push("Some selected high-quality routes need manual model files or already-running external endpoints.");
133
+ }
134
+
135
+ return {
136
+ generatedAt: new Date().toISOString(),
137
+ profile,
138
+ profileLabel: priorities.label,
139
+ defaultModel: priorities.defaultModel,
140
+ routeIds,
141
+ selected,
142
+ skipped,
143
+ manualAssets,
144
+ estimates: estimatePlan(profile, selected),
145
+ env: {
146
+ PREPPERGPT_PROFILE: profile,
147
+ PREPPERGPT_DEFAULT_MODEL: priorities.defaultModel,
148
+ PREPPERGPT_MODEL_ORDER_LIST: JSON.stringify(routeIds)
149
+ },
150
+ warnings
151
+ };
152
+ }
153
+
154
+ function estimatePlan(profile, selected) {
155
+ const chat = selected.chat || selected.fast || selected.reasoning;
156
+ const fast = selected.fast || chat;
157
+ const contextTokens = Math.max(
158
+ ...Object.values(selected)
159
+ .map((model) => model.contextTokens || 0)
160
+ .filter(Boolean),
161
+ 0
162
+ );
163
+ return {
164
+ defaultContextTokens: chat?.contextTokens || 8192,
165
+ maxContextTokens: contextTokens || 8192,
166
+ defaultTpsEstimate: chat?.tpsEstimate || "unknown until benchmarked",
167
+ bestTpsEstimate: fast?.tpsEstimate || "unknown until benchmarked",
168
+ note:
169
+ profile === "intelligence"
170
+ ? "Max intelligence favors quality and context over latency."
171
+ : profile === "speed"
172
+ ? "Max speed favors low-latency routes over largest weights."
173
+ : "Middle ground uses the local auto-router and keeps specialist routes additive."
174
+ };
175
+ }
@@ -0,0 +1,76 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { packagedPath, runtimePaths } from "./paths.mjs";
4
+ import { envQuote, writeJson, writeText } from "./util.mjs";
5
+
6
+ function secret(bytes = 24) {
7
+ return crypto.randomBytes(bytes).toString("hex");
8
+ }
9
+
10
+ function envFile(plan, paths, detection) {
11
+ const dataDir = process.env.PREPPERGPT_DATA_DIR || paths.dataDir;
12
+ const modelsDir = process.env.PREPPERGPT_MODELS_DIR || `${dataDir}/models`;
13
+ const adminPassword = process.env.PREPPERGPT_ADMIN_PASSWORD || secret(18);
14
+ const jupyterToken = process.env.JUPYTER_TOKEN || secret(18);
15
+ const searxngSecret = process.env.SEARXNG_SECRET_KEY || secret(24);
16
+ const lines = {
17
+ PREPPERGPT_PROFILE: plan.profile,
18
+ PREPPERGPT_DATA_DIR: dataDir,
19
+ PREPPERGPT_MODELS_DIR: modelsDir,
20
+ PREPPERGPT_PORT: process.env.PREPPERGPT_PORT || "8080",
21
+ PREPPERGPT_DEFAULT_MODEL: plan.defaultModel,
22
+ PREPPERGPT_MODEL_ORDER_LIST: JSON.stringify(plan.routeIds),
23
+ PREPPERGPT_DOCKER_GPUS: detection.gpus?.length ? "all" : "",
24
+ WEBUI_NAME: "PrepperGPT",
25
+ WEBUI_ADMIN_EMAIL: process.env.WEBUI_ADMIN_EMAIL || "admin@preppergpt.local",
26
+ WEBUI_ADMIN_PASSWORD: adminPassword,
27
+ WEBUI_ADMIN_NAME: process.env.WEBUI_ADMIN_NAME || "PrepperGPT Admin",
28
+ WEBUI_SECRET_KEY: process.env.WEBUI_SECRET_KEY || secret(24),
29
+ JUPYTER_TOKEN: jupyterToken,
30
+ SEARXNG_SECRET_KEY: searxngSecret,
31
+ GLM52_BASE_URL: process.env.GLM52_BASE_URL || "http://127.0.0.1:11441/v1",
32
+ SLOCODE_BASE_URL: process.env.SLOCODE_BASE_URL || "http://127.0.0.1:11438/v1",
33
+ OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434"
34
+ };
35
+ return `${Object.entries(lines)
36
+ .map(([key, value]) => `${key}=${envQuote(value)}`)
37
+ .join("\n")}\n`;
38
+ }
39
+
40
+ function generatedCompose(plan, detection) {
41
+ const modelOrder = JSON.stringify(plan.routeIds);
42
+ const gpuBlock = detection.gpus?.length
43
+ ? [
44
+ " ollama:",
45
+ " gpus: all",
46
+ " local-vision:",
47
+ " gpus: all"
48
+ ]
49
+ : [];
50
+ return [
51
+ "services:",
52
+ " open-webui:",
53
+ " environment:",
54
+ ` DEFAULT_MODELS: "${plan.defaultModel}"`,
55
+ ` MODEL_ORDER_LIST: '${modelOrder.replaceAll("'", "''")}'`,
56
+ ` TASK_MODEL: "${plan.selected.fast?.id || plan.defaultModel}"`,
57
+ ...gpuBlock
58
+ ].join("\n") + "\n";
59
+ }
60
+
61
+ export function renderInstall(plan, detection, options = {}) {
62
+ const paths = runtimePaths(options.home);
63
+ fs.mkdirSync(paths.root, { recursive: true });
64
+ fs.mkdirSync(paths.dataDir, { recursive: true });
65
+ fs.mkdirSync(paths.composeDir, { recursive: true });
66
+ fs.mkdirSync(`${paths.dataDir}/preppergpt`, { recursive: true });
67
+ fs.mkdirSync(`${paths.dataDir}/models`, { recursive: true });
68
+ writeText(paths.envFile, envFile(plan, paths, detection), 0o600);
69
+ writeText(paths.generatedCompose, generatedCompose(plan, detection));
70
+ writeJson(paths.modelPlan, plan);
71
+ writeJson(paths.detectReport, detection);
72
+ return {
73
+ ...paths,
74
+ packageCompose: packagedPath("compose", "preppergpt.yaml")
75
+ };
76
+ }
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+
5
+ export function commandResult(command, args = [], options = {}) {
6
+ const result = spawnSync(command, args, {
7
+ encoding: "utf8",
8
+ timeout: options.timeoutMs || 15000,
9
+ shell: false,
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ ...options
12
+ });
13
+ return {
14
+ ok: result.status === 0,
15
+ status: result.status,
16
+ stdout: result.stdout || "",
17
+ stderr: result.stderr || "",
18
+ error: result.error
19
+ };
20
+ }
21
+
22
+ export function commandExists(command) {
23
+ return commandResult("sh", ["-lc", `command -v ${shellQuote(command)}`], { timeoutMs: 3000 }).ok;
24
+ }
25
+
26
+ export function readJson(file) {
27
+ return JSON.parse(fs.readFileSync(file, "utf8"));
28
+ }
29
+
30
+ export function writeJson(file, value) {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ export function writeText(file, value, mode) {
36
+ fs.mkdirSync(path.dirname(file), { recursive: true });
37
+ fs.writeFileSync(file, value);
38
+ if (mode) {
39
+ fs.chmodSync(file, mode);
40
+ }
41
+ }
42
+
43
+ export function shellQuote(value) {
44
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
45
+ }
46
+
47
+ export function envQuote(value) {
48
+ const text = String(value ?? "");
49
+ if (/^[A-Za-z0-9_./:@,+-]*$/.test(text)) {
50
+ return text;
51
+ }
52
+ return JSON.stringify(text);
53
+ }
54
+
55
+ export function parseArgs(argv) {
56
+ const flags = {};
57
+ const positional = [];
58
+ for (let index = 0; index < argv.length; index += 1) {
59
+ const arg = argv[index];
60
+ if (!arg.startsWith("--")) {
61
+ positional.push(arg);
62
+ continue;
63
+ }
64
+ const [rawKey, inlineValue] = arg.slice(2).split(/=(.*)/s, 2);
65
+ const key = rawKey.replaceAll("-", "_");
66
+ if (inlineValue !== undefined) {
67
+ flags[key] = inlineValue;
68
+ } else if (argv[index + 1] && !argv[index + 1].startsWith("--")) {
69
+ flags[key] = argv[index + 1];
70
+ index += 1;
71
+ } else {
72
+ flags[key] = true;
73
+ }
74
+ }
75
+ return { flags, positional };
76
+ }
77
+
78
+ export function gb(bytes) {
79
+ return Math.round((bytes / 1024 ** 3) * 10) / 10;
80
+ }
81
+
82
+ export function unique(values) {
83
+ return [...new Set(values.filter(Boolean))];
84
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "preppergpt",
3
+ "version": "0.1.0",
4
+ "description": "A local-first ChatGPT-like field kit built on OpenWebUI and local models.",
5
+ "type": "module",
6
+ "bin": {
7
+ "preppergpt": "bin/preppergpt.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "installer/",
12
+ "compose/",
13
+ "profiles/",
14
+ "themes/",
15
+ "services/",
16
+ "docs/",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "check": "npm run test && node bin/preppergpt.js plan --profile balanced --json >/dev/null && node bin/preppergpt.js install --profile balanced --dry-run",
23
+ "pack:dry-run": "npm pack --dry-run"
24
+ },
25
+ "keywords": [
26
+ "openwebui",
27
+ "local-ai",
28
+ "llm",
29
+ "ollama",
30
+ "preppergpt",
31
+ "offline-ai"
32
+ ],
33
+ "homepage": "https://github.com/teamslop/preppergpt#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/teamslop/preppergpt/issues"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/teamslop/preppergpt.git"
40
+ },
41
+ "license": "MIT",
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }