@qvac/harness 0.8.9 → 0.8.10

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 (114) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/dist-lib/index.d.ts +1 -1
  3. package/dist-lib/index.js +51 -50
  4. package/dist-lib/lib/audio-transcode-bare.js +2 -2
  5. package/dist-lib/lib/capabilities/completion.d.ts +1 -2
  6. package/dist-lib/lib/capabilities/completion.js +29 -25
  7. package/dist-lib/lib/capabilities/tool-prompt-log.js +3 -3
  8. package/dist-lib/lib/capabilities/voice.js +7 -6
  9. package/dist-lib/lib/connect.js +18 -17
  10. package/dist-lib/lib/errors.d.ts +266 -0
  11. package/dist-lib/lib/errors.js +189 -0
  12. package/dist-lib/lib/helpers/load-stall.d.ts +1 -1
  13. package/dist-lib/lib/helpers/load-stall.js +2 -2
  14. package/dist-lib/lib/helpers/race-abort.d.ts +2 -1
  15. package/dist-lib/lib/helpers/race-abort.js +3 -4
  16. package/dist-lib/lib/hooks/built-in/arg-repair.js +2 -2
  17. package/dist-lib/lib/hooks/pipeline.js +4 -4
  18. package/dist-lib/lib/hooks/run.js +2 -2
  19. package/dist-lib/lib/log.d.ts +26 -2
  20. package/dist-lib/lib/log.js +66 -16
  21. package/dist-lib/lib/models/model-pool.js +11 -6
  22. package/dist-lib/lib/models/model-source.js +3 -2
  23. package/dist-lib/lib/no-redirect-fetch-bare.js +2 -1
  24. package/dist-lib/lib/no-redirect-fetch-impl.js +2 -1
  25. package/dist-lib/lib/office/extract.js +4 -3
  26. package/dist-lib/lib/office/index.js +2 -1
  27. package/dist-lib/lib/resolution-detector.js +3 -3
  28. package/dist-lib/lib/rpc-session.js +2 -1
  29. package/dist-lib/lib/sdk-loader.js +2 -2
  30. package/dist-lib/lib/serve.js +33 -29
  31. package/dist-lib/lib/spawn.d.ts +2 -4
  32. package/dist-lib/lib/spawn.js +14 -7
  33. package/dist-lib/lib/tools/edit-image.js +2 -1
  34. package/dist-lib/lib/tools/exec/adapters/python/attachments.js +8 -8
  35. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +7 -6
  36. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +2 -2
  37. package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
  38. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +8 -8
  39. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +11 -7
  40. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +5 -5
  41. package/dist-lib/lib/tools/exec/adapters/python/worker/active.js +2 -2
  42. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +9 -8
  43. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +2 -0
  44. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.js +4 -4
  45. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.d.ts +2 -1
  46. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.js +2 -1
  47. package/dist-lib/lib/tools/exec/obsidian/note-name.js +2 -1
  48. package/dist-lib/lib/tools/exec/obsidian/safe-path.js +5 -4
  49. package/dist-lib/lib/tools/exec/obsidian/vault-create.js +3 -2
  50. package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +2 -2
  51. package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +3 -2
  52. package/dist-lib/lib/tools/exec/obsidian/vault-read.js +6 -5
  53. package/dist-lib/lib/tools/exec/obsidian/vault-write.js +13 -12
  54. package/dist-lib/lib/tools/exec/runner.js +2 -1
  55. package/dist-lib/lib/tools/files/edit-file.d.ts +3 -0
  56. package/dist-lib/lib/tools/files/edit-file.js +73 -0
  57. package/dist-lib/lib/tools/files/glob.d.ts +3 -0
  58. package/dist-lib/lib/tools/files/glob.js +63 -0
  59. package/dist-lib/lib/tools/files/grep.d.ts +3 -0
  60. package/dist-lib/lib/tools/files/grep.js +114 -0
  61. package/dist-lib/lib/tools/files/index.d.ts +3 -0
  62. package/dist-lib/lib/tools/files/index.js +23 -0
  63. package/dist-lib/lib/tools/files/list-dir.d.ts +5 -0
  64. package/dist-lib/lib/tools/files/list-dir.js +130 -0
  65. package/dist-lib/lib/tools/files/make-dir.d.ts +3 -0
  66. package/dist-lib/lib/tools/files/make-dir.js +47 -0
  67. package/dist-lib/lib/tools/files/read-file.d.ts +3 -0
  68. package/dist-lib/lib/tools/files/read-file.js +103 -0
  69. package/dist-lib/lib/tools/files/transfer.d.ts +4 -0
  70. package/dist-lib/lib/tools/files/transfer.js +82 -0
  71. package/dist-lib/lib/tools/files/trash-file.d.ts +4 -0
  72. package/dist-lib/lib/tools/files/trash-file.js +61 -0
  73. package/dist-lib/lib/tools/files/write-file.d.ts +3 -0
  74. package/dist-lib/lib/tools/files/write-file.js +55 -0
  75. package/dist-lib/lib/tools/fs/document-text.d.ts +2 -0
  76. package/dist-lib/lib/tools/fs/document-text.js +38 -0
  77. package/dist-lib/lib/tools/fs/edit-match.d.ts +11 -0
  78. package/dist-lib/lib/tools/fs/edit-match.js +85 -0
  79. package/dist-lib/lib/tools/fs/remote.d.ts +2 -1
  80. package/dist-lib/lib/tools/fs/remote.js +4 -4
  81. package/dist-lib/lib/tools/fs/scope.d.ts +1 -1
  82. package/dist-lib/lib/tools/fs/scope.js +3 -2
  83. package/dist-lib/lib/tools/fs/targets.d.ts +10 -0
  84. package/dist-lib/lib/tools/fs/targets.js +32 -0
  85. package/dist-lib/lib/tools/fs/tool-path.d.ts +24 -0
  86. package/dist-lib/lib/tools/fs/tool-path.js +58 -0
  87. package/dist-lib/lib/tools/fs/walk.js +3 -3
  88. package/dist-lib/lib/tools/generate-image.d.ts +2 -2
  89. package/dist-lib/lib/tools/generate-image.js +5 -6
  90. package/dist-lib/lib/tools/office.js +2 -1
  91. package/dist-lib/lib/tools/skill.js +2 -1
  92. package/dist-lib/lib/tools/skills/bundled-dir.js +4 -4
  93. package/dist-lib/lib/tools/skills/catalog.js +2 -2
  94. package/dist-lib/lib/tools/skills/credential-health.js +3 -3
  95. package/dist-lib/lib/tools/skills/overlay-crud.js +3 -2
  96. package/dist-lib/lib/tools/translate.d.ts +2 -2
  97. package/dist-lib/lib/tools/translate.js +5 -6
  98. package/dist-lib/lib/tools/weather-lookup/geocode.js +5 -4
  99. package/dist-lib/lib/web-search/engines/bing.js +2 -1
  100. package/dist-lib/lib/web-search/engines/google.js +2 -1
  101. package/dist-lib/lib/web-search/fetch-html.d.ts +5 -5
  102. package/dist-lib/lib/web-search/fetch-html.js +12 -15
  103. package/package.json +66 -24
  104. package/worker/harness-sidecar.mjs +12 -13
  105. package/dist-lib/lib/tools/create-documents.d.ts +0 -3
  106. package/dist-lib/lib/tools/create-documents.js +0 -95
  107. package/dist-lib/lib/tools/edit-documents.d.ts +0 -3
  108. package/dist-lib/lib/tools/edit-documents.js +0 -132
  109. package/dist-lib/lib/tools/file-glob.d.ts +0 -14
  110. package/dist-lib/lib/tools/file-glob.js +0 -118
  111. package/dist-lib/lib/tools/grep.d.ts +0 -5
  112. package/dist-lib/lib/tools/grep.js +0 -184
  113. package/dist-lib/lib/tools/read-files.d.ts +0 -5
  114. package/dist-lib/lib/tools/read-files.js +0 -157
@@ -1,19 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../../errors.js';
3
4
  import { errorMessage } from '../../../../guards.js';
4
- import { hlog } from '../../../../log.js';
5
+ import { log } from '../../../../log.js';
5
6
  import { checkOutputBytes, MAX_INPUT_TOTAL_BYTES, RUN_OUTPUT_BUDGET_BYTES } from './output-policy.js';
6
7
  const STAGE_TIMEOUT_MS = 60_000;
7
- const STAGE_TIMEOUT_MESSAGE = `staging attachments timed out after ${STAGE_TIMEOUT_MS}ms`;
8
8
  const OVER_BUDGET_ERROR = 'inputs exceed maximum total size';
9
9
  const NO_UPLOAD_ERROR = 'no uploaded image in this chat — attach an image or pass an attachmentId';
10
10
  const DOCUMENT_INPUT = /\.(pdf|docx?|xlsx?|xlsm|pptx?|csv|tsv|txt|md|markdown|rtf|odt|ods|odp|epub|jsonl?|xhtml|html?|xml|ya?ml|log|zip)$/i;
11
11
  const NO_DOCUMENT_ERROR = 'an id-less input stages an uploaded image, and this path names a document — pass its ' +
12
12
  'attachmentId, which the [Attached file "…"] line on the user\'s message carries';
13
- function bounded(work, ms, message) {
13
+ function bounded(work, ms) {
14
14
  let timer;
15
15
  const bound = new Promise((_, reject) => {
16
- timer = setTimeout(() => reject(new Error(message)), ms);
16
+ timer = setTimeout(() => reject(new HarnessError('ATTACHMENT_STAGING_TIMED_OUT', [STAGE_TIMEOUT_MS])), ms);
17
17
  });
18
18
  return Promise.race([work, bound]).finally(() => {
19
19
  if (timer) {
@@ -54,14 +54,14 @@ async function probeUpload(fetchAttachment, input, { index, turnImages, signal,
54
54
  if (DOCUMENT_INPUT.test(input.path)) {
55
55
  return { error: NO_DOCUMENT_ERROR };
56
56
  }
57
- const bytes = await bounded(uploadedBytes(fetchAttachment, index, { turnImages, signal }), deadline - Date.now(), STAGE_TIMEOUT_MESSAGE);
57
+ const bytes = await bounded(uploadedBytes(fetchAttachment, index, { turnImages, signal }), deadline - Date.now());
58
58
  if ('error' in bytes) {
59
59
  return bytes;
60
60
  }
61
61
  return { size: bytes.byteLength, bytes };
62
62
  }
63
63
  async function probeAttachment(fetchAttachment, id, { signal, deadline }) {
64
- const head = await bounded(fetchAttachment({ attachmentId: id, meta: true, signal }), deadline - Date.now(), STAGE_TIMEOUT_MESSAGE);
64
+ const head = await bounded(fetchAttachment({ attachmentId: id, meta: true, signal }), deadline - Date.now());
65
65
  if (!head) {
66
66
  return { error: `attachment ${id} not found in this chat` };
67
67
  }
@@ -90,7 +90,7 @@ export async function stageInputs(fetchAttachment, inputs, opts = {}) {
90
90
  }
91
91
  total += probe.size;
92
92
  if (total > MAX_INPUT_TOTAL_BYTES) {
93
- hlog('python: input attachments over budget; skipping blob download', {
93
+ log.warn('python: input attachments over budget; skipping blob download', {
94
94
  total,
95
95
  cap: MAX_INPUT_TOTAL_BYTES,
96
96
  count: inputs.length
@@ -111,7 +111,7 @@ export async function stageInputs(fetchAttachment, inputs, opts = {}) {
111
111
  for (const { index, input } of pending) {
112
112
  const id = input.attachmentId;
113
113
  try {
114
- const fetched = await bounded(fetchAttachment({ attachmentId: id, signal }), deadline - Date.now(), STAGE_TIMEOUT_MESSAGE);
114
+ const fetched = await bounded(fetchAttachment({ attachmentId: id, signal }), deadline - Date.now());
115
115
  if (!fetched || !hasBytes(fetched)) {
116
116
  return { error: `attachment ${id} not found in this chat` };
117
117
  }
@@ -1,6 +1,7 @@
1
1
  import { AbortController } from 'bare-abort-controller';
2
2
  import b4a from 'b4a';
3
3
  import { noRedirectFetch } from '#no-redirect-fetch';
4
+ import { HarnessError } from '../../../../errors.js';
4
5
  import { lookupHost } from '../../../../dns-lookup.js';
5
6
  import { hostIsNamed } from './named-hosts.js';
6
7
  import { checkOutboundHost, resolveOutboundUrl } from '../../../url-guard.js';
@@ -182,18 +183,18 @@ export function createGuardedFetch(options = {}) {
182
183
  try {
183
184
  for (let hops = 0;; hops++) {
184
185
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
185
- throw new TypeError(`fetch refused: unsupported protocol ${url.protocol}`);
186
+ throw new HarnessError('FETCH_REFUSED', [`unsupported protocol ${url.protocol}`]);
186
187
  }
187
188
  const literal = checkOutboundHost(url);
188
189
  if (literal) {
189
- throw new TypeError(`fetch refused: ${literal}`);
190
+ throw new HarnessError('FETCH_REFUSED', [`${literal}`]);
190
191
  }
191
192
  if (!(await mayConnect(url.hostname))) {
192
- throw new TypeError(`fetch refused: ${url.hostname} was not approved for this run`);
193
+ throw new HarnessError('FETCH_REFUSED', [`${url.hostname} was not approved for this run`]);
193
194
  }
194
195
  const check = await resolveOutboundUrl(url, resolveHost, controller.signal);
195
196
  if (check.blocked) {
196
- throw new TypeError(`fetch refused: ${check.blocked}`);
197
+ throw new HarnessError('FETCH_REFUSED', [`${check.blocked}`]);
197
198
  }
198
199
  const response = await send(url, {
199
200
  method,
@@ -213,10 +214,10 @@ export function createGuardedFetch(options = {}) {
213
214
  });
214
215
  }
215
216
  if (init.redirect === 'error') {
216
- throw new TypeError('fetch refused: redirect not allowed');
217
+ throw new HarnessError('FETCH_REFUSED', ['redirect not allowed']);
217
218
  }
218
219
  if (hops >= MAX_REDIRECTS) {
219
- throw new TypeError('fetch refused: too many redirects');
220
+ throw new HarnessError('FETCH_REFUSED', ['too many redirects']);
220
221
  }
221
222
  const next = new URL(location, url);
222
223
  if (next.protocol !== url.protocol || next.host !== url.host) {
@@ -1,7 +1,7 @@
1
1
  import { fileURLToPath } from 'url';
2
2
  import { Worker } from 'worker_threads';
3
3
  import { errorMessage } from '../../../../guards.js';
4
- import { hlog } from '../../../../log.js';
4
+ import { log } from '../../../../log.js';
5
5
  import { DISARMED, FIRED } from './deadline-state.js';
6
6
  export function startInterruptTimer(ms) {
7
7
  const state = new Int32Array(new SharedArrayBuffer(4));
@@ -9,7 +9,7 @@ export function startInterruptTimer(ms) {
9
9
  const workerData = { state: state.buffer, interrupt: interrupt.buffer, ms };
10
10
  const entry = fileURLToPath(import.meta.resolve('#pyodide-interrupt-deadline'));
11
11
  new Worker(entry, { workerData })
12
- .on('error', (error) => hlog('python: interrupt deadline failed', { err: errorMessage(error) }))
12
+ .on('error', (error) => log.warn('python: interrupt deadline failed', { err: errorMessage(error) }))
13
13
  .unref();
14
14
  return {
15
15
  interrupt,
@@ -1,5 +1,5 @@
1
1
  import { errorMessage } from '../../../../guards.js';
2
- import { hlog } from '../../../../log.js';
2
+ import { log } from '../../../../log.js';
3
3
  import { MAX_OUTPUT_FILE_BYTES, MAX_OUTPUT_TOTAL_BYTES } from './output-policy.js';
4
4
  let runCounter = 0;
5
5
  function filePath(dir, name) {
@@ -69,7 +69,7 @@ export function removeScratch(fs, dir, previousCwd) {
69
69
  removeTree(fs, dir);
70
70
  }
71
71
  catch (error) {
72
- hlog('python: scratch cleanup failed', {
72
+ log.warn('python: scratch cleanup failed', {
73
73
  dir,
74
74
  err: errorMessage(error)
75
75
  });
@@ -1,8 +1,9 @@
1
1
  import { AbortController } from 'bare-abort-controller';
2
2
  import ScopeLock from 'scope-lock';
3
+ import { HarnessError } from '../../../../errors.js';
3
4
  import { errorMessage } from '../../../../guards.js';
4
5
  import { AbortedError, raceAbort } from '../../../../helpers/race-abort.js';
5
- import { hlog } from '../../../../log.js';
6
+ import { log } from '../../../../log.js';
6
7
  import { MAX_OUTPUT_CHARS } from '../../runner.js';
7
8
  import { startInterruptTimer } from './interrupt-timer.js';
8
9
  import { pyodideLoadOptions } from './loader.js';
@@ -15,15 +16,15 @@ const cache = new Map();
15
16
  function getPyodide(load) {
16
17
  let loaded = cache.get(load);
17
18
  if (!loaded) {
18
- hlog('python: booting interpreter');
19
+ log.info('python: booting interpreter');
19
20
  loaded = load()
20
21
  .then((py) => {
21
- hlog('python: interpreter ready');
22
+ log.info('python: interpreter ready');
22
23
  return py;
23
24
  })
24
25
  .catch((error) => {
25
26
  cache.delete(load);
26
- hlog('python: interpreter boot failed', {
27
+ log.error('python: interpreter boot failed', {
27
28
  err: errorMessage(error)
28
29
  });
29
30
  throw error;
@@ -35,7 +36,6 @@ function getPyodide(load) {
35
36
  const runLock = new ScopeLock();
36
37
  async function runInProcess(load, spec, options) {
37
38
  const deadline = Date.now() + options.timeoutMs;
38
- const timeoutMessage = `Command timed out after ${options.timeoutMs}ms`;
39
39
  let timedOut = false;
40
40
  const stop = new AbortController();
41
41
  const ask = options.askConnect;
@@ -63,10 +63,10 @@ async function runInProcess(load, spec, options) {
63
63
  }
64
64
  if (outcome.evict) {
65
65
  cache.delete(load);
66
- hlog('python: interpreter evicted; next run boots fresh');
66
+ log.warn('python: interpreter evicted; next run boots fresh');
67
67
  }
68
68
  if (timedOut) {
69
- throw new Error(timeoutMessage);
69
+ throw new HarnessError('COMMAND_TIMED_OUT', [options.timeoutMs]);
70
70
  }
71
71
  return outcome.result;
72
72
  }
@@ -84,7 +84,7 @@ async function runInProcess(load, spec, options) {
84
84
  const timeout = new Promise((_, reject) => {
85
85
  timer = setTimeout(() => {
86
86
  timedOut = true;
87
- reject(new Error(timeoutMessage));
87
+ reject(new HarnessError('COMMAND_TIMED_OUT', [options.timeoutMs]));
88
88
  }, options.timeoutMs);
89
89
  });
90
90
  try {
@@ -1,6 +1,7 @@
1
+ import { HarnessError } from '../../../../errors.js';
1
2
  import { errorMessage } from '../../../../guards.js';
2
3
  import { AbortedError } from '../../../../helpers/race-abort.js';
3
- import { hlog } from '../../../../log.js';
4
+ import { log } from '../../../../log.js';
4
5
  import { TRUNCATED_OUTPUT_SUFFIX } from '../../runner.js';
5
6
  import { collectOutputs, openScratch, stageInputFiles } from './memfs.js';
6
7
  import { isGuardedFetch, setConnectPolicy } from './guarded-fetch.js';
@@ -105,7 +106,10 @@ async function runOnce(py, code, opts) {
105
106
  }
106
107
  return retry;
107
108
  }
108
- class InstallTimeoutError extends Error {
109
+ class InstallTimeoutError extends HarnessError {
110
+ constructor(ms) {
111
+ super('PACKAGE_INSTALL_TIMED_OUT', [ms]);
112
+ }
109
113
  }
110
114
  function installSource(py, packages) {
111
115
  const plan = planVendoredInstall(packages);
@@ -113,7 +117,7 @@ function installSource(py, packages) {
113
117
  return `import micropip\nawait micropip.install(${JSON.stringify(packages)})`;
114
118
  }
115
119
  const specs = [...stageVendoredWheels(py.FS, plan), ...plan.packages];
116
- hlog('python: installing from vendored wheels', { specs });
120
+ log.info('python: installing from vendored wheels', { specs });
117
121
  return `import micropip\nawait micropip.install(${JSON.stringify(specs)}, deps=False)`;
118
122
  }
119
123
  async function installPackages(py, packages, { timeoutMs, isCancelled }) {
@@ -124,7 +128,7 @@ async function installPackages(py, packages, { timeoutMs, isCancelled }) {
124
128
  const install = py.runPythonAsync(installSource(py, packages));
125
129
  let timer;
126
130
  const bound = new Promise((_, reject) => {
127
- timer = setTimeout(() => reject(new InstallTimeoutError(`package install timed out after ${timeoutMs}ms`)), timeoutMs);
131
+ timer = setTimeout(() => reject(new InstallTimeoutError(timeoutMs)), timeoutMs);
128
132
  });
129
133
  try {
130
134
  await Promise.race([install, bound]);
@@ -158,7 +162,7 @@ export async function performRun(py, spec, opts = {}) {
158
162
  if (isCancelled()) {
159
163
  throw new AbortedError();
160
164
  }
161
- hlog('python: installing packages', {
165
+ log.info('python: installing packages', {
162
166
  packages: [...spec.packages],
163
167
  timeoutMs: spec.installTimeoutMs
164
168
  });
@@ -167,7 +171,7 @@ export async function performRun(py, spec, opts = {}) {
167
171
  timeoutMs: spec.installTimeoutMs,
168
172
  isCancelled
169
173
  });
170
- hlog('python: packages ready', { packages: [...spec.packages] });
174
+ log.info('python: packages ready', { packages: [...spec.packages] });
171
175
  }
172
176
  catch (error) {
173
177
  if (error instanceof AbortedError) {
@@ -176,7 +180,7 @@ export async function performRun(py, spec, opts = {}) {
176
180
  const message = errorMessage(error);
177
181
  const timedOut = error instanceof InstallTimeoutError;
178
182
  const evict = timedOut || isPyodideFatal(error);
179
- hlog(timedOut ? 'python: package install timed out' : 'python: package install failed', {
183
+ log.warn(timedOut ? 'python: package install timed out' : 'python: package install failed', {
180
184
  packages: [...spec.packages],
181
185
  err: message,
182
186
  evict
@@ -3,7 +3,7 @@ import process from 'process';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { z } from 'zod';
5
5
  import { errorMessage } from '../../../../guards.js';
6
- import { hlog } from '../../../../log.js';
6
+ import { log } from '../../../../log.js';
7
7
  const ManifestSchema = z.object({
8
8
  pyodide: z.string(),
9
9
  packages: z.record(z.string(), z.object({
@@ -21,14 +21,14 @@ export function wheelsDir() {
21
21
  export function vendoredManifest(dir = wheelsDir()) {
22
22
  const file = `${dir}/manifest.json`;
23
23
  if (!fs.existsSync(file)) {
24
- hlog('python: no vendored wheels', { dir });
24
+ log.info('python: no vendored wheels', { dir });
25
25
  return null;
26
26
  }
27
27
  try {
28
28
  return ManifestSchema.parse(JSON.parse(fs.readFileSync(file, 'utf8')));
29
29
  }
30
30
  catch (error) {
31
- hlog('python: vendored wheel manifest unusable', {
31
+ log.warn('python: vendored wheel manifest unusable', {
32
32
  file,
33
33
  err: errorMessage(error)
34
34
  });
@@ -72,7 +72,7 @@ export function planVendoredInstall(specs, opts = {}) {
72
72
  }
73
73
  for (const wheel of entry.pypi) {
74
74
  if (!fs.existsSync(`${dir}/${wheel}`)) {
75
- hlog('python: vendored wheel missing, installing from PyPI instead', { wheel });
75
+ log.warn('python: vendored wheel missing, installing from PyPI instead', { wheel });
76
76
  return null;
77
77
  }
78
78
  wheels.add(`${dir}/${wheel}`);
@@ -112,6 +112,6 @@ export function seedPackageCache(cacheDir, dir = wheelsDir()) {
112
112
  seeded++;
113
113
  }
114
114
  if (seeded > 0) {
115
- hlog('python: seeded package cache', { cacheDir, wheels: seeded });
115
+ log.debug('python: seeded package cache', { cacheDir, wheels: seeded });
116
116
  }
117
117
  }
@@ -1,4 +1,4 @@
1
- import { hlog } from '../../../../../log.js';
1
+ import { log } from '../../../../../log.js';
2
2
  import { asWorkerMessage } from './protocol.js';
3
3
  const SHUTDOWN_GRACE_MS = 3_000;
4
4
  let active = null;
@@ -20,7 +20,7 @@ export async function readMessage(link) {
20
20
  if (message?.type !== 'log') {
21
21
  return message;
22
22
  }
23
- hlog(`python worker: ${message.message}`);
23
+ log[message.level](`python worker: ${message.message}`);
24
24
  }
25
25
  }
26
26
  export async function drainAndReap(worker) {
@@ -1,8 +1,9 @@
1
1
  import { AbortController } from 'bare-abort-controller';
2
2
  import process from 'process';
3
3
  import { fileURLToPath } from 'url';
4
+ import { HarnessError } from '../../../../../errors.js';
4
5
  import { AbortedError, raceAbort } from '../../../../../helpers/race-abort.js';
5
- import { harnessLogEnabled, hlog } from '../../../../../log.js';
6
+ import { harnessLogLevel, log } from '../../../../../log.js';
6
7
  import { RESEND_MS, SIGINT } from '../deadline-state.js';
7
8
  import { fromWireResult } from './protocol.js';
8
9
  import { activeWorker, destroy, drainAndReap, forget, grace, readMessage } from './active.js';
@@ -21,18 +22,18 @@ async function awaitReady(link) {
21
22
  return;
22
23
  }
23
24
  if (message?.type === 'boot-error') {
24
- throw new Error(`python worker: ${message.message}`);
25
+ throw new HarnessError('PYTHON_WORKER_FAILED', [message.message]);
25
26
  }
26
27
  throw new Error('python worker sent an unexpected first message');
27
28
  }
28
29
  function boot() {
29
- hlog('python: booting worker thread');
30
+ log.info('python: booting worker thread');
30
31
  const sab = new SharedArrayBuffer(1);
31
- const link = spawnPythonWorker(workerEntry(), { sab, log: harnessLogEnabled() });
32
+ const link = spawnPythonWorker(workerEntry(), { sab, log: harnessLogLevel() });
32
33
  const worker = {
33
34
  link,
34
35
  view: new Uint8Array(sab),
35
- ready: awaitReady(link).then(() => hlog('python: worker ready')),
36
+ ready: awaitReady(link).then(() => log.info('python: worker ready')),
36
37
  nextId: 1
37
38
  };
38
39
  worker.ready.catch(() => {
@@ -55,7 +56,7 @@ async function resultFor(worker, id, askConnect) {
55
56
  return { result: fromWireResult(message.result), evict: message.evict };
56
57
  }
57
58
  if (message?.type === 'boot-error') {
58
- throw new Error(`python worker: ${message.message}`);
59
+ throw new HarnessError('PYTHON_WORKER_FAILED', [message.message]);
59
60
  }
60
61
  throw new Error('python worker sent an unexpected message');
61
62
  }
@@ -70,7 +71,7 @@ async function interrupt(worker, outcome) {
70
71
  timer.expired
71
72
  ]);
72
73
  if (settled === 'grace') {
73
- hlog('python: worker ignored interrupt; killing', { graceMs });
74
+ log.warn('python: worker ignored interrupt; killing', { graceMs });
74
75
  destroy(worker);
75
76
  }
76
77
  else {
@@ -134,7 +135,7 @@ export async function runPythonInWorker(spec, opts) {
134
135
  }
135
136
  if (first === 'timeout') {
136
137
  await cancel();
137
- throw new Error(`Command timed out after ${opts.timeoutMs}ms`);
138
+ throw new HarnessError('COMMAND_TIMED_OUT', [opts.timeoutMs]);
138
139
  }
139
140
  if (first.evict) {
140
141
  await drainAndReap(worker);
@@ -1,3 +1,4 @@
1
+ import type { LineLevel } from '../../../../../log.ts';
1
2
  import type { PythonRunResult, RunSpec } from '../run-session.ts';
2
3
  export type HostMessage = {
3
4
  readonly type: 'run';
@@ -23,6 +24,7 @@ export interface WireResult {
23
24
  }
24
25
  export type WorkerMessage = {
25
26
  readonly type: 'log';
27
+ readonly level: LineLevel;
26
28
  readonly message: string;
27
29
  } | {
28
30
  readonly type: 'ready';
@@ -1,12 +1,12 @@
1
1
  import { parentPort, workerData } from 'worker_threads';
2
2
  import { errorMessage, isRecord } from '../../../../../guards.js';
3
- import { setHarnessLog, setHarnessLogSink } from '../../../../../log.js';
3
+ import { isLogLevel, setHarnessLogLevel, setHarnessLogSink } from '../../../../../log.js';
4
4
  import { performRun } from '../run-session.js';
5
5
  import { asHostMessage, toWireResult } from './protocol.js';
6
6
  function narrowThreadData(data) {
7
7
  if (isRecord(data) && 'sab' in data) {
8
8
  const sab = data.sab;
9
- const log = 'log' in data && data.log === true;
9
+ const log = 'log' in data && isLogLevel(data.log) ? data.log : 'off';
10
10
  if (sab instanceof SharedArrayBuffer) {
11
11
  return { sab, log };
12
12
  }
@@ -73,8 +73,8 @@ async function serveRuns(port, py) {
73
73
  }
74
74
  export async function serve(load) {
75
75
  const { port, sab, log } = openPort();
76
- setHarnessLog(log);
77
- setHarnessLogSink((message) => port.write({ type: 'log', message }));
76
+ setHarnessLogLevel(log);
77
+ setHarnessLogSink((message, level) => port.write({ type: 'log', level, message }));
78
78
  try {
79
79
  const py = await load();
80
80
  py.setInterruptBuffer(new Uint8Array(sab));
@@ -1,3 +1,4 @@
1
+ import type { LogLevel } from '../../../../../log.ts';
1
2
  export interface WorkerLink {
2
3
  read(): Promise<unknown>;
3
4
  write(message: unknown): Promise<void>;
@@ -8,6 +9,6 @@ export interface WorkerLink {
8
9
  }
9
10
  export interface WorkerData {
10
11
  readonly sab: SharedArrayBuffer;
11
- readonly log: boolean;
12
+ readonly log: LogLevel;
12
13
  }
13
14
  export declare function spawnPythonWorker(entry: string, data: WorkerData): WorkerLink;
@@ -1,4 +1,5 @@
1
1
  import { Worker } from 'worker_threads';
2
+ import { HarnessError } from '../../../../../errors.js';
2
3
  export function spawnPythonWorker(entry, data) {
3
4
  const worker = new Worker(entry, {
4
5
  workerData: data,
@@ -26,7 +27,7 @@ export function spawnPythonWorker(entry, data) {
26
27
  };
27
28
  worker.on('message', push);
28
29
  worker.on('error', (error) => fail(error instanceof Error ? error : new Error(String(error))));
29
- worker.on('exit', (code) => fail(new Error(`python worker exited with code ${code}`)));
30
+ worker.on('exit', (code) => fail(new HarnessError('PYTHON_WORKER_EXITED', [code])));
30
31
  worker.unref();
31
32
  return {
32
33
  read() {
@@ -1,6 +1,7 @@
1
+ import { HarnessError } from '../../../errors.js';
1
2
  export function markdownFileName(name) {
2
3
  if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
3
- throw new Error('name= must be a note name, not a path.');
4
+ throw new HarnessError('OBSIDIAN_NOTE_NAME_IS_PATH');
4
5
  }
5
6
  return name.endsWith('.md') ? name : `${name}.md`;
6
7
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../errors.js';
3
4
  function lstatOrNull(p) {
4
5
  try {
5
6
  return fs.lstatSync(p);
@@ -18,7 +19,7 @@ function rejectSymlinkComponents(root, target) {
18
19
  return;
19
20
  }
20
21
  if (stat.isSymbolicLink()) {
21
- throw new Error(`Path "${target}" crosses a symlink inside the vault.`);
22
+ throw new HarnessError('OBSIDIAN_PATH_CROSSES_SYMLINK', [target]);
22
23
  }
23
24
  }
24
25
  }
@@ -26,7 +27,7 @@ export function ensureWithinRoot(root, rel) {
26
27
  const resolvedRoot = path.resolve(root);
27
28
  const target = path.resolve(resolvedRoot, rel);
28
29
  if (target !== resolvedRoot && !target.startsWith(resolvedRoot + path.sep)) {
29
- throw new Error(`Path "${rel}" escapes the vault root.`);
30
+ throw new HarnessError('OBSIDIAN_PATH_ESCAPES_VAULT', [rel]);
30
31
  }
31
32
  rejectSymlinkComponents(resolvedRoot, target);
32
33
  return target;
@@ -36,7 +37,7 @@ export function isSymlink(p) {
36
37
  }
37
38
  export function writeNewFileText(filePath, content) {
38
39
  if (fs.existsSync(filePath)) {
39
- throw new Error(`Destination already exists: ${filePath}`);
40
+ throw new HarnessError('OBSIDIAN_DESTINATION_EXISTS', [filePath]);
40
41
  }
41
42
  let fd;
42
43
  try {
@@ -44,7 +45,7 @@ export function writeNewFileText(filePath, content) {
44
45
  }
45
46
  catch (error) {
46
47
  if (fs.existsSync(filePath)) {
47
- throw new Error(`Destination already exists: ${filePath}`);
48
+ throw new HarnessError('OBSIDIAN_DESTINATION_EXISTS', [filePath]);
48
49
  }
49
50
  throw error;
50
51
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../errors.js';
3
4
  import { markdownFileName } from './note-name.js';
4
5
  import { ensureWithinRoot, getArg, getContentArg, writeNewFileText } from './vault-paths.js';
5
6
  function markdownPath(vaultPath, target) {
@@ -20,7 +21,7 @@ function createNamed(vaultPath, { name, folder, content }) {
20
21
  const fileName = markdownFileName(name);
21
22
  const filePath = ensureWithinRoot(vaultPath, path.join(folder, fileName));
22
23
  if (fs.existsSync(filePath)) {
23
- throw new Error(`Note "${name}" already exists at ${folder || '/'}`);
24
+ throw new HarnessError('OBSIDIAN_NOTE_EXISTS', [name, folder || '/']);
24
25
  }
25
26
  writeNewFileText(filePath, content);
26
27
  return `Created: ${folder ? `${folder}/` : ''}${fileName}`;
@@ -42,7 +43,7 @@ export function handleCreate(vaultPath, args) {
42
43
  }
43
44
  const target = filePath ?? file;
44
45
  if (!target) {
45
- throw new Error('Missing required argument: name=, file=, or path=');
46
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['name=, file=, or path=']);
46
47
  }
47
48
  return createAtPath(vaultPath, target, content || '');
48
49
  }
@@ -1,3 +1,4 @@
1
+ import { HarnessError } from '../../../errors.js';
1
2
  import { handleCreate } from './vault-create.js';
2
3
  import { todayStamp } from './vault-paths.js';
3
4
  import { handleDailyRead, handleFiles, handleFolders, handleOutline, handleProperties, handleRead, handleSearch, handleTags, handleVault, handleVaults, handleWordcount } from './vault-read.js';
@@ -51,7 +52,6 @@ export function executeVaultFsAction(vaultPath, action, args) {
51
52
  case 'daily:path':
52
53
  return `Daily Notes/${todayStamp()}.md`;
53
54
  default:
54
- throw new Error(`Action "${action}" is not supported in vault file mode. ` +
55
- `This action requires the Obsidian CLI connected to the running app.`);
55
+ throw new HarnessError('OBSIDIAN_ACTION_UNSUPPORTED', [action]);
56
56
  }
57
57
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../errors.js';
3
4
  import { ensureWithinRoot, isSymlink, writeNewFileText } from './safe-path.js';
4
5
  export { ensureWithinRoot, writeNewFileText } from './safe-path.js';
5
6
  export const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -90,13 +91,13 @@ export function writeIfUnchanged(filePath, content, expectedMtimeMs) {
90
91
  }
91
92
  if (expectedMtimeMs === null) {
92
93
  if (current) {
93
- throw new Error(`Note changed on disk during update (file appeared): ${path.basename(filePath)}`);
94
+ throw new HarnessError('OBSIDIAN_NOTE_CHANGED', ['file appeared', path.basename(filePath)]);
94
95
  }
95
96
  fs.writeFileSync(filePath, content);
96
97
  return;
97
98
  }
98
99
  if (!current || current.mtimeMs !== expectedMtimeMs) {
99
- throw new Error(`Note changed on disk during update (re-read and retry): ${path.basename(filePath)}`);
100
+ throw new HarnessError('OBSIDIAN_NOTE_CHANGED', ['re-read and retry', path.basename(filePath)]);
100
101
  }
101
102
  fs.writeFileSync(filePath, content);
102
103
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../errors.js';
3
4
  import { dailyCandidates, ensureWithinRoot, FRONTMATTER_RE, getArg, listFolders, listMarkdownFiles, parseFrontmatter, readFileText, resolveNotePath, todayStamp } from './vault-paths.js';
4
5
  export function handleVault(vaultPath) {
5
6
  return JSON.stringify({ name: path.basename(vaultPath), path: vaultPath });
@@ -17,7 +18,7 @@ export function handleVaults(vaultPath) {
17
18
  export function handleRead(vaultPath, args) {
18
19
  const notePath = resolveNotePath(vaultPath, args);
19
20
  if (!notePath || !fs.existsSync(notePath)) {
20
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
21
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
21
22
  }
22
23
  return readFileText(notePath);
23
24
  }
@@ -43,7 +44,7 @@ function parseLimit(args) {
43
44
  export function handleSearch(vaultPath, args) {
44
45
  const query = getArg(args, 'query');
45
46
  if (!query) {
46
- throw new Error('Missing required argument: query=');
47
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['query=']);
47
48
  }
48
49
  const limit = parseLimit(args);
49
50
  const lowerQuery = query.toLowerCase();
@@ -70,7 +71,7 @@ export function handleSearch(vaultPath, args) {
70
71
  export function handleProperties(vaultPath, args) {
71
72
  const notePath = resolveNotePath(vaultPath, args);
72
73
  if (!notePath || !fs.existsSync(notePath)) {
73
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
74
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
74
75
  }
75
76
  return JSON.stringify(parseFrontmatter(readFileText(notePath)), null, 2);
76
77
  }
@@ -99,7 +100,7 @@ export function handleTags(vaultPath) {
99
100
  export function handleWordcount(vaultPath, args) {
100
101
  const notePath = resolveNotePath(vaultPath, args);
101
102
  if (!notePath || !fs.existsSync(notePath)) {
102
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
103
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
103
104
  }
104
105
  const body = readFileText(notePath).replace(FRONTMATTER_RE, '').trim();
105
106
  const words = body.split(/\s+/).filter(Boolean).length;
@@ -108,7 +109,7 @@ export function handleWordcount(vaultPath, args) {
108
109
  export function handleOutline(vaultPath, args) {
109
110
  const notePath = resolveNotePath(vaultPath, args);
110
111
  if (!notePath || !fs.existsSync(notePath)) {
111
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
112
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
112
113
  }
113
114
  const headings = readFileText(notePath)
114
115
  .split('\n')