mercury-agent 0.18.2 → 0.19.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.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Expense file upload, host-side.
3
+ *
4
+ * Morning's upload is two hops and both live here, in one broker action:
5
+ *
6
+ * 1. `GET {uploadBase}/file-upload/v1/url` → a pre-signed S3 POST.
7
+ * 2. `POST <that url>` → multipart/form-data, no Authorization header.
8
+ *
9
+ * They are not two verbs, and that is deliberate: the pre-signed POST is valid
10
+ * for 60 SECONDS. A model that had to decide between two verbs would spend the
11
+ * window deciding. Nothing between the two hops may block on anything slow.
12
+ *
13
+ * Uploading always creates an expense DRAFT, which Morning parses itself and a
14
+ * human approves in Morning's UI. That is why this needs no
15
+ * `accountingClassification` — the field that keeps `POST /expenses` out of the
16
+ * extension — and why the human gate here is real rather than prompted.
17
+ *
18
+ * The container never sends bytes. It names a path RELATIVE to its own space
19
+ * workspace and the host reads it. `resolveInSpace` below is the only thing
20
+ * standing between that and an arbitrary-file-read primitive handed to a
21
+ * prompt-injectable agent, so read its comment before changing it.
22
+ */
23
+
24
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import {
27
+ extToMime,
28
+ hasExecutableHeader,
29
+ isAllowedExtension,
30
+ } from "mercury-agent/core/media";
31
+ import { hostsFor, type MorningEnvironment } from "./hosts.js";
32
+
33
+ /** Same bound as the rest of the extension; nothing upstream sets one. */
34
+ const UPLOAD_TIMEOUT_MS = 30_000;
35
+
36
+ /** Morning's own constant for this flow — `source` must be 5. */
37
+ const UPLOAD_SOURCE = 5;
38
+
39
+ export interface CapabilityResult {
40
+ status?: number;
41
+ data: unknown;
42
+ }
43
+
44
+ export interface UploadContext {
45
+ env: MorningEnvironment;
46
+ spaceId: string;
47
+ /**
48
+ * Root of all space workspaces, already project-resolved by the caller.
49
+ * `<spacesDir>/<spaceId>/` is the confinement boundary.
50
+ */
51
+ spacesDir: string;
52
+ fetchImpl?: typeof fetch;
53
+ /** Host-side logger. Refusals are logged here, never returned in detail. */
54
+ logDenial?: (message: string, detail: Record<string, unknown>) => void;
55
+ }
56
+
57
+ /**
58
+ * Token access, supplied by the caller so this module does not reach for the
59
+ * database. `getToken(true)` must bypass any cache — it is what makes the one
60
+ * 401 retry below meaningful.
61
+ */
62
+ export interface TokenProvider {
63
+ getToken(
64
+ forceRefresh: boolean,
65
+ ): Promise<
66
+ { ok: true; token: string } | { ok: false; result: CapabilityResult }
67
+ >;
68
+ /** Called when Morning rejects a freshly minted token, so statusCheck can see it. */
69
+ recordAuthError(message: string): void;
70
+ }
71
+
72
+ /**
73
+ * The three outcomes of resolving a caller-supplied path.
74
+ *
75
+ * `refused` and `missing` are deliberately distinct, and the line between them
76
+ * is what keeps this from being a filesystem oracle: `missing` is returned ONLY
77
+ * for a path that is structurally sound and lexically inside the caller's own
78
+ * space. Confirming such a path is empty tells the caller nothing it did not
79
+ * already know — it named the path. Everything else, including any path that
80
+ * resolves outside the space, is a single indistinguishable `refused`.
81
+ */
82
+ export type ResolveOutcome =
83
+ | { ok: true; absPath: string }
84
+ | { ok: false; reason: "refused" | "missing" };
85
+
86
+ const REFUSED = { ok: false, reason: "refused" } as const;
87
+ const MISSING = { ok: false, reason: "missing" } as const;
88
+
89
+ /**
90
+ * Resolve a container-supplied relative path inside one space's workspace.
91
+ *
92
+ * Three properties, each of which has been a real bug somewhere:
93
+ *
94
+ * - **Absence denies.** An empty, missing or non-string path is refused before
95
+ * anything touches the filesystem. A guard whose absent case falls through
96
+ * to allow is not a guard (see
97
+ * `docs/debug/moderate/08-2026/2026-08-27-body-size-limit-skipped-without-content-length.md`).
98
+ * - **Symlinks are resolved BEFORE the comparison, not after.** `inbox/` is
99
+ * written by a lower-trust boundary, so a symlink there pointing at the
100
+ * host's `.env` is precisely the attack. Comparing the pre-resolution path
101
+ * would pass it.
102
+ * - **The comparison is path-segment-wise, never a string prefix.** With a bare
103
+ * `startsWith`, space `a` reaches everything in space `abc`.
104
+ */
105
+ export async function resolveInSpace(
106
+ spacesDir: string,
107
+ spaceId: string,
108
+ rawPath: unknown,
109
+ ): Promise<ResolveOutcome> {
110
+ if (typeof rawPath !== "string") return REFUSED;
111
+ const candidate = rawPath.trim();
112
+ if (!candidate) return REFUSED;
113
+
114
+ // Cheap structural rejections first, before any I/O. Not the real guard —
115
+ // the realpath comparison below is — but they keep obvious junk out of the
116
+ // filesystem calls and make the intent readable.
117
+ if (path.isAbsolute(candidate)) return REFUSED;
118
+ if (candidate.split(/[\\/]/).includes("..")) return REFUSED;
119
+
120
+ // Resolve the base's own symlinks first (a spacesDir under /var on macOS is
121
+ // one), then build the target from the resolved base so the two are
122
+ // comparable.
123
+ let realBase: string;
124
+ try {
125
+ realBase = await realpath(path.resolve(spacesDir, spaceId));
126
+ } catch {
127
+ return REFUSED;
128
+ }
129
+
130
+ const target = path.resolve(realBase, candidate);
131
+ // Lexical containment before touching the target: a path that is outside
132
+ // even on paper is refused without a filesystem probe.
133
+ if (!isInside(realBase, target)) return REFUSED;
134
+
135
+ let realTarget: string;
136
+ try {
137
+ realTarget = await realpath(target);
138
+ } catch {
139
+ return (await isPlainTypo(realBase, target)) ? MISSING : REFUSED;
140
+ }
141
+
142
+ // The decisive check, AFTER symlink resolution — this is the one that catches
143
+ // a link in `inbox/` pointing anywhere else on the host.
144
+ if (!isInside(realBase, realTarget)) return REFUSED;
145
+ return { ok: true, absPath: realTarget };
146
+ }
147
+
148
+ /**
149
+ * `realpath` failed on a path that is lexically inside the space. Is that a
150
+ * plain typo, or something planted?
151
+ *
152
+ * Only a typo may be reported as `missing`, because the 404/400 split is
153
+ * otherwise a one-bit existence oracle for arbitrary host paths: a container
154
+ * can write symlinks into its own workspace, so it could plant a link to any
155
+ * path, read the status code, and learn whether the target exists. No content
156
+ * escapes either way — the containment check still holds for anything that
157
+ * resolves — but an oracle is exactly what the "never echo the resolved path"
158
+ * rule exists to prevent, so the two conditions below close it.
159
+ *
160
+ * 1. Nothing may exist at the name at all. `lstat` sees a dangling symlink
161
+ * where `realpath` does not, and a dangling symlink is never a typo.
162
+ * 2. The parent directory must itself resolve inside the space, or a symlinked
163
+ * intermediate directory is doing the escaping.
164
+ */
165
+ async function isPlainTypo(realBase: string, target: string): Promise<boolean> {
166
+ try {
167
+ await lstat(target);
168
+ return false; // Something is there; realpath refused to follow it.
169
+ } catch {
170
+ // Genuinely nothing at that name — carry on to the parent check.
171
+ }
172
+ try {
173
+ return isInside(realBase, await realpath(path.dirname(target)));
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Is `target` the same as, or beneath, `base`?
181
+ *
182
+ * `path.relative` rather than a string compare: it normalises separators and
183
+ * yields a result starting with `..` exactly when the target sits outside, so
184
+ * `/spaces/abc` is correctly outside `/spaces/a`. An empty result means the two
185
+ * are the same path, which is not a file and so not acceptable either — the
186
+ * caller's `stat` rejects it, but returning true here keeps this function about
187
+ * containment alone.
188
+ */
189
+ function isInside(base: string, target: string): boolean {
190
+ const rel = path.relative(base, target);
191
+ if (rel === "") return true;
192
+ if (rel.startsWith("..")) return false;
193
+ return !path.isAbsolute(rel);
194
+ }
195
+
196
+ interface LoadedFile {
197
+ bytes: Buffer;
198
+ name: string;
199
+ }
200
+
201
+ /**
202
+ * Read and validate the file once.
203
+ *
204
+ * The buffer is read a single time and reused for the header check, the size
205
+ * check and the upload body, so the bytes that were validated are exactly the
206
+ * bytes that get sent. Re-reading before the upload would leave a window for a
207
+ * swap by whatever wrote the file.
208
+ */
209
+ async function loadFile(
210
+ absPath: string,
211
+ displayName: string,
212
+ ): Promise<LoadedFile | CapabilityResult> {
213
+ let info: Awaited<ReturnType<typeof stat>>;
214
+ try {
215
+ info = await stat(absPath);
216
+ } catch {
217
+ return { status: 404, data: { error: `No such file: ${displayName}` } };
218
+ }
219
+ if (!info.isFile()) {
220
+ return { status: 400, data: { error: `Not a file: ${displayName}` } };
221
+ }
222
+
223
+ const name = path.basename(absPath);
224
+ if (!isAllowedExtension(name)) {
225
+ return {
226
+ status: 415,
227
+ data: {
228
+ error: `Refusing to upload ${displayName}: the file type is not one Mercury handles.`,
229
+ },
230
+ };
231
+ }
232
+
233
+ let bytes: Buffer;
234
+ try {
235
+ bytes = await readFile(absPath);
236
+ } catch (err) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ return {
239
+ status: 404,
240
+ data: { error: `Could not read ${displayName}: ${message}` },
241
+ };
242
+ }
243
+
244
+ if (hasExecutableHeader(bytes)) {
245
+ return {
246
+ status: 415,
247
+ data: {
248
+ error: `Refusing to upload ${displayName}: its first bytes are an executable header, whatever the extension says.`,
249
+ },
250
+ };
251
+ }
252
+
253
+ return { bytes, name };
254
+ }
255
+
256
+ function isResult(v: unknown): v is CapabilityResult {
257
+ return typeof v === "object" && v !== null && "data" in v;
258
+ }
259
+
260
+ interface PresignedPost {
261
+ url: string;
262
+ fields: Record<string, string>;
263
+ maxFileSize: number;
264
+ }
265
+
266
+ /**
267
+ * Outcome of step 1. The upstream status is kept separate from the error the
268
+ * caller would return, because the retry decision turns on the status alone and
269
+ * collapsing everything into a 502 would hide the one case worth retrying.
270
+ */
271
+ type PresignOutcome =
272
+ | { ok: true; presigned: PresignedPost }
273
+ | { ok: false; httpStatus: number | null; result: CapabilityResult };
274
+
275
+ /**
276
+ * Step 1 — ask Morning for a pre-signed POST.
277
+ *
278
+ * Not `callOnce`: a different base, query parameters rather than a JSON body,
279
+ * and success is 201 rather than 200.
280
+ */
281
+ async function requestUploadUrl(
282
+ ctx: UploadContext,
283
+ token: string,
284
+ expenseId: string | null,
285
+ ): Promise<PresignOutcome> {
286
+ const { uploadBase } = hostsFor(ctx.env);
287
+ const data = expenseId
288
+ ? { id: expenseId, source: UPLOAD_SOURCE, state: "expense" }
289
+ : { source: UPLOAD_SOURCE };
290
+ const query = new URLSearchParams({
291
+ context: "expense",
292
+ data: JSON.stringify(data),
293
+ });
294
+ const fetchImpl = ctx.fetchImpl ?? fetch;
295
+
296
+ let res: Response;
297
+ try {
298
+ res = await fetchImpl(`${uploadBase}/file-upload/v1/url?${query}`, {
299
+ method: "GET",
300
+ headers: {
301
+ "Content-Type": "application/json",
302
+ Authorization: `Bearer ${token}`,
303
+ },
304
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
305
+ });
306
+ } catch (err) {
307
+ const message = err instanceof Error ? err.message : String(err);
308
+ return {
309
+ ok: false,
310
+ httpStatus: null,
311
+ result: {
312
+ status: 502,
313
+ data: {
314
+ error: `Could not reach Morning (${ctx.env}): ${message}`,
315
+ transport: true,
316
+ },
317
+ },
318
+ };
319
+ }
320
+
321
+ const raw = await res.text().catch(() => "");
322
+ let body: unknown = raw;
323
+ try {
324
+ body = raw ? JSON.parse(raw) : null;
325
+ } catch {
326
+ // Not JSON — keep the raw text for the error below.
327
+ }
328
+
329
+ // 201 is the documented success. Treat any other 2xx as success too rather
330
+ // than pinning the exact code, but anything else is Morning saying no.
331
+ if (!res.ok) {
332
+ return {
333
+ ok: false,
334
+ httpStatus: res.status,
335
+ result: {
336
+ status: res.status === 429 ? 429 : 502,
337
+ data: {
338
+ error: "Morning refused to issue an upload URL.",
339
+ httpStatus: res.status,
340
+ morning: body,
341
+ },
342
+ },
343
+ };
344
+ }
345
+
346
+ const parsed = body as Partial<PresignedPost> | null;
347
+ if (
348
+ !parsed ||
349
+ typeof parsed.url !== "string" ||
350
+ typeof parsed.fields !== "object" ||
351
+ parsed.fields === null
352
+ ) {
353
+ return {
354
+ ok: false,
355
+ httpStatus: res.status,
356
+ result: {
357
+ status: 502,
358
+ data: {
359
+ error:
360
+ "Morning returned an upload URL in a shape this extension does not understand.",
361
+ // Key names only, never the body. A malformed-but-2xx response can
362
+ // still carry `url` and `fields`, and those are a bearer capability
363
+ // for the bucket that must not reach the container.
364
+ keys: Object.keys((body ?? {}) as Record<string, unknown>),
365
+ },
366
+ },
367
+ };
368
+ }
369
+
370
+ return {
371
+ ok: true,
372
+ presigned: {
373
+ url: parsed.url,
374
+ fields: parsed.fields as Record<string, string>,
375
+ // Absent or nonsensical → 0, which the caller reads as "no stated cap".
376
+ maxFileSize:
377
+ typeof parsed.maxFileSize === "number" && parsed.maxFileSize > 0
378
+ ? parsed.maxFileSize
379
+ : 0,
380
+ },
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Step 2 — the pre-signed POST itself.
386
+ *
387
+ * Every field Morning returned goes in first, in the order returned, and `file`
388
+ * goes LAST: an S3 pre-signed POST ignores anything after the file part, so a
389
+ * field appended afterwards is silently dropped and the signature fails.
390
+ *
391
+ * No Authorization header. The signature is in the fields; sending a bearer
392
+ * token to a bucket would leak it to a third party for no benefit.
393
+ *
394
+ * NOT retried. A POST that failed midway may already have created a draft, and
395
+ * a blind retry risks a duplicate expense against real books. Report instead.
396
+ */
397
+ async function putToBucket(
398
+ ctx: UploadContext,
399
+ presigned: PresignedPost,
400
+ file: LoadedFile,
401
+ ): Promise<CapabilityResult | null> {
402
+ const form = new FormData();
403
+ for (const [key, value] of Object.entries(presigned.fields)) {
404
+ form.append(key, String(value));
405
+ }
406
+ // Give the part its real content type. Morning's `fields` carry no
407
+ // Content-Type condition, so an untyped part would not break the signature —
408
+ // but it would reach the parser as application/octet-stream, and being parsed
409
+ // is the entire point of an expense draft.
410
+ form.append(
411
+ "file",
412
+ new Blob([new Uint8Array(file.bytes)], { type: extToMime(file.name) }),
413
+ file.name,
414
+ );
415
+
416
+ const fetchImpl = ctx.fetchImpl ?? fetch;
417
+ let res: Response;
418
+ try {
419
+ res = await fetchImpl(presigned.url, {
420
+ method: "POST",
421
+ body: form,
422
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
423
+ });
424
+ } catch (err) {
425
+ const message = err instanceof Error ? err.message : String(err);
426
+ return {
427
+ status: 502,
428
+ data: {
429
+ error: `The upload did not complete: ${message}`,
430
+ transport: true,
431
+ },
432
+ };
433
+ }
434
+
435
+ if (res.status === 403) {
436
+ // The overwhelmingly likely cause, and the one the user can act on.
437
+ return {
438
+ status: 502,
439
+ data: {
440
+ error:
441
+ "The upload link expired before the file finished uploading (it is valid for 60 seconds). Send the file again.",
442
+ expired: true,
443
+ },
444
+ };
445
+ }
446
+
447
+ // S3 is not described by Morning's OpenAPI document, so the exact success
448
+ // code is not spec-verified. A pre-signed POST with no `success_action_status`
449
+ // answers 204; accept any 2xx rather than pinning it.
450
+ if (!res.ok) {
451
+ // Only S3's <Code> element, never the raw body. An error document can echo
452
+ // request material — policy content among it — and the pre-signed fields are
453
+ // a bearer capability for the bucket that must not reach the container.
454
+ const body = await res.text().catch(() => "");
455
+ const code = body.match(/<Code>([^<]{1,64})<\/Code>/)?.[1];
456
+ return {
457
+ status: 502,
458
+ data: {
459
+ error: "The storage service rejected the upload.",
460
+ httpStatus: res.status,
461
+ ...(code ? { code } : {}),
462
+ transport: true,
463
+ },
464
+ };
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ /**
471
+ * The `expense-upload` verb.
472
+ *
473
+ * Tokens come from the caller, but the one-401-retry policy lives here, because
474
+ * only this module knows which of the two hops is safe to repeat. Step 1 is
475
+ * idempotent — it just asks for a URL — so a stale cached token costs one
476
+ * re-mint. Step 2 is never retried at all: a POST that failed midway may
477
+ * already have created a draft.
478
+ */
479
+ export async function expenseUpload(
480
+ ctx: UploadContext,
481
+ body: Record<string, unknown>,
482
+ tokens: TokenProvider,
483
+ ): Promise<CapabilityResult> {
484
+ const rawPath = body.path;
485
+ const displayName = typeof rawPath === "string" ? rawPath : "(no path)";
486
+
487
+ const resolved = await resolveInSpace(ctx.spacesDir, ctx.spaceId, rawPath);
488
+ if (!resolved.ok) {
489
+ // WARN host-side with the detail, generic to the caller. A silent refusal
490
+ // is indistinguishable from "never happened" in the host log, and an
491
+ // informative one is a filesystem oracle for the container.
492
+ ctx.logDenial?.("morning expense-upload: path not usable", {
493
+ spaceId: ctx.spaceId,
494
+ requested: displayName,
495
+ reason: resolved.reason,
496
+ });
497
+ if (resolved.reason === "missing") {
498
+ // A typo should read as a typo. Telling the model the file is simply not
499
+ // there is what makes it list `inbox/` instead of guessing again.
500
+ return {
501
+ status: 404,
502
+ data: { error: `No such file: ${displayName}` },
503
+ };
504
+ }
505
+ return {
506
+ status: 400,
507
+ data: {
508
+ error:
509
+ "expense-upload needs a path to a file inside this space's workspace, such as inbox/receipt.pdf.",
510
+ },
511
+ };
512
+ }
513
+
514
+ const loaded = await loadFile(resolved.absPath, displayName);
515
+ if (isResult(loaded)) return loaded;
516
+
517
+ const expenseId =
518
+ typeof body.expenseId === "string" && body.expenseId.trim()
519
+ ? body.expenseId.trim()
520
+ : null;
521
+
522
+ // One retry, matching `apiCall`'s policy: the token is either stale (a single
523
+ // re-mint fixes it) or the credentials are rejected (a loop would hammer the
524
+ // endpoint and still fail). The second 401 is recorded so `statusCheck` stops
525
+ // reporting "connected" through a real auth failure.
526
+ let presigned: PresignedPost | undefined;
527
+ for (const attempt of [0, 1]) {
528
+ const token = await tokens.getToken(attempt === 1);
529
+ if (!token.ok) return token.result;
530
+
531
+ const outcome = await requestUploadUrl(ctx, token.token, expenseId);
532
+ if (outcome.ok) {
533
+ presigned = outcome.presigned;
534
+ break;
535
+ }
536
+ if (outcome.httpStatus === 401) {
537
+ if (attempt === 0) continue;
538
+ tokens.recordAuthError(
539
+ `Morning returned 401 after a token refresh (${ctx.env}).`,
540
+ );
541
+ }
542
+ return outcome.result;
543
+ }
544
+ if (!presigned) {
545
+ // Unreachable: the loop either breaks with a value or returns.
546
+ return {
547
+ status: 502,
548
+ data: { error: "Morning did not issue an upload URL.", transport: true },
549
+ };
550
+ }
551
+
552
+ // Between the two legs, and only here: the cap is Morning's own number, not a
553
+ // guess, so it cannot be checked before step 1 returns it. Checking it before
554
+ // step 2 means an oversized file fails instantly rather than burning the
555
+ // 60-second window on an upload that was always going to be refused.
556
+ if (
557
+ presigned.maxFileSize > 0 &&
558
+ loaded.bytes.length > presigned.maxFileSize
559
+ ) {
560
+ return {
561
+ status: 413,
562
+ data: {
563
+ error: `${loaded.name} is ${loaded.bytes.length} bytes; Morning accepts at most ${presigned.maxFileSize}.`,
564
+ maxFileSize: presigned.maxFileSize,
565
+ },
566
+ };
567
+ }
568
+
569
+ const failed = await putToBucket(ctx, presigned, loaded);
570
+ if (failed) return failed;
571
+
572
+ return {
573
+ data: {
574
+ uploaded: true,
575
+ mode: expenseId ? "attached" : "draft",
576
+ expenseId,
577
+ file: loaded.name,
578
+ // Said plainly because it is the thing that misleads: Morning has ACCEPTED
579
+ // the file, not finished with it. Parsing is asynchronous and its outcome
580
+ // arrives on a webhook no extension can receive.
581
+ note: "Morning accepted the file. It is parsed asynchronously — use expense-draft-search to see whether the draft appeared.",
582
+ },
583
+ };
584
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: morning
3
- description: Issue and look up tax documents in Morning (Green Invoice) — invoices, receipts, quotes — and find or create clients. Use when someone asks to issue, produce or send an invoice or receipt, asks what was invoiced in a period, or asks about a client's documents. Fires on חשבונית, חשבונית מס, קבלה, הצעת מחיר, תוציא חשבונית, תפיק חשבונית, לשלוח חשבונית, מורנינג, גרין אינבויס, חשבונית ירוקה, invoice, receipt, quote, bill a client. Not for expenses, supplier documents or uploading a receipt photo — those are not supported yet, say so.
3
+ description: Issue and look up tax documents in Morning (Green Invoice) — invoices, receipts, quotes — find or create clients, and upload a supplier receipt as an expense draft. Use when someone asks to issue, produce or send an invoice or receipt, asks what was invoiced in a period, asks about a client's documents, or sends a receipt or supplier invoice to file. Fires on חשבונית, חשבונית מס, קבלה, הצעת מחיר, תוציא חשבונית, תפיק חשבונית, לשלוח חשבונית, מורנינג, גרין אינבויס, חשבונית ירוקה, הוצאה, קבלה מספק, תעלה קבלה, תכניס את הקבלה, invoice, receipt, quote, bill a client, expense, upload a receipt, file this receipt. Not for entering an expense by hand or payment collection — those are not supported yet, say so.
4
4
  allowed-tools: Bash
5
5
  ---
6
6
 
@@ -52,6 +52,32 @@ behaviour and not an error to work around.
52
52
  | Issue a document | `document-create` | preview body + `"issueToken":"…"` |
53
53
  | Find documents | `document-search` | `mrctl capability morning document-search '{"fromDate":"2026-09-01","toDate":"2026-09-30"}'` |
54
54
  | Get download links | `document-links` | `mrctl capability morning document-links '{"id":"<document id>"}'` |
55
+ | Upload a receipt (expense) | `expense-upload` | `mrctl capability morning expense-upload '{"path":"inbox/receipt.pdf"}'` |
56
+ | Check uploaded receipts | `expense-draft-search` | `mrctl capability morning expense-draft-search '{"fromDate":"2026-09-01"}'` |
57
+
58
+ ### Uploading a receipt someone sent
59
+
60
+ A file the user sends arrives in `inbox/`. Pass its path **relative to the
61
+ current workspace** — never an absolute path, and never the file's contents:
62
+
63
+ ```bash
64
+ mrctl capability morning expense-upload '{"path":"inbox/receipt-2026-09-06.pdf"}'
65
+ ```
66
+
67
+ To attach the scan to an expense that already exists in Morning, add its id:
68
+
69
+ ```bash
70
+ mrctl capability morning expense-upload '{"path":"inbox/receipt.pdf","expenseId":"<expense id>"}'
71
+ ```
72
+
73
+ **A successful upload means Morning accepted the file, not that the expense
74
+ exists.** Morning reads the receipt asynchronously and creates a *draft*, which
75
+ a human approves in Morning's own interface. Say that — "it is in Morning
76
+ waiting for approval" — rather than "the expense was created". To see whether
77
+ the draft appeared, use `expense-draft-search`; the answer may take a minute.
78
+
79
+ Do not upload the same receipt twice to "make sure". Duplicates become two
80
+ drafts a human then has to untangle. Check with `expense-draft-search` instead.
55
81
 
56
82
  ### Document body
57
83
 
@@ -139,6 +165,10 @@ not reissue — the document is already there.
139
165
 
140
166
  ## Not supported yet
141
167
 
142
- Expenses, supplier documents, uploading a receipt or invoice photo, and payment
143
- collection are not available through this skill. If asked, say so plainly rather
144
- than attempting a workaround.
168
+ Creating an expense from typed-in details (`POST /expenses`), editing or closing
169
+ an existing expense, and payment collection are not available through this
170
+ skill. If asked, say so plainly rather than attempting a workaround.
171
+
172
+ Uploading a receipt file *is* supported — see `expense-upload` above. What is
173
+ not supported is entering an expense by hand: that needs an accounting
174
+ classification, which is the bookkeeper's judgment call, not the assistant's.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -24,6 +24,10 @@
24
24
  "types": "./src/core/global-admin.ts",
25
25
  "default": "./src/core/global-admin.ts"
26
26
  },
27
+ "./core/media": {
28
+ "types": "./src/core/media.ts",
29
+ "default": "./src/core/media.ts"
30
+ },
27
31
  "./core/model-leg": {
28
32
  "types": "./src/core/model-leg.ts",
29
33
  "default": "./src/core/model-leg.ts"
@@ -80,8 +84,8 @@
80
84
  "typecheck": "bunx tsc --noEmit",
81
85
  "typecheck:scripts": "bunx tsc --noEmit -p tsconfig.scripts.json",
82
86
  "typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
83
- "lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
84
- "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
87
+ "lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
88
+ "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
85
89
  "check:silent-catch": "bun run scripts/check-silent-catch.ts",
86
90
  "check:sync-calls": "bun run scripts/check-sync-calls.ts",
87
91
  "check:dep-floors": "bun run scripts/check-dep-floors.ts",