@qvac/harness 0.2.10 → 0.3.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 (41) hide show
  1. package/dist/harness.bundle +13 -11
  2. package/dist-lib/index.d.ts +26 -1
  3. package/dist-lib/index.js +94 -3
  4. package/dist-lib/lib/capabilities/completion.d.ts +2 -0
  5. package/dist-lib/lib/capabilities/detect-language.d.ts +11 -0
  6. package/dist-lib/lib/capabilities/detect-language.js +13 -0
  7. package/dist-lib/lib/capabilities/enhance.d.ts +10 -0
  8. package/dist-lib/lib/capabilities/enhance.js +29 -0
  9. package/dist-lib/lib/capabilities/invoke.d.ts +24 -0
  10. package/dist-lib/lib/capabilities/invoke.js +57 -0
  11. package/dist-lib/lib/connect.d.ts +4 -1
  12. package/dist-lib/lib/connect.js +50 -21
  13. package/dist-lib/lib/contract-types.d.ts +21 -0
  14. package/dist-lib/lib/helpers/clean-answer.d.ts +1 -0
  15. package/dist-lib/lib/helpers/clean-answer.js +16 -0
  16. package/dist-lib/lib/helpers/load-stall.d.ts +6 -0
  17. package/dist-lib/lib/helpers/load-stall.js +24 -0
  18. package/dist-lib/lib/hooks/built-in/sandbox.js +3 -1
  19. package/dist-lib/lib/models/model-pool.d.ts +6 -0
  20. package/dist-lib/lib/models/model-pool.js +12 -0
  21. package/dist-lib/lib/models/nmt-catalog.d.ts +34 -0
  22. package/dist-lib/lib/models/nmt-catalog.js +166 -0
  23. package/dist-lib/lib/serve.js +33 -4
  24. package/dist-lib/lib/tools/generate-image.js +2 -25
  25. package/dist-lib/lib/tools/http-request.js +1 -1
  26. package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
  27. package/dist-lib/lib/tools/skills/manifest-parse.js +92 -1
  28. package/dist-lib/lib/tools/skills/manifest.d.ts +12 -0
  29. package/dist-lib/lib/tools/translate.d.ts +78 -0
  30. package/dist-lib/lib/tools/translate.js +228 -0
  31. package/dist-lib/lib/tools/web-fetch.js +12 -2
  32. package/dist-lib/schema/harness.js +53 -3
  33. package/dist-lib/spec/bind.js +3 -0
  34. package/dist-lib/spec/calls.js +1 -0
  35. package/dist-lib/spec/hrpc/hrpc.json +13 -0
  36. package/dist-lib/spec/hrpc/index.js +15 -3
  37. package/dist-lib/spec/hrpc/messages.js +206 -10
  38. package/dist-lib/spec/hyperschema/index.js +206 -10
  39. package/dist-lib/spec/hyperschema/schema.json +163 -1
  40. package/dist-lib/spec/hyperschema/types.d.ts +43 -2
  41. package/package.json +8 -2
@@ -0,0 +1,228 @@
1
+ import ScopeLock from 'scope-lock';
2
+ import { getLangName } from '@qvac/langdetect-text';
3
+ import { detectLanguage, UNDETERMINED } from '../capabilities/detect-language.js';
4
+ import { AbortedError, raceAbort } from '../helpers/race-abort.js';
5
+ import { ENGLISH, NMT_FROM_ENGLISH, NMT_TO_ENGLISH, languageLabel, SLM_LANGUAGE_CODES, sameLanguage, translationPlanFor } from '../models/nmt-catalog.js';
6
+ import { createLoadStall } from '../helpers/load-stall.js';
7
+ import { createLoadTracker } from '../models/model-pool.js';
8
+ import { resolveModelSrc } from '../models/model-source.js';
9
+ export const TRANSLATION_LOAD_STALL_MS = 15 * 60_000;
10
+ export const TRANSLATION_LOAD_STALL_ERROR = 'translation model download stalled';
11
+ function packName(plan) {
12
+ const pair = plan.model.match(/model\.([a-z]+)\.intgemm/)?.[1];
13
+ if (pair)
14
+ return `Bergamot ${pair}`;
15
+ return plan.model.includes('/en-xx/') ? 'EuroNano en-xx' : 'EuroNano xx-en';
16
+ }
17
+ const fingerprintOf = (plan) => `${plan.model}|${plan.pivot ?? ''}`;
18
+ function bergamotConfig(plan) {
19
+ const config = { engine: 'Bergamot', from: plan.from, to: plan.to };
20
+ if (plan.srcVocab)
21
+ config.srcVocabSrc = plan.srcVocab;
22
+ if (plan.dstVocab)
23
+ config.dstVocabSrc = plan.dstVocab;
24
+ return config;
25
+ }
26
+ export function createTranslationRuntime({ loadSdk, loads = createLoadTracker(), onProgress, stallMs = TRANSLATION_LOAD_STALL_MS }) {
27
+ let loading = null;
28
+ let residentFingerprint = null;
29
+ const lock = new ScopeLock();
30
+ const turn = new ScopeLock();
31
+ const loadPack = async (sdk, plan) => {
32
+ const modelSrc = await resolveModelSrc(sdk, plan.model, 'nmt');
33
+ const modelConfig = bergamotConfig(plan);
34
+ if (plan.pivot) {
35
+ modelConfig.pivotModel = { modelSrc: await resolveModelSrc(sdk, plan.pivot, 'nmt') };
36
+ }
37
+ const name = packName(plan);
38
+ const stall = createLoadStall(stallMs, TRANSLATION_LOAD_STALL_ERROR);
39
+ onProgress?.(name, {});
40
+ const pending = sdk.loadModel({
41
+ modelSrc,
42
+ modelType: 'nmtcpp-translation',
43
+ modelConfig,
44
+ onProgress: (progress) => {
45
+ stall.bump();
46
+ onProgress?.(name, progress);
47
+ }
48
+ });
49
+ try {
50
+ const modelId = await Promise.race([loads.track(pending), stall.promise]);
51
+ return { modelId };
52
+ }
53
+ catch (err) {
54
+ if (err instanceof Error &&
55
+ err.message === TRANSLATION_LOAD_STALL_ERROR &&
56
+ pending.requestId) {
57
+ void sdk.cancel?.({ requestId: pending.requestId }).catch(() => { });
58
+ }
59
+ throw err;
60
+ }
61
+ finally {
62
+ stall.clear();
63
+ }
64
+ };
65
+ const doUnload = async () => {
66
+ const pending = loading;
67
+ if (!pending) {
68
+ residentFingerprint = null;
69
+ return;
70
+ }
71
+ const loaded = await pending.catch(() => null);
72
+ if (!loaded) {
73
+ loading = null;
74
+ residentFingerprint = null;
75
+ return;
76
+ }
77
+ const sdk = await loadSdk();
78
+ await sdk.unloadModel({ modelId: loaded.modelId });
79
+ loading = null;
80
+ residentFingerprint = null;
81
+ };
82
+ const unload = async () => {
83
+ await lock.lock();
84
+ try {
85
+ await doUnload();
86
+ }
87
+ finally {
88
+ lock.unlock();
89
+ }
90
+ };
91
+ return {
92
+ loadSdk,
93
+ ensureLoaded: async (sdk, plan, signal) => {
94
+ const fingerprint = fingerprintOf(plan);
95
+ await lock.lock();
96
+ try {
97
+ if (loading && residentFingerprint !== fingerprint)
98
+ await doUnload();
99
+ residentFingerprint = fingerprint;
100
+ loading ??= loadPack(sdk, plan).catch((err) => {
101
+ loading = null;
102
+ residentFingerprint = null;
103
+ throw err;
104
+ });
105
+ }
106
+ finally {
107
+ lock.unlock();
108
+ }
109
+ return raceAbort(loading, signal);
110
+ },
111
+ unload,
112
+ enter: async (signal) => {
113
+ const pending = turn.lock();
114
+ try {
115
+ await raceAbort(pending, signal);
116
+ }
117
+ catch (err) {
118
+ void pending.then(() => turn.unlock());
119
+ throw err;
120
+ }
121
+ },
122
+ exit: () => turn.unlock()
123
+ };
124
+ }
125
+ export function stripLanguageLabel(text, ...codes) {
126
+ const at = text.indexOf(':');
127
+ if (at === -1)
128
+ return text;
129
+ const head = text.slice(0, at).trim();
130
+ const labels = codes.map((code) => languageLabel(code) ?? getLangName(code));
131
+ return labels.includes(head) ? text.slice(at + 1).trim() : text;
132
+ }
133
+ export function parseTranslateArgs(args) {
134
+ const text = typeof args.text === 'string' ? args.text.trim() : '';
135
+ if (!text)
136
+ return { error: 'text must be a non-empty string' };
137
+ const to = typeof args.to === 'string' ? args.to.trim() : '';
138
+ if (!to)
139
+ return { error: 'to must be a language code' };
140
+ const from = typeof args.from === 'string' ? args.from.trim() : undefined;
141
+ return { text, to, from };
142
+ }
143
+ export function sourceLanguage(text, from) {
144
+ if (from)
145
+ return from;
146
+ const [{ code }] = detectLanguage({ text });
147
+ if (code === UNDETERMINED)
148
+ return { error: NEEDS_SOURCE };
149
+ return code;
150
+ }
151
+ const NEEDS_SOURCE = 'could not detect the source language; pass `from` explicitly';
152
+ export function createTranslateTool(runtime, { slm } = {}) {
153
+ return {
154
+ name: 'translate',
155
+ toolset: null,
156
+ description: 'Translate text between languages using the local neural translation models. ' +
157
+ `\`to\` and \`from\` are ISO 639-1 codes. Translating into ${ENGLISH} supports ` +
158
+ `${NMT_TO_ENGLISH.length} languages and out of it ${NMT_FROM_ENGLISH.length}; a pair ` +
159
+ 'with English on neither side is translated through English, except between the ' +
160
+ 'languages below. ' +
161
+ `${SLM_LANGUAGE_CODES.length} further languages, mostly African, may also be named by ` +
162
+ 'their FLORES-200 code and always need an explicit `from`, which the detector cannot ' +
163
+ 'name. Elsewhere `from` is detected from the text when omitted; pass it when known.',
164
+ parameters: {
165
+ type: 'object',
166
+ properties: {
167
+ text: { type: 'string', description: 'The text to translate' },
168
+ to: {
169
+ type: 'string',
170
+ description: 'Target language as an ISO 639-1 code, e.g. `es`, or a FLORES-200 code'
171
+ },
172
+ from: {
173
+ type: 'string',
174
+ description: 'Source language as an ISO 639-1 or FLORES-200 code; detected from the text when omitted'
175
+ }
176
+ },
177
+ required: ['text', 'to']
178
+ },
179
+ effect: () => 'read',
180
+ resource: (args) => ({ kind: 'translation', label: String(args.to ?? '') }),
181
+ execute: async (args, ctx) => {
182
+ const parsed = parseTranslateArgs(args);
183
+ if ('error' in parsed)
184
+ return parsed;
185
+ const from = sourceLanguage(parsed.text, parsed.from);
186
+ if (typeof from === 'object')
187
+ return from;
188
+ if (sameLanguage(from, parsed.to)) {
189
+ return { text: parsed.text, from, to: parsed.to, engine: 'none' };
190
+ }
191
+ const plan = translationPlanFor(from, parsed.to);
192
+ if ('error' in plan)
193
+ return plan;
194
+ if (plan.route === 'slm' && !slm) {
195
+ return { error: `this device cannot translate ${from} to ${parsed.to}` };
196
+ }
197
+ let entered = false;
198
+ try {
199
+ await runtime.enter(ctx?.signal);
200
+ entered = true;
201
+ if (plan.route === 'slm') {
202
+ const text = await slm(plan.model, { text: parsed.text, from: plan.from, to: plan.to }, ctx?.signal);
203
+ return {
204
+ text: stripLanguageLabel(text, plan.from, plan.to),
205
+ from,
206
+ to: parsed.to,
207
+ engine: 'slm',
208
+ model: plan.model
209
+ };
210
+ }
211
+ const sdk = await runtime.loadSdk();
212
+ const { modelId } = await runtime.ensureLoaded(sdk, plan, ctx?.signal);
213
+ const source = plan.tag ? `${plan.tag} ${parsed.text}` : parsed.text;
214
+ const text = await raceAbort(sdk.translate({ modelId, text: source, stream: false, modelType: 'nmt' }).text, ctx?.signal);
215
+ return { text, from, to: parsed.to, engine: 'nmt', pivoted: Boolean(plan.pivot) };
216
+ }
217
+ catch (err) {
218
+ if (err instanceof AbortedError)
219
+ return { error: 'translation canceled' };
220
+ throw err;
221
+ }
222
+ finally {
223
+ if (entered)
224
+ runtime.exit();
225
+ }
226
+ }
227
+ };
228
+ }
@@ -28,7 +28,11 @@ export function createWebFetchTool({ fetch: fetchImpl, resolveHost = lookupHost
28
28
  toolset: 'web-search',
29
29
  description: 'Fetch a public web page and return its readable text. ' +
30
30
  'Use this to read the full content behind a web_search result URL, or a page the user ' +
31
- 'shares, when a search snippet is not enough to answer.',
31
+ 'shares, when a search snippet is not enough to answer. ' +
32
+ 'When the URL serves a file rather than a page — PDF, .docx, .xlsx, .pptx, an image, any ' +
33
+ 'binary — this is also the tool that downloads it: the bytes become a chat attachment and ' +
34
+ 'the result carries its attachmentId. It is the only way to get a remote file into the ' +
35
+ 'chat; exec has no network and http_request truncates binary bodies.',
32
36
  parameters: {
33
37
  type: 'object',
34
38
  properties: {
@@ -178,8 +182,14 @@ async function attachDocument(response, url, mimeType, attach) {
178
182
  raw: !mime.startsWith('image/')
179
183
  });
180
184
  return {
185
+ attachmentId: record.id,
186
+ fileName: record.fileName,
187
+ byteLength: record.byteLength,
181
188
  content: `Attached the file "${record.fileName}" (${Math.round(record.byteLength / 1024)}KB, ${mime}) from ${url.toString()} — ` +
182
- 'the user can open or save it. Its contents were NOT read: do not claim to know what is inside.'
189
+ 'the user can open or save it. Its contents were NOT read, so do not describe what is inside ' +
190
+ 'yet. To read it, stage it with exec as ' +
191
+ `inputs [{ "attachmentId": "${record.id}", "path": "${record.fileName}" }] — never ask the ` +
192
+ 'user to attach a file this call already attached.'
183
193
  };
184
194
  }
185
195
  const MIME_EXTENSIONS = {
@@ -69,7 +69,8 @@ export function registerHarnessTypes(ns) {
69
69
  'fetch-result',
70
70
  'rag-result',
71
71
  'question-result',
72
- 'drive-result'
72
+ 'drive-result',
73
+ 'invoke'
73
74
  ],
74
75
  strings: true
75
76
  });
@@ -109,7 +110,10 @@ export function registerHarnessTypes(ns) {
109
110
  { name: 'grantedDrives', type: '@harness/granted-drive', array: true },
110
111
  { name: 'payload', type: 'json' },
111
112
  { name: 'grantedWriteDirs', type: 'string', array: true },
112
- { name: 'captureInferenceCalls', type: 'bool' }
113
+ { name: 'captureInferenceCalls', type: 'bool' },
114
+ { name: 'capability', type: 'string' },
115
+ { name: 'arguments', type: 'json' },
116
+ { name: 'enhance', type: 'string' }
113
117
  ]
114
118
  });
115
119
  ns.register({
@@ -459,6 +463,25 @@ export function registerHarnessTypes(ns) {
459
463
  name: 'transcribe-response',
460
464
  fields: [{ name: 'text', type: 'string', required: true }]
461
465
  });
466
+ ns.register({
467
+ name: 'detect-language-request',
468
+ fields: [
469
+ { name: 'text', type: 'string', required: true },
470
+ { name: 'topK', type: 'uint' }
471
+ ]
472
+ });
473
+ ns.register({
474
+ name: 'detected-language',
475
+ fields: [
476
+ { name: 'code', type: 'string', required: true },
477
+ { name: 'language', type: 'string', required: true },
478
+ { name: 'probability', type: 'float64' }
479
+ ]
480
+ });
481
+ ns.register({
482
+ name: 'detect-language-response',
483
+ fields: [{ name: 'languages', type: '@harness/detected-language', array: true, required: true }]
484
+ });
462
485
  ns.register({
463
486
  name: 'speak-request',
464
487
  fields: [
@@ -507,7 +530,8 @@ export function registerHarnessTypes(ns) {
507
530
  { name: 'steps', type: 'string', array: true },
508
531
  { name: 'configSteps', type: 'string', array: true },
509
532
  { name: 'fields', type: '@harness/skill-setup-field', array: true },
510
- { name: 'satisfied', type: 'bool' }
533
+ { name: 'satisfied', type: 'bool' },
534
+ { name: 'oauth', type: '@harness/skill-oauth-config' }
511
535
  ]
512
536
  });
513
537
  ns.register({
@@ -614,6 +638,27 @@ export function registerHarnessTypes(ns) {
614
638
  name: 'kv-cache-delete-request',
615
639
  fields: [{ name: 'kvCacheKey', type: 'string', required: true }]
616
640
  });
641
+ ns.register({
642
+ name: 'skill-oauth-param',
643
+ fields: [
644
+ { name: 'key', type: 'string', required: true },
645
+ { name: 'value', type: 'string', required: true }
646
+ ]
647
+ });
648
+ ns.register({
649
+ name: 'skill-oauth-config',
650
+ fields: [
651
+ { name: 'stateKey', type: 'string', required: true },
652
+ { name: 'authUrl', type: 'string', required: true },
653
+ { name: 'tokenUrl', type: 'string', required: true },
654
+ { name: 'tokenAuth', type: 'string', required: true },
655
+ { name: 'scopes', type: 'string', array: true },
656
+ { name: 'extraAuthParams', type: '@harness/skill-oauth-param', array: true },
657
+ { name: 'registrationEndpoint', type: 'string' },
658
+ { name: 'port', type: 'uint' },
659
+ { name: 'sharedIosSession', type: 'bool' }
660
+ ]
661
+ });
617
662
  }
618
663
  export function registerHarnessApi(api) {
619
664
  api.register({
@@ -756,4 +801,9 @@ export function registerHarnessApi(api) {
756
801
  request: { name: '@harness/kv-cache-delete-request', stream: false },
757
802
  response: { name: '@harness/kv-cache-deletion-response', stream: false }
758
803
  });
804
+ api.register({
805
+ name: 'detect-language',
806
+ request: { name: '@harness/detect-language-request', stream: false },
807
+ response: { name: '@harness/detect-language-response', stream: false }
808
+ });
759
809
  }
@@ -124,4 +124,7 @@ export function bindApi(rpc, api) {
124
124
  if (api.deleteKvCache) {
125
125
  rpc.onDeleteKvCache((req) => api.deleteKvCache(req));
126
126
  }
127
+ if (api.detectLanguage) {
128
+ rpc.onDetectLanguage((req) => api.detectLanguage(req));
129
+ }
127
130
  }
@@ -38,5 +38,6 @@ export function harnessClient(rpc) {
38
38
  copySkillForEdit: (input) => rpc.copySkillForEdit(input),
39
39
  deleteOverlaySkill: (input) => rpc.deleteOverlaySkill(input),
40
40
  deleteKvCache: (input) => rpc.deleteKvCache(input),
41
+ detectLanguage: (input) => rpc.detectLanguage(input),
41
42
  };
42
43
  }
@@ -364,6 +364,19 @@
364
364
  "stream": false
365
365
  },
366
366
  "version": 1
367
+ },
368
+ {
369
+ "id": 28,
370
+ "name": "@harness/detect-language",
371
+ "request": {
372
+ "name": "@harness/detect-language-request",
373
+ "stream": false
374
+ },
375
+ "response": {
376
+ "name": "@harness/detect-language-response",
377
+ "stream": false
378
+ },
379
+ "version": 1
367
380
  }
368
381
  ]
369
382
  }
@@ -61,7 +61,9 @@ const methods = new Map([
61
61
  ['@harness/delete-overlay-skill', 26],
62
62
  [26, '@harness/delete-overlay-skill'],
63
63
  ['@harness/delete-kv-cache', 27],
64
- [27, '@harness/delete-kv-cache']
64
+ [27, '@harness/delete-kv-cache'],
65
+ ['@harness/detect-language', 28],
66
+ [28, '@harness/detect-language']
65
67
  ])
66
68
 
67
69
  class HRPC {
@@ -96,7 +98,8 @@ class HRPC {
96
98
  ['@harness/write-skill-file', getEncoding('@harness/write-skill-request')],
97
99
  ['@harness/copy-skill-for-edit', getEncoding('@harness/skill-name-request')],
98
100
  ['@harness/delete-overlay-skill', getEncoding('@harness/skill-name-request')],
99
- ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-delete-request')]
101
+ ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-delete-request')],
102
+ ['@harness/detect-language', getEncoding('@harness/detect-language-request')]
100
103
  ])
101
104
  this._responseEncodings = new Map([
102
105
  ['@harness/completion', getEncoding('@harness/completion-event')],
@@ -126,7 +129,8 @@ class HRPC {
126
129
  ['@harness/write-skill-file', getEncoding('@harness/ok-response')],
127
130
  ['@harness/copy-skill-for-edit', getEncoding('@harness/copy-skill-response')],
128
131
  ['@harness/delete-overlay-skill', getEncoding('@harness/delete-skill-response')],
129
- ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-deletion-response')]
132
+ ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-deletion-response')],
133
+ ['@harness/detect-language', getEncoding('@harness/detect-language-response')]
130
134
  ])
131
135
  this._rpc = new RPC(stream, async (req) => {
132
136
  const command = methods.get(req.command)
@@ -336,6 +340,10 @@ class HRPC {
336
340
  return this._call('@harness/delete-kv-cache', args)
337
341
  }
338
342
 
343
+ async detectLanguage(args) {
344
+ return this._call('@harness/detect-language', args)
345
+ }
346
+
339
347
  onCompletion(responseFn) {
340
348
  this._handlers['@harness/completion'] = responseFn
341
349
  }
@@ -448,6 +456,10 @@ class HRPC {
448
456
  this._handlers['@harness/delete-kv-cache'] = responseFn
449
457
  }
450
458
 
459
+ onDetectLanguage(responseFn) {
460
+ this._handlers['@harness/detect-language'] = responseFn
461
+ }
462
+
451
463
  _requestIsStream(command) {
452
464
  return [
453
465
  '@harness/completion',