hob-bot 0.0.0-stage → 1.0.2

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
@@ -0,0 +1,452 @@
1
+ // HOB_HOME/settings.json:界面可改的设置,带 revision(写入走 CAS)。
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type {
5
+ MemoryConfig,
6
+ MemoryProvider,
7
+ ModelPick,
8
+ ModelSettings,
9
+ ProviderSettings,
10
+ RosterEntry,
11
+ Settings,
12
+ SettingsFile,
13
+ SettingsPatch,
14
+ Theme,
15
+ ThinkingLevel,
16
+ ThinkingLevelMapSetting,
17
+ } from "../../shared/types.ts";
18
+ import { defaultSearch, normalizeSearch } from "../../shared/search.ts";
19
+ import { checkImageSettings, normalizeImageSettings } from "./image.ts";
20
+ import { checkThinkingMap, isThinkingLevel } from "./models.ts";
21
+ import { normalizeThinkingMap } from "../../shared/thinking.ts";
22
+ import type { ModelScores } from "../../shared/types.ts";
23
+ import { isMemoryProvider, loreConnection, resolveMemoryProvider } from "./memory.ts";
24
+ import { checkProvider } from "./providers.ts";
25
+ import { checkTimezone } from "./time.ts";
26
+
27
+ const FILE = "settings.json";
28
+ const THEMES: Theme[] = ["system", "light", "dark"];
29
+ const DEFAULT_CONVERSATION_THINKING: ThinkingLevel = "low";
30
+ const DEFAULT_WORK_THINKING: ThinkingLevel = "high";
31
+
32
+ /** config.json 里搬过来的旧模型设置。 */
33
+ export interface LegacyModels {
34
+ conversation?: string;
35
+ conversationThinking?: string;
36
+ work?: string;
37
+ workThinking?: string;
38
+ }
39
+
40
+ /** 读 HOB_HOME/config.json 里还没搬走的模型字段(启动在建 settings 之前调)。 */
41
+ export function readLegacyModels(home: string): LegacyModels | undefined {
42
+ const file = join(home, "config.json");
43
+ if (!existsSync(file)) return undefined;
44
+ let stored: Record<string, unknown>;
45
+ try {
46
+ stored = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ const text = (key: string) => (typeof stored[key] === "string" ? String(stored[key]).trim() : undefined);
51
+ const legacy: LegacyModels = {
52
+ conversation: text("routerModel"),
53
+ conversationThinking: text("routerThinking"),
54
+ work: text("workerModel"),
55
+ workThinking: text("workerThinking"),
56
+ };
57
+ return Object.values(legacy).some(Boolean) ? legacy : undefined;
58
+ }
59
+
60
+ /**
61
+ * 一个模型都没配时的名单:只从 config.json 搬来的旧设置里取,没有就是空的(没有服务商时名单就是空的)。
62
+ * 两个角色都用它,思考档沿用旧的默认(对话 low、任务执行 high)。
63
+ */
64
+ export function seedModels(legacy?: LegacyModels): ModelSettings {
65
+ const conversation = legacy?.conversation || legacy?.work || "";
66
+ const work = legacy?.work || conversation;
67
+ const refs = [...new Set([conversation, work].filter(Boolean))];
68
+ return {
69
+ roster: refs.map((ref) => ({ ref, note: "" })),
70
+ conversation: {
71
+ ref: conversation,
72
+ thinking: isThinkingLevel(legacy?.conversationThinking) ? legacy!.conversationThinking! : DEFAULT_CONVERSATION_THINKING,
73
+ },
74
+ work: {
75
+ ref: work,
76
+ thinking: isThinkingLevel(legacy?.workThinking) ? legacy!.workThinking! : DEFAULT_WORK_THINKING,
77
+ },
78
+ };
79
+ }
80
+
81
+ export function systemTimezone(): string {
82
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
83
+ }
84
+
85
+ export function defaultSettings(timezone = systemTimezone(), models: ModelSettings = seedModels()): Settings {
86
+ return {
87
+ general: { theme: "system", timezone, showPage: true, aboutYou: { name: "", language: "", notes: "" } },
88
+ providers: [],
89
+ models,
90
+ image: { source: "off" },
91
+ search: defaultSearch(),
92
+ work: { maxJobs: 2, jobStallMinutes: 15, routerStallMinutes: 6 },
93
+ push: { email: "" },
94
+ };
95
+ }
96
+
97
+ function writeJsonAtomic(path: string, value: unknown): void {
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ const tmp = `${path}.${process.pid}.tmp`;
100
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
101
+ renameSync(tmp, path);
102
+ }
103
+
104
+ function text(value: unknown, fallback: string): string {
105
+ return typeof value === "string" ? value.trim() : fallback;
106
+ }
107
+
108
+ function int(value: unknown, fallback: number, min: number, max: number): number {
109
+ const n = Math.round(Number(value));
110
+ if (!Number.isFinite(n)) return fallback;
111
+ return Math.min(max, Math.max(min, n));
112
+ }
113
+
114
+ /** 自定义思考档:键是模型 ref,值是这个模型的档位表;坏的项丢掉,不影响其他项。 */
115
+ function normalizeThinking(raw: unknown): Record<string, ThinkingLevelMapSetting> | undefined {
116
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
117
+ const out: Record<string, ThinkingLevelMapSetting> = {};
118
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
119
+ const ref = key.trim();
120
+ const clean = normalizeThinkingMap(value);
121
+ if (ref && clean) out[ref] = clean;
122
+ }
123
+ return Object.keys(out).length ? out : undefined;
124
+ }
125
+
126
+ function validScores(value: unknown): value is ModelScores {
127
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
128
+ const scores = value as Record<string, unknown>;
129
+ return ["smart", "cost", "speed"].every((key) => typeof scores[key] === "number" && Number.isInteger(scores[key]) && (scores[key] as number) >= 0 && (scores[key] as number) <= 100);
130
+ }
131
+
132
+ /** 名单:去掉空的和重复的;角色必须在名单里,不在就退到角色的默认值。 */
133
+ function normalizeModels(raw: unknown, base: ModelSettings): ModelSettings {
134
+ const input = (raw ?? {}) as Partial<ModelSettings>;
135
+ let roster: RosterEntry[] = base.roster;
136
+ if (Array.isArray(input.roster)) {
137
+ roster = [];
138
+ const seen = new Set<string>();
139
+ for (const entry of input.roster) {
140
+ const ref = typeof entry?.ref === "string" ? entry.ref.trim() : "";
141
+ if (!ref || seen.has(ref)) continue;
142
+ seen.add(ref);
143
+ roster.push({
144
+ ref,
145
+ note: typeof entry.note === "string" ? entry.note.trim() : "",
146
+ ...(validScores(entry.scores) ? { scores: { smart: entry.scores.smart, cost: entry.scores.cost, speed: entry.scores.speed } } : {}),
147
+ ...(typeof entry.scoresConfirmed === "boolean" ? { scoresConfirmed: entry.scoresConfirmed } : {}),
148
+ ...(isThinkingLevel(entry.thinking) ? { thinking: entry.thinking } : {}),
149
+ });
150
+ }
151
+ }
152
+ const pick = (value: unknown, fallback: ModelPick): ModelPick => {
153
+ const raw = (value ?? {}) as Partial<ModelPick>;
154
+ const ref = typeof raw.ref === "string" ? raw.ref.trim() : "";
155
+ const usable = roster.some((entry) => entry.ref === ref) ? ref : roster.some((entry) => entry.ref === fallback.ref) ? fallback.ref : (roster[0]?.ref ?? "");
156
+ return {
157
+ ref: usable,
158
+ thinking: isThinkingLevel(raw.thinking) ? raw.thinking : fallback.thinking,
159
+ };
160
+ };
161
+ const thinking = input.thinking === undefined ? undefined : normalizeThinking(input.thinking);
162
+ return {
163
+ roster,
164
+ conversation: pick(input.conversation, base.conversation),
165
+ work: pick(input.work, base.work),
166
+ ...(input.thinkingChoice === "auto" || input.thinkingChoice === "fixed" ? { thinkingChoice: input.thinkingChoice } : {}),
167
+ ...(thinking ? { thinking } : {}),
168
+ };
169
+ }
170
+
171
+ /** 服务商:不合法的一项丢掉(比如手改坏了文件),不影响其他项;Key 不在这里。 */
172
+ function normalizeProviders(raw: unknown): ProviderSettings[] {
173
+ if (!Array.isArray(raw)) return [];
174
+ const providers: ProviderSettings[] = [];
175
+ const seen = new Set<string>();
176
+ for (const entry of raw) {
177
+ const id = typeof (entry as { id?: unknown } | undefined)?.id === "string" ? String((entry as { id: string }).id) : "";
178
+ if (!id) continue;
179
+ try {
180
+ const provider = checkProvider(id, entry);
181
+ if (seen.has(provider.id)) continue;
182
+ seen.add(provider.id);
183
+ providers.push(provider);
184
+ } catch {
185
+ // 这一项坏了:跳过
186
+ }
187
+ }
188
+ return providers;
189
+ }
190
+
191
+ /** 把读到的(或补丁里的)内容规整成合法设置:不合法的项退回默认值,时区不合法直接报错。 */
192
+ function normalize(raw: unknown, base: Settings): Settings {
193
+ const input = (raw ?? {}) as Partial<Settings>;
194
+ const general = (input.general ?? {}) as Partial<Settings["general"]>;
195
+ const about = (general.aboutYou ?? {}) as Partial<Settings["general"]["aboutYou"]>;
196
+ const work = (input.work ?? {}) as Partial<Settings["work"]>;
197
+ const push = (input.push ?? {}) as Partial<Settings["push"]>;
198
+ const theme = text(general.theme, base.general.theme) as Theme;
199
+ return {
200
+ general: {
201
+ theme: THEMES.includes(theme) ? theme : base.general.theme,
202
+ timezone: checkTimezone(text(general.timezone, "") || base.general.timezone),
203
+ showPage: typeof general.showPage === "boolean" ? general.showPage : base.general.showPage !== false,
204
+ aboutYou: {
205
+ name: text(about.name, base.general.aboutYou.name),
206
+ language: text(about.language, base.general.aboutYou.language),
207
+ notes: text(about.notes, base.general.aboutYou.notes),
208
+ },
209
+ },
210
+ models: normalizeModels(input.models, base.models),
211
+ providers: normalizeProviders(input.providers ?? base.providers),
212
+ image: normalizeImageSettings(input.image, base.image),
213
+ search: normalizeSearch(input.search ?? base.search),
214
+ skills: { disabled: Array.isArray(input.skills?.disabled) ? input.skills.disabled.filter((v): v is string => typeof v === "string") : (base.skills?.disabled ?? []) },
215
+ work: {
216
+ maxJobs: int(work.maxJobs, base.work.maxJobs, 1, 8),
217
+ jobStallMinutes: int(work.jobStallMinutes, base.work.jobStallMinutes, 1, 24 * 60),
218
+ routerStallMinutes: int(work.routerStallMinutes, base.work.routerStallMinutes, 1, 24 * 60),
219
+ },
220
+ push: { email: text(push.email, base.push.email) },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * 旧 config.json 里那几项搬到 settings.json:只在 settings.json 还不存在时读一次,
226
+ * 之后 config.json 里再出现这些键也不会被读。
227
+ */
228
+ function legacySeed(home: string): SettingsPatch {
229
+ const file = join(home, "config.json");
230
+ if (!existsSync(file)) return {};
231
+ let stored: Record<string, unknown>;
232
+ try {
233
+ stored = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
234
+ } catch {
235
+ return {};
236
+ }
237
+ const seed: SettingsPatch = {};
238
+ const general: Partial<Settings["general"]> = {};
239
+ if (typeof stored.theme === "string") general.theme = stored.theme as Theme;
240
+ if (typeof stored.timezone === "string" && stored.timezone.trim()) general.timezone = stored.timezone.trim();
241
+ if (Object.keys(general).length) seed.general = general;
242
+ const work: Partial<Settings["work"]> = {};
243
+ if (typeof stored.maxJobs === "number") work.maxJobs = stored.maxJobs;
244
+ if (typeof stored.routerStallMs === "number") {
245
+ work.routerStallMinutes = Math.max(1, Math.round(stored.routerStallMs / 60_000));
246
+ }
247
+ if (Object.keys(work).length) seed.work = work;
248
+ return seed;
249
+ }
250
+
251
+ export interface SettingsStoreOptions {
252
+ /** config.json 里还没搬走的模型设置 */
253
+ legacyModels?: LegacyModels;
254
+ }
255
+
256
+ export class SettingsStore {
257
+ private file: SettingsFile;
258
+ /** 没有 models 节时的兜底(迁移只发生一次,之后以 settings.json 为准) */
259
+ private readonly base: Settings;
260
+ /** 用户在设置里选过的记忆提供者;没选过就是 undefined(按有没有配好 Lore 决定) */
261
+ private memoryProvider?: MemoryProvider;
262
+
263
+ constructor(
264
+ readonly home: string,
265
+ options: SettingsStoreOptions = {},
266
+ ) {
267
+ this.base = defaultSettings(systemTimezone(), seedModels(options.legacyModels));
268
+ const path = join(home, FILE);
269
+ if (!existsSync(path)) {
270
+ this.file = { revision: 1, settings: normalize(legacySeed(home), this.base) };
271
+ this.write();
272
+ return;
273
+ }
274
+ let stored: Partial<SettingsFile> & { memory?: { provider?: unknown } } = {};
275
+ try {
276
+ stored = JSON.parse(readFileSync(path, "utf8")) as Partial<SettingsFile> & { memory?: { provider?: unknown } };
277
+ } catch {
278
+ // 文件坏了就按默认值重建,不静默改用户的设置内容以外的行为
279
+ this.file = { revision: 1, settings: this.base };
280
+ this.write();
281
+ return;
282
+ }
283
+ if (isMemoryProvider(stored.memory?.provider)) this.memoryProvider = stored.memory.provider;
284
+ this.file = {
285
+ revision: Number.isFinite(stored.revision) ? Number(stored.revision) : 1,
286
+ settings: normalize(stored.settings, this.base),
287
+ };
288
+ // 旧文件没有 models 节:搬来的模型马上落盘,config.json 里的旧键已经删了,下次启动就读不到了
289
+ if (!stored.settings?.models) this.write();
290
+ }
291
+
292
+ private write(): void {
293
+ // 记忆提供者单独一个顶层键:没写过就不写,别把“没选过”写成“选了本地记忆”
294
+ writeJsonAtomic(join(this.home, FILE), {
295
+ ...this.file,
296
+ ...(this.memoryProvider ? { memory: { provider: this.memoryProvider } } : {}),
297
+ });
298
+ }
299
+
300
+ get(): SettingsFile {
301
+ return {
302
+ revision: this.file.revision,
303
+ settings: normalize(this.file.settings, this.base),
304
+ };
305
+ }
306
+
307
+ get settings(): Settings {
308
+ return this.get().settings;
309
+ }
310
+
311
+ /** 记忆提供者 + Lore 连接状态(Token 从不返回)。 */
312
+ memory(): MemoryConfig {
313
+ const lore = loreConnection(this.home);
314
+ return {
315
+ revision: this.file.revision,
316
+ provider: resolveMemoryProvider(this.memoryProvider, lore.configured),
317
+ providerExplicit: this.memoryProvider !== undefined,
318
+ lore,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * 改记忆提供者(同一份 revision 做 CAS)。选 Lore 但连接没配好 → 报错,绝不静默回退。
324
+ */
325
+ setMemoryProvider(revision: number, provider: unknown): MemoryConfig | undefined {
326
+ if (revision !== this.file.revision) return undefined;
327
+ if (!isMemoryProvider(provider)) throw new Error("记忆来源无效:可选值为 local、lore 或 off。");
328
+ if (provider === "lore" && !loreConnection(this.home).configured) {
329
+ throw new Error("Lore 尚未配置完成:请填写服务地址与 Token,并通过连接测试。");
330
+ }
331
+ this.memoryProvider = provider;
332
+ this.file = { ...this.file, revision: this.file.revision + 1 };
333
+ this.write();
334
+ return this.memory();
335
+ }
336
+
337
+ /**
338
+ * 按节合并:revision 不是当前的 → 不动,返回 undefined(调用方回 409)。
339
+ */
340
+ patch(revision: number, patch: SettingsPatch): SettingsFile | undefined {
341
+ if (revision !== this.file.revision) return undefined;
342
+ const current = this.settings;
343
+ const general = patch.general ?? {};
344
+ const models = patch.models ?? {};
345
+ if (models.thinkingChoice !== undefined && models.thinkingChoice !== "auto" && models.thinkingChoice !== "fixed") {
346
+ throw new Error("任务思考档选择方式无效:可选值为 auto 或 fixed。");
347
+ }
348
+ if (Array.isArray(models.roster)) {
349
+ for (const entry of models.roster) {
350
+ if (entry?.scores !== undefined && !validScores(entry.scores)) throw new Error("模型分数须为 0–100 的整数。");
351
+ if (entry?.scoresConfirmed !== undefined && typeof entry.scoresConfirmed !== "boolean") throw new Error("模型用途确认状态无效。");
352
+ }
353
+ }
354
+ const merged = normalize(
355
+ {
356
+ general: {
357
+ ...current.general,
358
+ ...general,
359
+ aboutYou: { ...current.general.aboutYou, ...(general.aboutYou ?? {}) },
360
+ },
361
+ models: {
362
+ roster: models.roster ?? current.models.roster,
363
+ conversation: models.conversation ?? current.models.conversation,
364
+ work: models.work ?? current.models.work,
365
+ thinkingChoice: models.thinkingChoice ?? current.models.thinkingChoice,
366
+ ...((models.thinking ?? current.models.thinking) ? { thinking: models.thinking ?? current.models.thinking } : {}),
367
+ },
368
+ // image / providers 不在这份补丁里(各走自己的接口),别把它们冲回默认
369
+ image: current.image,
370
+ search: current.search,
371
+ skills: patch.skills ?? current.skills,
372
+ providers: current.providers,
373
+ work: { ...current.work, ...(patch.work ?? {}) },
374
+ push: { ...current.push, ...(patch.push ?? {}) },
375
+ },
376
+ current,
377
+ );
378
+ this.file = { revision: this.file.revision + 1, settings: merged };
379
+ this.write();
380
+ return this.get();
381
+ }
382
+
383
+ /**
384
+ * 改图像生成配置(同一份 revision 做 CAS)。配置不合法直接抛错,界面显示原因。
385
+ */
386
+ setImage(revision: number, image: unknown): SettingsFile | undefined {
387
+ if (revision !== this.file.revision) return undefined;
388
+ const next = checkImageSettings(image);
389
+ this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, image: next } };
390
+ this.write();
391
+ return this.get();
392
+ }
393
+
394
+ setSearch(revision: number, search: unknown): SettingsFile | undefined {
395
+ if (revision !== this.file.revision) return undefined;
396
+ if (!Array.isArray(search)) throw new Error("联网搜索配置无效。");
397
+ this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, search: normalizeSearch(search) } };
398
+ this.write();
399
+ return this.get();
400
+ }
401
+
402
+ /**
403
+ * 存一个模型的自定义思考档(null = 恢复默认,只用默认规则)。不合法直接抛错,界面显示原因。
404
+ * 存完模型名单缓存要重拉(ModelCatalog.reset()),引擎那边由服务商服务重新套一次。
405
+ */
406
+ setThinking(ref: string, levels: unknown): SettingsFile {
407
+ const key = ref.trim();
408
+ if (!key) throw new Error("未指定模型。");
409
+ const models = { ...this.settings.models };
410
+ if (levels === null) {
411
+ const next = { ...(models.thinking ?? {}) };
412
+ delete next[key];
413
+ if (Object.keys(next).length) models.thinking = next;
414
+ else delete models.thinking;
415
+ } else {
416
+ models.thinking = { ...(models.thinking ?? {}), [key]: checkThinkingMap(levels) };
417
+ }
418
+ this.file = {
419
+ revision: this.file.revision + 1,
420
+ settings: { ...this.get().settings, models },
421
+ };
422
+ this.write();
423
+ return this.get();
424
+ }
425
+
426
+ /** 新建或改一个服务商(Key 不在这里,见 secrets.json)。 */
427
+ setProvider(provider: ProviderSettings): SettingsFile {
428
+ // 改已有的原地替换(列表顺序不跳),新的排到最后
429
+ const providers = this.settings.providers;
430
+ const next = providers.some((item) => item.id === provider.id)
431
+ ? providers.map((item) => (item.id === provider.id ? provider : item))
432
+ : [...providers, provider];
433
+ this.file = {
434
+ revision: this.file.revision + 1,
435
+ settings: { ...this.get().settings, providers: next },
436
+ };
437
+ this.write();
438
+ return this.get();
439
+ }
440
+
441
+ /** 删服务商;不在里面就返回 false。 */
442
+ removeProvider(id: string): boolean {
443
+ const providers = this.settings.providers;
444
+ if (!providers.some((provider) => provider.id === id)) return false;
445
+ this.file = {
446
+ revision: this.file.revision + 1,
447
+ settings: { ...this.get().settings, providers: providers.filter((provider) => provider.id !== id) },
448
+ };
449
+ this.write();
450
+ return true;
451
+ }
452
+ }
@@ -0,0 +1,150 @@
1
+ import { loadSkillsFromDir, type Skill } from "@earendil-works/pi-coding-agent";
2
+ import { homedir } from "node:os";
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
4
+ import { basename, isAbsolute, join, relative } from "node:path";
5
+ import type { SkillImportResult, SkillImportSource, SkillImportView, SkillSource, SkillView } from "../../shared/skills.ts";
6
+
7
+ /** 只读通用目录和 Hob 自己的目录;同名时 Hob 优先。 */
8
+ export function availableSkills(home: string, userHome = homedir(), disabled: string[] = [], lorePaths: string[] = []): Skill[] {
9
+ const own = directSkills(join(home, "skills"), "hob");
10
+ const lore = lorePaths.flatMap((dir) => directSkills(dir, "lore"));
11
+ const common = directSkills(join(userHome, ".agents", "skills"), "common");
12
+ const seen = new Set<string>();
13
+ return [...own, ...lore, ...common].filter((skill) => {
14
+ if (disabled.includes(`${skill.sourceInfo.source}:${skill.name}`)) return false;
15
+ if (seen.has(skill.name)) return false;
16
+ seen.add(skill.name);
17
+ return true;
18
+ });
19
+ }
20
+
21
+ const MAX_SKILL_BYTES = 20 * 1024 * 1024;
22
+ const IMPORT_DIRS: Record<SkillImportSource, string[]> = {
23
+ pi: [".pi", "agent", "skills"], claude: [".claude", "skills"], codex: [".codex", "skills"],
24
+ };
25
+
26
+ function importRoot(source: SkillImportSource, userHome: string): string {
27
+ const parts = IMPORT_DIRS[source];
28
+ if (!parts) throw new Error("技能导入来源无效");
29
+ return join(userHome, ...parts);
30
+ }
31
+
32
+ function validId(id: string): boolean {
33
+ return id !== "." && id !== ".." && id.length > 0 && !id.includes("/") && !id.includes("\\") && !id.includes("\0");
34
+ }
35
+
36
+ function directSkills(root: string, source: string, confined = false): Skill[] {
37
+ if (!existsSync(root)) return [];
38
+ const result: Skill[] = [];
39
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
40
+ if (!validId(entry.name) || entry.name.startsWith(".")) continue;
41
+ const dir = join(root, entry.name);
42
+ const file = join(dir, "SKILL.md");
43
+ if (!existsSync(file)) continue;
44
+ if (confined && (!contained(realpathSync(root), realpathSync(dir)) || !contained(realpathSync(dir), realpathSync(file)))) continue;
45
+ result.push(...loadSkillsFromDir({ dir, source }).skills.filter((skill) => skill.filePath === file));
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /** 三个来源原样展示,覆盖关系不影响列表中的开关。 */
51
+ export function skillViews(home: string, disabled: string[], lorePaths: string[], userHome = homedir()): SkillView[] {
52
+ const groups: [SkillSource, Skill[]][] = [
53
+ ["hob", directSkills(join(home, "skills"), "hob")],
54
+ ["lore", lorePaths.flatMap((root) => directSkills(root, "lore"))],
55
+ ["common", directSkills(join(userHome, ".agents", "skills"), "common")],
56
+ ];
57
+ const seen = new Set<string>();
58
+ return groups.flatMap(([source, skills]) => skills.map((skill) => {
59
+ const enabled = !disabled.includes(`${source}:${skill.name}`);
60
+ const overridden = seen.has(skill.name);
61
+ if (enabled) seen.add(skill.name);
62
+ return { id: basename(skill.baseDir), name: skill.name, description: skill.description, source,
63
+ content: readFileSync(skill.filePath, "utf8"), enabled, overridden };
64
+ }));
65
+ }
66
+
67
+ export function listImportSkills(home: string, source: SkillImportSource, userHome = homedir()): SkillImportView {
68
+ const root = importRoot(source, userHome);
69
+ if (!existsSync(root)) return { found: false, skills: [], skipped: [] };
70
+ const own = directSkills(join(home, "skills"), "hob");
71
+ const skipped = readdirSync(root, { withFileTypes: true }).filter((entry) => {
72
+ if (!entry.isSymbolicLink()) return false;
73
+ try { return !contained(realpathSync(root), realpathSync(join(root, entry.name))); }
74
+ catch { return true; }
75
+ }).map((entry) => `${entry.name}:跳过越界或无效符号链接`);
76
+ return { found: true, skipped, skills: directSkills(root, source, true).map((skill) => ({
77
+ id: basename(skill.baseDir), name: skill.name, description: skill.description,
78
+ exists: own.some((item) => item.name === skill.name) || existsSync(join(home, "skills", basename(skill.baseDir))),
79
+ })) };
80
+ }
81
+
82
+ function contained(root: string, target: string): boolean {
83
+ const rel = relative(root, target);
84
+ return rel === "" || (!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && rel !== ".." && !isAbsolute(rel));
85
+ }
86
+
87
+ /** 复制到暂存目录后才替换:限制大小,不让符号链接逃出来源。 */
88
+ export function importSkills(home: string, source: SkillImportSource, ids: string[], overwrite: boolean, userHome = homedir()): SkillImportResult {
89
+ const root = importRoot(source, userHome);
90
+ const list = listImportSkills(home, source, userHome);
91
+ const result: SkillImportResult = { imported: [], skipped: [...list.skipped], errors: [] };
92
+ const destination = join(home, "skills");
93
+ mkdirSync(destination, { recursive: true });
94
+ if (lstatSync(destination).isSymbolicLink()) throw new Error("Hob 技能目录不能是符号链接");
95
+ for (const id of new Set(ids)) {
96
+ if (!validId(id) || !list.skills.some((item) => item.id === id)) { result.errors.push(`${id}:技能不存在`); continue; }
97
+ const candidate = list.skills.find((item) => item.id === id)!;
98
+ const existing = directSkills(destination, "hob").find((skill) => skill.name === candidate.name);
99
+ const target = join(destination, existing ? basename(existing.baseDir) : id);
100
+ if (existsSync(target) && !overwrite) { result.skipped.push(`${id}:已存在`); continue; }
101
+ const stage = mkdtempSync(join(destination, ".import-"));
102
+ try {
103
+ const from = join(root, id);
104
+ const realRoot = realpathSync(from);
105
+ if (!contained(realpathSync(root), realRoot)) throw new Error("技能目录的符号链接指向来源之外");
106
+ let bytes = 0;
107
+ const copy = (src: string, dst: string, parents: Set<string>) => {
108
+ if (basename(src) === ".git" || basename(src) === "node_modules") return;
109
+ let real: string;
110
+ try { real = realpathSync(src); }
111
+ catch (error) {
112
+ if (lstatSync(src).isSymbolicLink()) { result.skipped.push(`${id}:跳过无效符号链接 ${relative(from, src)}`); return; }
113
+ throw error;
114
+ }
115
+ if (!contained(realRoot, real)) { result.skipped.push(`${id}:跳过越界符号链接 ${relative(from, src)}`); return; }
116
+ const stat = statSync(real);
117
+ if (stat.isDirectory()) {
118
+ if (parents.has(real)) { result.skipped.push(`${id}:跳过循环符号链接 ${relative(from, src)}`); return; }
119
+ mkdirSync(dst, { recursive: true });
120
+ const next = new Set(parents); next.add(real);
121
+ for (const entry of readdirSync(src)) copy(join(src, entry), join(dst, entry), next);
122
+ } else if (stat.isFile()) {
123
+ bytes += stat.size;
124
+ if (bytes > MAX_SKILL_BYTES) throw new Error("超过 20MB");
125
+ copyFileSync(real, dst);
126
+ }
127
+ };
128
+ copy(from, stage, new Set());
129
+ const backup = join(destination, `.backup-${basename(stage)}`);
130
+ const replacing = existsSync(target);
131
+ if (replacing) renameSync(target, backup);
132
+ try { renameSync(stage, target); }
133
+ catch (error) { if (replacing) renameSync(backup, target); throw error; }
134
+ if (replacing) rmSync(backup, { recursive: true, force: true });
135
+ result.imported.push(id);
136
+ } catch (error) { result.errors.push(`${id}:${error instanceof Error ? error.message : String(error)}`); }
137
+ finally { rmSync(stage, { recursive: true, force: true }); }
138
+ }
139
+ return result;
140
+ }
141
+
142
+ export function deleteOwnSkill(home: string, id: string): void {
143
+ if (!validId(id)) throw new Error("技能名称无效");
144
+ const dir = join(home, "skills");
145
+ if (lstatSync(dir).isSymbolicLink()) throw new Error("Hob 技能目录不能是符号链接");
146
+ const target = join(dir, id);
147
+ const stat = lstatSync(target);
148
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("只能删除 Hob 自己的技能目录");
149
+ rmSync(target, { recursive: true });
150
+ }