@poco-ai/agentero-dsh-host 0.0.0-stage → 0.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 (67) hide show
  1. package/lib/bookmarks-write.d.ts +24 -0
  2. package/lib/bookmarks-write.js +181 -0
  3. package/lib/bookmarks-write.js.map +1 -0
  4. package/lib/catalog.d.ts +28 -0
  5. package/lib/catalog.js +58 -0
  6. package/lib/catalog.js.map +1 -0
  7. package/lib/cli-run.d.ts +15 -0
  8. package/lib/cli-run.js +102 -0
  9. package/lib/cli-run.js.map +1 -0
  10. package/lib/client/index.d.ts +71 -0
  11. package/lib/client/settings-model.d.ts +28 -0
  12. package/lib/client/settings.d.ts +27 -0
  13. package/lib/client/ui/button.d.ts +10 -0
  14. package/lib/client/ui/input.d.ts +2 -0
  15. package/lib/client/utils.d.ts +2 -0
  16. package/lib/client.js +4071 -0
  17. package/lib/index.d.ts +55 -0
  18. package/lib/index.js +1103 -0
  19. package/lib/index.js.map +1 -0
  20. package/lib/marks-store.d.ts +80 -0
  21. package/lib/marks-store.js +393 -0
  22. package/lib/marks-store.js.map +1 -0
  23. package/lib/notes-files.d.ts +47 -0
  24. package/lib/notes-files.js +427 -0
  25. package/lib/notes-files.js.map +1 -0
  26. package/lib/pdf-file.d.ts +23 -0
  27. package/lib/pdf-file.js +84 -0
  28. package/lib/pdf-file.js.map +1 -0
  29. package/lib/recommend.d.ts +61 -0
  30. package/lib/recommend.js +864 -0
  31. package/lib/recommend.js.map +1 -0
  32. package/lib/tools.d.ts +8 -0
  33. package/lib/tools.js +204 -0
  34. package/lib/tools.js.map +1 -0
  35. package/lib/translate.d.ts +70 -0
  36. package/lib/translate.js +80 -0
  37. package/lib/translate.js.map +1 -0
  38. package/lib/translation/batches.d.ts +21 -0
  39. package/lib/translation/batches.js +88 -0
  40. package/lib/translation/batches.js.map +1 -0
  41. package/lib/translation/chunks.d.ts +2 -0
  42. package/lib/translation/chunks.js +36 -0
  43. package/lib/translation/chunks.js.map +1 -0
  44. package/lib/translation/mask.d.ts +22 -0
  45. package/lib/translation/mask.js +69 -0
  46. package/lib/translation/mask.js.map +1 -0
  47. package/lib/translation/pipeline.d.ts +3 -0
  48. package/lib/translation/pipeline.js +119 -0
  49. package/lib/translation/pipeline.js.map +1 -0
  50. package/lib/translation/preserved-links.d.ts +26 -0
  51. package/lib/translation/preserved-links.js +127 -0
  52. package/lib/translation/preserved-links.js.map +1 -0
  53. package/lib/translation/prompt.d.ts +99 -0
  54. package/lib/translation/prompt.js +224 -0
  55. package/lib/translation/prompt.js.map +1 -0
  56. package/lib/translation/retry.d.ts +3 -0
  57. package/lib/translation/retry.js +30 -0
  58. package/lib/translation/retry.js.map +1 -0
  59. package/lib/translation/sentences.d.ts +5 -0
  60. package/lib/translation/sentences.js +77 -0
  61. package/lib/translation/sentences.js.map +1 -0
  62. package/lib/vault.d.ts +21 -0
  63. package/lib/vault.js +69 -0
  64. package/lib/vault.js.map +1 -0
  65. package/package.json +44 -3
  66. package/src/translation/MOTIF-LICENSE.txt +21 -0
  67. package/README.md +0 -3
@@ -0,0 +1,864 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { cliFailure, runAgentero } from './cli-run.js';
6
+ const PREFIX = '/agentero/api/recommend';
7
+ const BODY_LIMIT = 128 * 1024;
8
+ const FEED_TIMEOUT_MS = 30_000;
9
+ const EMBED_TIMEOUT_MS = 120_000;
10
+ const ZHIHU_TIMEOUT_MS = 20_000;
11
+ const CLI_TIMEOUT_MS = 20_000;
12
+ const EMBED_BATCH = 20;
13
+ const EMBED_CHARS = 4_000;
14
+ const MAX_CORPUS = 2_000;
15
+ const MAX_TOP_N = 100;
16
+ const DEFAULT_TOP_N = 20;
17
+ const ZHIHU_SCORE_FLOOR = 0.5;
18
+ const DEFAULT_CATEGORIES = ['cs.AI', 'cs.CL', 'cs.LG', 'cs.CV', 'stat.ML'];
19
+ const ZHIHU_BASE = 'https://developer.zhihu.com';
20
+ const ARXIV_RSS_BASE = 'https://rss.arxiv.org/rss/';
21
+ function record(value) {
22
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
23
+ return undefined;
24
+ return value;
25
+ }
26
+ function stringValue(value) {
27
+ return typeof value === 'string' ? value.trim() : '';
28
+ }
29
+ function maskedSecret(value) {
30
+ return value !== '' && /^\*+$/.test(value);
31
+ }
32
+ function configHome() {
33
+ const xdg = stringValue(process.env.XDG_CONFIG_HOME);
34
+ if (xdg !== '')
35
+ return xdg;
36
+ if (process.platform === 'win32') {
37
+ const appData = stringValue(process.env.APPDATA);
38
+ if (appData !== '')
39
+ return appData;
40
+ }
41
+ return join(homedir(), '.config');
42
+ }
43
+ function cacheHome() {
44
+ const xdg = stringValue(process.env.XDG_CACHE_HOME);
45
+ if (xdg !== '')
46
+ return xdg;
47
+ if (process.platform === 'win32') {
48
+ const local = stringValue(process.env.LOCALAPPDATA);
49
+ if (local !== '')
50
+ return local;
51
+ }
52
+ return join(homedir(), '.cache');
53
+ }
54
+ function agenteroSettingsPath(settings) {
55
+ const explicit = settings.agenteroSettings.trim();
56
+ return explicit !== '' ? explicit : join(configHome(), 'agentero', 'settings.json');
57
+ }
58
+ function overrideSettingsPath() {
59
+ return join(configHome(), 'agentero', 'dsh-recommend.json');
60
+ }
61
+ function cacheFilePath() {
62
+ return join(cacheHome(), 'agentero', 'dsh-recommend-cache.json');
63
+ }
64
+ async function readJsonFile(path) {
65
+ try {
66
+ const text = await readFile(path, 'utf8');
67
+ return record(JSON.parse(text));
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ async function writeSecureJson(path, value) {
74
+ const directory = dirname(path);
75
+ await mkdir(directory, { recursive: true, mode: 0o700 });
76
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
77
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
78
+ await rename(temporary, path);
79
+ }
80
+ async function loadOverrides() {
81
+ const raw = await readJsonFile(overrideSettingsPath());
82
+ if (raw === undefined)
83
+ return {};
84
+ const out = {};
85
+ const baseUrl = stringValue(raw.embeddingBaseUrl);
86
+ const apiKey = stringValue(raw.embeddingApiKey);
87
+ const model = stringValue(raw.embeddingModel);
88
+ const zhihu = stringValue(raw.zhihuAccessSecret);
89
+ if (baseUrl !== '')
90
+ out.embeddingBaseUrl = baseUrl;
91
+ if (apiKey !== '' && !maskedSecret(apiKey))
92
+ out.embeddingApiKey = apiKey;
93
+ if (model !== '')
94
+ out.embeddingModel = model;
95
+ if (zhihu !== '' && !maskedSecret(zhihu))
96
+ out.zhihuAccessSecret = zhihu;
97
+ return out;
98
+ }
99
+ async function resolveRuntime(settings) {
100
+ const agentero = await readJsonFile(agenteroSettingsPath(settings));
101
+ const overrides = await loadOverrides();
102
+ const agenteroEmbedding = record(agentero?.embedding);
103
+ const agenteroSource = stringValue(agenteroEmbedding?.source);
104
+ const agenteroIsBuiltin = agenteroSource === 'builtin';
105
+ const agenteroBase = agenteroIsBuiltin ? '' : stringValue(agenteroEmbedding?.baseUrl);
106
+ const agenteroModel = agenteroIsBuiltin ? '' : stringValue(agenteroEmbedding?.model);
107
+ const agenteroKeyRaw = agenteroIsBuiltin ? '' : stringValue(agenteroEmbedding?.apiKey);
108
+ const agenteroKey = maskedSecret(agenteroKeyRaw) ? '' : agenteroKeyRaw;
109
+ const overrideBase = overrides.embeddingBaseUrl ?? '';
110
+ const overrideModel = overrides.embeddingModel ?? '';
111
+ const overrideKey = overrides.embeddingApiKey ?? '';
112
+ let baseUrl = '';
113
+ let model = '';
114
+ let key = '';
115
+ let source = 'agentero';
116
+ if (overrideBase !== '' || overrideModel !== '') {
117
+ baseUrl = overrideBase !== '' ? overrideBase : agenteroBase;
118
+ model = overrideModel !== '' ? overrideModel : agenteroModel;
119
+ key = overrideKey !== '' ? overrideKey : agenteroKey;
120
+ source = 'dsh';
121
+ }
122
+ else {
123
+ baseUrl = agenteroBase;
124
+ model = agenteroModel;
125
+ key = overrideKey !== '' ? overrideKey : agenteroKey;
126
+ source = overrideKey !== '' ? 'dsh' : 'agentero';
127
+ }
128
+ const embedding = baseUrl !== '' && model !== ''
129
+ ? { baseUrl, model, source, ...(key !== '' ? { apiKey: key } : {}) }
130
+ : undefined;
131
+ const agenteroZhihuRaw = stringValue(agentero?.zhihuAccessSecret);
132
+ const agenteroZhihu = maskedSecret(agenteroZhihuRaw) ? '' : agenteroZhihuRaw;
133
+ const zhihuSecret = overrides.zhihuAccessSecret !== undefined && overrides.zhihuAccessSecret !== ''
134
+ ? overrides.zhihuAccessSecret
135
+ : agenteroZhihu;
136
+ return {
137
+ ...(embedding !== undefined ? { embedding } : {}),
138
+ ...(zhihuSecret !== '' ? { zhihuSecret, zhihuSource: overrides.zhihuAccessSecret !== undefined ? 'dsh' : 'agentero' } : {}),
139
+ };
140
+ }
141
+ function settingsView(settings, runtime) {
142
+ return {
143
+ vaultConfigured: settings.vault.trim() !== '',
144
+ embedding: {
145
+ configured: runtime.embedding !== undefined,
146
+ baseUrl: runtime.embedding?.baseUrl ?? '',
147
+ model: runtime.embedding?.model ?? '',
148
+ keyPresent: runtime.embedding?.apiKey !== undefined,
149
+ source: runtime.embedding?.source ?? 'none',
150
+ },
151
+ zhihu: {
152
+ configured: runtime.zhihuSecret !== undefined,
153
+ source: runtime.zhihuSource ?? 'none',
154
+ },
155
+ };
156
+ }
157
+ async function saveOverrides(raw) {
158
+ const current = await loadOverrides();
159
+ const next = { ...current };
160
+ const assign = (key, value, allowMask) => {
161
+ if (typeof value !== 'string')
162
+ return;
163
+ const trimmed = value.trim();
164
+ if (trimmed === '' || (allowMask && maskedSecret(trimmed)))
165
+ return;
166
+ next[key] = trimmed;
167
+ };
168
+ assign('embeddingBaseUrl', raw.embeddingBaseUrl, false);
169
+ assign('embeddingModel', raw.embeddingModel, false);
170
+ assign('embeddingApiKey', raw.embeddingApiKey, true);
171
+ assign('zhihuAccessSecret', raw.zhihuAccessSecret, true);
172
+ await writeSecureJson(overrideSettingsPath(), next);
173
+ }
174
+ async function readCache() {
175
+ const raw = await readJsonFile(cacheFilePath());
176
+ const runs = record(raw?.runs);
177
+ if (runs === undefined)
178
+ return { runs: {} };
179
+ const out = {};
180
+ for (const [key, value] of Object.entries(runs)) {
181
+ const entry = record(value);
182
+ const computedAt = stringValue(entry?.computedAt);
183
+ const result = parseArxivResult(entry?.result);
184
+ if (computedAt === '' || result === undefined)
185
+ continue;
186
+ out[key] = { computedAt, result };
187
+ }
188
+ return { runs: out };
189
+ }
190
+ async function writeCache(cache) {
191
+ const entries = Object.entries(cache.runs)
192
+ .sort((a, b) => b[1].computedAt.localeCompare(a[1].computedAt))
193
+ .slice(0, 24);
194
+ await writeSecureJson(cacheFilePath(), { runs: Object.fromEntries(entries) });
195
+ }
196
+ function parseArxivResult(value) {
197
+ const raw = record(value);
198
+ if (raw === undefined)
199
+ return undefined;
200
+ const itemsRaw = raw.items;
201
+ if (!Array.isArray(itemsRaw))
202
+ return undefined;
203
+ const items = [];
204
+ for (const entry of itemsRaw) {
205
+ const item = record(entry);
206
+ if (item === undefined)
207
+ continue;
208
+ const arxivId = stringValue(item.arxivId);
209
+ const title = stringValue(item.title);
210
+ const url = stringValue(item.url);
211
+ if (arxivId === '' || title === '' || url === '')
212
+ continue;
213
+ const score = typeof item.score === 'number' && Number.isFinite(item.score) ? item.score : 0;
214
+ items.push({
215
+ arxivId,
216
+ title,
217
+ abstract: stringValue(item.abstract),
218
+ url,
219
+ ...(stringValue(item.publishedAt) !== '' ? { publishedAt: stringValue(item.publishedAt) } : {}),
220
+ score,
221
+ });
222
+ }
223
+ const categories = Array.isArray(raw.categories)
224
+ ? raw.categories.filter((c) => typeof c === 'string' && c.trim() !== '')
225
+ : [];
226
+ return {
227
+ items,
228
+ computedAt: stringValue(raw.computedAt),
229
+ categories,
230
+ corpusSize: typeof raw.corpusSize === 'number' && Number.isFinite(raw.corpusSize) ? raw.corpusSize : 0,
231
+ reusedCache: raw.reusedCache === true,
232
+ };
233
+ }
234
+ function normalizeCategories(input) {
235
+ const values = Array.isArray(input) ? input : [];
236
+ const out = [];
237
+ for (const value of values) {
238
+ if (typeof value !== 'string')
239
+ continue;
240
+ const trimmed = value.trim();
241
+ if (trimmed === '' || trimmed.length > 40)
242
+ continue;
243
+ if (!out.some(category => category.toLowerCase() === trimmed.toLowerCase()))
244
+ out.push(trimmed);
245
+ }
246
+ return out;
247
+ }
248
+ function topNFrom(value) {
249
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : DEFAULT_TOP_N;
250
+ return Math.min(MAX_TOP_N, Math.max(1, n));
251
+ }
252
+ function todayUtc() {
253
+ return new Date().toISOString().slice(0, 10);
254
+ }
255
+ function fresh(result) {
256
+ return result.items.length > 0 && result.computedAt.startsWith(todayUtc());
257
+ }
258
+ function runKey(model, categories, topN) {
259
+ const normalized = categories.map(category => category.toLowerCase()).join(',');
260
+ return createHash('sha256').update(`arxiv|${model}|${topN}|${normalized}`).digest('hex');
261
+ }
262
+ function sameCategories(left, right) {
263
+ if (left.length !== right.length)
264
+ return false;
265
+ const normalizedRight = new Set(right.map(category => category.toLowerCase()));
266
+ return left.every(category => normalizedRight.has(category.toLowerCase()));
267
+ }
268
+ function latestSameDay(cache, categories) {
269
+ const entries = Object.values(cache.runs)
270
+ .filter(entry => fresh(entry.result))
271
+ .filter(entry => categories === undefined || categories.length === 0 || sameCategories(entry.result.categories, categories))
272
+ .sort((a, b) => b.computedAt.localeCompare(a.computedAt));
273
+ return entries[0]?.result;
274
+ }
275
+ function decodeXml(input) {
276
+ return input
277
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
278
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
279
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)))
280
+ .replace(/&lt;/g, '<')
281
+ .replace(/&gt;/g, '>')
282
+ .replace(/&quot;/g, '"')
283
+ .replace(/&apos;/g, "'")
284
+ .replace(/&nbsp;/g, ' ')
285
+ .replace(/&amp;/g, '&');
286
+ }
287
+ function stripHtml(input) {
288
+ return input.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
289
+ }
290
+ function itemTag(item, name) {
291
+ const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i').exec(item);
292
+ return match?.[1] !== undefined ? decodeXml(match[1]) : '';
293
+ }
294
+ function itemLink(item) {
295
+ const text = itemTag(item, 'link').trim();
296
+ if (text !== '')
297
+ return text;
298
+ const match = /<link\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')/i.exec(item);
299
+ return decodeXml(match?.[1] ?? match?.[2] ?? '').trim();
300
+ }
301
+ function stripLabeledPrefix(text, label) {
302
+ const lower = text.toLowerCase();
303
+ if (!lower.startsWith(label))
304
+ return undefined;
305
+ const after = text.slice(label.length).trimStart();
306
+ if (label === 'abstract:')
307
+ return after;
308
+ const whitespace = after.search(/\s/);
309
+ return after.slice(whitespace === -1 ? after.length : whitespace).trimStart();
310
+ }
311
+ function cleanAbstract(input) {
312
+ let text = stripHtml(decodeXml(input));
313
+ const labels = ['arxiv:', 'announce type:', 'comments:', 'subjects:', 'journal-ref:', 'report-no:', 'license:', 'abstract:'];
314
+ for (let changed = true; changed;) {
315
+ changed = false;
316
+ for (const label of labels) {
317
+ const next = stripLabeledPrefix(text, label);
318
+ if (next !== undefined) {
319
+ text = next;
320
+ changed = true;
321
+ }
322
+ }
323
+ }
324
+ return text;
325
+ }
326
+ function arxivIdFrom(text) {
327
+ const match = /(?:arxiv\.org\/(?:abs|pdf|html)\/|arxiv:)([a-z-]+(?:\.[a-z]{2})?\/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?/i.exec(text);
328
+ if (match?.[1] !== undefined)
329
+ return match[1];
330
+ return '';
331
+ }
332
+ function parseFeed(xml) {
333
+ const out = [];
334
+ const items = xml.match(/<item\b[\s\S]*?<\/item>/gi) ?? [];
335
+ for (const item of items) {
336
+ const title = stripHtml(itemTag(item, 'title'));
337
+ const description = itemTag(item, 'description');
338
+ const link = itemLink(item);
339
+ const abstract = cleanAbstract(description);
340
+ if (title === '' || abstract === '')
341
+ continue;
342
+ const arxivId = arxivIdFrom(`${link}\n${description}\n${itemTag(item, 'guid')}`);
343
+ if (arxivId === '')
344
+ continue;
345
+ const published = itemTag(item, 'pubDate').trim();
346
+ out.push({
347
+ arxivId,
348
+ title,
349
+ abstract,
350
+ url: `https://arxiv.org/abs/${arxivId}`,
351
+ ...(published !== '' ? { publishedAt: published } : {}),
352
+ });
353
+ }
354
+ return out;
355
+ }
356
+ async function fetchText(url, timeoutMs) {
357
+ const response = await fetch(url, {
358
+ headers: { 'user-agent': 'Agentero/1.0 (+https://github.com/Phil-Fan)' },
359
+ signal: AbortSignal.timeout(timeoutMs),
360
+ });
361
+ if (!response.ok)
362
+ throw new Error(`${url} returned HTTP ${response.status}`);
363
+ return response.text();
364
+ }
365
+ async function fetchCandidates(categories) {
366
+ const out = [];
367
+ const seen = new Set();
368
+ let lastError;
369
+ for (const category of categories) {
370
+ try {
371
+ const xml = await fetchText(`${ARXIV_RSS_BASE}${encodeURIComponent(category)}`, FEED_TIMEOUT_MS);
372
+ for (const candidate of parseFeed(xml)) {
373
+ if (seen.has(candidate.arxivId))
374
+ continue;
375
+ seen.add(candidate.arxivId);
376
+ out.push(candidate);
377
+ }
378
+ }
379
+ catch (error) {
380
+ lastError = error instanceof Error ? error : new Error(String(error));
381
+ }
382
+ }
383
+ if (out.length === 0 && lastError !== undefined)
384
+ throw lastError;
385
+ return out;
386
+ }
387
+ function resolveEmbeddingEndpoint(baseUrl) {
388
+ const base = baseUrl.trim().replace(/\/+$/, '');
389
+ if (base === '')
390
+ return '';
391
+ return base.endsWith('/embeddings') ? base : `${base}/embeddings`;
392
+ }
393
+ function embedText(title, abstract) {
394
+ return `${title.trim()}\n\n${abstract.trim()}`.slice(0, EMBED_CHARS);
395
+ }
396
+ function normalizeVector(vector) {
397
+ let sum = 0;
398
+ for (const value of vector)
399
+ sum += value * value;
400
+ const norm = Math.sqrt(sum);
401
+ return norm > 0 ? vector.map(value => value / norm) : vector;
402
+ }
403
+ function dot(left, right) {
404
+ if (left.length !== right.length)
405
+ return 0;
406
+ let sum = 0;
407
+ for (let index = 0; index < left.length; index += 1)
408
+ sum += (left[index] ?? 0) * (right[index] ?? 0);
409
+ return sum;
410
+ }
411
+ function isBatchRejection(status, body) {
412
+ if (status !== 400 && status !== 413 && status !== 422)
413
+ return false;
414
+ const lower = body.toLowerCase();
415
+ return lower.includes('batch')
416
+ || lower.includes('too many input')
417
+ || lower.includes('too many text')
418
+ || lower.includes('maximum number of');
419
+ }
420
+ async function embedBatch(endpoint, apiKey, model, texts) {
421
+ if (texts.length === 0)
422
+ return [];
423
+ const headers = { 'content-type': 'application/json' };
424
+ if (apiKey !== undefined)
425
+ headers.authorization = `Bearer ${apiKey}`;
426
+ let response;
427
+ try {
428
+ response = await fetch(endpoint, {
429
+ method: 'POST',
430
+ headers,
431
+ body: JSON.stringify({ model, input: texts }),
432
+ signal: AbortSignal.timeout(EMBED_TIMEOUT_MS),
433
+ });
434
+ }
435
+ catch (error) {
436
+ throw new Error(`embeddings request failed: ${error instanceof Error ? error.message : String(error)}`);
437
+ }
438
+ if (!response.ok) {
439
+ const body = await response.text().catch(() => '');
440
+ if (texts.length > 1 && isBatchRejection(response.status, body)) {
441
+ const middle = Math.ceil(texts.length / 2);
442
+ const left = await embedBatch(endpoint, apiKey, model, texts.slice(0, middle));
443
+ const right = await embedBatch(endpoint, apiKey, model, texts.slice(middle));
444
+ return [...left, ...right];
445
+ }
446
+ throw new Error(`embeddings HTTP ${response.status}: ${body.slice(0, 300)}`);
447
+ }
448
+ let body;
449
+ try {
450
+ body = await response.json();
451
+ }
452
+ catch {
453
+ throw new Error('embeddings returned invalid JSON');
454
+ }
455
+ const data = record(body)?.data;
456
+ if (!Array.isArray(data))
457
+ throw new Error('embeddings response has no data');
458
+ const vectors = [];
459
+ for (const entry of data) {
460
+ const row = record(entry);
461
+ const vector = row?.embedding;
462
+ if (!Array.isArray(vector))
463
+ throw new Error('embeddings response has no vector');
464
+ const values = [];
465
+ for (const value of vector) {
466
+ if (typeof value !== 'number' || !Number.isFinite(value))
467
+ throw new Error('embeddings response has an invalid vector');
468
+ values.push(value);
469
+ }
470
+ vectors.push(values);
471
+ }
472
+ if (vectors.length !== texts.length)
473
+ throw new Error(`embeddings returned ${vectors.length} vectors for ${texts.length} inputs`);
474
+ return vectors;
475
+ }
476
+ async function embedAll(endpoint, apiKey, model, texts) {
477
+ const out = [];
478
+ for (let start = 0; start < texts.length; start += EMBED_BATCH) {
479
+ const chunk = texts.slice(start, start + EMBED_BATCH);
480
+ out.push(...await embedBatch(endpoint, apiKey, model, chunk));
481
+ }
482
+ return out;
483
+ }
484
+ /** Raw vectors kept for this host process so a refresh does not re-embed the corpus. */
485
+ const vectorMemo = new Map();
486
+ const VECTOR_MEMO_LIMIT = 5_000;
487
+ function memoKey(model, text) {
488
+ return `${model}\0${createHash('sha256').update(text).digest('hex')}`;
489
+ }
490
+ async function embedAllMemo(endpoint, apiKey, model, texts) {
491
+ const keys = texts.map(text => memoKey(model, text));
492
+ const missing = new Map();
493
+ for (let index = 0; index < keys.length; index += 1) {
494
+ const key = keys[index];
495
+ const text = texts[index];
496
+ if (key === undefined || text === undefined || vectorMemo.has(key) || missing.has(key))
497
+ continue;
498
+ missing.set(key, text);
499
+ }
500
+ if (missing.size > 0) {
501
+ const missingKeys = [...missing.keys()];
502
+ const vectors = await embedAll(endpoint, apiKey, model, [...missing.values()]);
503
+ for (let index = 0; index < missingKeys.length; index += 1) {
504
+ const key = missingKeys[index];
505
+ const vector = vectors[index];
506
+ if (key === undefined || vector === undefined)
507
+ continue;
508
+ vectorMemo.set(key, vector);
509
+ if (vectorMemo.size > VECTOR_MEMO_LIMIT) {
510
+ const oldest = vectorMemo.keys().next().value;
511
+ if (oldest !== undefined)
512
+ vectorMemo.delete(oldest);
513
+ }
514
+ }
515
+ }
516
+ return keys.map(key => vectorMemo.get(key) ?? []);
517
+ }
518
+ async function corpusTexts(settings, children) {
519
+ const result = await runAgentero(settings.agenteroBin, [
520
+ '--vault', settings.vault,
521
+ '--json',
522
+ 'paper', 'list',
523
+ '--fields', 'abstract,added_at',
524
+ ], CLI_TIMEOUT_MS, children);
525
+ if (result.timedOut || result.spawnError !== undefined || result.code !== 0) {
526
+ throw new Error(cliFailure(result, 'agentero paper list'));
527
+ }
528
+ let body;
529
+ try {
530
+ body = JSON.parse(result.stdout);
531
+ }
532
+ catch {
533
+ throw new Error('agentero paper list returned invalid JSON');
534
+ }
535
+ const root = record(body);
536
+ const data = Array.isArray(root?.data) ? root.data : Array.isArray(root?.items) ? root.items : [];
537
+ const rows = [];
538
+ for (const entry of data) {
539
+ const row = record(entry);
540
+ if (row === undefined)
541
+ continue;
542
+ const title = stringValue(row.title);
543
+ const abstract = stringValue(row.abstract);
544
+ if (title === '' || abstract === '')
545
+ continue;
546
+ rows.push({ addedAt: stringValue(row.added_at), text: embedText(title, abstract) });
547
+ }
548
+ rows.sort((a, b) => b.addedAt.localeCompare(a.addedAt));
549
+ return rows.slice(0, MAX_CORPUS).map(row => row.text);
550
+ }
551
+ function decayWeights(length) {
552
+ const raw = [];
553
+ for (let index = 0; index < length; index += 1)
554
+ raw.push(1 / (1 + Math.log10(index + 2)));
555
+ const total = raw.reduce((sum, value) => sum + value, 0);
556
+ return total > 0 ? raw.map(value => value / total) : raw.map(() => 0);
557
+ }
558
+ async function recommendArxiv(settings, runtime, input, children) {
559
+ if (settings.vault.trim() === '')
560
+ throw new Error('vault is not configured');
561
+ const categories = normalizeCategories(input.categories);
562
+ const resolvedCategories = categories.length > 0 ? categories : [...DEFAULT_CATEGORIES];
563
+ const topN = topNFrom(input.topN);
564
+ const force = input.force === true;
565
+ const cache = await readCache();
566
+ if (runtime.embedding === undefined) {
567
+ const stored = latestSameDay(cache, resolvedCategories);
568
+ if (!force && stored !== undefined)
569
+ return { ...stored, reusedCache: true };
570
+ throw new Error('recommend.no_embedding');
571
+ }
572
+ const key = runKey(runtime.embedding.model, resolvedCategories, topN);
573
+ const cached = cache.runs[key];
574
+ if (!force && cached !== undefined && fresh(cached.result))
575
+ return { ...cached.result, reusedCache: true };
576
+ const corpus = await corpusTexts(settings, children);
577
+ if (corpus.length === 0)
578
+ throw new Error('recommend.empty_corpus');
579
+ const candidates = await fetchCandidates(resolvedCategories);
580
+ if (candidates.length === 0)
581
+ throw new Error('recommend.no_candidates');
582
+ const endpoint = resolveEmbeddingEndpoint(runtime.embedding.baseUrl);
583
+ if (endpoint === '')
584
+ throw new Error('recommend.no_embedding');
585
+ const corpusVectors = (await embedAllMemo(endpoint, runtime.embedding.apiKey, runtime.embedding.model, corpus))
586
+ .map(normalizeVector);
587
+ const candidateVectors = (await embedAllMemo(endpoint, runtime.embedding.apiKey, runtime.embedding.model, candidates.map(candidate => embedText(candidate.title, candidate.abstract)))).map(normalizeVector);
588
+ const weights = decayWeights(corpusVectors.length);
589
+ const items = candidates.map((candidate, index) => {
590
+ const candidateVector = candidateVectors[index] ?? [];
591
+ let score = 0;
592
+ for (let corpusIndex = 0; corpusIndex < corpusVectors.length; corpusIndex += 1) {
593
+ const weight = weights[corpusIndex] ?? 0;
594
+ score += dot(corpusVectors[corpusIndex] ?? [], candidateVector) * weight;
595
+ }
596
+ return { ...candidate, score };
597
+ });
598
+ items.sort((left, right) => right.score - left.score);
599
+ const result = {
600
+ items: items.slice(0, topN),
601
+ computedAt: new Date().toISOString(),
602
+ categories: resolvedCategories,
603
+ corpusSize: corpusVectors.length,
604
+ reusedCache: false,
605
+ };
606
+ cache.runs[key] = { computedAt: result.computedAt, result };
607
+ await writeCache(cache);
608
+ return result;
609
+ }
610
+ async function probeEmbedding(runtime, input) {
611
+ const overrideBase = stringValue(input.baseUrl);
612
+ const overrideModel = stringValue(input.model);
613
+ const rawKey = stringValue(input.apiKey);
614
+ const baseUrl = overrideBase !== '' ? overrideBase : runtime.embedding?.baseUrl ?? '';
615
+ const model = overrideModel !== '' ? overrideModel : runtime.embedding?.model ?? '';
616
+ const apiKey = rawKey !== '' && !maskedSecret(rawKey) ? rawKey : runtime.embedding?.apiKey;
617
+ if (baseUrl === '' || model === '')
618
+ throw new Error('recommend.no_embedding');
619
+ const endpoint = resolveEmbeddingEndpoint(baseUrl);
620
+ const started = Date.now();
621
+ try {
622
+ const vectors = await embedBatch(endpoint, apiKey, model, ['hi']);
623
+ const vector = vectors[0] ?? [];
624
+ if (vector.length === 0)
625
+ throw new Error('response has no embedding');
626
+ return { dim: vector.length, latencyMs: Date.now() - started };
627
+ }
628
+ catch (error) {
629
+ console.warn('[agentero] embedding probe failed:', error);
630
+ throw new Error('recommend.probe_failed');
631
+ }
632
+ }
633
+ function parseZhihuItems(body) {
634
+ const root = record(body);
635
+ const code = typeof root?.Code === 'number' ? root.Code : 0;
636
+ if (code !== 0) {
637
+ const message = stringValue(root?.Message);
638
+ throw new Error(message !== '' ? message : '知乎检索失败');
639
+ }
640
+ const data = record(root?.Data);
641
+ const items = Array.isArray(data?.Items) ? data.Items : [];
642
+ const out = [];
643
+ for (const raw of items) {
644
+ const item = record(raw);
645
+ if (item === undefined)
646
+ continue;
647
+ const title = stringValue(item.Title).replace(/\s*-\s*知乎$/, '');
648
+ const url = stringValue(item.Url).split('?')[0] ?? '';
649
+ if (title === '' || url === '')
650
+ continue;
651
+ const score = typeof item.RankingScore === 'number' && Number.isFinite(item.RankingScore) ? item.RankingScore : 0;
652
+ if (score < ZHIHU_SCORE_FLOOR)
653
+ continue;
654
+ const contentType = stringValue(item.ContentType).toLowerCase();
655
+ out.push({
656
+ kind: contentType === 'answer' ? 'answer' : 'article',
657
+ title,
658
+ url,
659
+ excerpt: [...stringValue(item.ContentText)].slice(0, 160).join(''),
660
+ author: stringValue(item.AuthorName),
661
+ votes: typeof item.VoteUpCount === 'number' && Number.isFinite(item.VoteUpCount) ? item.VoteUpCount : 0,
662
+ score,
663
+ });
664
+ }
665
+ return out;
666
+ }
667
+ async function searchZhihu(runtime, query, count) {
668
+ if (runtime.zhihuSecret === undefined)
669
+ throw new Error('zhihu.no_secret');
670
+ const trimmed = query.trim();
671
+ if (trimmed === '')
672
+ throw new Error('zhihu.empty_query');
673
+ const url = new URL(`${ZHIHU_BASE}/api/v1/content/zhihu_search`);
674
+ url.searchParams.set('Query', trimmed);
675
+ url.searchParams.set('Count', String(Math.min(10, Math.max(1, count))));
676
+ let response;
677
+ try {
678
+ response = await fetch(url, {
679
+ headers: {
680
+ authorization: `Bearer ${runtime.zhihuSecret}`,
681
+ 'x-request-timestamp': String(Math.floor(Date.now() / 1000)),
682
+ },
683
+ signal: AbortSignal.timeout(ZHIHU_TIMEOUT_MS),
684
+ });
685
+ }
686
+ catch (error) {
687
+ throw new Error(`知乎检索请求失败:${error instanceof Error ? error.message : String(error)}`);
688
+ }
689
+ const text = await response.text();
690
+ if (!response.ok)
691
+ throw new Error(`知乎检索 HTTP ${response.status}: ${text.slice(0, 300)}`);
692
+ let body;
693
+ try {
694
+ body = JSON.parse(text);
695
+ }
696
+ catch {
697
+ throw new Error('知乎检索响应解析失败');
698
+ }
699
+ return parseZhihuItems(body);
700
+ }
701
+ function sendJson(res, status, payload) {
702
+ res.statusCode = status;
703
+ res.setHeader('content-type', 'application/json');
704
+ res.setHeader('cache-control', 'no-store');
705
+ res.end(JSON.stringify(payload));
706
+ }
707
+ function readJsonBody(req) {
708
+ const declared = req.headers['content-length'];
709
+ if (declared !== undefined) {
710
+ const length = Number(declared);
711
+ if (!Number.isFinite(length) || length > BODY_LIMIT)
712
+ return Promise.resolve(undefined);
713
+ }
714
+ return new Promise((resolve, reject) => {
715
+ const chunks = [];
716
+ let size = 0;
717
+ let settled = false;
718
+ const cleanup = () => {
719
+ req.off('data', onData);
720
+ req.off('end', onEnd);
721
+ req.off('error', onError);
722
+ };
723
+ const finish = (value, error) => {
724
+ if (settled)
725
+ return;
726
+ settled = true;
727
+ cleanup();
728
+ if (error !== undefined)
729
+ reject(error instanceof Error ? error : new Error(String(error)));
730
+ else
731
+ resolve(value);
732
+ };
733
+ const onData = (chunk) => {
734
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
735
+ size += buffer.length;
736
+ if (size > BODY_LIMIT) {
737
+ finish(undefined);
738
+ return;
739
+ }
740
+ chunks.push(buffer);
741
+ };
742
+ const onEnd = () => {
743
+ const text = Buffer.concat(chunks).toString('utf8').trim();
744
+ if (text === '') {
745
+ finish({});
746
+ return;
747
+ }
748
+ try {
749
+ finish(record(JSON.parse(text)));
750
+ }
751
+ catch {
752
+ finish(undefined);
753
+ }
754
+ };
755
+ const onError = (error) => { finish(undefined, error); };
756
+ req.on('data', onData);
757
+ req.on('end', onEnd);
758
+ req.on('error', onError);
759
+ });
760
+ }
761
+ function statusFor(error) {
762
+ const message = error.message;
763
+ if (message === 'invalid body')
764
+ return 400;
765
+ if (message === 'vault is not configured')
766
+ return 503;
767
+ return 502;
768
+ }
769
+ /**
770
+ * Handle `/agentero/api/recommend/*`. Returns false when the path is outside
771
+ * this prefix so the caller can continue routing.
772
+ */
773
+ export async function handleRecommendRequest(settings, req, res, children) {
774
+ let url;
775
+ try {
776
+ url = new URL(req.url ?? '/', 'http://127.0.0.1');
777
+ }
778
+ catch {
779
+ return false;
780
+ }
781
+ if (url.pathname !== PREFIX && !url.pathname.startsWith(`${PREFIX}/`))
782
+ return false;
783
+ const route = url.pathname.slice(PREFIX.length) || '/';
784
+ try {
785
+ if (route === '/settings') {
786
+ if (req.method === 'GET') {
787
+ sendJson(res, 200, settingsView(settings, await resolveRuntime(settings)));
788
+ return true;
789
+ }
790
+ if (req.method === 'PUT') {
791
+ const body = await readJsonBody(req);
792
+ if (body === undefined) {
793
+ sendJson(res, 400, { error: 'invalid body' });
794
+ return true;
795
+ }
796
+ await saveOverrides(body);
797
+ sendJson(res, 200, settingsView(settings, await resolveRuntime(settings)));
798
+ return true;
799
+ }
800
+ res.statusCode = 405;
801
+ res.setHeader('allow', 'GET, PUT');
802
+ res.end();
803
+ return true;
804
+ }
805
+ if (route === '/probe') {
806
+ if (req.method !== 'POST') {
807
+ res.statusCode = 405;
808
+ res.setHeader('allow', 'POST');
809
+ res.end();
810
+ return true;
811
+ }
812
+ const body = await readJsonBody(req);
813
+ if (body === undefined) {
814
+ sendJson(res, 400, { error: 'invalid body' });
815
+ return true;
816
+ }
817
+ sendJson(res, 200, await probeEmbedding(await resolveRuntime(settings), body));
818
+ return true;
819
+ }
820
+ if (route === '/arxiv') {
821
+ if (req.method === 'GET') {
822
+ const requested = normalizeCategories(url.searchParams.getAll('categories').flatMap(value => value.split(',')));
823
+ const cache = await readCache();
824
+ sendJson(res, 200, { result: latestSameDay(cache, requested) ?? null });
825
+ return true;
826
+ }
827
+ if (req.method === 'POST') {
828
+ const body = await readJsonBody(req);
829
+ if (body === undefined) {
830
+ sendJson(res, 400, { error: 'invalid body' });
831
+ return true;
832
+ }
833
+ sendJson(res, 200, await recommendArxiv(settings, await resolveRuntime(settings), body, children));
834
+ return true;
835
+ }
836
+ res.statusCode = 405;
837
+ res.setHeader('allow', 'GET, POST');
838
+ res.end();
839
+ return true;
840
+ }
841
+ if (route === '/zhihu') {
842
+ if (req.method !== 'GET') {
843
+ res.statusCode = 405;
844
+ res.setHeader('allow', 'GET');
845
+ res.end();
846
+ return true;
847
+ }
848
+ const query = stringValue(url.searchParams.get('query'));
849
+ const countRaw = Number(url.searchParams.get('count') ?? '10');
850
+ const count = Number.isFinite(countRaw) ? countRaw : 10;
851
+ sendJson(res, 200, { items: await searchZhihu(await resolveRuntime(settings), query, count) });
852
+ return true;
853
+ }
854
+ sendJson(res, 404, { error: 'not found' });
855
+ return true;
856
+ }
857
+ catch (error) {
858
+ const failure = error instanceof Error ? error : new Error(String(error));
859
+ if (!res.headersSent)
860
+ sendJson(res, statusFor(failure), { error: failure.message });
861
+ return true;
862
+ }
863
+ }
864
+ //# sourceMappingURL=recommend.js.map