hob-bot 1.0.3 → 1.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 (86) hide show
  1. package/README.md +75 -5
  2. package/README.zh-CN.md +75 -5
  3. package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
  4. package/dist/web/assets/index-B-8ISOr1.js +267 -0
  5. package/dist/web/assets/index-Cu1Pf8yX.css +1 -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/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
  13. package/package.json +10 -2
  14. package/server/src/actions.ts +24 -57
  15. package/server/src/assistant-skills.ts +201 -0
  16. package/server/src/boot.ts +1 -0
  17. package/server/src/claude-status.ts +47 -0
  18. package/server/src/config.ts +3 -1
  19. package/server/src/delegations.ts +40 -12
  20. package/server/src/desktop-control.ts +152 -0
  21. package/server/src/desktop-events.ts +63 -0
  22. package/server/src/desktop-public.ts +43 -0
  23. package/server/src/desktop-tools.ts +161 -0
  24. package/server/src/desktop.ts +76 -0
  25. package/server/src/embedding.ts +52 -0
  26. package/server/src/files.ts +63 -43
  27. package/server/src/http.ts +334 -15
  28. package/server/src/jobs.ts +330 -59
  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 +1 -1
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +46 -154
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +42 -7
  43. package/server/src/router-activity.ts +4 -0
  44. package/server/src/router-tools.ts +132 -38
  45. package/server/src/router.ts +159 -19
  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/thumbs.ts +49 -0
  51. package/server/src/transcript.ts +57 -4
  52. package/server/src/worker-tools.ts +14 -12
  53. package/shared/knowledge.ts +92 -0
  54. package/shared/mcp.ts +91 -0
  55. package/shared/media.ts +6 -0
  56. package/shared/plain-text.ts +31 -0
  57. package/shared/skills.ts +18 -0
  58. package/shared/sprites.ts +198 -0
  59. package/shared/types.ts +105 -20
  60. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  61. package/vendor/claude-bridge/LICENSE +21 -0
  62. package/vendor/claude-bridge/package.json +62 -0
  63. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  64. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  65. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  66. package/vendor/claude-bridge/src/attachments.ts +135 -0
  67. package/vendor/claude-bridge/src/config.ts +105 -0
  68. package/vendor/claude-bridge/src/convert.ts +222 -0
  69. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  70. package/vendor/claude-bridge/src/index.ts +2503 -0
  71. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  72. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  73. package/vendor/claude-bridge/src/models.ts +164 -0
  74. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  75. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  76. package/vendor/claude-bridge/src/query-state.ts +118 -0
  77. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  78. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  79. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  80. package/vendor/claude-bridge/src/skills.ts +20 -0
  81. package/vendor/claude-bridge/src/transcript.ts +86 -0
  82. package/vendor/claude-bridge/src/usage.ts +53 -0
  83. package/vendor/lore-pi-extension/package.json +1 -1
  84. package/vendor/lore-pi-extension/tools.ts +8 -5
  85. package/dist/web/assets/index-C72gAwgN.css +0 -1
  86. package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
@@ -0,0 +1,451 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
4
+ import { fileKind, isTextKind } from "../../shared/files.ts";
5
+ import { knowledgeGroup, type KnowledgeCollection, type KnowledgeEntry, type KnowledgeEntryView, type KnowledgeGroup, type KnowledgeOrigin, type KnowledgePage } from "../../shared/knowledge.ts";
6
+ import type { EmbeddingSettings } from "../../shared/types.ts";
7
+ import { embed, embeddingKey, embeddingModel } from "./embedding.ts";
8
+ import { extractChunks, UnsupportedIndex, type IndexedChunk } from "./knowledge-index.ts";
9
+ import type { Bus } from "./bus.ts";
10
+ import { looksText } from "./files.ts";
11
+ import { newId } from "./ids.ts";
12
+ import type { Store } from "./store.ts";
13
+
14
+ type Entry = KnowledgeEntry & { sourceKey?: string };
15
+ type CollectInput = { origin: "output" | "chat"; path: string; name?: string; description?: string; assistantId: string; itemId?: string; jobId?: string };
16
+ type Query = { origin?: string; collection?: string; kind?: string; assistantId?: string; q?: string; before?: string; limit?: number };
17
+ type Collection = Omit<KnowledgeCollection, "count">;
18
+ type IndexFile = { model: string; dims: number; name?: string; chunks: IndexedChunk[] };
19
+ type SearchResult = { entry: KnowledgeEntry; chunk: IndexedChunk; score: number };
20
+ // 评测(knowledge-eval.mjs):0.55 保留 19/20 相关问题,无关误附 0/8。
21
+ // 只有关键词时不自动附:同一组题,覆盖率门槛 0.3 只附对 8/20,附错 7 次,噪音比收益大,交给模型自己调工具。
22
+ const VECTOR_GATE = 0.55;
23
+ function terms(text: string): string[] {
24
+ const words = text.toLowerCase().match(/[a-z0-9]+|[\u3400-\u9fff]+/g) ?? [];
25
+ return words.flatMap((word) => /^[\u3400-\u9fff]+$/.test(word) && word.length > 1 ? Array.from({ length: word.length - 1 }, (_, i) => word.slice(i, i + 2)) : [word]);
26
+ }
27
+
28
+ export class KnowledgeStore {
29
+ private root: string;
30
+ private pending: Promise<unknown> = Promise.resolve();
31
+ private indexing: Promise<unknown> = Promise.resolve();
32
+ private generations = new Map<string, number>();
33
+ private eventTimer?: ReturnType<typeof setTimeout>;
34
+ constructor(private home: string, private bus: Bus, private store: Store, private maxBytes = 100 * 1024 * 1024, private embedding: () => EmbeddingSettings = () => ({ source: "off" })) {
35
+ this.root = join(home, "knowledge");
36
+ queueMicrotask(() => { void this.refreshIndexes().catch((error) => console.error("知识库启动索引检查失败:", error)); });
37
+ }
38
+ private enqueue<T>(fn: () => T | Promise<T>): Promise<T> {
39
+ const next = this.pending.then(fn);
40
+ this.pending = next.catch(() => {});
41
+ return next;
42
+ }
43
+ whenIdle(): Promise<unknown> {
44
+ return this.pending;
45
+ }
46
+ private read<T>(name: string): T[] {
47
+ const path = join(this.root, name);
48
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as T[] : [];
49
+ }
50
+ private write(name: string, data: unknown): void {
51
+ const path = join(this.root, name);
52
+ mkdirSync(this.root, { recursive: true });
53
+ const tmp = `${path}.${process.pid}.tmp`;
54
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
55
+ renameSync(tmp, path);
56
+ }
57
+ private changed(): void {
58
+ this.bus.emit({ type: "knowledge" });
59
+ }
60
+ private indexChanged(): void {
61
+ if (this.eventTimer) return;
62
+ this.eventTimer = setTimeout(() => { this.eventTimer = undefined; this.changed(); }, 1000);
63
+ this.eventTimer.unref();
64
+ }
65
+ private indexPath(hash: string, ext: "json" | "vec"): string { return join(this.root, "index", `${hash}.${ext}`); }
66
+ private model(): string { return embeddingModel(this.embedding()); }
67
+ private readIndex(hash: string): IndexFile | undefined {
68
+ const path = this.indexPath(hash, "json");
69
+ if (!existsSync(path)) return;
70
+ return JSON.parse(readFileSync(path, "utf8")) as IndexFile;
71
+ }
72
+ private removeUnusedIndex(hash: string, entries = this.entries()): void {
73
+ if (entries.some((e) => e.hash === hash && e.collections.length)) return;
74
+ rmSync(this.indexPath(hash, "json"), { force: true });
75
+ rmSync(this.indexPath(hash, "vec"), { force: true });
76
+ }
77
+ private schedule(entry: Entry): void {
78
+ const generation = (this.generations.get(entry.id) ?? 0) + 1;
79
+ this.generations.set(entry.id, generation);
80
+ this.indexing = this.indexing.then(() => this.buildIndex(entry.id, entry.hash, generation)).catch((error) => console.warn("知识库索引失败:", error));
81
+ }
82
+ async whenIndexed(): Promise<void> { await this.whenIdle(); await this.indexing; await this.whenIdle(); }
83
+ async refreshIndexes(force = false): Promise<void> {
84
+ await this.enqueue(() => {
85
+ const entries = this.entries();
86
+ for (const entry of entries) {
87
+ if (!entry.collections.length) continue;
88
+ let existing: IndexFile | undefined;
89
+ try { existing = this.readIndex(entry.hash); } catch { /* 损坏的索引重新生成 */ }
90
+ const incomplete = !existing || existing.model !== this.model() || (existing.dims > 0 && !existsSync(this.indexPath(entry.hash, "vec")));
91
+ if (force || (incomplete && entry.index?.state !== "unsupported" && entry.index?.state !== "failed") || entry.index?.state === "queued" || entry.index?.state === "indexing") {
92
+ entry.index = { state: "queued" };
93
+ this.schedule(entry);
94
+ } else if (!entry.index && existing) entry.index = { state: "ready", chunks: existing.chunks.length };
95
+ }
96
+ this.write("entries.json", entries);
97
+ this.indexChanged();
98
+ });
99
+ }
100
+ private async buildIndex(id: string, hash: string, generation: number): Promise<void> {
101
+ let entry: Entry | undefined;
102
+ await this.enqueue(() => {
103
+ const all = this.entries();
104
+ entry = all.find((e) => e.id === id && e.hash === hash && e.collections.length > 0);
105
+ if (!entry || this.generations.get(id) !== generation) { entry = undefined; return; }
106
+ entry.index = { state: "indexing" };
107
+ this.write("entries.json", all);
108
+ this.indexChanged();
109
+ });
110
+ if (!entry) return;
111
+ const config = this.embedding();
112
+ const model = this.model();
113
+ const indexedName = entry.name;
114
+ try {
115
+ const chunks = await extractChunks(entry);
116
+ if (!chunks.length) throw new UnsupportedIndex("文件没有可索引的文字");
117
+ const vectors = config.source === "openai" && chunks.length ? await embed(config, embeddingKey(this.home), chunks.map((c) => `${c.heading}\n${c.text}`)) : [];
118
+ await this.enqueue(() => {
119
+ const all = this.entries();
120
+ const current = all.find((e) => e.id === id && e.hash === hash && e.collections.length);
121
+ if (!current || this.generations.get(id) !== generation || model !== this.model()) return;
122
+ const dir = join(this.root, "index");
123
+ mkdirSync(dir, { recursive: true });
124
+ const dims = vectors[0]?.length ?? 0;
125
+ const vector = new Float32Array(vectors.flat());
126
+ if (dims) writeFileSync(this.indexPath(hash, "vec"), Buffer.from(vector.buffer), { mode: 0o600 });
127
+ else rmSync(this.indexPath(hash, "vec"), { force: true });
128
+ const file = this.indexPath(hash, "json");
129
+ const tmp = `${file}.${process.pid}.tmp`;
130
+ writeFileSync(tmp, JSON.stringify({ model, dims, name: indexedName, chunks } satisfies IndexFile), { mode: 0o600 });
131
+ renameSync(tmp, file);
132
+ current.index = { state: "ready", chunks: chunks.length };
133
+ this.write("entries.json", all);
134
+ this.indexChanged();
135
+ });
136
+ } catch (error) {
137
+ await this.enqueue(() => {
138
+ const all = this.entries();
139
+ const current = all.find((e) => e.id === id && e.hash === hash && e.collections.length);
140
+ if (!current || this.generations.get(id) !== generation) return;
141
+ current.index = { state: error instanceof UnsupportedIndex ? "unsupported" : "failed", error: error instanceof Error ? error.message : String(error) };
142
+ this.write("entries.json", all);
143
+ this.indexChanged();
144
+ });
145
+ }
146
+ }
147
+ private entries(): Entry[] {
148
+ return this.read<Entry>("entries.json");
149
+ }
150
+ private collections(): Collection[] {
151
+ return this.read<Collection>("collections.json");
152
+ }
153
+ private timestamp(entries: Entry[]): string {
154
+ return new Date(Math.max(Date.now(), ...entries.map((entry) => Date.parse(entry.updatedAt) + 1))).toISOString();
155
+ }
156
+ private collectFile(input: { origin: KnowledgeOrigin; path: string; name?: string; description?: string; assistantId?: string; itemId?: string; jobId?: string }, collectionId?: string): Entry | undefined {
157
+ let stat;
158
+ try {
159
+ stat = statSync(input.path);
160
+ } catch (error) {
161
+ console.warn("知识库跳过文件:", input.path, error);
162
+ return;
163
+ }
164
+ if (!stat.isFile() || stat.size > this.maxBytes) {
165
+ console.warn("知识库跳过目录或过大文件:", input.path);
166
+ return;
167
+ }
168
+ // ponytail: 文件上限 100 MB,目前同步读写;需要处理更大文件时改为流式哈希和复制。
169
+ const data = readFileSync(input.path);
170
+ if (data.length > this.maxBytes) {
171
+ console.warn("知识库跳过过大文件:", input.path);
172
+ return;
173
+ }
174
+ const hash = createHash("sha256").update(data).digest("hex");
175
+ const entries = this.entries();
176
+ const duplicate = entries.find((entry) => entry.hash === hash);
177
+ if (duplicate) {
178
+ if (collectionId && !duplicate.collections.includes(collectionId)) {
179
+ duplicate.collections.push(collectionId);
180
+ if (duplicate.collections.length === 1) { duplicate.index = { state: "queued" }; this.schedule(duplicate); }
181
+ this.write("entries.json", entries);
182
+ this.changed();
183
+ }
184
+ return duplicate;
185
+ }
186
+ const sourceKey = input.origin === "upload" ? undefined : resolve(input.path);
187
+ const previous = sourceKey ? entries.find((entry) => entry.sourceKey === sourceKey) : undefined;
188
+ const originalName = basename(input.path);
189
+ const name = input.name || (input.origin === "chat" ? originalName.replace(/^u-[^-]+-/, "") : originalName);
190
+ const kind = fileKind(originalName) ?? (looksText(data) ? "text" : "other");
191
+ const inPages = !relative(join(this.home, "pages"), resolve(input.path)).startsWith(`..${sep}`) && relative(join(this.home, "pages"), resolve(input.path)) !== "..";
192
+ const ext = extname(originalName).toLowerCase().replace(/[^.a-z0-9]/g, "");
193
+ const path = join(this.root, inPages ? "pages" : "blobs", `${hash}${inPages ? ".html" : ext}`);
194
+ mkdirSync(dirname(path), { recursive: true });
195
+ writeFileSync(path, data, { mode: 0o600 });
196
+ if (isTextKind(kind)) {
197
+ mkdirSync(join(this.root, "text"), { recursive: true });
198
+ const content = data.toString("utf8");
199
+ const text = kind === "html"
200
+ ? content.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " ").replace(/<[^>]*>/g, " ")
201
+ : content;
202
+ writeFileSync(join(this.root, "text", `${hash}.txt`), Buffer.from(text).subarray(0, 64 * 1024), { mode: 0o600 });
203
+ }
204
+ const rel = relative(join(this.home, "jobs"), resolve(input.path));
205
+ const jobId = input.jobId || (!rel.startsWith(`..${sep}`) && rel !== ".." && rel.includes(sep) ? rel.split(sep)[0] : undefined);
206
+ const description = input.description || (jobId ? this.store.job(jobId)?.title : undefined);
207
+ const now = this.timestamp(entries);
208
+ if (previous) {
209
+ previous.versions = [...(previous.versions ?? []), { hash: previous.hash, path: previous.path, size: previous.size, at: previous.updatedAt }];
210
+ const oldHash = previous.hash;
211
+ Object.assign(previous, { path, hash, size: data.length, kind, updatedAt: now });
212
+ if (previous.collections.length) { previous.index = { state: "queued" }; this.schedule(previous); }
213
+ this.removeUnusedIndex(oldHash, entries);
214
+ if (description) previous.description = description;
215
+ this.write("entries.json", entries);
216
+ this.changed();
217
+ return previous;
218
+ }
219
+ const entry: Entry = {
220
+ id: newId("k-"),
221
+ origin: input.origin,
222
+ name,
223
+ ...(description ? { description } : {}),
224
+ kind,
225
+ path,
226
+ hash,
227
+ size: data.length,
228
+ createdAt: now,
229
+ updatedAt: now,
230
+ collections: collectionId ? [collectionId] : [],
231
+ ...(sourceKey ? { sourceKey } : {}),
232
+ ...(input.assistantId ? { source: { assistantId: input.assistantId, ...(input.itemId ? { itemId: input.itemId } : {}), ...(jobId ? { jobId } : {}) } } : {}),
233
+ };
234
+ entries.push(entry);
235
+ if (entry.collections.length) { entry.index = { state: "queued" }; this.schedule(entry); }
236
+ this.write("entries.json", entries);
237
+ this.changed();
238
+ return entry;
239
+ }
240
+ collect(input: CollectInput): Promise<Entry | undefined> {
241
+ return this.enqueue(() => this.collectFile(input));
242
+ }
243
+ upload(file: { path: string; name: string }, collectionId: string): Promise<Entry | undefined> {
244
+ return this.enqueue(() => {
245
+ if (!this.collections().some((c) => c.id === collectionId)) throw new Error("知识库不存在");
246
+ return this.collectFile({ origin: "upload", ...file }, collectionId);
247
+ });
248
+ }
249
+ async list(query: Query = {}): Promise<KnowledgePage> {
250
+ await this.whenIdle();
251
+ const all = this.entries();
252
+ const collections = this.collections().map((c) => {
253
+ const entries = all.filter((e) => e.collections.includes(c.id));
254
+ return { ...c, count: entries.length, indexed: entries.filter((e) => e.index?.state === "ready").length, failed: entries.filter((e) => e.index?.state === "failed").length, unsupported: entries.filter((e) => e.index?.state === "unsupported").length };
255
+ });
256
+ const q = query.q?.toLocaleLowerCase();
257
+ const limit = Math.max(1, Math.min(200, query.limit || 50));
258
+ const candidates = all.filter((e) => (!query.origin || e.origin === query.origin) && (!query.collection || e.collections.includes(query.collection)) && (!query.kind || knowledgeGroup(e.kind) === query.kind as KnowledgeGroup) && (!query.assistantId || e.source?.assistantId === query.assistantId) && (!query.before || e.updatedAt < query.before)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
259
+ // ponytail: 几千条以内线性扫描,凑够一页(多一条判断 hasMore)就停;后续向量检索时改为索引。
260
+ const entries: (KnowledgeEntry & { snippet?: string })[] = [];
261
+ for (const e of candidates) {
262
+ if (entries.length > limit) break;
263
+ if (!q) { entries.push(e); continue; }
264
+ const textPath = join(this.root, "text", `${e.hash}.txt`);
265
+ const body = existsSync(textPath) ? readFileSync(textPath, "utf8") : "";
266
+ const index = body.toLocaleLowerCase().indexOf(q);
267
+ if (index < 0 && !e.name.toLocaleLowerCase().includes(q) && !e.description?.toLocaleLowerCase().includes(q)) continue;
268
+ entries.push({ ...e, ...(index >= 0 ? { snippet: body.slice(Math.max(0, index - 40), index + q.length + 40) } : {}) });
269
+ }
270
+ return { entries: entries.slice(0, limit), hasMore: entries.length > limit, collections };
271
+ }
272
+ async entry(id: string, chunk?: number): Promise<KnowledgeEntryView | undefined> {
273
+ await this.whenIdle();
274
+ const entry = this.entries().find((e) => e.id === id);
275
+ if (!entry) return;
276
+ const index = entry.index?.state === "ready" ? this.readIndex(entry.hash) : undefined;
277
+ const selected = chunk === undefined ? undefined : index?.chunks.find((c) => c.index === chunk);
278
+ const displayed = selected && index?.name && selected.heading.startsWith(index.name) ? { ...selected, heading: entry.name + selected.heading.slice(index.name.length) } : selected;
279
+ return { entry, ...(displayed ? { chunk: displayed } : {}) };
280
+ }
281
+ /** 不给 collectionId 就重试所有库里失败的(换了 Key 时用)。 */
282
+ reindex(collectionId?: string): Promise<boolean> {
283
+ return this.enqueue(() => {
284
+ if (collectionId && !this.collections().some((c) => c.id === collectionId)) return false;
285
+ const all = this.entries();
286
+ for (const entry of all) if ((collectionId ? entry.collections.includes(collectionId) : entry.collections.length > 0) && entry.index?.state === "failed") {
287
+ entry.index = { state: "queued" };
288
+ this.schedule(entry);
289
+ }
290
+ this.write("entries.json", all);
291
+ this.indexChanged();
292
+ return true;
293
+ });
294
+ }
295
+ async search(query: string, collectionIds: string[], options: { limit?: number; gate?: boolean } = {}): Promise<SearchResult[]> {
296
+ await this.whenIdle();
297
+ const words = [...new Set(terms(query))];
298
+ if (!words.length || !collectionIds.length || (options.gate && this.embedding().source !== "openai")) return [];
299
+ const all = this.entries().filter((e) => e.index?.state === "ready" && e.collections.some((id) => collectionIds.includes(id)));
300
+ const candidates: { entry: Entry; chunk: IndexedChunk; words: string[]; vector?: Float32Array; bm: number; cosine?: number; score: number }[] = [];
301
+ // ponytail: 几万块以内线性扫描;更大时换 SQLite FTS5 和近似最近邻。
302
+ for (const entry of all) {
303
+ let index: IndexFile | undefined;
304
+ try { index = this.readIndex(entry.hash); } catch (error) { console.warn("知识库索引读取失败:", error); continue; }
305
+ if (!index || index.model !== this.model()) continue;
306
+ let values: Float32Array | undefined;
307
+ if (index.dims && existsSync(this.indexPath(entry.hash, "vec"))) {
308
+ const data = readFileSync(this.indexPath(entry.hash, "vec"));
309
+ if (data.length === index.chunks.length * index.dims * 4) values = new Float32Array(data.buffer, data.byteOffset, data.length / 4);
310
+ }
311
+ index.chunks.forEach((stored, i) => {
312
+ const chunk = index.name && stored.heading.startsWith(index.name) ? { ...stored, heading: entry.name + stored.heading.slice(index.name.length) } : stored;
313
+ const text = `${chunk.heading} ${chunk.text}`;
314
+ const tokens = terms(text);
315
+ for (const word of words) if (/^[\u3400-\u9fff]$/.test(word)) tokens.push(...Array.from(text).filter((char) => char === word));
316
+ candidates.push({ entry, chunk, words: tokens, ...(values ? { vector: values.slice(i * index.dims, (i + 1) * index.dims) } : {}), bm: 0, score: 0 });
317
+ });
318
+ }
319
+ if (!candidates.length) return [];
320
+ const avg = candidates.reduce((sum, c) => sum + c.words.length, 0) / candidates.length || 1;
321
+ const dfs = words.map((word) => candidates.filter((c) => c.words.includes(word)).length);
322
+ for (const c of candidates) {
323
+ words.forEach((word, i) => {
324
+ const frequency = c.words.filter((term) => term === word).length;
325
+ if (!frequency) return;
326
+ const idf = Math.log(1 + (candidates.length - dfs[i]! + 0.5) / (dfs[i]! + 0.5));
327
+ c.bm += idf * frequency * 2.2 / (frequency + 1.2 * (0.25 + 0.75 * c.words.length / avg));
328
+ });
329
+ }
330
+ const rankedWords = candidates.filter((c) => c.bm > 0).sort((a, b) => b.bm - a.bm);
331
+ rankedWords.forEach((c, i) => { c.score += 1 / (60 + i + 1); });
332
+ let vectorAvailable = false;
333
+ const config = this.embedding();
334
+ if (config.source === "openai" && candidates.some((c) => c.vector)) {
335
+ try {
336
+ const [q] = await embed(config, embeddingKey(this.home), [query]);
337
+ const norm = Math.hypot(...q!);
338
+ if (!norm) throw new Error("查询向量为零");
339
+ for (const c of candidates) {
340
+ if (!c.vector || c.vector.length !== q!.length) continue;
341
+ let dot = 0; let square = 0;
342
+ for (let i = 0; i < q!.length; i++) { dot += q![i]! * c.vector[i]!; square += c.vector[i]! ** 2; }
343
+ if (square) c.cosine = dot / (norm * Math.sqrt(square));
344
+ }
345
+ const ranked = candidates.filter((c) => c.cosine !== undefined).sort((a, b) => b.cosine! - a.cosine!);
346
+ vectorAvailable = ranked.length > 0;
347
+ ranked.forEach((c, i) => { c.score += 1 / (60 + i + 1); });
348
+ } catch (error) { console.warn("知识库向量检索失败,退回关键词:", error); }
349
+ }
350
+ if (options.gate && !vectorAvailable) return [];
351
+ const counts = new Map<string, number>();
352
+ return candidates.filter((c) => c.score > 0 && (!options.gate || (c.cosine ?? -1) >= VECTOR_GATE))
353
+ .sort((a, b) => b.score - a.score).filter((c) => {
354
+ const n = counts.get(c.entry.id) ?? 0;
355
+ if (n >= 3) return false;
356
+ counts.set(c.entry.id, n + 1);
357
+ return true;
358
+ }).slice(0, Math.max(0, options.limit ?? 6)).map(({ entry, chunk, score }) => ({ entry, chunk, score }));
359
+ }
360
+ createCollection(name: string): Promise<Collection> {
361
+ return this.enqueue(() => {
362
+ const list = this.collections();
363
+ const item = { id: newId("kc-"), name, createdAt: new Date().toISOString() };
364
+ list.push(item);
365
+ this.write("collections.json", list);
366
+ this.changed();
367
+ return item;
368
+ });
369
+ }
370
+ renameCollection(id: string, name: string): Promise<Collection | undefined> {
371
+ return this.enqueue(() => {
372
+ const list = this.collections();
373
+ const item = list.find((c) => c.id === id);
374
+ if (!item) return;
375
+ item.name = name;
376
+ this.write("collections.json", list);
377
+ this.changed();
378
+ return item;
379
+ });
380
+ }
381
+ private cleanup(removed: Entry[], remaining: Entry[]): void {
382
+ const used = new Set(remaining.flatMap((e) => [e.path, ...(e.versions ?? []).map((v) => v.path)]));
383
+ const hashes = new Set(remaining.flatMap((e) => [e.hash, ...(e.versions ?? []).map((v) => v.hash)]));
384
+ for (const e of removed) {
385
+ for (const v of [e, ...(e.versions ?? [])]) {
386
+ if (!used.has(v.path)) rmSync(v.path, { force: true });
387
+ if (!hashes.has(v.hash)) rmSync(join(this.root, "text", `${v.hash}.txt`), { force: true });
388
+ }
389
+ }
390
+ }
391
+ deleteCollection(id: string): Promise<number | undefined> {
392
+ return this.enqueue(() => {
393
+ const collections = this.collections();
394
+ if (!collections.some((c) => c.id === id)) return;
395
+ const all = this.entries();
396
+ const removed: Entry[] = [];
397
+ const kept = all.filter((e) => {
398
+ const had = e.collections.includes(id);
399
+ e.collections = e.collections.filter((c) => c !== id);
400
+ if (had && e.origin === "upload" && !e.collections.length) {
401
+ removed.push(e);
402
+ return false;
403
+ }
404
+ return true;
405
+ });
406
+ this.write("entries.json", kept);
407
+ this.write("collections.json", collections.filter((c) => c.id !== id));
408
+ this.cleanup(removed, kept);
409
+ for (const entry of all) if (!entry.collections.length) { delete entry.index; this.removeUnusedIndex(entry.hash, kept); }
410
+ this.write("entries.json", kept);
411
+ for (const assistant of this.store.assistants()) {
412
+ if (assistant.knowledge?.includes(id)) {
413
+ this.store.saveAssistant({ ...assistant, knowledge: assistant.knowledge.filter((value) => value !== id) });
414
+ this.bus.emit({ type: "profile", assistantId: assistant.id });
415
+ }
416
+ }
417
+ this.changed();
418
+ return removed.length;
419
+ });
420
+ }
421
+ updateEntry(id: string, patch: { name?: string; collections?: string[] }): Promise<Entry | undefined> {
422
+ return this.enqueue(() => {
423
+ const all = this.entries();
424
+ const entry = all.find((e) => e.id === id);
425
+ if (!entry) return;
426
+ if (patch.collections && patch.collections.some((id) => !this.collections().some((c) => c.id === id))) throw new Error("知识库不存在");
427
+ if (patch.name !== undefined) entry.name = patch.name;
428
+ const wasIndexed = entry.collections.length > 0;
429
+ if (patch.collections !== undefined) entry.collections = [...new Set(patch.collections)];
430
+ if (!wasIndexed && entry.collections.length) { entry.index = { state: "queued" }; this.schedule(entry); }
431
+ if (wasIndexed && !entry.collections.length) { delete entry.index; this.removeUnusedIndex(entry.hash, all); }
432
+ // 改名、整理不算更新:updatedAt 只跟内容走,列表顺序和「最近几天」都不受整理影响
433
+ this.write("entries.json", all);
434
+ this.changed();
435
+ return entry;
436
+ });
437
+ }
438
+ deleteEntry(id: string): Promise<boolean> {
439
+ return this.enqueue(() => {
440
+ const all = this.entries();
441
+ const entry = all.find((e) => e.id === id);
442
+ if (!entry) return false;
443
+ const kept = all.filter((e) => e.id !== id);
444
+ this.write("entries.json", kept);
445
+ this.cleanup([entry], kept);
446
+ this.removeUnusedIndex(entry.hash, kept);
447
+ this.changed();
448
+ return true;
449
+ });
450
+ }
451
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const entry = new URL("../../vendor/lore-pi-extension/index.ts", import.meta.url).href;
4
+ const { default: loreExtension } = await import(entry);
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ const api = pi as ExtensionAPI & { pluginConfig?: Record<string, unknown> };
8
+ api.pluginConfig = { ...api.pluginConfig, injectPromptGuidance: false };
9
+ return loreExtension(pi);
10
+ }
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { serve } from "@hono/node-server";
4
4
  import { engineDir, loadConfig, resolveHome } from "./config.ts";
5
5
  import { createApp } from "./http.ts";
6
+ import { attachDesktopWebSocket } from "./desktop-control.ts";
6
7
  import { PiHost } from "./pi.ts";
7
8
  import { createRuntime, type HobRuntime } from "./runtime.ts";
8
9
  import { readLegacyModels } from "./settings.ts";
@@ -39,15 +40,20 @@ export async function startHob(options: StartOptions = {}): Promise<HobServer> {
39
40
  runtime.delegations.recover();
40
41
  runtime.routines.start();
41
42
  runtime.jobs.startWatchdog();
43
+ runtime.desktopEvents.restart();
42
44
 
43
45
  const app = createApp(runtime, { webDir: fileURLToPath(new URL("../../dist/web", import.meta.url)), home });
44
46
  const port = options.port ?? Number(env.HOB_PORT ?? config.port);
45
47
  const server = serve({ fetch: app.fetch, port, hostname: config.host });
48
+ const detachDesktop = attachDesktopWebSocket(server, runtime);
46
49
 
47
50
  return {
48
51
  runtime,
49
52
  port,
50
53
  close: async () => {
54
+ detachDesktop();
55
+ runtime.desktopEvents.stop();
56
+ runtime.desktopControl.dispose();
51
57
  runtime.routines.stop();
52
58
  runtime.jobs.disposeAll();
53
59
  runtime.router.disposeAll();