@heylemon/lemonade 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3b89844316612a865be1e4c2c9302a4043b8e428920fc2948219aa7e67113aa3
|
|
@@ -8,7 +8,15 @@ import { loadInternalHooks } from "../hooks/loader.js";
|
|
|
8
8
|
import { startPluginServices } from "../plugins/services.js";
|
|
9
9
|
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
|
10
10
|
import { scheduleRestartSentinelWake, shouldWakeFromRestartSentinel, } from "./server-restart-sentinel.js";
|
|
11
|
+
import { pullSkillPreferences } from "./skills-sync.js";
|
|
11
12
|
export async function startGatewaySidecars(params) {
|
|
13
|
+
// Restore skill preferences from cloud (non-blocking).
|
|
14
|
+
try {
|
|
15
|
+
await pullSkillPreferences();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
params.log.warn(`skills-sync: pull failed on startup: ${String(err)}`);
|
|
19
|
+
}
|
|
12
20
|
// Start Lemonade browser control server (unless disabled via config).
|
|
13
21
|
let browserControl = null;
|
|
14
22
|
try {
|
|
@@ -20,6 +20,7 @@ import { resolveBundledSkillsDir } from "../agents/skills/bundled-dir.js";
|
|
|
20
20
|
import { authorizeGatewayConnect } from "./auth.js";
|
|
21
21
|
import { sendJson, sendMethodNotAllowed, sendUnauthorized, sendInvalidRequest, readJsonBodyOrError, } from "./http-common.js";
|
|
22
22
|
import { getBearerToken } from "./http-utils.js";
|
|
23
|
+
import { pushSkillPreferences } from "./skills-sync.js";
|
|
23
24
|
const fsp = fs.promises;
|
|
24
25
|
/**
|
|
25
26
|
* Handle skills API requests.
|
|
@@ -241,9 +242,12 @@ async function handleCreateSkill(req, res) {
|
|
|
241
242
|
enabled: true,
|
|
242
243
|
},
|
|
243
244
|
...(finalName !== safeName
|
|
244
|
-
? {
|
|
245
|
+
? {
|
|
246
|
+
note: `Renamed to '${finalName}' because '${safeName}' is a built-in skill. The built-in version has been deactivated.`,
|
|
247
|
+
}
|
|
245
248
|
: {}),
|
|
246
249
|
});
|
|
250
|
+
void pushSkillPreferences().catch(() => { });
|
|
247
251
|
return true;
|
|
248
252
|
}
|
|
249
253
|
async function handleUpdateSkill(req, res, skillName) {
|
|
@@ -296,6 +300,7 @@ async function handleUpdateSkill(req, res, skillName) {
|
|
|
296
300
|
}
|
|
297
301
|
}
|
|
298
302
|
sendJson(res, 200, { ok: true, updated });
|
|
303
|
+
void pushSkillPreferences().catch(() => { });
|
|
299
304
|
return true;
|
|
300
305
|
}
|
|
301
306
|
async function handleDeleteSkill(res, skillName) {
|
|
@@ -328,9 +333,13 @@ async function handleDeleteSkill(res, skillName) {
|
|
|
328
333
|
config.skills = {};
|
|
329
334
|
if (!config.skills.entries)
|
|
330
335
|
config.skills.entries = {};
|
|
331
|
-
config.skills.entries[baseSkillName] = {
|
|
336
|
+
config.skills.entries[baseSkillName] = {
|
|
337
|
+
...config.skills.entries[baseSkillName],
|
|
338
|
+
enabled: true,
|
|
339
|
+
};
|
|
332
340
|
}
|
|
333
341
|
await writeConfigFile(config);
|
|
334
342
|
sendJson(res, 200, { ok: true, deleted: skillName });
|
|
343
|
+
void pushSkillPreferences().catch(() => { });
|
|
335
344
|
return true;
|
|
336
345
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud sync for skill preferences.
|
|
3
|
+
*
|
|
4
|
+
* - pushSkillPreferences() — after local skill changes, push to backend
|
|
5
|
+
* - pullSkillPreferences() — on startup, pull from backend and apply locally
|
|
6
|
+
*
|
|
7
|
+
* Uses the Anthropic provider's baseUrl to derive the backend URL,
|
|
8
|
+
* and the gateway token for auth.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { loadConfig, writeConfigFile } from "../config/config.js";
|
|
13
|
+
import { CONFIG_DIR } from "../utils.js";
|
|
14
|
+
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
15
|
+
const log = createSubsystemLogger("skills-sync");
|
|
16
|
+
const fsp = fs.promises;
|
|
17
|
+
const MANAGED_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
|
|
18
|
+
function resolveSyncEndpoint() {
|
|
19
|
+
const cfg = loadConfig();
|
|
20
|
+
const token = cfg.gateway?.auth?.token ?? process.env.LEMONADE_GATEWAY_TOKEN ?? undefined;
|
|
21
|
+
if (!token)
|
|
22
|
+
return null;
|
|
23
|
+
const providers = cfg.models?.providers ?? {};
|
|
24
|
+
let backendBase;
|
|
25
|
+
for (const key of ["anthropic", "openai"]) {
|
|
26
|
+
const entry = providers[key];
|
|
27
|
+
if (entry?.baseUrl?.trim()) {
|
|
28
|
+
const raw = entry.baseUrl.trim();
|
|
29
|
+
const idx = raw.indexOf("/api/lemonade/proxy");
|
|
30
|
+
if (idx > 0) {
|
|
31
|
+
backendBase = raw.slice(0, idx);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!backendBase)
|
|
37
|
+
return null;
|
|
38
|
+
return {
|
|
39
|
+
url: `${backendBase}/api/lemonade/skills/sync`,
|
|
40
|
+
token,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function collectCurrentState() {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const preferences = {};
|
|
46
|
+
const customSkills = {};
|
|
47
|
+
const entries = cfg.skills?.entries ?? {};
|
|
48
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
49
|
+
if (typeof entry?.enabled === "boolean") {
|
|
50
|
+
preferences[name] = { enabled: entry.enabled };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (fs.existsSync(MANAGED_SKILLS_DIR)) {
|
|
54
|
+
try {
|
|
55
|
+
const dirs = fs.readdirSync(MANAGED_SKILLS_DIR, { withFileTypes: true });
|
|
56
|
+
for (const d of dirs) {
|
|
57
|
+
if (!d.isDirectory())
|
|
58
|
+
continue;
|
|
59
|
+
const skillFile = path.join(MANAGED_SKILLS_DIR, d.name, "SKILL.md");
|
|
60
|
+
if (fs.existsSync(skillFile)) {
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(skillFile, "utf-8");
|
|
63
|
+
customSkills[d.name] = { content, description: "" };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// skip unreadable files
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// skip if dir can't be read
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { preferences, customSkills };
|
|
76
|
+
}
|
|
77
|
+
export async function pushSkillPreferences() {
|
|
78
|
+
try {
|
|
79
|
+
const endpoint = resolveSyncEndpoint();
|
|
80
|
+
if (!endpoint) {
|
|
81
|
+
log.debug("skills-sync: no backend endpoint configured, skipping push");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { preferences, customSkills } = collectCurrentState();
|
|
85
|
+
const res = await fetch(endpoint.url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
Authorization: `Bearer ${endpoint.token}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ preferences, customSkills }),
|
|
92
|
+
signal: AbortSignal.timeout(10_000),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
log.warn(`skills-sync: push failed (status ${res.status})`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
log.info("skills-sync: pushed preferences to cloud");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
log.warn(`skills-sync: push error: ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function pullSkillPreferences() {
|
|
106
|
+
try {
|
|
107
|
+
const endpoint = resolveSyncEndpoint();
|
|
108
|
+
if (!endpoint) {
|
|
109
|
+
log.debug("skills-sync: no backend endpoint configured, skipping pull");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const res = await fetch(endpoint.url, {
|
|
113
|
+
method: "GET",
|
|
114
|
+
headers: {
|
|
115
|
+
Authorization: `Bearer ${endpoint.token}`,
|
|
116
|
+
},
|
|
117
|
+
signal: AbortSignal.timeout(10_000),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
log.warn(`skills-sync: pull failed (status ${res.status})`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const data = (await res.json());
|
|
124
|
+
if (!data.ok)
|
|
125
|
+
return;
|
|
126
|
+
const cloudPrefs = data.preferences ?? {};
|
|
127
|
+
const cloudCustom = data.customSkills ?? {};
|
|
128
|
+
if (Object.keys(cloudPrefs).length === 0 && Object.keys(cloudCustom).length === 0) {
|
|
129
|
+
log.info("skills-sync: no cloud preferences to apply");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const cfg = loadConfig();
|
|
133
|
+
let changed = false;
|
|
134
|
+
// Apply preference overrides (enabled/disabled)
|
|
135
|
+
if (Object.keys(cloudPrefs).length > 0) {
|
|
136
|
+
if (!cfg.skills)
|
|
137
|
+
cfg.skills = {};
|
|
138
|
+
if (!cfg.skills.entries)
|
|
139
|
+
cfg.skills.entries = {};
|
|
140
|
+
for (const [name, pref] of Object.entries(cloudPrefs)) {
|
|
141
|
+
const current = cfg.skills.entries[name];
|
|
142
|
+
if (!current || current.enabled !== pref.enabled) {
|
|
143
|
+
cfg.skills.entries[name] = { ...current, enabled: pref.enabled };
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Restore custom skills (write SKILL.md files)
|
|
149
|
+
let customCount = 0;
|
|
150
|
+
for (const [name, skill] of Object.entries(cloudCustom)) {
|
|
151
|
+
if (!skill.content?.trim())
|
|
152
|
+
continue;
|
|
153
|
+
const skillDir = path.join(MANAGED_SKILLS_DIR, name);
|
|
154
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
155
|
+
// Only write if the file doesn't already exist locally
|
|
156
|
+
if (!fs.existsSync(skillFile)) {
|
|
157
|
+
await fsp.mkdir(skillDir, { recursive: true });
|
|
158
|
+
await fsp.writeFile(skillFile, skill.content, "utf-8");
|
|
159
|
+
customCount++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (changed) {
|
|
163
|
+
await writeConfigFile(cfg);
|
|
164
|
+
}
|
|
165
|
+
log.info(`skills-sync: pulled preferences from cloud (${Object.keys(cloudPrefs).length} prefs, ${customCount} custom skills restored)`);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log.warn(`skills-sync: pull error: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|