hob-bot 1.0.4 → 1.2.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 (82) hide show
  1. package/README.md +76 -6
  2. package/README.zh-CN.md +76 -6
  3. package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
  4. package/dist/web/assets/index-Di5lyTHO.css +1 -0
  5. package/dist/web/assets/index-y09i1RGd.js +267 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/package.json +7 -1
  13. package/server/src/actions.ts +16 -56
  14. package/server/src/assistant-skills.ts +215 -0
  15. package/server/src/boot.ts +1 -0
  16. package/server/src/claude-status.ts +47 -0
  17. package/server/src/config.ts +3 -1
  18. package/server/src/delegations.ts +21 -11
  19. package/server/src/desktop-control.ts +152 -0
  20. package/server/src/desktop-events.ts +63 -0
  21. package/server/src/desktop-public.ts +43 -0
  22. package/server/src/desktop-tools.ts +161 -0
  23. package/server/src/desktop.ts +76 -0
  24. package/server/src/embedding.ts +52 -0
  25. package/server/src/files.ts +63 -43
  26. package/server/src/http.ts +325 -13
  27. package/server/src/job-view.ts +52 -0
  28. package/server/src/jobs.ts +326 -44
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +4 -4
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +51 -156
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +39 -5
  43. package/server/src/router-activity.ts +6 -0
  44. package/server/src/router-tools.ts +184 -39
  45. package/server/src/router.ts +154 -17
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/transcript.ts +57 -4
  51. package/server/src/worker-tools.ts +14 -12
  52. package/shared/knowledge.ts +92 -0
  53. package/shared/mcp.ts +91 -0
  54. package/shared/skills.ts +18 -0
  55. package/shared/types.ts +104 -20
  56. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  57. package/vendor/claude-bridge/LICENSE +21 -0
  58. package/vendor/claude-bridge/package.json +62 -0
  59. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  60. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  61. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  62. package/vendor/claude-bridge/src/attachments.ts +135 -0
  63. package/vendor/claude-bridge/src/config.ts +105 -0
  64. package/vendor/claude-bridge/src/convert.ts +222 -0
  65. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  66. package/vendor/claude-bridge/src/index.ts +2503 -0
  67. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  68. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  69. package/vendor/claude-bridge/src/models.ts +164 -0
  70. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  71. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  72. package/vendor/claude-bridge/src/query-state.ts +118 -0
  73. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  74. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  75. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  76. package/vendor/claude-bridge/src/skills.ts +20 -0
  77. package/vendor/claude-bridge/src/transcript.ts +86 -0
  78. package/vendor/claude-bridge/src/usage.ts +53 -0
  79. package/vendor/lore-pi-extension/package.json +1 -1
  80. package/vendor/lore-pi-extension/tools.ts +8 -5
  81. package/dist/web/assets/index-BCGEZvgg.css +0 -1
  82. package/dist/web/assets/index-BqkIAlk9.js +0 -258
@@ -0,0 +1,123 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { KnowledgeChunk, KnowledgeEntry } from "../../shared/knowledge.ts";
3
+
4
+ export type IndexedChunk = KnowledgeChunk & { start: number; end: number };
5
+ export class UnsupportedIndex extends Error {}
6
+ const MAX_TEXT = 5 * 1024 * 1024;
7
+ // 中文 PDF 的文字层常把「手」「见」存成康熙部首、部首补充(Chrome、Word 导出都这样),查不到;
8
+ // NFKC 能还原康熙部首,部首补充和「⼾」(NFKC 给的是繁体「戶」)按简体补上。
9
+ const RADICALS: Record<string, string> = Object.fromEntries([..."⼾户⻅见⻆角⻓长⻔门⻚页⻛风⻜飞⻢马⻥鱼⻦鸟⻬齐⻮齿⻰龙⻳龟⻉贝⻋车⻘青⻙韦⻝食⻨麦⻩黄⻤鬼"].flatMap((c, i, all) => i % 2 ? [] : [[c, all[i + 1]!]]));
10
+
11
+ /** PDF 抽出的文字:部首还原成汉字,去掉汉字之间的折行(不然跨行的词查不到)。PDF 只按页定位,位置变了不影响。 */
12
+ export function cleanPdfText(text: string): string {
13
+ return text.replace(/[\u2E80-\u2FDF\uF900-\uFAFF]/g, (c) => RADICALS[c] ?? c.normalize("NFKC")).replace(/([\u3400-\u9fff])\n(?=[\u3400-\u9fff])/g, "$1");
14
+ }
15
+
16
+ /** 位置针对抽出的文字;PDF 每页独立计算。 */
17
+ export async function extractChunks(entry: KnowledgeEntry): Promise<IndexedChunk[]> {
18
+ if (!["markdown", "text", "code", "csv", "json", "html", "xml", "pdf"].includes(entry.kind)) throw new UnsupportedIndex("这类文件还不能建索引");
19
+ const data = await readFile(entry.path);
20
+ const chunks: IndexedChunk[] = [];
21
+ let bytes = 0;
22
+ const add = (text: string, heading: string, start: number, page?: number) => {
23
+ const clean = text.trim();
24
+ if (!clean) return;
25
+ const offset = text.indexOf(clean);
26
+ chunks.push({ index: chunks.length, heading, text: clean, start: start + offset, end: start + offset + clean.length, ...(page ? { page } : {}) });
27
+ };
28
+ const split = (text: string, heading: string, start = 0, page?: number, prefix = "") => {
29
+ let pos = 0;
30
+ while (pos < text.length) {
31
+ let end = Math.min(pos + Math.max(1, 800 - prefix.length), text.length);
32
+ if (end < text.length) {
33
+ const part = text.slice(pos, end);
34
+ const boundary = Math.max(part.lastIndexOf("\n\n"), part.lastIndexOf("\n"), part.lastIndexOf("。"));
35
+ if (boundary >= 300) end = pos + boundary + 1;
36
+ }
37
+ add(prefix + text.slice(pos, end), heading, start + pos, page);
38
+ pos = end;
39
+ }
40
+ };
41
+ const sections = (text: string, page?: number) => {
42
+ bytes += Buffer.byteLength(text);
43
+ if (bytes > MAX_TEXT) throw new Error("文件太大:抽出的文字超过 5 MB");
44
+ const headings: string[] = [];
45
+ const pattern = entry.kind === "html" ? /^[ \t]{0,3}(#{1,6})[ \t]*(.+?)[ \t]*#*[ \t]*$/gm : /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
46
+ let sectionStart = 0;
47
+ const base = page ? [entry.name, `第 ${page} 页`] : [entry.name];
48
+ let heading = base.join(" › ");
49
+ for (const match of text.matchAll(pattern)) {
50
+ const at = match.index;
51
+ split(text.slice(sectionStart, at), heading, sectionStart, page);
52
+ const level = match[1]!.length;
53
+ headings.length = level - 1;
54
+ headings[level - 1] = match[2]!.trim();
55
+ heading = [...base, ...headings.filter(Boolean)].join(" › ");
56
+ sectionStart = at + match[0].length;
57
+ }
58
+ split(text.slice(sectionStart), heading, sectionStart, page);
59
+ };
60
+ if (entry.kind === "pdf") {
61
+ const { extractText } = await import("unpdf");
62
+ const result = await extractText(new Uint8Array(data), { mergePages: false });
63
+ const pages = Array.isArray(result.text) ? result.text : [result.text];
64
+ if (!pages.some((p) => p.trim())) throw new UnsupportedIndex("没有文字层,三期识字");
65
+ pages.forEach((page, i) => sections(cleanPdfText(page), i + 1));
66
+ } else {
67
+ let text = data.toString("utf8");
68
+ if (Buffer.byteLength(text) > MAX_TEXT) throw new Error("文件太大:抽出的文字超过 5 MB");
69
+ if (entry.kind === "html") {
70
+ const blank = (value: string) => value.replace(/[^\n]/g, " ");
71
+ text = text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank)
72
+ .replace(/<h([1-3])\b[^>]*>/gi, (tag, level: string) => `\n${"#".repeat(Number(level))}${" ".repeat(Math.max(0, tag.length - Number(level) - 1))}`)
73
+ .replace(/<\/h[1-3]\s*>/gi, (tag) => `\n${" ".repeat(tag.length - 1)}`)
74
+ .replace(/<[^>]*>/g, blank);
75
+ sections(text);
76
+ } else if (entry.kind === "markdown") sections(text);
77
+ else if (entry.kind === "csv") {
78
+ const ends: number[] = [];
79
+ let quoted = false;
80
+ for (let i = 0; i < text.length; i++) {
81
+ if (text[i] === '"') {
82
+ if (quoted && text[i + 1] === '"') i++;
83
+ else quoted = !quoted;
84
+ } else if (text[i] === "\n" && !quoted) ends.push(i + 1);
85
+ }
86
+ if (ends.at(-1) !== text.length) ends.push(text.length);
87
+ const first = ends.shift() ?? text.length;
88
+ const header = text.slice(0, first).trimEnd();
89
+ let start = first;
90
+ let end = first;
91
+ for (const rowEnd of ends) {
92
+ if (end > start && rowEnd - start + header.length + 1 > 800) {
93
+ chunks.push({ index: chunks.length, heading: entry.name, text: `${header}\n${text.slice(start, end).trimEnd()}`, start, end });
94
+ start = end;
95
+ }
96
+ end = rowEnd;
97
+ }
98
+ if (end > start) chunks.push({ index: chunks.length, heading: entry.name, text: `${header}\n${text.slice(start, end).trimEnd()}`, start, end });
99
+ else if (!chunks.length && header) add(header, entry.name, 0);
100
+ } else if (entry.kind === "json") {
101
+ JSON.parse(text);
102
+ const open = text.search(/\S/);
103
+ if (text[open] !== "{" && text[open] !== "[") split(text, entry.name);
104
+ else {
105
+ let start = open + 1, depth = 0, quoted = false, escaped = false;
106
+ for (let i = start; i < text.length; i++) {
107
+ const char = text[i]!;
108
+ if (quoted) {
109
+ if (escaped) escaped = false;
110
+ else if (char === "\\") escaped = true;
111
+ else if (char === '"') quoted = false;
112
+ } else if (char === '"') quoted = true;
113
+ else if (char === "{" || char === "[") depth++;
114
+ else if (char === "}" || char === "]") {
115
+ if (depth) depth--;
116
+ else { split(text.slice(start, i), entry.name, start); break; }
117
+ } else if (char === "," && depth === 0) { split(text.slice(start, i), entry.name, start); start = i + 1; }
118
+ }
119
+ }
120
+ } else split(text, entry.name);
121
+ }
122
+ return chunks;
123
+ }
@@ -0,0 +1,94 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { knowledgeGroup, knowledgeLink, type KnowledgeCollection, type KnowledgeChunk, type KnowledgeEntry } from "../../shared/knowledge.ts";
4
+ import type { HobRuntime } from "./runtime.ts";
5
+ import { monthDayTimeText } from "./time.ts";
6
+
7
+ export const KNOWLEDGE_INTRO = "(知识库里可能相关的内容。用得上就引用,并照抄出处链接;用不上就忽略。)";
8
+
9
+ /** 知识库读不出来时当作没绑定:对话和任务照常,不被知识库拖垮。 */
10
+ export async function boundKnowledge(runtime: HobRuntime, assistantId: string): Promise<KnowledgeCollection[]> {
11
+ const ids = runtime.store.assistant(assistantId)?.knowledge ?? [];
12
+ if (!ids.length) return [];
13
+ try {
14
+ const { collections } = await runtime.knowledge.list({ limit: 1 });
15
+ return collections.filter((collection) => ids.includes(collection.id));
16
+ } catch (error) {
17
+ console.error("读取知识库失败:", error);
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export function formatKnowledge(results: { entry: KnowledgeEntry; chunk: KnowledgeChunk }[], max: number): string {
23
+ let remaining = max;
24
+ const lines: string[] = [];
25
+ for (const { entry, chunk } of results) {
26
+ const header = `${lines.length + 1}. ${chunk.heading}([${entry.name}](${knowledgeLink(entry.id, chunk.index)}))\n `;
27
+ const overhead = [...header].length + (lines.length ? 1 : 0);
28
+ if (remaining <= overhead) break;
29
+ const text = [...chunk.text].slice(0, remaining - overhead).join("");
30
+ if (!text) continue;
31
+ lines.push(header + text);
32
+ remaining -= overhead + [...text].length;
33
+ }
34
+ return lines.join("\n");
35
+ }
36
+
37
+ export function searchKnowledgeTool(runtime: HobRuntime, assistantId: string): ToolDefinition<any, any> {
38
+ return {
39
+ name: "search_knowledge",
40
+ label: "查知识库",
41
+ description: "在绑定给你的知识库里查内容,找出和问题最相关的几段原文,每段带出处链接。回答用到时照抄出处链接标明来源;查不到就直说知识库里没有,不要编。",
42
+ parameters: Type.Object({
43
+ query: Type.String({ description: "要查的问题或关键词,写成完整的一句效果更好。" }),
44
+ collection: Type.Optional(Type.String({ description: "可选,只查这个知识库,填名称。" })),
45
+ }),
46
+ execute: async (_id, params: { query: string; collection?: string }) => {
47
+ try {
48
+ const bound = await boundKnowledge(runtime, assistantId);
49
+ const selected = bound.find((collection) => collection.name === params.collection);
50
+ const results = await runtime.knowledge.search(params.query, selected ? [selected.id] : bound.map((collection) => collection.id), { limit: 6 });
51
+ const prefix = runtime.settings.settings.knowledge?.embedding.source === "openai" ? "" : "(只按关键词查)\n";
52
+ const intro = `${KNOWLEDGE_INTRO}\n`;
53
+ const formatted = formatKnowledge(results, 6000 - [...prefix, ...intro].length);
54
+ return { content: [{ type: "text", text: prefix + (formatted ? intro + formatted : "知识库里没有找到相关内容。") }], details: undefined };
55
+ } catch (error) {
56
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: undefined, isError: true };
57
+ }
58
+ },
59
+ };
60
+ }
61
+
62
+ export function findKnowledgeTool(runtime: HobRuntime): ToolDefinition<any, any> {
63
+ return {
64
+ name: "find_knowledge",
65
+ label: "找资料",
66
+ description: "在知识库里找文件:助手产出、用户在对话里发的素材和用户建的知识库都在里面。按关键词、类型和时间找,找到后用 read 打开。",
67
+ parameters: Type.Object({
68
+ query: Type.Optional(Type.String({ description: "可选,关键词,匹配名称、说明和文字内容;不填就按时间列最近的。" })),
69
+ type: Type.Optional(Type.Union([Type.Literal("image"), Type.Literal("document"), Type.Literal("table"), Type.Literal("page")], { description: "可选:image 图片、document 文档、table 表格、page 网页。" })),
70
+ days: Type.Optional(Type.Number({ description: "可选,只找最近几天的。", exclusiveMinimum: 0 })),
71
+ }),
72
+ execute: async (_id, params: { query?: string; type?: string; days?: number }) => {
73
+ try {
74
+ if (params.days !== undefined && (!Number.isFinite(params.days) || params.days <= 0)) throw new Error("days 必须是大于 0 的数字。");
75
+ const page = await runtime.knowledge.list({ q: params.query?.trim(), kind: params.type, limit: 21 });
76
+ const since = params.days === undefined ? -Infinity : Date.now() - params.days * 86400_000;
77
+ const entries = page.entries.filter((entry) => Date.parse(entry.updatedAt) >= since);
78
+ const labels = { image: "图片", document: "文档", table: "表格", page: "网页", other: "其他" };
79
+ const timezone = runtime.settings.settings.general.timezone;
80
+ const lines = entries.slice(0, 20).map((entry) => {
81
+ const assistant = entry.source?.assistantId;
82
+ const name = assistant ? runtime.store.assistant(assistant)?.name ?? assistant : "用户";
83
+ const line = `${entry.name}(${labels[knowledgeGroup(entry.kind)]},${name},${monthDayTimeText(timezone, new Date(entry.updatedAt))}):${entry.path}`;
84
+ return entry.snippet ? `${line}\n ${entry.snippet.replace(/\s+/g, " ").trim()}` : line;
85
+ });
86
+ if (!lines.length) lines.push("没有找到。");
87
+ if (entries.length > 20) lines.push("还有更多,换个关键词或缩短时间。");
88
+ return { content: [{ type: "text", text: lines.join("\n") }], details: undefined };
89
+ } catch (error) {
90
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: undefined, isError: true };
91
+ }
92
+ },
93
+ };
94
+ }