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,69 @@
1
+ import { join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { serve } from "@hono/node-server";
4
+ import { engineDir, loadConfig, resolveHome } from "./config.ts";
5
+ import { createApp } from "./http.ts";
6
+ import { PiHost } from "./pi.ts";
7
+ import { createRuntime, type HobRuntime } from "./runtime.ts";
8
+ import { readLegacyModels } from "./settings.ts";
9
+
10
+ export interface StartOptions {
11
+ home?: string;
12
+ port?: number;
13
+ pi?: PiHost;
14
+ }
15
+
16
+ export interface HobServer {
17
+ runtime: HobRuntime;
18
+ port: number;
19
+ close: () => Promise<void>;
20
+ }
21
+
22
+ /** 起一个 Hob 进程内全部服务(HTTP + Router + 任务 + 定时)。试跑和测试都走这里。 */
23
+ export async function startHob(options: StartOptions = {}): Promise<HobServer> {
24
+ const home = options.home ?? resolveHome();
25
+ const env = process.env;
26
+ const pi = options.pi ?? new PiHost({ engineDir: engineDir(home) });
27
+ // config.json 里的旧模型字段在 loadConfig 里被清掉,所以先读出来搬进设置
28
+ const legacyModels = readLegacyModels(home);
29
+ const config = loadConfig(home, env);
30
+ const runtime = createRuntime({
31
+ home,
32
+ config,
33
+ pi,
34
+ ...(legacyModels ? { legacyModels } : {}),
35
+ });
36
+
37
+ runtime.delegations.recovering = true;
38
+ runtime.jobs.recover();
39
+ runtime.delegations.recover();
40
+ runtime.routines.start();
41
+ runtime.jobs.startWatchdog();
42
+
43
+ const app = createApp(runtime, { webDir: fileURLToPath(new URL("../../dist/web", import.meta.url)), home });
44
+ const port = options.port ?? Number(env.HOB_PORT ?? config.port);
45
+ const server = serve({ fetch: app.fetch, port, hostname: config.host });
46
+
47
+ return {
48
+ runtime,
49
+ port,
50
+ close: async () => {
51
+ runtime.routines.stop();
52
+ runtime.jobs.disposeAll();
53
+ runtime.router.disposeAll();
54
+ await new Promise<void>((resolve) => server.close(() => resolve()));
55
+ },
56
+ };
57
+ }
58
+
59
+ const isMain = process.argv[1]?.endsWith("main.ts") || process.argv[1]?.endsWith("main.js");
60
+ if (isMain) {
61
+ startHob()
62
+ .then((server) => {
63
+ console.log(`Hob 在 http://${server.runtime.config.host}:${server.port}(数据目录 ${server.runtime.home})`);
64
+ })
65
+ .catch((error: unknown) => {
66
+ console.error(error instanceof Error ? error.message : error);
67
+ process.exit(1);
68
+ });
69
+ }
@@ -0,0 +1,272 @@
1
+ // 记忆:提供者解析、Lore 连接配置(HOB_HOME/lore/config.json)、连接测试、提示词一节。
2
+ //
3
+ // 和 pi 生态里的做法一致:本地记忆用捆绑的 pi-memory,Lore 用捆绑的 @local/lore-pi-extension,
4
+ // 两者互斥。Lore 的连接信息只读写 Hob 自己的 `HOB_HOME/lore/config.json`(格式
5
+ // `{ base_url, api_token }`,和 Lore 客户端一致),不读环境变量、不碰别的工具的配置。
6
+ // Token 只写不读:这里的任何返回值都不带明文 Token。
7
+ import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { LoreConnectionInfo, LoreTestResult, MemoryProvider } from "../../shared/types.ts";
10
+ import { loreDir } from "./config.ts";
11
+
12
+ const LORE_CONFIG_MAX_BYTES = 64 * 1024;
13
+ const LORE_TEST_TIMEOUT_MS = 10_000;
14
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
15
+
16
+ /** 明文 http 只许这些网段(本机 / 局域网 / Tailscale / IPv6 ULA 与 link-local)。 */
17
+ const PRIVATE_IPV4_RANGES: readonly (readonly [number, number])[] = [
18
+ [ipv4ToInt([127, 0, 0, 0]), 8],
19
+ [ipv4ToInt([10, 0, 0, 0]), 8],
20
+ [ipv4ToInt([172, 16, 0, 0]), 12],
21
+ [ipv4ToInt([192, 168, 0, 0]), 16],
22
+ [ipv4ToInt([169, 254, 0, 0]), 16],
23
+ [ipv4ToInt([100, 64, 0, 0]), 10], // Tailscale
24
+ ];
25
+
26
+ function ipv4ToInt(octets: readonly number[]): number {
27
+ return ((octets[0]! << 24) | (octets[1]! << 16) | (octets[2]! << 8) | octets[3]!) >>> 0;
28
+ }
29
+
30
+ function isPrivateIpv4(hostname: string): boolean {
31
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
32
+ if (!match) return false;
33
+ const octets = match.slice(1).map(Number);
34
+ if (octets.some((octet) => octet > 255)) return false;
35
+ const value = ipv4ToInt(octets);
36
+ return PRIVATE_IPV4_RANGES.some(([base, bits]) => value >>> (32 - bits) === base >>> (32 - bits));
37
+ }
38
+
39
+ /** IPv6 ULA(fc00::/7,含 Tailscale 的 fd7a:115c:a1e0::/48)和 link-local(fe80::/10)。 */
40
+ function isPrivateIpv6(hostname: string): boolean {
41
+ if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false;
42
+ const address = hostname.slice(1, -1).toLowerCase();
43
+ return /^f[cd][0-9a-f]{2}:/.test(address) || /^fe[89ab][0-9a-f]:/.test(address);
44
+ }
45
+
46
+ /** 明文 http 只留给不会离开用户自己网络的地址;公网必须 https。 */
47
+ export function isPlainHttpAllowedHost(hostname: string): boolean {
48
+ if (LOOPBACK_HOSTNAMES.has(hostname)) return true;
49
+ if (isPrivateIpv4(hostname) || isPrivateIpv6(hostname)) return true;
50
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.(local|ts\.net)$/.test(hostname);
51
+ }
52
+
53
+ function nonEmpty(value: unknown): string | undefined {
54
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
55
+ }
56
+
57
+ function normalizeBase(value: string): string {
58
+ return value.trim().replace(/\/+$/, "");
59
+ }
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function loreConfigPath(home: string): string {
66
+ return join(loreDir(home), "config.json");
67
+ }
68
+
69
+ interface LoreConfig {
70
+ baseUrl?: string;
71
+ apiToken?: string;
72
+ raw: Record<string, unknown>;
73
+ }
74
+
75
+ function readLoreConfig(home: string): LoreConfig {
76
+ let text: string;
77
+ try {
78
+ text = readFileSync(loreConfigPath(home), "utf8");
79
+ } catch {
80
+ return { raw: {} };
81
+ }
82
+ if (Buffer.byteLength(text, "utf8") > LORE_CONFIG_MAX_BYTES) return { raw: {} };
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(text);
86
+ } catch {
87
+ return { raw: {} };
88
+ }
89
+ if (!isRecord(parsed)) return { raw: {} };
90
+ const baseUrl = nonEmpty(parsed.base_url);
91
+ const apiToken = nonEmpty(parsed.api_token);
92
+ return {
93
+ ...(baseUrl === undefined ? {} : { baseUrl }),
94
+ ...(apiToken === undefined ? {} : { apiToken }),
95
+ raw: parsed,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * 生效的 Lore 连接信息(不带 Token):只来自 `HOB_HOME/lore/config.json`。
101
+ */
102
+ export function loreConnection(home: string): LoreConnectionInfo {
103
+ const config = readLoreConfig(home);
104
+ const baseUrl = config.baseUrl ?? null;
105
+ const tokenConfigured = config.apiToken !== undefined;
106
+ return {
107
+ configured: baseUrl !== null && tokenConfigured,
108
+ baseUrl,
109
+ source: config.baseUrl !== undefined ? "config" : "none",
110
+ tokenConfigured,
111
+ };
112
+ }
113
+
114
+ /** 没写过就是缺省值:已经配好 Lore 就用 Lore,否则本地记忆。 */
115
+ export function resolveMemoryProvider(explicit: MemoryProvider | undefined, loreConfigured: boolean): MemoryProvider {
116
+ if (explicit) return explicit;
117
+ return loreConfigured ? "lore" : "local";
118
+ }
119
+
120
+ export function isMemoryProvider(value: unknown): value is MemoryProvider {
121
+ return value === "local" || value === "lore" || value === "off";
122
+ }
123
+
124
+ function validateBaseUrl(raw: string): URL {
125
+ let url: URL;
126
+ try {
127
+ url = new URL(raw);
128
+ } catch {
129
+ throw new Error("Lore 服务地址不是合法的 URL");
130
+ }
131
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Lore 服务地址必须是 http(s)");
132
+ if (url.protocol === "http:" && !isPlainHttpAllowedHost(url.hostname)) {
133
+ throw new Error("公网地址必须用 https;明文 http 只允许本机或局域网地址");
134
+ }
135
+ return url;
136
+ }
137
+
138
+ /**
139
+ * 写 `HOB_HOME/lore/config.json`(0600、原子写、保留不认识的兄弟键)。
140
+ * Token 规则和官方 CLI 一致:给了就替换;没给且地址没变就保留;换了地址就清掉旧 Token。
141
+ */
142
+ export function saveLoreConnection(
143
+ home: string,
144
+ input: { baseUrl?: unknown; token?: unknown },
145
+ ): LoreConnectionInfo {
146
+ const raw = typeof input.baseUrl === "string" ? input.baseUrl.trim() : "";
147
+ if (!raw) throw new Error("Lore 服务地址不能为空");
148
+ const baseUrl = normalizeBase(raw);
149
+ validateBaseUrl(baseUrl);
150
+ const config = readLoreConfig(home);
151
+ const previousBase = config.baseUrl ? normalizeBase(config.baseUrl) : undefined;
152
+ let apiToken: string | undefined;
153
+ if (typeof input.token === "string" && input.token.length > 0) apiToken = input.token;
154
+ else if (previousBase === baseUrl) apiToken = config.apiToken;
155
+
156
+ const document: Record<string, unknown> = { ...config.raw, base_url: baseUrl };
157
+ if (apiToken === undefined) delete document.api_token;
158
+ else document.api_token = apiToken;
159
+
160
+ const dir = loreDir(home);
161
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
162
+ if (lstatSync(dir).isSymbolicLink()) throw new Error("Lore 目录是个符号链接,不往里面写");
163
+ const path = join(dir, "config.json");
164
+ const tmp = `${path}.${process.pid}.tmp`;
165
+ writeFileSync(tmp, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
166
+ renameSync(tmp, path);
167
+ return loreConnection(home);
168
+ }
169
+
170
+ type FetchLike = typeof fetch;
171
+
172
+ /**
173
+ * 连一下 Lore(`GET <base>/api/health` + Bearer,和捆绑扩展启动时那次健康检查一样)。
174
+ * 失败是正常的返回值,不抛错;返回值里没有 Token。
175
+ */
176
+ export async function testLoreConnection(
177
+ home: string,
178
+ input: { baseUrl?: unknown; token?: unknown },
179
+ options: { fetchImpl?: FetchLike } = {},
180
+ ): Promise<LoreTestResult> {
181
+ const config = readLoreConfig(home);
182
+ const inputBase = typeof input.baseUrl === "string" && input.baseUrl.trim() !== "" ? input.baseUrl : undefined;
183
+ const rawBase = normalizeBase(inputBase ?? config.baseUrl ?? "");
184
+ if (rawBase === "") return { ok: false, reason: "invalid_url" };
185
+ let url: URL;
186
+ try {
187
+ url = new URL(rawBase);
188
+ } catch {
189
+ return { ok: false, reason: "invalid_url" };
190
+ }
191
+ if (url.protocol !== "http:" && url.protocol !== "https:") return { ok: false, reason: "invalid_url" };
192
+ if (url.protocol === "http:" && !isPlainHttpAllowedHost(url.hostname)) return { ok: false, reason: "insecure_http" };
193
+
194
+ const explicitToken = typeof input.token === "string" && input.token.length > 0 ? input.token : undefined;
195
+ // 保存的 Token 只发给当前生效的地址:测试别的地址必须自带 Token,否则会把旧密钥送给别的服务。
196
+ const effectiveBase = normalizeBase(config.baseUrl ?? "");
197
+ const apiToken =
198
+ inputBase !== undefined && rawBase !== effectiveBase ? explicitToken : (explicitToken ?? config.apiToken);
199
+ if (apiToken === undefined) return { ok: false, reason: "missing_token" };
200
+
201
+ const health = new URL(`${rawBase}/api/health`);
202
+ health.searchParams.set("client_type", "pi");
203
+ const doFetch = options.fetchImpl ?? fetch;
204
+ let response: Response;
205
+ try {
206
+ response = await doFetch(health.toString(), {
207
+ method: "GET",
208
+ headers: { authorization: `Bearer ${apiToken}` },
209
+ signal: AbortSignal.timeout(LORE_TEST_TIMEOUT_MS),
210
+ // 跟随重定向会把 Bearer 送到另一个源;直接当失败处理。
211
+ redirect: "manual",
212
+ });
213
+ } catch {
214
+ return { ok: false, reason: "unreachable" };
215
+ }
216
+ if (response.status >= 300 && response.status < 400) return { ok: false, reason: "unreachable" };
217
+ if (response.status === 401 || response.status === 403) return { ok: false, reason: "unauthorized" };
218
+ if (!response.ok) return { ok: false, reason: "unreachable" };
219
+ const healthData = await response.json().catch(() => undefined) as unknown;
220
+ const current = readLoreConfig(home);
221
+ if (isRecord(healthData) && isRecord(healthData.capabilities) && current.baseUrl === rawBase && current.apiToken === apiToken) {
222
+ const document = { ...current.raw, server_profile: { base_url: rawBase, capabilities: healthData.capabilities } };
223
+ const path = loreConfigPath(home);
224
+ const tmp = `${path}.${process.pid}.tmp`;
225
+ writeFileSync(tmp, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
226
+ renameSync(tmp, path);
227
+ }
228
+ return { ok: true, baseUrl: rawBase };
229
+ }
230
+
231
+ /** 提示词里「记忆」一节的文案:按当前提供者给,关闭时整节不出。 */
232
+ export function memoryPromptLines(provider: MemoryProvider): string[] {
233
+ if (provider === "local") {
234
+ return [
235
+ "## 记忆",
236
+ "你和用户是长期合作。涉及过去的偏好、决定和约定时,先用 memory_read 查看记忆;遇到值得长期记住的新事实,用 memory_write 记下来。记忆要精炼,过时的内容及时更新或删除。",
237
+ ];
238
+ }
239
+ if (provider === "lore") {
240
+ return [
241
+ "## 记忆",
242
+ "你和用户是长期合作。涉及过去的偏好、决定和约定时,先在 Lore 里查;遇到值得长期记住的新事实,记进 Lore。",
243
+ ];
244
+ }
245
+ return [];
246
+ }
247
+
248
+ /** 各提供者要放行的记忆工具(插件注册的工具名;没加载插件时一个都不存在)。 */
249
+ export const MEMORY_TOOL_PATTERNS: Record<MemoryProvider, string[]> = {
250
+ local: ["memory_*", "scratchpad"],
251
+ lore: ["lore_*"],
252
+ off: [],
253
+ };
254
+
255
+ /**
256
+ * 会写记忆的工具:Router 调成功后在对话里出一行「记忆已更新 · …」。
257
+ * 名字取自捆绑插件的实际注册(pi-memory 0.4.2 的 memory_write,Lore 1.12.4 的建/改节点)。
258
+ */
259
+ export const MEMORY_WRITE_TOOLS = new Set(["memory_write", "lore_create_node", "lore_update_node"]);
260
+
261
+ /** note 里的摘要:写入内容的第一行,最多 30 个字。 */
262
+ export function memoryNoteText(params: unknown): string | undefined {
263
+ const record = isRecord(params) ? params : {};
264
+ for (const key of ["content", "text", "title", "new_text", "old_text"]) {
265
+ const value = record[key];
266
+ if (typeof value !== "string") continue;
267
+ const first = value.split("\n").map((line) => line.trim()).find((line) => line !== "");
268
+ if (!first) continue;
269
+ return first.length > 30 ? `${first.slice(0, 30)}…` : first;
270
+ }
271
+ return undefined;
272
+ }