@wirecat/cli-core 0.531.0 → 0.533.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.533.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **A `search all` meeting transcript hit names its cue and its speaker**: `cue_ref`
15
+ (`meeting:<account>/<meeting>/<transcript>/<cue>`) and `sender_name` (marked untrusted). `meetings passage <cue_ref>
16
+ --before-n 3 --after-n 3` opens the lines around it; `ref` stays the meeting. Chat and summary hits are unchanged.
17
+ `SearchHit` from the meetings store carries the same cue as `ref` and `speakerName`; `MeetingSearchItem` carries
18
+ `cueRef` and `speakerName`.
19
+
20
+ ### Fixed
21
+
22
+ - **A `serve` unit or launchd agent survives an upgrade with pnpm.** `server install` wrote the script's resolved
23
+ path, a versioned folder in pnpm's global store that the next upgrade removes, so the unit could not start until it
24
+ was installed again. It now writes the path the shim gave, a link pnpm points at the new version.
25
+
26
+ ## 0.532.0 — 01.11.2026
27
+
28
+ ### Added
29
+
30
+ - **A message hit names its files**: `search messages` and `search all` hits carry
31
+ `attachments: [{ position, kind, name, size }]` (files only, absent when there are none; `name` and `size` when the
32
+ store has them). `position` counts every attachment from 1, as `attachments show --attachment` takes it.
33
+ `attachments.name` is listed in the hit's `untrusted` fields.
34
+ - **`messages download --offline`** copies files the local store already holds, by chat and id or by a `msg:`
35
+ locator, and never connects; `--attachment <n>` picks one. A file not saved here fails with `not_found` before
36
+ anything is written, and the store keeps pointing at its own copy.
37
+ - **`messages download` takes a `msg:` locator** in place of chat and id; the chat and id form still works.
38
+ - **`messages link` lists a message's files**: `files: [{ position, kind, name, url, reason }]`. No messenger gives
39
+ a file its own link yet, so `url` is null with reason `unsupported_provider`.
40
+
10
41
  ## 0.531.0 — 01.11.2026
11
42
 
12
43
  ### Added
@@ -1,6 +1,6 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { closeSync, mkdirSync, openSync, realpathSync } from "node:fs";
3
- import { dirname } from "node:path";
2
+ import { closeSync, mkdirSync, openSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
5
  import { CliError } from "@wirecat/cli-base";
6
6
  export const runProgram = (argv, env) => new Promise((resolve, reject) => {
@@ -14,7 +14,9 @@ export const runProgram = (argv, env) => new Promise((resolve, reject) => {
14
14
  });
15
15
  });
16
16
  export const thisMachine = () => {
17
- const entry = [process.execPath, realpathSync(process.argv[1] ?? "")];
17
+ // The path as the package manager's shim gave it, not resolved: pnpm's global link keeps its name across upgrades
18
+ // while the folder it points at is versioned and removed by the next upgrade, which would leave the unit dead.
19
+ const entry = [process.execPath, resolve(process.argv[1] ?? "")];
18
20
  return {
19
21
  platform: process.platform,
20
22
  run: runProgram,
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
2
  import { type Messenger } from "./context.js";
3
+ export declare const count: (value: string) => number;
3
4
  /** Files of stored messages: what is in them, for `content:` in a search. Nothing here is sent. */
4
5
  export declare const attachmentsCommand: (messenger: Messenger) => Command;
@@ -18,7 +18,7 @@ import { messengerContext, refuseLocalWrite } from "./context.js";
18
18
  import { addMediaArchiveCommands } from "./media-archive-command.js";
19
19
  import { stopOnSignal } from "./patience.js";
20
20
  import { readAll } from "./stdin.js";
21
- const count = (value) => {
21
+ export const count = (value) => {
22
22
  const parsed = Number(value);
23
23
  if (!Number.isInteger(parsed) || parsed < 1)
24
24
  throw new CliError("validation_error", `"${value}" is not a count`);
@@ -4,6 +4,8 @@ import { setTimeout as sleep } from "node:timers/promises";
4
4
  import { CliError, writeSecurely } from "@wirecat/cli-base";
5
5
  import { annotate } from "@wirecat/cli-base/commands";
6
6
  import { NOT_FILES } from "../../domain/attachments.js";
7
+ import { isLocator } from "../../domain/locator.js";
8
+ import { messageLinkTarget } from "../../domain/message-link.js";
7
9
  import { FETCHING, keyOf } from "../../services/archive.js";
8
10
  import { batchProgress, mergeBatches } from "../../services/batch.js";
9
11
  import { saveFiles } from "../../services/file-download.js";
@@ -15,25 +17,45 @@ import { patiently } from "../../services/patience.js";
15
17
  import { FETCH } from "../effects.js";
16
18
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
17
19
  import { nextStep } from "../steps.js";
20
+ import { count } from "./attachments-command.js";
18
21
  import { messengerContext, refuseLocalWrite } from "./context.js";
19
22
  import { stopOnSignal } from "./patience.js";
20
23
  /** `messages download`: every file of one message, or with `--all` of a whole chat, never over a file already there. */
21
24
  export const downloadSubcommand = (messages, messenger) => annotate(messages.command("download"), {
22
25
  output: { entity: "message_download" },
23
- examples: [`${messenger.app.command} personal messages download "Book club" 101 --output-dir ./downloads`],
26
+ examples: [
27
+ `${messenger.app.command} personal messages download "Book club" 101 --output-dir ./downloads`,
28
+ `${messenger.app.command} personal messages download msg:${messenger.provider}/me/500/101 --attachment 1 --offline`,
29
+ ],
24
30
  effects: FETCH,
25
31
  })
26
32
  .summary("download files attached to stored messages")
27
- .description("save a message's photos, files, videos and voice notes to a folder — or a whole chat's with --all")
28
- .argument("<chat>", messenger.chatArgument)
29
- .argument("[message]", "the message id; left out with --all")
33
+ .description("save a message's photos, files, videos and voice notes to a folder — or a whole chat's with --all; " +
34
+ "with --offline, copy the ones this computer already holds, one by --attachment")
35
+ .argument("<chat>", `${messenger.chatArgument}; or a msg: locator, with no message id after it`)
36
+ .argument("[message]", "the message id; left out with --all or a locator")
30
37
  .option("--output-dir <dir>", "where to save them; created if missing", ".")
38
+ .option("--attachment <n>", "which file of the message, from 1; needed when it has more than one", count)
31
39
  .option("--all", "every file of the chat, newest first; run it again to continue where it stopped")
32
40
  .option("--pause <duration>", "with --all, a pause between pages, to stay under the provider's limits", messenger.fetching?.pause ?? FETCHING.pause)
33
41
  .option("--extract", "read text layers from the files this download maps into the local content index")
34
42
  .action(async function (chat, messageId) {
35
43
  const context = messengerContext(this, messenger);
36
- const { outputDir: output, all, pause, extract, } = this.opts();
44
+ const { outputDir: output, all, pause, extract, attachment, } = this.opts();
45
+ if (context.settings.offline) {
46
+ if (all || extract)
47
+ throw new CliError("validation_error", "--offline copies one message's saved files; leave out --all and --extract");
48
+ const saved = await context.withServices((services) => services.attachments.saveHeld({
49
+ chat,
50
+ ...(messageId === undefined ? {} : { message: messageId.trim() }),
51
+ ...(attachment === undefined ? {} : { attachment }),
52
+ output,
53
+ }));
54
+ renderAnswer(this, context, itemOf(snakeKeys({ files: saved })), () => `${saved.map((one) => one.path).join("\n")}\n`);
55
+ return;
56
+ }
57
+ if (attachment !== undefined)
58
+ throw new CliError("validation_error", "--attachment picks a file this computer already holds; add --offline");
37
59
  if (extract)
38
60
  refuseLocalWrite(context, messenger.app.command, "attachments.extract");
39
61
  if (all && messageId !== undefined) {
@@ -43,18 +65,19 @@ export const downloadSubcommand = (messages, messenger) => annotate(messages.com
43
65
  await downloadChat(this, context, messenger, chat, output, parseDuration(pause, "--pause"), extract);
44
66
  return;
45
67
  }
46
- if (messageId === undefined)
68
+ if (messageId === undefined && !isLocator(chat))
47
69
  throw new CliError("validation_error", "name a message id, or use --all");
48
- const id = messageId.trim();
70
+ const target = messageLinkTarget(chat, messageId?.trim(), messenger.provider);
71
+ const id = target.message;
49
72
  const result = await context.withServices(async (services) => {
50
- const { files, skipped } = await services.messages.download(chat, id);
73
+ const { files, skipped } = await services.messages.download(chat, messageId?.trim());
51
74
  if (skipped.length > 0)
52
75
  context.renderer.note(`not a file, not downloaded: ${skipped.join(", ")}`);
53
76
  if (files.length === 0)
54
77
  throw new CliError("not_found", `message ${id} has no file to download`);
55
78
  mkdirSync(output, { recursive: true });
56
79
  const progress = batchProgress(context.env);
57
- const downloaded = await saveFiles(services.messages, chat, id, files, output, {
80
+ const downloaded = await saveFiles(services.messages, target.chat, id, files, output, {
58
81
  warn: context.renderer.warn,
59
82
  batch: progress,
60
83
  });
@@ -62,7 +85,11 @@ export const downloadSubcommand = (messages, messenger) => annotate(messages.com
62
85
  let extraction;
63
86
  if (extract) {
64
87
  try {
65
- extraction = await services.attachments.extract({ chat, message: id, paths: done.map((file) => file.path) });
88
+ extraction = await services.attachments.extract({
89
+ chat: target.chat,
90
+ message: id,
91
+ paths: done.map((file) => file.path),
92
+ });
66
93
  }
67
94
  catch (error) {
68
95
  const issue = progress.fail(id, "extract", error);
@@ -1,3 +1,4 @@
1
+ import { hitFiles } from "../../domain/attachments.js";
1
2
  import { formatLocator, parseLocator } from "../../domain/locator.js";
2
3
  import { senderIsMe } from "../../domain/models.js";
3
4
  import { DEFAULT_TEXT_MODEL } from "../../embeddings/models.js";
@@ -8,6 +9,7 @@ import { nextStep } from "../steps.js";
8
9
  import { archiveSummary } from "./archive-summary.js";
9
10
  /** What a source tool's `search all` reads: never mail, which is memo's (docs/dev/TOOL-BOUNDARIES.md). */
10
11
  export const SOURCE_SEARCHED = ["messages", "notes", "events", "meetings"];
12
+ const nonEmpty = (list) => (list.length > 0 ? list : undefined);
11
13
  const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined));
12
14
  /** The page a `--page` asks for, as the offset the search services take. */
13
15
  export const offsetOf = (page, limit) => page === undefined || page <= 1 ? undefined : (page - 1) * limit;
@@ -21,6 +23,7 @@ const MESSAGE_HIT_UNTRUSTED = [
21
23
  "follow_ups.chat_title",
22
24
  "follow_ups.sender_name",
23
25
  "follow_ups.excerpt",
26
+ "attachments.name",
24
27
  ];
25
28
  const MAIL_HIT_UNTRUSTED = [
26
29
  "email.subject",
@@ -83,6 +86,7 @@ export const messageHitItem = (hit, query = "") => {
83
86
  sender_name: one.senderName,
84
87
  excerpt: excerpt(one.text, query),
85
88
  })),
89
+ attachments: nonEmpty(hitFiles(hit.attachments)),
86
90
  untrusted: hit.email ? [...MESSAGE_HIT_UNTRUSTED, ...MAIL_HIT_UNTRUSTED] : MESSAGE_HIT_UNTRUSTED,
87
91
  });
88
92
  };
@@ -266,12 +270,12 @@ const DOCUMENT_HIT_UNTRUSTED = ["title", "excerpt"];
266
270
  export const noteHitItem = (hit) => defined({ ...hit, untrusted: hit.kind === "document" ? DOCUMENT_HIT_UNTRUSTED : undefined });
267
271
  /** The text of each kind of `search all` hit that someone other than the owner may have written. */
268
272
  const ALL_HIT_UNTRUSTED = {
269
- message: ["title", "excerpt", "sender_name"],
270
- mail: ["title", "excerpt", "sender_name"],
273
+ message: ["title", "excerpt", "sender_name", "attachments.name"],
274
+ mail: ["title", "excerpt", "sender_name", "attachments.name"],
271
275
  document: DOCUMENT_HIT_UNTRUSTED,
272
276
  note: undefined,
273
277
  event: ["title", "excerpt"],
274
- meeting: ["title", "excerpt"],
278
+ meeting: ["title", "excerpt", "sender_name"],
275
279
  };
276
280
  export const allHitItem = (item) => item.kind === "meeting"
277
281
  ? {
@@ -285,6 +289,7 @@ export const allHitItem = (item) => item.kind === "meeting"
285
289
  offset_ms: item.startMs,
286
290
  scope: item.scope,
287
291
  id: String(item.id),
292
+ ...(item.cueRef === undefined ? {} : { cue_ref: item.cueRef, sender_name: item.speakerName ?? null }),
288
293
  untrusted: ALL_HIT_UNTRUSTED.meeting,
289
294
  }
290
295
  : defined({
@@ -300,6 +305,7 @@ export const allHitItem = (item) => item.kind === "meeting"
300
305
  sender_is_me: item.senderIsMe,
301
306
  sender_ref: item.from?.ref,
302
307
  found_by: item.foundBy,
308
+ attachments: item.attachments === undefined ? undefined : nonEmpty(item.attachments),
303
309
  untrusted: ALL_HIT_UNTRUSTED[item.kind],
304
310
  });
305
311
  /** Meaning was not searched because the model is not downloaded; `--exact` leaving it out is no gap. */
@@ -1,6 +1,14 @@
1
- import type { ProviderMetadata } from "./models.js";
1
+ import type { Attachment, ProviderMetadata } from "./models.js";
2
2
  /** Attachments that are never a file, so their messages cost no download request. */
3
3
  export declare const NOT_FILES: ReadonlySet<string>;
4
+ /** A file a message carries, as a search hit names it; `position` counts every attachment, from 1. */
5
+ export interface HitFile {
6
+ position: number;
7
+ kind: string;
8
+ name?: string;
9
+ size?: number;
10
+ }
11
+ export declare const hitFiles: (attachments: readonly Attachment[] | undefined) => HitFile[];
4
12
  /** The stored kinds `has:<kind>` matches: MAX stores a file as `file`, Telegram as `document`. */
5
13
  export declare const storedKinds: (kind: string) => string[];
6
14
  /** One file `messages download` saved, as the messenger described it. */
@@ -9,6 +9,18 @@ export const NOT_FILES = new Set([
9
9
  "call",
10
10
  "inline_keyboard",
11
11
  ]);
12
+ // The position is taken before the filter: the store numbers every attachment, and `--attachment` takes that number.
13
+ // A server search hit built by an adapter may come without attachments at all.
14
+ export const hitFiles = (attachments) => (attachments ?? []).flatMap(({ kind, name, size }, index) => NOT_FILES.has(kind)
15
+ ? []
16
+ : [
17
+ {
18
+ position: index + 1,
19
+ kind,
20
+ ...(name === undefined ? {} : { name }),
21
+ ...(size === undefined ? {} : { size }),
22
+ },
23
+ ]);
12
24
  /** The stored kinds `has:<kind>` matches: MAX stores a file as `file`, Telegram as `document`. */
13
25
  export const storedKinds = (kind) => (kind === "file" ? ["file", "document"] : [kind]);
14
26
  /**
@@ -1,3 +1,4 @@
1
+ import type { Attachment } from "./models.js";
1
2
  export type MessagePermalink = {
2
3
  url: string;
3
4
  access: "public" | "restricted" | "unknown";
@@ -7,9 +8,22 @@ export type MessagePermalink = {
7
8
  access: "unavailable";
8
9
  reason: "unsupported_chat" | "unsupported_provider" | "offline";
9
10
  };
11
+ /**
12
+ * A link to one file of the message, apart from the message's own. No messenger gives one yet: Telegram and
13
+ * WhatsApp have none, and MAX's are short-lived download addresses that hand the bytes to whoever holds them.
14
+ */
15
+ export interface FileLink {
16
+ position: number;
17
+ kind: string;
18
+ name?: string;
19
+ url: null;
20
+ reason: "unsupported_provider";
21
+ }
10
22
  export type MessageLink = MessagePermalink & {
11
23
  locator: string;
24
+ files?: FileLink[];
12
25
  };
26
+ export declare const fileLinks: (attachments: readonly Attachment[]) => FileLink[];
13
27
  export declare const messageLinkTarget: (reference: string, message: string | undefined, provider: string) => {
14
28
  chat: string;
15
29
  message: string;
@@ -1,5 +1,13 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { hitFiles } from "./attachments.js";
2
3
  import { isLocator, parseLocator } from "./locator.js";
4
+ export const fileLinks = (attachments) => hitFiles(attachments).map(({ position, kind, name }) => ({
5
+ position,
6
+ kind,
7
+ ...(name === undefined ? {} : { name }),
8
+ url: null,
9
+ reason: "unsupported_provider",
10
+ }));
3
11
  export const messageLinkTarget = (reference, message, provider) => {
4
12
  if (isLocator(reference)) {
5
13
  if (message !== undefined)
@@ -39,3 +39,10 @@ export declare const attachment: v.StrictObjectSchema<{
39
39
  readonly url: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
40
40
  }, undefined>, undefined>, undefined>, undefined>;
41
41
  }, undefined>;
42
+ /** A file named on a search hit: `position` is the one `attachments show` and `messages download` take. */
43
+ export declare const hitFile: v.StrictObjectSchema<{
44
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
45
+ readonly kind: v.StringSchema<undefined>;
46
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
47
+ readonly size: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
48
+ }, undefined>;
@@ -35,3 +35,5 @@ export const attachment = v.strictObject({
35
35
  url: v.optional(text),
36
36
  })))),
37
37
  });
38
+ /** A file named on a search hit: `position` is the one `attachments show` and `messages download` take. */
39
+ export const hitFile = v.strictObject({ position: count, kind: text, name: v.optional(text), size: v.optional(count) });
@@ -22,8 +22,17 @@ export declare const hit: v.StrictObjectSchema<{
22
22
  readonly offset_ms: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>, undefined>;
23
23
  /** A meeting passage's place: its transcript, chat or summary. */
24
24
  readonly scope: v.OptionalSchema<v.PicklistSchema<["transcript", "chat", "summary"], undefined>, undefined>;
25
+ /** A meeting transcript hit's cue, which `meetings passage` opens with the lines around it. */
26
+ readonly cue_ref: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
25
27
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
26
28
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
29
+ /** A message's files; absent when it has none. */
30
+ readonly attachments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
31
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
32
+ readonly kind: v.StringSchema<undefined>;
33
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
34
+ readonly size: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
35
+ }, undefined>, undefined>, undefined>;
27
36
  /** Absent on a note hit: the owner wrote it. */
28
37
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
29
38
  }, undefined>;
@@ -1,4 +1,5 @@
1
1
  import * as v from "valibot";
2
+ import { hitFile } from "./attachment.js";
2
3
  import { count, reference, text, time, untrusted } from "./fields.js";
3
4
  /** One result of `search all`: a message, a mail, a note, a calendar event or a meeting passage, best first. */
4
5
  export const hit = v.strictObject({
@@ -23,8 +24,12 @@ export const hit = v.strictObject({
23
24
  offset_ms: v.optional(v.nullable(count)),
24
25
  /** A meeting passage's place: its transcript, chat or summary. */
25
26
  scope: v.optional(v.picklist(["transcript", "chat", "summary"])),
27
+ /** A meeting transcript hit's cue, which `meetings passage` opens with the lines around it. */
28
+ cue_ref: v.optional(reference),
26
29
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
27
30
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
31
+ /** A message's files; absent when it has none. */
32
+ attachments: v.optional(v.array(hitFile)),
28
33
  /** Absent on a note hit: the owner wrote it. */
29
34
  untrusted,
30
35
  });
@@ -1231,6 +1231,13 @@ export declare const ENTITIES: {
1231
1231
  readonly demoted: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
1232
1232
  }, undefined>, undefined>;
1233
1233
  }, undefined>, undefined>;
1234
+ /** The files the message carries; absent when it has none. */
1235
+ readonly attachments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
1236
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1237
+ readonly kind: v.StringSchema<undefined>;
1238
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1239
+ readonly size: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
1240
+ }, undefined>, undefined>, undefined>;
1234
1241
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
1235
1242
  }, undefined>;
1236
1243
  readonly "message.summary": Omit<v.StrictObjectSchema<{
@@ -1895,6 +1902,13 @@ export declare const ENTITIES: {
1895
1902
  readonly url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
1896
1903
  readonly access: v.PicklistSchema<["public", "restricted", "unknown", "unavailable"], undefined>;
1897
1904
  readonly reason: v.NullableSchema<v.PicklistSchema<["unsupported_chat", "unsupported_provider", "offline"], undefined>, undefined>;
1905
+ readonly files: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
1906
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
1907
+ readonly kind: v.StringSchema<undefined>;
1908
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1909
+ readonly url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
1910
+ readonly reason: v.NullableSchema<v.PicklistSchema<["unsupported_provider"], undefined>, undefined>;
1911
+ }, undefined>, undefined>, undefined>;
1898
1912
  }, undefined>;
1899
1913
  readonly message_write: v.StrictObjectSchema<{
1900
1914
  readonly operation_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -4553,7 +4567,14 @@ export declare const ENTITIES: {
4553
4567
  readonly found_by: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<["title", "words", "meaning"], undefined>, undefined>, undefined>;
4554
4568
  readonly offset_ms: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>, undefined>;
4555
4569
  readonly scope: v.OptionalSchema<v.PicklistSchema<["transcript", "chat", "summary"], undefined>, undefined>;
4570
+ readonly cue_ref: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4556
4571
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
4572
+ readonly attachments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
4573
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
4574
+ readonly kind: v.StringSchema<undefined>;
4575
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
4576
+ readonly size: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
4577
+ }, undefined>, undefined>, undefined>;
4557
4578
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
4558
4579
  }, undefined>;
4559
4580
  readonly document: v.StrictObjectSchema<{
@@ -8,7 +8,7 @@ import { accountSession } from "./account-session.js";
8
8
  import { adminReport } from "./admin-report.js";
9
9
  import { agenda } from "./agenda.js";
10
10
  import { analysisConsent, analysisConsentRevocation } from "./analysis-consent.js";
11
- import { attachment } from "./attachment.js";
11
+ import { attachment, hitFile } from "./attachment.js";
12
12
  import { attachmentBytes, attachmentExtraction, attachmentTextChange } from "./attachment-results.js";
13
13
  import { chart } from "./chart.js";
14
14
  import { chat } from "./chat.js";
@@ -120,6 +120,8 @@ export const ENTITIES = {
120
120
  "follow_ups",
121
121
  "email",
122
122
  ]).entries,
123
+ /** The files the message carries; absent when it has none. */
124
+ attachments: v.optional(v.array(hitFile)),
123
125
  untrusted,
124
126
  }),
125
127
  "message.summary": v.pick(message, [
@@ -6,4 +6,12 @@ export declare const messageLink: v.StrictObjectSchema<{
6
6
  readonly access: v.PicklistSchema<["public", "restricted", "unknown", "unavailable"], undefined>;
7
7
  /** Why there is no link. */
8
8
  readonly reason: v.NullableSchema<v.PicklistSchema<["unsupported_chat", "unsupported_provider", "offline"], undefined>, undefined>;
9
+ /** The message's files, each with its own link where the messenger gives one; absent when it has none. */
10
+ readonly files: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
11
+ readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
12
+ readonly kind: v.StringSchema<undefined>;
13
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
14
+ readonly url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
15
+ readonly reason: v.NullableSchema<v.PicklistSchema<["unsupported_provider"], undefined>, undefined>;
16
+ }, undefined>, undefined>, undefined>;
9
17
  }, undefined>;
@@ -1,5 +1,5 @@
1
1
  import * as v from "valibot";
2
- import { reference, text } from "./fields.js";
2
+ import { count, reference, text } from "./fields.js";
3
3
  /** Where a message can be opened: its locator always, a provider link when there is one and who that link reaches. */
4
4
  export const messageLink = v.strictObject({
5
5
  ref: reference,
@@ -7,4 +7,12 @@ export const messageLink = v.strictObject({
7
7
  access: v.picklist(["public", "restricted", "unknown", "unavailable"]),
8
8
  /** Why there is no link. */
9
9
  reason: v.nullable(v.picklist(["unsupported_chat", "unsupported_provider", "offline"])),
10
+ /** The message's files, each with its own link where the messenger gives one; absent when it has none. */
11
+ files: v.optional(v.array(v.strictObject({
12
+ position: count,
13
+ kind: text,
14
+ name: v.optional(text),
15
+ url: v.nullable(text),
16
+ reason: v.nullable(v.picklist(["unsupported_provider"])),
17
+ }))),
10
18
  });
@@ -88,6 +88,8 @@ export interface SearchHit {
88
88
  id: number;
89
89
  text: string;
90
90
  startMs: number | null;
91
+ /** Who spoke a transcript hit's cue, as the transcript names them; absent for chat and summary hits. */
92
+ speakerName?: string | null;
91
93
  /** `corrected` when a query word matched only as a stored word recognition may have garbled. */
92
94
  match?: "words" | "corrected";
93
95
  /** For a `corrected` hit, the stored words each such query word matched in this passage. */
@@ -62,7 +62,12 @@ export const meetingStoreContract = (make) => {
62
62
  assert.equal((await s.participants("example.com", 1)).length, 1);
63
63
  assert.equal((await s.participants("alice", 2)).length, 0);
64
64
  assert.deepEqual((await s.search("EXAMPLE")).map((h) => h.scope).sort(), ["chat", "summary", "transcript"]);
65
- assert.equal((await s.search("Ship"))[0]?.startMs, 0);
65
+ const [ship] = await s.search("Ship");
66
+ assert.equal(ship?.startMs, 0);
67
+ const cue = t.rows.find((row) => row.id === ship?.id);
68
+ assert.ok(cue);
69
+ assert.equal(ship?.ref, `meeting:${d.meeting.accountId}/${d.meeting.id}/${t.transcript.id}/${cue.position}`, "a transcript hit names its cue");
70
+ assert.equal(ship?.speakerName, cue.speakerName);
66
71
  }),
67
72
  test("idempotence, updates and omitted parts retain history", async (s) => {
68
73
  const input = sampleMeeting();
@@ -3,6 +3,7 @@ import { EVENT_QUERY } from "../event-query.js";
3
3
  import { MEETING_QUERY, meetingItem } from "../item.js";
4
4
  import { eventItem } from "../read-answers.js";
5
5
  import { MeetingError, positiveInteger } from "../reads.js";
6
+ import { formatMeetingReference } from "../reference.js";
6
7
  const overlap = (start, end, from, to) => start !== null && start <= to && (end ?? start) >= from;
7
8
  /** A test store keeps no accounts: every id is an account of the `memory` provider. */
8
9
  const MEMORY_OWNER = (id) => ({ provider: "memory", account: String(id) });
@@ -107,11 +108,19 @@ export const memoryMeetingStore = () => {
107
108
  const rows = d.transcripts
108
109
  .filter((t) => t.transcript.supersededAt === null && t.transcript.deletedAt === null)
109
110
  .flatMap((t) => t.rows.map((r) => ({
111
+ ref: formatMeetingReference({
112
+ type: "meeting",
113
+ accountId: d.meeting.accountId,
114
+ meetingId: d.meeting.id,
115
+ transcriptId: t.transcript.id,
116
+ cuePosition: r.position,
117
+ }),
110
118
  meetingId: d.meeting.id,
111
119
  scope: "transcript",
112
120
  id: r.id,
113
121
  text: r.text,
114
122
  startMs: r.startMs,
123
+ speakerName: r.speakerName,
115
124
  })));
116
125
  rows.push(...d.chat.map((r) => ({
117
126
  meetingId: d.meeting.id,
@@ -9,6 +9,7 @@ import { attachment as attachmentEntity } from "../entities/attachment.js";
9
9
  import { type TextOrigin } from "../store/store.js";
10
10
  import { batchProgress } from "./batch.js";
11
11
  import { type ServiceDeps } from "./deps.js";
12
+ import { type Saved } from "./file-download.js";
12
13
  export type ExtractStatus = "extracted" | "needs-agent" | "unreadable" | "engine-missing" | "too-large" | "missing";
13
14
  /** One file looked at. Never its text: the text stays in the local store. */
14
15
  export interface ExtractItem {
@@ -114,6 +115,16 @@ export interface AttachmentsService {
114
115
  items: AttachmentItem[];
115
116
  has_more: boolean;
116
117
  }>;
118
+ /**
119
+ * Copies files the store already holds to `output`, never over a file there, and never asks the messenger: one
120
+ * not held locally is an error before anything is written. The store keeps pointing at its own copy.
121
+ */
122
+ saveHeld(target: {
123
+ chat: string;
124
+ message?: string;
125
+ attachment?: number;
126
+ output: string;
127
+ }): Promise<Saved[]>;
117
128
  /** Keeps text an agent read from a file — a scan, a photo — replacing whatever was there. Sends nothing. */
118
129
  setText(target: {
119
130
  chat: string;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { constants } from "node:fs";
3
- import { open, readFile, stat } from "node:fs/promises";
2
+ import { constants, mkdirSync } from "node:fs";
3
+ import { lstat, open, readFile, stat } from "node:fs/promises";
4
4
  import { CliError } from "@wirecat/cli-base";
5
5
  import { queryFieldsOf, walkQuery } from "@wirecat/cli-base/query";
6
6
  import { directoryPaths } from "../attachments/directory.js";
@@ -18,6 +18,7 @@ import { refusedPlace } from "../sends/upload.js";
18
18
  import { attachmentColumns, queryFilter, } from "../store/store.js";
19
19
  import { batchProgress } from "./batch.js";
20
20
  import { accountStore } from "./deps.js";
21
+ import { save } from "./file-download.js";
21
22
  import { chatIdIn } from "./message-resolution.js";
22
23
  export const ATTACHMENT_QUERY = {
23
24
  entity: "attachment",
@@ -407,6 +408,48 @@ export const attachmentsService = (deps) => ({
407
408
  ...preview,
408
409
  };
409
410
  },
411
+ saveHeld: async ({ chat, message, attachment, output }) => {
412
+ if (attachment !== undefined && (!Number.isSafeInteger(attachment) || attachment < 1))
413
+ throw new CliError("validation_error", "attachment must be a position from 1");
414
+ const store = await accountStore(deps);
415
+ const { account } = store;
416
+ const { chatId, messageId } = await messageOf(deps, store, chat, message);
417
+ const ref = formatLocator({ ...account, chat: chatId, message: messageId });
418
+ const files = (await store.attachments.attachments({ chatId, messageId, limit: 1000 })).filter(({ kind }) => !NOT_FILES.has(kind));
419
+ if (files.length === 0)
420
+ throw new CliError("not_found", `the local store holds no file of ${ref}`);
421
+ const chosen = attachment === undefined ? files : files.filter(({ position }) => position === attachment - 1);
422
+ if (chosen.length === 0)
423
+ throw new CliError("not_found", `${ref} has no file at position ${attachment}; its files are at ${files.map(({ position }) => position + 1).join(", ")}`);
424
+ const env = deps.env ?? process.env;
425
+ const held = await Promise.all(chosen.map(async (file) => {
426
+ const missing = new CliError("not_found", `file ${file.position + 1} of ${ref} is not saved on this computer; run messages download without --offline`);
427
+ if (file.localPath === null)
428
+ throw missing;
429
+ if (refusedPlace(file.localPath, deps.messenger.app, env))
430
+ throw new CliError("validation_error", "cannot copy known credential files, the WireCat apps' own files or the message store");
431
+ const found = await lstat(file.localPath).catch(() => undefined);
432
+ if (!found)
433
+ throw missing;
434
+ if (!found.isFile())
435
+ throw new CliError("validation_error", "the saved attachment must be a regular file");
436
+ return { file, path: file.localPath };
437
+ }));
438
+ mkdirSync(output, { recursive: true });
439
+ const saved = [];
440
+ for (const { file, path } of held)
441
+ saved.push(await save({
442
+ kind: file.kind,
443
+ ...(file.name === null ? {} : { name: file.name }),
444
+ ...(file.mime === null ? {} : { mime: file.mime }),
445
+ position: file.position,
446
+ bytes: async function* () {
447
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
448
+ yield* handle.createReadStream();
449
+ },
450
+ }, output, `${messageId}-${file.position + 1}`));
451
+ return saved;
452
+ },
410
453
  setText: async ({ chat, message, attachment, text }) => {
411
454
  if (text.trim() === "")
412
455
  throw new CliError("validation_error", "the text is empty — nothing to keep");
@@ -72,7 +72,7 @@ export declare const fromStore: (deps: Pick<ServiceDeps, "offline" | "reads">) =
72
72
  export declare const nothingStored: (messenger: MessengerTraits) => string;
73
73
  /** Where a store-mode messenger's history comes from, for what it cannot do. */
74
74
  export declare const PUSHED = "this messenger's history is read from the local store";
75
- export declare const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context`, `contacts list|show` and `review`";
75
+ export declare const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context|download`, `contacts list|show` and `review`";
76
76
  /** Over a connection that is already open — an MCP session holds one for minutes. */
77
77
  export declare const onlineDeps: (messenger: ServiceMessenger, adapter: MessengerAdapter, guard: SendGuard, where?: {
78
78
  profile?: string;
@@ -55,7 +55,7 @@ export const fromStore = (deps) => deps.offline || deps.reads === "store";
55
55
  export const nothingStored = (messenger) => `nothing stored for this chat yet — keep \`${typedCommand(messenger.app.command, "serve")}\` running`;
56
56
  /** Where a store-mode messenger's history comes from, for what it cannot do. */
57
57
  export const PUSHED = "this messenger's history is read from the local store";
58
- export const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context`, `contacts list|show` and `review`";
58
+ export const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context|download`, `contacts list|show` and `review`";
59
59
  /** Over a connection that is already open — an MCP session holds one for minutes. */
60
60
  export const onlineDeps = (messenger, adapter, guard, where = {}) => ({
61
61
  messenger,
@@ -197,7 +197,6 @@ export interface Reacted {
197
197
  }
198
198
  /** max-cli's: many at once is what a ban for automation looks like. */
199
199
  export declare const DELETE_AT_ONCE = 10;
200
- /** Every write goes through the guard: asked before it goes, told after, on every outcome. */
201
200
  export interface MessagesService {
202
201
  /** The account these messages are read as, which names each one's locator. */
203
202
  reader(): Promise<AccountKey>;
@@ -212,8 +211,8 @@ export interface MessagesService {
212
211
  }): Promise<Page<Message> & {
213
212
  discussion: Discussion;
214
213
  }>;
215
- /** The files of one message. Always from the messenger, whatever its history is read from. */
216
- download(chat: string, message: Id): Promise<Download>;
214
+ /** The files of one message, `chat` and its id or a `msg:` locator alone. Always from the messenger. */
215
+ download(chat: string, message?: Id): Promise<Download>;
217
216
  /**
218
217
  * Records in the local store where the files went, so their text can be read later. Only a
219
218
  * message the store holds; answers how many attachments it recorded.
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { validateFormattedText, visibleFormattedText } from "../domain/formatting.js";
3
3
  import { formatLocator } from "../domain/locator.js";
4
- import { messageLinkTarget, validatePermalink } from "../domain/message-link.js";
4
+ import { fileLinks, messageLinkTarget, validatePermalink } from "../domain/message-link.js";
5
5
  import { pinDurationsText } from "../domain/pin-duration.js";
6
6
  import { threadIdOf } from "../domain/thread.js";
7
7
  import { capability } from "../ports/messenger.js";
@@ -22,6 +22,12 @@ import { searchServer, sourceKey, } from "./server-search.js";
22
22
  import { readThreadContext, threadBounds } from "./thread-context.js";
23
23
  /** max-cli's: many at once is what a ban for automation looks like. */
24
24
  export const DELETE_AT_ONCE = 10;
25
+ /** Every write goes through the guard: asked before it goes, told after, on every outcome. */
26
+ /** Only a message with files says what can be linked of them. */
27
+ const filesOf = ({ attachments }) => {
28
+ const files = fileLinks(attachments);
29
+ return files.length > 0 ? { files } : {};
30
+ };
25
31
  export const messagesService = (deps) => {
26
32
  const { guard } = deps;
27
33
  const inStore = async (read) => read(await deps.store(), await deps.account());
@@ -185,13 +191,15 @@ export const messagesService = (deps) => {
185
191
  const store = await deps.store();
186
192
  const chat = await readChatId(deps, target.chat, store, account);
187
193
  const found = await store.messages.around(account, chat, target.message, { before: 0, after: 0 });
188
- if (!found.some((item) => item.id === target.message && item.chatId === chat))
194
+ const held = found.find((item) => item.id === target.message && item.chatId === chat);
195
+ if (!held)
189
196
  throw new CliError("not_found", "no such message in this account's stored chat");
190
197
  return {
191
198
  locator: formatLocator({ ...account, chat, message: target.message }),
192
199
  url: null,
193
200
  access: "unavailable",
194
201
  reason: deps.offline ? "offline" : "unsupported_provider",
202
+ ...filesOf(held),
195
203
  };
196
204
  }
197
205
  const connection = await deps.connection();
@@ -199,20 +207,31 @@ export const messagesService = (deps) => {
199
207
  throw new CliError("authentication_error", "the connected account differs from the recorded profile; refresh the session before linking");
200
208
  const { id: chat } = await connection.resolve(target.chat);
201
209
  const locator = formatLocator({ ...account, chat, message: target.message });
202
- if (connection.permalink)
203
- return { locator, ...validatePermalink(await connection.permalink(chat, target.message)) };
210
+ if (connection.permalink) {
211
+ const permalink = validatePermalink(await connection.permalink(chat, target.message));
212
+ const held = await deps
213
+ .store()
214
+ .then((store) => store.messages.around(account, chat, target.message, { before: 0, after: 0 }))
215
+ .then((found) => found.find((item) => item.id === target.message && item.chatId === chat))
216
+ .catch(() => undefined);
217
+ return { locator, ...permalink, ...(held ? filesOf(held) : {}) };
218
+ }
204
219
  const found = await capability(connection, "around", "read a message")(chat, target.message, {
205
220
  before: 0,
206
221
  after: 0,
207
222
  });
208
- if (!found.some((item) => item.id === target.message && item.chatId === chat))
223
+ const held = found.find((item) => item.id === target.message && item.chatId === chat);
224
+ if (!held)
209
225
  throw new CliError("not_found", "no such message in this chat");
210
- return { locator, url: null, access: "unavailable", reason: "unsupported_provider" };
226
+ return { locator, url: null, access: "unavailable", reason: "unsupported_provider", ...filesOf(held) };
211
227
  },
212
- download: async (chat, message) => {
228
+ download: async (reference, message) => {
229
+ const target = messageLinkTarget(reference, message, deps.messenger.provider);
230
+ if (target.account !== undefined && target.account !== (await deps.account()).account)
231
+ throw new CliError("validation_error", "that locator belongs to another account; select its profile first");
213
232
  const connection = await deps.connection();
214
233
  const download = capability(connection, "download", "download attachments");
215
- return download((await connection.resolve(chat)).id, message);
234
+ return download((await connection.resolve(target.chat)).id, target.message);
216
235
  },
217
236
  keepDownloaded: (chat, message, files) => inStore(async (store, account) => {
218
237
  const chatId = (await storedChatId(deps.messenger, chat, store, account).catch(() => undefined)) ??
@@ -19,6 +19,9 @@ export interface MeetingSearchItem {
19
19
  scope: SearchHit["scope"];
20
20
  id: number;
21
21
  startMs: number | null;
22
+ /** A transcript hit's cue, `meeting:<account>/<meeting>/<transcript>/<cue>`, which `meetings passage` opens. */
23
+ cueRef?: string;
24
+ speakerName?: string | null;
22
25
  }
23
26
  export interface MeetingSearchCursor {
24
27
  accountId: number;
@@ -104,6 +104,9 @@ const collectMeetings = async (store, account, request, query) => {
104
104
  scope: hit.scope,
105
105
  id: hit.id,
106
106
  startMs: hit.startMs,
107
+ ...(hit.scope === "transcript" && hit.ref !== undefined
108
+ ? { cueRef: hit.ref, speakerName: hit.speakerName ?? null }
109
+ : {}),
107
110
  },
108
111
  });
109
112
  if (collected.items.length > request.limit) {
@@ -1,4 +1,5 @@
1
1
  import { type ErrorCode } from "@wirecat/cli-base";
2
+ import { type HitFile } from "../domain/attachments.js";
2
3
  import { type MailFields } from "../domain/models.js";
3
4
  import type { MessengerTraits } from "../ports/traits.js";
4
5
  import type { AccountKey, MessageStore } from "../store/store.js";
@@ -30,6 +31,8 @@ export interface SearchAllItem {
30
31
  senderIsMe?: boolean;
31
32
  /** A mail's people, labels and thread, as `search mail` gives them. */
32
33
  email?: MailFields;
34
+ /** A message's or a mail's files, as `search messages` names them. */
35
+ attachments?: HitFile[];
33
36
  /** Which search found it: words, meaning or both. */
34
37
  foundBy: ("title" | "words" | "meaning")[];
35
38
  }
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { parseLucene } from "@wirecat/cli-base/query";
3
+ import { hitFiles } from "../domain/attachments.js";
3
4
  import { parseLocator } from "../domain/locator.js";
4
5
  import { senderIsMe } from "../domain/models.js";
5
6
  import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
@@ -102,6 +103,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
102
103
  : { name: hit.senderName, ref: null },
103
104
  senderIsMe: senderIsMe(hit, at.account),
104
105
  ...(hit.email ? { email: hit.email } : {}),
106
+ attachments: hitFiles(hit.attachments),
105
107
  foundBy,
106
108
  };
107
109
  };
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { joinKeyOf, MeetingError } from "../../meetings/index.js";
2
+ import { formatMeetingReference, joinKeyOf, MeetingError } from "../../meetings/index.js";
3
3
  import { normalize } from "../normalize.js";
4
4
  import * as eventQueries from "./events.js";
5
5
  import { fromJson, int, page, str, toJson } from "./events.js";
@@ -515,17 +515,18 @@ const search = (database, query, filter) => {
515
515
  const rows = database
516
516
  .prepare(`WITH hit AS (SELECT rowid AS r FROM meeting_words WHERE meeting_words MATCH ?),
517
517
  picked AS (SELECT id, row_number() OVER (ORDER BY coalesce(started_at, 0) DESC, id) AS n FROM (${picked.sql}))
518
- SELECT p.n, 0 AS kind, t.id AS a, r.position AS b, r.id, t.meeting_id, r.start_ms, coalesce(length(CAST(r.text AS BLOB)),0) AS bytes
518
+ SELECT p.n, 0 AS kind, t.id AS a, r.position AS b, r.id, t.meeting_id, r.start_ms, coalesce(length(CAST(r.text AS BLOB)),0) AS bytes,
519
+ r.speaker_name AS speaker, m.account_id
519
520
  FROM hit h JOIN meeting_transcript_rows r ON h.r % 4 = 1 AND r.id = h.r / 4
520
521
  JOIN meeting_transcripts t ON t.id = r.meeting_transcript_id
521
522
  AND t.superseded_at IS NULL AND t.deleted_at IS NULL
522
- JOIN picked p ON p.id = t.meeting_id
523
+ JOIN picked p ON p.id = t.meeting_id JOIN meetings m ON m.id = t.meeting_id
523
524
  UNION ALL
524
- SELECT p.n, 1, c.id, 0, c.id, c.meeting_id, NULL, coalesce(length(CAST(c.text AS BLOB)),0)
525
+ SELECT p.n, 1, c.id, 0, c.id, c.meeting_id, NULL, coalesce(length(CAST(c.text AS BLOB)),0), NULL, NULL
525
526
  FROM hit h JOIN meeting_chat_messages c ON h.r % 4 = 2 AND c.id = h.r / 4
526
527
  JOIN picked p ON p.id = c.meeting_id
527
528
  UNION ALL
528
- SELECT p.n, 2, s.id, 0, s.id, s.meeting_id, NULL, coalesce(length(CAST(s.title AS BLOB)),0)+coalesce(length(CAST(s.overview AS BLOB)),0)+coalesce(length(CAST(s.sections AS BLOB)),0)+coalesce(length(CAST(s.next_steps AS BLOB)),0)+coalesce(length(CAST(s.content AS BLOB)),0)
529
+ SELECT p.n, 2, s.id, 0, s.id, s.meeting_id, NULL, coalesce(length(CAST(s.title AS BLOB)),0)+coalesce(length(CAST(s.overview AS BLOB)),0)+coalesce(length(CAST(s.sections AS BLOB)),0)+coalesce(length(CAST(s.next_steps AS BLOB)),0)+coalesce(length(CAST(s.content AS BLOB)),0), NULL, NULL
529
530
  FROM hit h JOIN meeting_summaries s ON h.r % 4 = 3 AND s.id = h.r / 4
530
531
  JOIN picked p ON p.id = s.meeting_id
531
532
  ORDER BY 1, 2, 3, 4 LIMIT 10001`)
@@ -540,7 +541,20 @@ const search = (database, query, filter) => {
540
541
  const scopes = ["transcript", "chat", "summary"];
541
542
  return rows.map((row) => {
542
543
  const scope = scopes[Number(row.kind)];
544
+ const cue = scope === "transcript"
545
+ ? {
546
+ ref: formatMeetingReference({
547
+ type: "meeting",
548
+ accountId: Number(row.account_id),
549
+ meetingId: Number(row.meeting_id),
550
+ transcriptId: Number(row.a),
551
+ cuePosition: Number(row.b),
552
+ }),
553
+ speakerName: row.speaker === null ? null : String(row.speaker),
554
+ }
555
+ : {};
543
556
  return {
557
+ ...cue,
544
558
  meetingId: Number(row.meeting_id),
545
559
  scope,
546
560
  id: Number(row.id),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.531.0",
3
+ "version": "0.533.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -3329,6 +3329,9 @@
3329
3329
  "--all": {
3330
3330
  "in": "all"
3331
3331
  },
3332
+ "--attachment": {
3333
+ "in": "all"
3334
+ },
3332
3335
  "--extract": "all",
3333
3336
  "--output-dir": {
3334
3337
  "in": "all"