@wirecat/cli-core 0.532.0 → 0.534.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,30 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.534.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`parseMail` also returns `messageId`, `references`, `replyTo` and `date`**, and takes a third argument, the most
15
+ attachments to keep (`MAX_ATTACHMENTS`, 50, by default), so a command that lists one message's files can ask for all
16
+ of them.
17
+
18
+ ## 0.533.0 — 01.11.2026
19
+
20
+ ### Added
21
+
22
+ - **A `search all` meeting transcript hit names its cue and its speaker**: `cue_ref`
23
+ (`meeting:<account>/<meeting>/<transcript>/<cue>`) and `sender_name` (marked untrusted). `meetings passage <cue_ref>
24
+ --before-n 3 --after-n 3` opens the lines around it; `ref` stays the meeting. Chat and summary hits are unchanged.
25
+ `SearchHit` from the meetings store carries the same cue as `ref` and `speakerName`; `MeetingSearchItem` carries
26
+ `cueRef` and `speakerName`.
27
+
28
+ ### Fixed
29
+
30
+ - **A `serve` unit or launchd agent survives an upgrade with pnpm.** `server install` wrote the script's resolved
31
+ path, a versioned folder in pnpm's global store that the next upgrade removes, so the unit could not start until it
32
+ was installed again. It now writes the path the shim gave, a link pnpm points at the new version.
33
+
10
34
  ## 0.532.0 — 01.11.2026
11
35
 
12
36
  ### 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,
@@ -275,7 +275,7 @@ const ALL_HIT_UNTRUSTED = {
275
275
  document: DOCUMENT_HIT_UNTRUSTED,
276
276
  note: undefined,
277
277
  event: ["title", "excerpt"],
278
- meeting: ["title", "excerpt"],
278
+ meeting: ["title", "excerpt", "sender_name"],
279
279
  };
280
280
  export const allHitItem = (item) => item.kind === "meeting"
281
281
  ? {
@@ -289,6 +289,7 @@ export const allHitItem = (item) => item.kind === "meeting"
289
289
  offset_ms: item.startMs,
290
290
  scope: item.scope,
291
291
  id: String(item.id),
292
+ ...(item.cueRef === undefined ? {} : { cue_ref: item.cueRef, sender_name: item.speakerName ?? null }),
292
293
  untrusted: ALL_HIT_UNTRUSTED.meeting,
293
294
  }
294
295
  : defined({
@@ -22,6 +22,8 @@ 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>;
27
29
  /** A message's files; absent when it has none. */
@@ -24,6 +24,8 @@ export const hit = v.strictObject({
24
24
  offset_ms: v.optional(v.nullable(count)),
25
25
  /** A meeting passage's place: its transcript, chat or summary. */
26
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),
27
29
  /** A meeting passage's stored line, so two passages of one meeting stay apart; `search meetings` gives the same. */
28
30
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
29
31
  /** A message's files; absent when it has none. */
@@ -4567,6 +4567,7 @@ export declare const ENTITIES: {
4567
4567
  readonly found_by: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<["title", "words", "meaning"], undefined>, undefined>, undefined>;
4568
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>;
4569
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>;
4570
4571
  readonly id: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
4571
4572
  readonly attachments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
4572
4573
  readonly position: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
@@ -6,7 +6,7 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
6
6
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
7
7
  export { type MailIdsInput, mailIds } from "./ids.js";
8
8
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
9
- export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
9
+ export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
10
10
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
11
11
  export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
12
12
  export { type MailFolderProgress, mailProgress } from "./progress.js";
@@ -6,7 +6,7 @@ export { dropMissing, tooManyGone } from "./gone.js";
6
6
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
7
7
  export { mailIds } from "./ids.js";
8
8
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
9
- export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.js";
9
+ export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
10
10
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
11
11
  export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
12
12
  export { mailProgress } from "./progress.js";
@@ -8,6 +8,12 @@ export interface ParsedMail {
8
8
  to: Address[];
9
9
  cc: Address[];
10
10
  bcc: Address[];
11
+ replyTo: Address[];
12
+ date: string | null;
13
+ /** Message-ID as the first `<id>` the header holds, brackets kept; the header as written when it holds none. */
14
+ messageId: string | null;
15
+ /** Every `<id>` of References, in the header's order, brackets kept. */
16
+ references: string[];
11
17
  attachments: {
12
18
  id: number;
13
19
  name: string;
@@ -17,8 +23,10 @@ export interface ParsedMail {
17
23
  text: string;
18
24
  }
19
25
  export declare const MAX_BODY_CHARS = 1000000;
26
+ /** What sync stores; a command that shows the files of one message passes its own limit. */
27
+ export declare const MAX_ATTACHMENTS = 50;
20
28
  export declare const htmlToText: (html: string) => string;
21
29
  /** Some senders put their HTML in the text/plain part; its first tag gives it away. */
22
30
  export declare const looksLikeHtml: (text: string) => boolean;
23
31
  /** A raw RFC 5322 message, as IMAP's `BODY[]` holds it. */
24
- export declare const parseMail: (raw: Uint8Array, maxBodyChars?: number) => Promise<ParsedMail>;
32
+ export declare const parseMail: (raw: Uint8Array, maxBodyChars?: number, maxAttachments?: number) => Promise<ParsedMail>;
@@ -2,6 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import PostalMime, {} from "postal-mime";
3
3
  // The store cuts a body into pieces itself; this only guards against a pathological one, like a pasted log.
4
4
  export const MAX_BODY_CHARS = 1_000_000;
5
+ /** What sync stores; a command that shows the files of one message passes its own limit. */
6
+ export const MAX_ATTACHMENTS = 50;
7
+ const BRACKETED_ID = /<[^<>\s]+>/g;
5
8
  const addresses = (list) => list
6
9
  .flatMap((entry) => entry.group ?? [entry])
7
10
  .flatMap(({ address, name }) => (address ? [{ email: address.toLowerCase(), name: name || null }] : []));
@@ -52,7 +55,7 @@ const bytesOf = (content) => typeof content === "string"
52
55
  ? content
53
56
  : new Uint8Array(content);
54
57
  /** A raw RFC 5322 message, as IMAP's `BODY[]` holds it. */
55
- export const parseMail = async (raw, maxBodyChars = MAX_BODY_CHARS) => {
58
+ export const parseMail = async (raw, maxBodyChars = MAX_BODY_CHARS, maxAttachments = MAX_ATTACHMENTS) => {
56
59
  let email;
57
60
  try {
58
61
  email = await PostalMime.parse(raw);
@@ -68,7 +71,11 @@ export const parseMail = async (raw, maxBodyChars = MAX_BODY_CHARS) => {
68
71
  to: addresses(email.to ?? []),
69
72
  cc: addresses(email.cc ?? []),
70
73
  bcc: addresses(email.bcc ?? []),
71
- attachments: email.attachments.slice(0, 50).map((part, id) => ({
74
+ replyTo: addresses(email.replyTo ?? []),
75
+ date: email.date ?? null,
76
+ messageId: email.messageId?.match(BRACKETED_ID)?.[0] ?? email.messageId ?? null,
77
+ references: email.references?.match(BRACKETED_ID) ?? [],
78
+ attachments: email.attachments.slice(0, maxAttachments).map((part, id) => ({
72
79
  id,
73
80
  name: (part.filename ?? `attachment-${id}`).slice(0, 200),
74
81
  mime: part.mimeType || null,
@@ -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,
@@ -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,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.532.0",
3
+ "version": "0.534.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",