@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
package/lib/index.js ADDED
@@ -0,0 +1,1103 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import z from '@deepseek-ai/schemastery';
6
+ import { cliFailure, runAgentero } from './cli-run.js';
7
+ import { changeTag, deletePaper, discoverArxiv, downloadPaper, exportBib, importBib, importId, setRead, } from './catalog.js';
8
+ import { listVaultNotes, locateVaultPath, readVaultFile, within, writeVaultNote } from './notes-files.js';
9
+ import { readMarks, writeMark } from './marks-store.js';
10
+ import { bookmarkNodesFrom, writePdfBookmarks } from './bookmarks-write.js';
11
+ import { locateVaultPdf, readVaultPdf } from './pdf-file.js';
12
+ import { handleRecommendRequest } from './recommend.js';
13
+ import { registerTools } from './tools.js';
14
+ import { createTranslator, translationTarget } from './translate.js';
15
+ import { selectVault, vaultInfo, vaultScope } from './vault.js';
16
+ /** Cordis plugin name. The loader row id matches this name. */
17
+ export const name = 'agentero-host';
18
+ /** HTTP routes are registered on the composition web server. `agents` cites into a session. `tools` exposes the vault to the model. */
19
+ export const inject = ['webServer', 'agents', 'tools', 'settings'];
20
+ export const Config = z.object({
21
+ vault: z.string().default('').volatile(),
22
+ agenteroBin: z.string().default('agentero').volatile(),
23
+ agenteroSettings: z.string().default(''),
24
+ recentVaults: z.array(z.string()).default([]).volatile(),
25
+ translationProvider: z.string().default(''),
26
+ translationModel: z.string().default(''),
27
+ translationSystemPrompt: z.string().default(''),
28
+ translationUserPrompt: z.string().default(''),
29
+ });
30
+ /** Prefix owned by this plugin. Longer paths under it are answered here. */
31
+ const API_PREFIX = '/agentero/api';
32
+ const VAULT_PATH = '/agentero/api/vault';
33
+ const VAULT_PICK_PATH = '/agentero/api/vault/pick';
34
+ /** Paper list route. */
35
+ const PAPERS_PATH = '/agentero/api/papers';
36
+ /** Markdown notes under the configured vault. */
37
+ const NOTES_PATH = '/agentero/api/notes';
38
+ /** Read or write one vault-relative file. Writes stay under `notes/` or one paper `NOTES.md`. */
39
+ const NOTES_FILE_PATH = '/agentero/api/notes/file';
40
+ /** PDF bytes for a paper directory or a `.pdf` path. */
41
+ const PDF_PATH = '/agentero/api/pdf';
42
+ /** Rewrite the outline (bookmarks) of one PDF. */
43
+ const PDF_BOOKMARKS_PATH = '/agentero/api/pdf/bookmarks';
44
+ const PDFIUM_WASM_PATH = '/agentero/api/pdfium.wasm';
45
+ const require = createRequire(import.meta.url);
46
+ /** List or add reading marks through the Agentero CLI. */
47
+ const MARKS_PATH = '/agentero/api/marks';
48
+ /** Serve the PNG crop behind a visual region mark. */
49
+ const MARK_ASSET_PATH = '/agentero/api/marks/asset';
50
+ /** Translate PDF text with a one-shot Harness model call. */
51
+ const TRANSLATE_PATH = '/agentero/api/translate';
52
+ /** Queue a citation into the still-selected dsh session. */
53
+ const CONTEXT_PATH = '/agentero/api/context';
54
+ /** Import one identifier, or BibTeX on the `/bib` path. */
55
+ const IMPORT_PATH = '/agentero/api/import';
56
+ const IMPORT_BIB_PATH = '/agentero/api/import/bib';
57
+ /** BibTeX export of the catalog. */
58
+ const EXPORT_BIB_PATH = '/agentero/api/export/bib';
59
+ /** Add or remove one tag. */
60
+ const TAGS_PATH = '/agentero/api/papers/tags';
61
+ /** Move a paper to the recycle bin. */
62
+ const DELETE_PATH = '/agentero/api/papers/delete';
63
+ /** Set `is_read`. */
64
+ const READ_PATH = '/agentero/api/papers/read';
65
+ /** Download a missing PDF. */
66
+ const DOWNLOAD_PATH = '/agentero/api/papers/download';
67
+ /** Short arXiv ranking for one keyword. */
68
+ const DISCOVER_PATH = '/agentero/api/discover';
69
+ /** Bound for one `paper list` or `mark list` process. A timeout kills the child. */
70
+ const PAPER_LIST_TIMEOUT_MS = 20_000;
71
+ /** `mark add` may run PDFium and a translation request. */
72
+ const MARK_ADD_TIMEOUT_MS = 90_000;
73
+ /** Largest JSON body accepted by `PUT /agentero/api/notes/file`. */
74
+ const NOTE_BODY_LIMIT = 8 * 1024 * 1024;
75
+ /** Read a loader value; a field declared volatile arrives as a live reference. */
76
+ function liveValue(value) {
77
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
78
+ return value.get();
79
+ }
80
+ return value;
81
+ }
82
+ function settingsFrom(config) {
83
+ const raw = config !== null && typeof config === 'object' ? config : {};
84
+ const text = (key, fallback) => {
85
+ const value = liveValue(raw[key]);
86
+ return typeof value === 'string' ? value : fallback;
87
+ };
88
+ const strings = (key) => {
89
+ const value = liveValue(raw[key]);
90
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
91
+ };
92
+ return {
93
+ get vault() { return text('vault', ''); },
94
+ get agenteroBin() {
95
+ const value = text('agenteroBin', 'agentero').trim();
96
+ return value === '' ? 'agentero' : value;
97
+ },
98
+ get agenteroSettings() { return text('agenteroSettings', ''); },
99
+ get recentVaults() { return strings('recentVaults'); },
100
+ get translationProvider() { return text('translationProvider', ''); },
101
+ get translationModel() { return text('translationModel', ''); },
102
+ get translationSystemPrompt() { return text('translationSystemPrompt', ''); },
103
+ get translationUserPrompt() { return text('translationUserPrompt', ''); },
104
+ };
105
+ }
106
+ /** Host header name without the port. Bracketed IPv6 keeps the address inside the brackets. */
107
+ function hostName(header) {
108
+ if (header === undefined)
109
+ return undefined;
110
+ const value = header.trim().toLowerCase();
111
+ if (value === '')
112
+ return undefined;
113
+ if (value.startsWith('[')) {
114
+ const end = value.indexOf(']');
115
+ if (end < 2)
116
+ return undefined;
117
+ return value.slice(1, end);
118
+ }
119
+ if (value === '::1')
120
+ return value;
121
+ const colon = value.lastIndexOf(':');
122
+ if (colon === -1)
123
+ return value;
124
+ if (value.indexOf(':') !== colon)
125
+ return undefined;
126
+ return value.slice(0, colon);
127
+ }
128
+ /** True when the request Host names this machine's loopback. */
129
+ function isLoopbackHost(header) {
130
+ const name = hostName(header);
131
+ return name === '127.0.0.1' || name === 'localhost' || name === '::1';
132
+ }
133
+ function pathnameOf(req) {
134
+ try {
135
+ return new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ function sendJson(res, status, payload) {
142
+ res.statusCode = status;
143
+ res.setHeader('content-type', 'application/json');
144
+ res.setHeader('cache-control', 'no-store');
145
+ res.end(JSON.stringify(payload));
146
+ }
147
+ function sendRawJson(res, body) {
148
+ res.statusCode = 200;
149
+ res.setHeader('content-type', 'application/json');
150
+ res.setHeader('cache-control', 'no-store');
151
+ res.end(body);
152
+ }
153
+ function queryPath(req) {
154
+ try {
155
+ return new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('path');
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function readTextBody(req) {
162
+ const declared = req.headers['content-length'];
163
+ if (declared !== undefined) {
164
+ const length = Number(declared);
165
+ if (!Number.isFinite(length) || length > NOTE_BODY_LIMIT)
166
+ return Promise.resolve(undefined);
167
+ }
168
+ return new Promise((resolve, reject) => {
169
+ const chunks = [];
170
+ let size = 0;
171
+ let settled = false;
172
+ const cleanup = () => {
173
+ req.off('data', onData);
174
+ req.off('end', onEnd);
175
+ req.off('error', onError);
176
+ };
177
+ const finish = (value, error) => {
178
+ if (settled)
179
+ return;
180
+ settled = true;
181
+ cleanup();
182
+ if (error !== undefined)
183
+ reject(error instanceof Error ? error : new Error(String(error)));
184
+ else
185
+ resolve(value);
186
+ };
187
+ const onData = (chunk) => {
188
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
189
+ size += buffer.length;
190
+ if (size > NOTE_BODY_LIMIT) {
191
+ finish(undefined);
192
+ return;
193
+ }
194
+ chunks.push(buffer);
195
+ };
196
+ const onEnd = () => {
197
+ finish(Buffer.concat(chunks).toString('utf8'));
198
+ };
199
+ const onError = (error) => {
200
+ finish(undefined, error);
201
+ };
202
+ req.on('data', onData);
203
+ req.on('end', onEnd);
204
+ req.on('error', onError);
205
+ });
206
+ }
207
+ function textFromBody(raw) {
208
+ try {
209
+ const parsed = JSON.parse(raw);
210
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
211
+ return undefined;
212
+ if (!('text' in parsed) || typeof parsed.text !== 'string')
213
+ return undefined;
214
+ return parsed.text;
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
220
+ function requireVault(settings, res) {
221
+ if (settings.vault !== '')
222
+ return true;
223
+ sendJson(res, 503, { error: 'vault is not configured' });
224
+ return false;
225
+ }
226
+ async function handlePapers(settings, req, res, children) {
227
+ if (req.method !== 'GET') {
228
+ res.statusCode = 405;
229
+ res.setHeader('allow', 'GET');
230
+ res.end();
231
+ return;
232
+ }
233
+ if (!requireVault(settings, res))
234
+ return;
235
+ const result = await runAgentero(settings.agenteroBin, [
236
+ '--vault', settings.vault,
237
+ '--json',
238
+ 'paper', 'list',
239
+ '--fields', 'year,authors,tags,date',
240
+ ], PAPER_LIST_TIMEOUT_MS, children);
241
+ if (result.timedOut || result.spawnError !== undefined || result.code !== 0) {
242
+ sendJson(res, 502, { error: cliFailure(result, 'agentero paper list') });
243
+ return;
244
+ }
245
+ try {
246
+ JSON.parse(result.stdout);
247
+ }
248
+ catch {
249
+ sendJson(res, 502, { error: 'agentero paper list returned invalid JSON' });
250
+ return;
251
+ }
252
+ sendRawJson(res, result.stdout);
253
+ }
254
+ async function handleNotesList(settings, req, res) {
255
+ if (req.method !== 'GET') {
256
+ res.statusCode = 405;
257
+ res.setHeader('allow', 'GET');
258
+ res.end();
259
+ return;
260
+ }
261
+ if (!requireVault(settings, res))
262
+ return;
263
+ const notes = await listVaultNotes(settings.vault);
264
+ sendJson(res, 200, { notes });
265
+ }
266
+ async function handleNotesFile(settings, req, res) {
267
+ if (req.method !== 'GET' && req.method !== 'PUT') {
268
+ res.statusCode = 405;
269
+ res.setHeader('allow', 'GET, PUT');
270
+ res.end();
271
+ return;
272
+ }
273
+ if (!requireVault(settings, res))
274
+ return;
275
+ const rawPath = queryPath(req);
276
+ if (rawPath === null) {
277
+ sendJson(res, 400, { error: 'invalid path' });
278
+ return;
279
+ }
280
+ if (req.method === 'GET') {
281
+ const result = await readVaultFile(settings.vault, rawPath);
282
+ if (result.status === 200) {
283
+ sendJson(res, 200, { path: result.path, text: result.text });
284
+ return;
285
+ }
286
+ sendJson(res, result.status, { error: result.error });
287
+ return;
288
+ }
289
+ const raw = await readTextBody(req);
290
+ if (raw === undefined) {
291
+ sendJson(res, 400, { error: 'invalid body' });
292
+ req.destroy();
293
+ return;
294
+ }
295
+ const text = textFromBody(raw);
296
+ if (text === undefined) {
297
+ sendJson(res, 400, { error: 'invalid body' });
298
+ return;
299
+ }
300
+ const result = await writeVaultNote(settings.vault, rawPath, text);
301
+ if (result.status === 200) {
302
+ sendJson(res, 200, { path: result.path });
303
+ return;
304
+ }
305
+ sendJson(res, result.status, { error: result.error });
306
+ }
307
+ function queryParam(req, name) {
308
+ try {
309
+ return new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(name);
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ }
315
+ function pdfiumWasmFile() {
316
+ return require.resolve('@embedpdf/pdfium/pdfium.wasm');
317
+ }
318
+ /** PDFium wasm for the browser engine. The worker fetches this by absolute URL. */
319
+ function handlePdfiumWasm(req, res) {
320
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
321
+ res.statusCode = 405;
322
+ res.setHeader('allow', 'GET, HEAD');
323
+ res.end();
324
+ return;
325
+ }
326
+ let file;
327
+ try {
328
+ file = pdfiumWasmFile();
329
+ }
330
+ catch {
331
+ sendJson(res, 404, { error: 'pdfium wasm missing' });
332
+ return;
333
+ }
334
+ res.statusCode = 200;
335
+ res.setHeader('content-type', 'application/wasm');
336
+ res.setHeader('cache-control', 'public, max-age=86400');
337
+ if (req.method === 'HEAD') {
338
+ res.end();
339
+ return;
340
+ }
341
+ const stream = createReadStream(file);
342
+ stream.on('error', () => {
343
+ if (!res.headersSent)
344
+ res.statusCode = 404;
345
+ res.end();
346
+ });
347
+ stream.pipe(res);
348
+ }
349
+ async function handlePdf(settings, req, res) {
350
+ if (req.method !== 'GET') {
351
+ res.statusCode = 405;
352
+ res.setHeader('allow', 'GET');
353
+ res.end();
354
+ return;
355
+ }
356
+ if (!requireVault(settings, res))
357
+ return;
358
+ const ref = queryParam(req, 'ref');
359
+ if (ref === null || ref.trim() === '') {
360
+ sendJson(res, 400, { error: 'invalid path' });
361
+ return;
362
+ }
363
+ const result = await readVaultPdf(settings.vault, ref);
364
+ if (result.status !== 200) {
365
+ sendJson(res, result.status, { error: result.error });
366
+ return;
367
+ }
368
+ res.statusCode = 200;
369
+ res.setHeader('content-type', 'application/pdf');
370
+ res.setHeader('cache-control', 'no-store');
371
+ res.end(result.bytes);
372
+ }
373
+ /**
374
+ * Write the outline back into the PDF file.
375
+ * The browser sends the whole tree; the writer keeps original destinations and
376
+ * refuses signed or unsupported documents. The save is atomic (tmp + rename).
377
+ */
378
+ async function handlePdfBookmarks(settings, req, res) {
379
+ if (req.method !== 'POST') {
380
+ res.statusCode = 405;
381
+ res.setHeader('allow', 'POST');
382
+ res.end();
383
+ return;
384
+ }
385
+ if (!requireVault(settings, res))
386
+ return;
387
+ const raw = await readTextBody(req);
388
+ if (raw === undefined) {
389
+ sendJson(res, 400, { error: 'invalid body' });
390
+ req.destroy();
391
+ return;
392
+ }
393
+ let parsed;
394
+ try {
395
+ parsed = JSON.parse(raw);
396
+ }
397
+ catch {
398
+ sendJson(res, 400, { error: 'invalid body' });
399
+ return;
400
+ }
401
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
402
+ sendJson(res, 400, { error: 'invalid body' });
403
+ return;
404
+ }
405
+ const record = parsed;
406
+ const ref = typeof record.ref === 'string' ? record.ref.trim() : '';
407
+ if (ref === '') {
408
+ sendJson(res, 400, { error: 'invalid path' });
409
+ return;
410
+ }
411
+ const nodes = bookmarkNodesFrom(record.nodes);
412
+ if (nodes === undefined) {
413
+ sendJson(res, 400, { error: 'invalid bookmarks' });
414
+ return;
415
+ }
416
+ const located = await locateVaultPdf(settings.vault, ref);
417
+ if (located.status !== 200) {
418
+ sendJson(res, located.status, { error: located.error });
419
+ return;
420
+ }
421
+ let saved;
422
+ try {
423
+ const bytes = await readFile(located.realPath);
424
+ saved = await writePdfBookmarks(bytes, nodes);
425
+ }
426
+ catch (error) {
427
+ const text = error instanceof Error ? error.message : '';
428
+ sendJson(res, 422, { error: text === '' ? 'could not write bookmarks' : text });
429
+ return;
430
+ }
431
+ const temp = `${located.realPath}.${process.pid}.${Date.now()}.tmp`;
432
+ try {
433
+ await writeFile(temp, saved);
434
+ await rename(temp, located.realPath);
435
+ }
436
+ catch {
437
+ await rm(temp, { force: true }).catch(() => undefined);
438
+ sendJson(res, 500, { error: 'could not save bookmarks' });
439
+ return;
440
+ }
441
+ sendJson(res, 200, { ok: true });
442
+ }
443
+ function markRects(value) {
444
+ if (!Array.isArray(value) || value.length === 0)
445
+ return undefined;
446
+ const rects = [];
447
+ for (const item of value) {
448
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
449
+ return undefined;
450
+ const record = item;
451
+ const origin = record.origin !== null && typeof record.origin === 'object' ? record.origin : undefined;
452
+ const size = record.size !== null && typeof record.size === 'object' ? record.size : undefined;
453
+ const x = origin === undefined ? record.x : origin.x;
454
+ const y = origin === undefined ? record.y : origin.y;
455
+ const w = size === undefined ? record.w : size.width;
456
+ const h = size === undefined ? record.h : size.height;
457
+ if (typeof x !== 'number' || typeof y !== 'number')
458
+ return undefined;
459
+ if (typeof w !== 'number' || typeof h !== 'number')
460
+ return undefined;
461
+ if (!Number.isFinite(x) || !Number.isFinite(y))
462
+ return undefined;
463
+ if (!Number.isFinite(w) || !Number.isFinite(h))
464
+ return undefined;
465
+ if (w <= 0 || h <= 0)
466
+ return undefined;
467
+ rects.push({ x, y, w, h });
468
+ }
469
+ return rects;
470
+ }
471
+ function markPageSize(value) {
472
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
473
+ return undefined;
474
+ const record = value;
475
+ const width = record.width;
476
+ const height = record.height;
477
+ if (typeof width !== 'number' || typeof height !== 'number')
478
+ return undefined;
479
+ if (!Number.isFinite(width) || !Number.isFinite(height))
480
+ return undefined;
481
+ if (!(width > 0) || !(height > 0))
482
+ return undefined;
483
+ return { width, height };
484
+ }
485
+ function markAddFrom(raw) {
486
+ try {
487
+ const parsed = JSON.parse(raw);
488
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
489
+ return undefined;
490
+ const record = parsed;
491
+ const ref = typeof record.ref === 'string' ? record.ref.trim() : '';
492
+ const quote = typeof record.quote === 'string' ? record.quote.trim() : '';
493
+ const kind = record.kind;
494
+ const page = record.page;
495
+ if (ref === '' || (quote === '' && kind !== 'visual'))
496
+ return undefined;
497
+ if (kind !== 'highlight' && kind !== 'ask' && kind !== 'translate' && kind !== 'visual')
498
+ return undefined;
499
+ if (typeof page !== 'number' || !Number.isInteger(page) || page < 1)
500
+ return undefined;
501
+ const body = { ref, kind, quote, page };
502
+ if (typeof record.comment === 'string' && record.comment.trim() !== '')
503
+ body.comment = record.comment;
504
+ if (typeof record.question === 'string' && record.question.trim() !== '')
505
+ body.question = record.question;
506
+ if (typeof record.to === 'string' && record.to.trim() !== '')
507
+ body.to = record.to.trim();
508
+ if (typeof record.color === 'string' && record.color.trim() !== '')
509
+ body.color = record.color.trim();
510
+ const asset = record.asset;
511
+ if (asset !== null && typeof asset === 'object' && !Array.isArray(asset)) {
512
+ const item = asset;
513
+ const mediaType = typeof item.mediaType === 'string' ? item.mediaType : '';
514
+ const data = typeof item.data === 'string' ? item.data : '';
515
+ if (mediaType !== '' && data !== '')
516
+ body.asset = { mediaType, data };
517
+ }
518
+ const rects = markRects(record.rects);
519
+ if (rects !== undefined)
520
+ body.rects = rects;
521
+ const pageSize = markPageSize(record.pageSize);
522
+ if (pageSize !== undefined)
523
+ body.pageSize = pageSize;
524
+ return body;
525
+ }
526
+ catch {
527
+ return undefined;
528
+ }
529
+ }
530
+ async function finishCliJson(res, result, label) {
531
+ if (result.timedOut || result.spawnError !== undefined || result.code !== 0) {
532
+ sendJson(res, 502, { error: cliFailure(result, label) });
533
+ return;
534
+ }
535
+ try {
536
+ JSON.parse(result.stdout);
537
+ }
538
+ catch {
539
+ sendJson(res, 502, { error: `${label} returned invalid JSON` });
540
+ return;
541
+ }
542
+ sendRawJson(res, result.stdout);
543
+ }
544
+ async function handleMarks(settings, req, res, children, translate) {
545
+ if (req.method !== 'GET' && req.method !== 'POST') {
546
+ res.statusCode = 405;
547
+ res.setHeader('allow', 'GET, POST');
548
+ res.end();
549
+ return;
550
+ }
551
+ if (!requireVault(settings, res))
552
+ return;
553
+ if (req.method === 'GET') {
554
+ const ref = queryParam(req, 'ref');
555
+ if (ref === null || ref.trim() === '') {
556
+ sendJson(res, 400, { error: 'invalid path' });
557
+ return;
558
+ }
559
+ const result = await readMarks(settings.vault, ref.trim());
560
+ if (!result.ok) {
561
+ sendJson(res, result.status, { error: result.error });
562
+ return;
563
+ }
564
+ sendJson(res, 200, { paperPath: result.paperPath, marks: result.marks });
565
+ return;
566
+ }
567
+ const raw = await readTextBody(req);
568
+ if (raw === undefined) {
569
+ sendJson(res, 400, { error: 'invalid body' });
570
+ req.destroy();
571
+ return;
572
+ }
573
+ const body = markAddFrom(raw);
574
+ if (body === undefined) {
575
+ sendJson(res, 400, { error: 'invalid body' });
576
+ return;
577
+ }
578
+ if (body.kind === 'translate' && (body.rects === undefined || body.pageSize === undefined || body.quote.length > 20_000)) {
579
+ sendJson(res, 400, { error: 'translation requires selection rectangles, page size, and at most 20000 characters' });
580
+ return;
581
+ }
582
+ // Browser selections already contain exact geometry; do not re-locate through the CLI.
583
+ if (body.rects !== undefined && body.pageSize !== undefined) {
584
+ const result = body.kind === 'translate'
585
+ ? await translateForResponse(translate, body.quote, translationTarget(body.to), res)
586
+ : undefined;
587
+ const written = await writeMark(settings.vault, {
588
+ ref: body.ref,
589
+ kind: body.kind,
590
+ quote: body.quote,
591
+ page: body.page,
592
+ rects: body.rects,
593
+ pageSize: body.pageSize,
594
+ ...(result === undefined ? {} : { result }),
595
+ ...(body.color === undefined ? {} : { color: body.color }),
596
+ ...(body.comment === undefined ? {} : { comment: body.comment }),
597
+ ...(body.question === undefined ? {} : { question: body.question }),
598
+ ...(body.asset === undefined ? {} : { asset: body.asset }),
599
+ });
600
+ if (!written.ok) {
601
+ sendJson(res, written.status, { error: written.error });
602
+ return;
603
+ }
604
+ sendJson(res, 200, { ok: true, ids: written.ids, path: written.path, ...(result === undefined ? {} : { marks: [{ result }] }) });
605
+ return;
606
+ }
607
+ const args = [
608
+ '--vault', settings.vault,
609
+ '--json',
610
+ 'mark', 'add', body.ref,
611
+ '--kind', body.kind,
612
+ '--quote', body.quote,
613
+ '--page', String(body.page),
614
+ ];
615
+ if (body.color !== undefined)
616
+ args.push('--mark-color', body.color);
617
+ if (body.comment !== undefined)
618
+ args.push('--comment', body.comment);
619
+ if (body.question !== undefined)
620
+ args.push('--question', body.question);
621
+ if (body.to !== undefined)
622
+ args.push('--to', body.to);
623
+ const result = await runAgentero(settings.agenteroBin, args, MARK_ADD_TIMEOUT_MS, children);
624
+ await finishCliJson(res, result, 'agentero mark add');
625
+ }
626
+ /** Serve one PNG crop stored under `<paper>/marks/assets`. */
627
+ async function handleMarkAsset(settings, req, res) {
628
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
629
+ res.statusCode = 405;
630
+ res.setHeader('allow', 'GET, HEAD');
631
+ res.end();
632
+ return;
633
+ }
634
+ if (!requireVault(settings, res))
635
+ return;
636
+ const rawPath = queryParam(req, 'path');
637
+ const relative = (rawPath ?? '').trim().replaceAll('\\', '/');
638
+ if (!/(^|\/)marks\/assets\/[^/]+\.png$/.test(relative)) {
639
+ sendJson(res, 400, { error: 'invalid path' });
640
+ return;
641
+ }
642
+ const located = await locateVaultPath(settings.vault, relative);
643
+ if (!located.ok) {
644
+ sendJson(res, located.status, { error: located.error });
645
+ return;
646
+ }
647
+ let real;
648
+ try {
649
+ real = await realpath(located.location.lexical);
650
+ }
651
+ catch {
652
+ sendJson(res, 404, { error: 'not found' });
653
+ return;
654
+ }
655
+ if (!within(located.location.root, real, false)) {
656
+ sendJson(res, 400, { error: 'invalid path' });
657
+ return;
658
+ }
659
+ const stat = await lstat(real);
660
+ if (!stat.isFile()) {
661
+ sendJson(res, 400, { error: 'invalid path' });
662
+ return;
663
+ }
664
+ res.statusCode = 200;
665
+ res.setHeader('content-type', 'image/png');
666
+ res.setHeader('cache-control', 'no-store');
667
+ if (req.method === 'HEAD') {
668
+ res.end();
669
+ return;
670
+ }
671
+ res.end(await readFile(real));
672
+ }
673
+ /** Cancel a model call when its HTTP consumer disconnects. */
674
+ async function translateForResponse(translate, text, target, res) {
675
+ const controller = new AbortController();
676
+ const abort = () => { controller.abort(); };
677
+ res.once('close', abort);
678
+ try {
679
+ if (res.destroyed)
680
+ controller.abort();
681
+ return await translate(text, target, controller.signal);
682
+ }
683
+ finally {
684
+ res.off('close', abort);
685
+ }
686
+ }
687
+ /** Translate text directly through the Harness model service. */
688
+ async function handleTranslate(settings, req, res, translate) {
689
+ if (!requirePost(req, res) || !requireVault(settings, res))
690
+ return;
691
+ const body = await readObject(req, res);
692
+ if (body === undefined)
693
+ return;
694
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
695
+ if (text === '' || text.length > 20_000) {
696
+ sendJson(res, 400, { error: 'invalid body' });
697
+ return;
698
+ }
699
+ const to = translationTarget(body.to);
700
+ const translated = await translateForResponse(translate, text, to, res);
701
+ sendJson(res, 200, { text: translated, sourceLang: 'auto', targetLang: to });
702
+ }
703
+ function contextFrom(raw) {
704
+ try {
705
+ const parsed = JSON.parse(raw);
706
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
707
+ return undefined;
708
+ const record = parsed;
709
+ const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : '';
710
+ const text = typeof record.text === 'string' ? record.text.trim() : '';
711
+ if (sessionId === '' || text === '')
712
+ return undefined;
713
+ return { sessionId, text };
714
+ }
715
+ catch {
716
+ return undefined;
717
+ }
718
+ }
719
+ async function handleContext(agents, req, res) {
720
+ if (req.method !== 'POST') {
721
+ res.statusCode = 405;
722
+ res.setHeader('allow', 'POST');
723
+ res.end();
724
+ return;
725
+ }
726
+ const raw = await readTextBody(req);
727
+ if (raw === undefined) {
728
+ sendJson(res, 400, { error: 'invalid body' });
729
+ req.destroy();
730
+ return;
731
+ }
732
+ const body = contextFrom(raw);
733
+ if (body === undefined) {
734
+ sendJson(res, 400, { error: 'invalid body' });
735
+ return;
736
+ }
737
+ const agent = agents.get(body.sessionId);
738
+ if (agent === undefined) {
739
+ sendJson(res, 404, { error: 'session not found' });
740
+ return;
741
+ }
742
+ agent.inject({
743
+ id: randomUUID(),
744
+ role: 'user',
745
+ content: [{ type: 'text', text: body.text }],
746
+ source: { kind: 'user' },
747
+ });
748
+ sendJson(res, 200, { ok: true });
749
+ }
750
+ function objectBody(raw) {
751
+ try {
752
+ const parsed = JSON.parse(raw);
753
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
754
+ return undefined;
755
+ return parsed;
756
+ }
757
+ catch {
758
+ return undefined;
759
+ }
760
+ }
761
+ async function readObject(req, res) {
762
+ const raw = await readTextBody(req);
763
+ if (raw === undefined) {
764
+ sendJson(res, 400, { error: 'invalid body' });
765
+ req.destroy();
766
+ return undefined;
767
+ }
768
+ const body = objectBody(raw);
769
+ if (body === undefined)
770
+ sendJson(res, 400, { error: 'invalid body' });
771
+ return body;
772
+ }
773
+ function requirePost(req, res) {
774
+ if (req.method === 'POST')
775
+ return true;
776
+ res.statusCode = 405;
777
+ res.setHeader('allow', 'POST');
778
+ res.end();
779
+ return false;
780
+ }
781
+ async function sendCatalog(res, result) {
782
+ if (result.status !== 200) {
783
+ sendJson(res, result.status, { error: result.error });
784
+ return;
785
+ }
786
+ sendRawJson(res, result.raw);
787
+ }
788
+ async function handleImport(settings, req, res, children) {
789
+ if (!requirePost(req, res) || !requireVault(settings, res))
790
+ return;
791
+ const body = await readObject(req, res);
792
+ if (body === undefined)
793
+ return;
794
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
795
+ if (text === '') {
796
+ sendJson(res, 400, { error: 'invalid body' });
797
+ return;
798
+ }
799
+ await sendCatalog(res, await importId(settings, text, children));
800
+ }
801
+ async function handleImportBib(settings, req, res, children) {
802
+ if (!requirePost(req, res) || !requireVault(settings, res))
803
+ return;
804
+ const body = await readObject(req, res);
805
+ if (body === undefined)
806
+ return;
807
+ const text = typeof body.text === 'string' ? body.text : '';
808
+ if (text.trim() === '') {
809
+ sendJson(res, 400, { error: 'invalid body' });
810
+ return;
811
+ }
812
+ await sendCatalog(res, await importBib(settings, text, children));
813
+ }
814
+ async function handleExportBib(settings, req, res, children) {
815
+ if (req.method !== 'GET') {
816
+ res.statusCode = 405;
817
+ res.setHeader('allow', 'GET');
818
+ res.end();
819
+ return;
820
+ }
821
+ if (!requireVault(settings, res))
822
+ return;
823
+ await sendCatalog(res, await exportBib(settings, children));
824
+ }
825
+ async function handleTags(settings, req, res, children) {
826
+ if (!requirePost(req, res) || !requireVault(settings, res))
827
+ return;
828
+ const body = await readObject(req, res);
829
+ if (body === undefined)
830
+ return;
831
+ const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
832
+ const tag = typeof body.tag === 'string' ? body.tag.trim() : '';
833
+ const op = body.op;
834
+ if (ref === '' || tag === '' || (op !== 'add' && op !== 'rm')) {
835
+ sendJson(res, 400, { error: 'invalid body' });
836
+ return;
837
+ }
838
+ await sendCatalog(res, await changeTag(settings, ref, op, tag, children));
839
+ }
840
+ async function handleDelete(settings, req, res, children) {
841
+ if (!requirePost(req, res) || !requireVault(settings, res))
842
+ return;
843
+ const body = await readObject(req, res);
844
+ if (body === undefined)
845
+ return;
846
+ const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
847
+ if (ref === '') {
848
+ sendJson(res, 400, { error: 'invalid body' });
849
+ return;
850
+ }
851
+ await sendCatalog(res, await deletePaper(settings, ref, children));
852
+ }
853
+ async function handleReadFlag(settings, req, res, children) {
854
+ if (!requirePost(req, res) || !requireVault(settings, res))
855
+ return;
856
+ const body = await readObject(req, res);
857
+ if (body === undefined)
858
+ return;
859
+ const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
860
+ if (ref === '' || typeof body.read !== 'boolean') {
861
+ sendJson(res, 400, { error: 'invalid body' });
862
+ return;
863
+ }
864
+ await sendCatalog(res, await setRead(settings, ref, body.read, children));
865
+ }
866
+ async function handleDownload(settings, req, res, children) {
867
+ if (!requirePost(req, res) || !requireVault(settings, res))
868
+ return;
869
+ const body = await readObject(req, res);
870
+ if (body === undefined)
871
+ return;
872
+ const ref = typeof body.ref === 'string' ? body.ref.trim() : '';
873
+ if (ref === '') {
874
+ sendJson(res, 400, { error: 'invalid body' });
875
+ return;
876
+ }
877
+ await sendCatalog(res, await downloadPaper(settings, ref, children));
878
+ }
879
+ async function handleDiscover(settings, req, res, children) {
880
+ if (!requirePost(req, res) || !requireVault(settings, res))
881
+ return;
882
+ const body = await readObject(req, res);
883
+ if (body === undefined)
884
+ return;
885
+ const keyword = typeof body.keyword === 'string' ? body.keyword.trim() : '';
886
+ if (keyword === '') {
887
+ sendJson(res, 400, { error: 'invalid body' });
888
+ return;
889
+ }
890
+ await sendCatalog(res, await discoverArxiv(settings, keyword, children));
891
+ }
892
+ async function handleApi(settings, agents, req, res, children, vaultHost, translate) {
893
+ if (!isLoopbackHost(req.headers.host)) {
894
+ res.statusCode = 403;
895
+ res.end();
896
+ return;
897
+ }
898
+ const pathname = pathnameOf(req);
899
+ if (pathname === VAULT_PATH && req.method === 'GET') {
900
+ sendJson(res, 200, vaultInfo(settings, vaultHost.canPick()));
901
+ return;
902
+ }
903
+ const scope = req.headers['x-agentero-vault']
904
+ ?? (pathname === MARK_ASSET_PATH ? new URL(req.url ?? '/', 'http://localhost').searchParams.get('scope') : undefined);
905
+ if (pathname !== PDFIUM_WASM_PATH && scope !== vaultScope(settings.vault)) {
906
+ sendJson(res, 409, { error: 'vault changed' });
907
+ return;
908
+ }
909
+ if (await handleRecommendRequest(settings, req, res, children))
910
+ return;
911
+ if (pathname === VAULT_PATH || pathname === VAULT_PICK_PATH) {
912
+ const expectedMethod = pathname === VAULT_PATH ? 'PUT' : 'POST';
913
+ if (req.method !== expectedMethod) {
914
+ res.statusCode = 405;
915
+ res.setHeader('allow', expectedMethod);
916
+ res.end();
917
+ return;
918
+ }
919
+ // JSON + same-origin prevents a website from opening a host chooser or changing the profile.
920
+ const origin = req.headers.origin;
921
+ if ((origin !== undefined && origin !== `http://${req.headers.host}` && origin !== `https://${req.headers.host}`)
922
+ || !req.headers['content-type']?.toLowerCase().startsWith('application/json')) {
923
+ sendJson(res, 403, { error: 'invalid origin' });
924
+ return;
925
+ }
926
+ if (pathname === VAULT_PICK_PATH) {
927
+ if (!vaultHost.canPick()) {
928
+ sendJson(res, 409, { error: 'directory picker unavailable' });
929
+ return;
930
+ }
931
+ const controller = new AbortController();
932
+ const abort = () => { controller.abort(); };
933
+ res.once('close', abort);
934
+ try {
935
+ sendJson(res, 200, { path: await vaultHost.pick(controller.signal) });
936
+ }
937
+ finally {
938
+ res.off('close', abort);
939
+ }
940
+ return;
941
+ }
942
+ const body = await readObject(req, res);
943
+ if (body === undefined)
944
+ return;
945
+ if (typeof body.path !== 'string') {
946
+ sendJson(res, 400, { error: 'invalid vault' });
947
+ return;
948
+ }
949
+ try {
950
+ sendJson(res, 200, await vaultHost.save(body.path));
951
+ }
952
+ catch (error) {
953
+ if (error instanceof Error && error.message === 'invalid vault')
954
+ sendJson(res, 400, { error: 'invalid vault' });
955
+ else
956
+ throw error;
957
+ }
958
+ return;
959
+ }
960
+ if (pathname === PAPERS_PATH) {
961
+ await handlePapers(settings, req, res, children);
962
+ return;
963
+ }
964
+ if (pathname === NOTES_PATH) {
965
+ await handleNotesList(settings, req, res);
966
+ return;
967
+ }
968
+ if (pathname === NOTES_FILE_PATH) {
969
+ await handleNotesFile(settings, req, res);
970
+ return;
971
+ }
972
+ if (pathname === PDFIUM_WASM_PATH) {
973
+ handlePdfiumWasm(req, res);
974
+ return;
975
+ }
976
+ if (pathname === PDF_PATH) {
977
+ await handlePdf(settings, req, res);
978
+ return;
979
+ }
980
+ if (pathname === PDF_BOOKMARKS_PATH) {
981
+ await handlePdfBookmarks(settings, req, res);
982
+ return;
983
+ }
984
+ if (pathname === MARKS_PATH) {
985
+ await handleMarks(settings, req, res, children, translate);
986
+ return;
987
+ }
988
+ if (pathname === MARK_ASSET_PATH) {
989
+ await handleMarkAsset(settings, req, res);
990
+ return;
991
+ }
992
+ if (pathname === TRANSLATE_PATH) {
993
+ await handleTranslate(settings, req, res, translate);
994
+ return;
995
+ }
996
+ if (pathname === CONTEXT_PATH) {
997
+ await handleContext(agents, req, res);
998
+ return;
999
+ }
1000
+ if (pathname === IMPORT_BIB_PATH) {
1001
+ await handleImportBib(settings, req, res, children);
1002
+ return;
1003
+ }
1004
+ if (pathname === IMPORT_PATH) {
1005
+ await handleImport(settings, req, res, children);
1006
+ return;
1007
+ }
1008
+ if (pathname === EXPORT_BIB_PATH) {
1009
+ await handleExportBib(settings, req, res, children);
1010
+ return;
1011
+ }
1012
+ if (pathname === TAGS_PATH) {
1013
+ await handleTags(settings, req, res, children);
1014
+ return;
1015
+ }
1016
+ if (pathname === DELETE_PATH) {
1017
+ await handleDelete(settings, req, res, children);
1018
+ return;
1019
+ }
1020
+ if (pathname === READ_PATH) {
1021
+ await handleReadFlag(settings, req, res, children);
1022
+ return;
1023
+ }
1024
+ if (pathname === DOWNLOAD_PATH) {
1025
+ await handleDownload(settings, req, res, children);
1026
+ return;
1027
+ }
1028
+ if (pathname === DISCOVER_PATH) {
1029
+ await handleDiscover(settings, req, res, children);
1030
+ return;
1031
+ }
1032
+ res.statusCode = 404;
1033
+ res.end();
1034
+ }
1035
+ /**
1036
+ * Read `vault` and `agenteroBin`. Serve paper list, PDF bytes, marks, and
1037
+ * note files under `/agentero/api`. `POST /agentero/api/context` queues a
1038
+ * citation on the named session without waking its driver.
1039
+ * `vault`, `agenteroBin`, and `recentVaults` are volatile, so the Plugins page
1040
+ * writes them through DSH's settings service while this instance keeps running.
1041
+ * @param ctx - Host plugin context.
1042
+ * @param config - Loader config with live references. Missing fields use the defaults.
1043
+ */
1044
+ export function apply(ctx, config) {
1045
+ const settings = settingsFrom(config);
1046
+ const agents = ctx.agents;
1047
+ const picker = () => ctx.get('directoryPicker')?.capability();
1048
+ const vaultHost = {
1049
+ canPick: () => picker()?.kind === 'native',
1050
+ async pick(signal) {
1051
+ const capability = picker();
1052
+ if (capability?.kind !== 'native' || capability.pick === undefined)
1053
+ throw new Error('directory picker unavailable');
1054
+ return capability.pick(signal);
1055
+ },
1056
+ async save(input) {
1057
+ const canPick = vaultHost.canPick();
1058
+ const next = await selectVault(settings, input, async (value) => {
1059
+ const writer = ctx.get('settings');
1060
+ await writer.mutate('agentero-host', [
1061
+ { op: 'set', path: ['vault'], value: value.vault },
1062
+ { op: 'set', path: ['recentVaults'], value: value.recentVaults },
1063
+ ]);
1064
+ });
1065
+ return vaultInfo(next, canPick);
1066
+ },
1067
+ };
1068
+ ctx.effect(() => {
1069
+ console.log('[agentero] plugin loaded');
1070
+ const children = new Set();
1071
+ const lifetime = new AbortController();
1072
+ const modelTranslate = createTranslator(settings, () => ctx.get('llm'), () => ctx.get('agentDefaultModel'));
1073
+ const translate = (text, target, signal) => modelTranslate(text, target, AbortSignal.any([signal, lifetime.signal]));
1074
+ const route = {
1075
+ kind: 'prefix',
1076
+ path: API_PREFIX,
1077
+ handler: (req, res) => handleApi(settings, agents, req, res, children, vaultHost, translate).catch((error) => {
1078
+ if (res.headersSent)
1079
+ return;
1080
+ const message = error instanceof Error ? error.message : String(error);
1081
+ sendJson(res, 502, { error: message });
1082
+ }),
1083
+ };
1084
+ const disposeTools = registerTools(ctx, settings, children);
1085
+ const dispose = ctx.webServer.register(route);
1086
+ return () => {
1087
+ lifetime.abort();
1088
+ disposeTools();
1089
+ dispose();
1090
+ for (const child of children) {
1091
+ try {
1092
+ child.kill();
1093
+ }
1094
+ catch {
1095
+ // The child already exited.
1096
+ }
1097
+ }
1098
+ children.clear();
1099
+ console.log('[agentero] plugin unloaded');
1100
+ };
1101
+ });
1102
+ }
1103
+ //# sourceMappingURL=index.js.map