@markmnl/fmsg-mcp 0.2.6 → 0.2.8

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
@@ -2,6 +2,56 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `wait_for_message` no longer lets a model step past an unread message silently. Messages on other
6
+ threads that arrive while a batch settles are not returned and `after_id` moves past them, as
7
+ before; now `next` (and the text) names each one first ("Also new and not included here: message
8
+ 40 in another thread … read it with get_thread (or get_message) "40" … before waiting again"),
9
+ and results add `pending_ids`. The cursor is not held back, which would replay the returned batch.
10
+ - `get_thread` says it returns one lineage: results add `scope: "lineage"`, the description and
11
+ `next` say other replies in the thread (siblings and other branches) are not included and that
12
+ `list_messages` shows newer messages, and `complete` is described as the lineage being complete.
13
+ The Web API's thread routes return only the lineage, so other replies are not listed.
14
+ - Thread messages add `no_reply`, `terminal`, `important`, `added` and `compressed`, and results add
15
+ the target's `no_reply`. `next` never suggests replying to a terminal message (no replies are
16
+ possible) or a no-reply one (the sender asked for none; `reply` refuses unless `allow_no_reply`).
17
+ - Addresses in text results are code spans instead of Markdown-escaped text, so a model copying
18
+ `@bob_mcp@example.com` no longer gets `@bob\_mcp@example.com`. Address validation follows the
19
+ fmsg specification's user-part rule (letters and digits with single `.`, `_` or `-` between them)
20
+ and rejects backslashes, spaces and control characters, with a hint about Markdown escaping.
21
+ - `size` is described as the size on the wire (the compressed length when `compressed` is true), and
22
+ message items add `compressed`.
23
+ - Redaction covers more credential formats: cloud access key ids (`AKIA…`, `ASIA…`, including the
24
+ widely published documentation example) and 40-character secret access keys next to their usual
25
+ label, `gho_`/`ghs_`/`ghu_` and other source-hosting tokens (`glpat-…`), chat-platform tokens
26
+ (`xox…`, `xapp-…`), `AIza…` API keys, payment-platform keys (`sk_live_…`, `rk_live_…`,
27
+ `sk_test_…`, `whsec_…`) and `PGP PRIVATE KEY BLOCK`s. Ordinary text is left alone.
28
+ - `download_attachment` and `save_attachment` add `type` (the same value as `content_type`). A
29
+ filename the message does not have now says so and lists the message's attachments instead of
30
+ suggesting the message may not be visible.
31
+ - Attachments are listed in one order (by filename) by every tool, including send results.
32
+ - `delivery_status` describes received messages (the host's own receipt records, often with code
33
+ null) and what `via` means.
34
+ - `get_message` adds `thread_topic` (one thread lookup, only for replies) and shows it in the text;
35
+ `send_message` and `reply` results add `thread_topic` when it is known without another request.
36
+
37
+ - Tool output schemas are open and additive, so hosts that cache a tool's `outputSchema` when the
38
+ connector is added keep validating results after upgrades. Every output object, at every depth,
39
+ accepts unknown properties (no `additionalProperties: false`); values that may grow (statuses,
40
+ transports, resolutions, delivery `status` and `via`, skip reasons, `authentication`) are strings
41
+ with their current values in the description instead of enums; and only fields present since
42
+ 0.2.5 are required, so fields added later are optional in the schema though still returned.
43
+ Schemas published by 0.2.6 and earlier were closed, so hosts that cached one reject results with
44
+ newer fields ("must NOT have additional properties") until the connector is reconnected once;
45
+ no server change can fix an already-cached closed schema.
46
+ - `whoami` again matches its 0.2.5 shape: `transport` is `stdio` or `http` (0.2.6 reported
47
+ `streamable-http`; the text still says Streamable HTTP) and `directory_names` is always present,
48
+ empty when there is no directory. Hosts holding 0.2.5 schemas validate `whoami`,
49
+ `resolve_address`, `mark_read` and `react` again. Hosts that cached 0.2.6 schemas validate every
50
+ tool except `whoami` over HTTP, which needs the one-time reconnect.
51
+ - Tests check that no published output schema is closed or uses `enum`/`const`, that required
52
+ fields match 0.2.5, and that representative results of every tool validate against the
53
+ published schemas and against the 0.2.5 schemas apart from unknown properties.
54
+
5
55
  - Graceful shutdown. On SIGINT/SIGTERM or `HttpServerHandle.close()`, in-flight `wait_for_message`
6
56
  calls return at once with the new status `interrupted`, the caller's `after_id` and a `next`
7
57
  step ("call again with the same after_id; no messages are lost") instead of hanging until the
@@ -34,9 +84,8 @@
34
84
  count as pending rather than failed.
35
85
  - List items add `reaction` (the emoji when the item is itself a reaction). Hidden reactions never
36
86
  count as unread; `mark_read` accepts listed ones.
37
- - `whoami` reports `transport` as `stdio` or `streamable-http` (was `http`), says "connected over
38
- Streamable HTTP", and no longer states the token expiry in its text; `token_expires_at` stays in
39
- the structured result, described as internal. `directory_names` is omitted when empty.
87
+ - `whoami` says "connected over Streamable HTTP" and no longer states the token expiry in its
88
+ text; `token_expires_at` stays in the structured result, described as internal.
40
89
  `resolve_address`'s description names only the resolution steps the operator configured.
41
90
  - Descriptions and errors mention `save_attachment` and `get_attachment_download_url` only where
42
91
  those tools are registered. The server instructions now state that other fmsg tools or local
package/README.md CHANGED
@@ -128,28 +128,42 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
128
128
 
129
129
  | Tool | What it does |
130
130
  |---|---|
131
- | `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `streamable-http`) and short-name defaults |
131
+ | `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP) and short-name defaults |
132
132
  | `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
133
- | `list_messages` | Inbox, newest first, with previews and attachment types; reactions hidden and never counted as unread unless `include_reactions` |
133
+ | `list_messages` | Inbox, newest first, with previews, attachment types and wire `size` (`compressed` marks a deflated body); reactions hidden and never counted as unread unless `include_reactions` |
134
134
  | `list_sent` | Sent messages with per-recipient delivery state |
135
- | `get_message` | One message with headers, full text body, attachments and reactions |
136
- | `get_thread` | The lineage from the thread root to a message, with gaps for messages you cannot see, and the root's `thread_topic` |
135
+ | `get_message` | One message with headers, full text body, attachments, reactions and, for a reply, the root's `thread_topic` |
136
+ | `get_thread` | The lineage from the thread root to a message (`scope: "lineage"`; other replies in the thread are not included), with gaps for messages you cannot see, per-message flags and added recipients, the root's `thread_topic`, and a `next` step that never suggests replying to a terminal or no-reply message |
137
137
  | `send_message` | Start a new thread; sends immediately (fmsg messages are immutable) |
138
138
  | `reply` | Reply into a thread; reply-all by default, refuses terminal and no-reply parents |
139
139
  | `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
140
140
  | `react` | Set or clear your emoji reaction |
141
141
  | `mark_read` | Mark received messages read |
142
- | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
142
+ | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources; a wrong filename lists the names the message has |
143
143
  | `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL; hosts that cannot send the connection's Authorization header use `download_attachment` |
144
144
  | `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
145
- | `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded) |
146
- | `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step |
145
+ | `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded); for a received message, the host's own receipt records; `via` is `to` or `add_to` (added later) |
146
+ | `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step that names messages from other threads (`pending_ids`) the cursor moved past |
147
147
 
148
148
  Every tool returns readable Markdown plus `structuredContent`. Ids are decimal strings. Message
149
149
  bodies are labelled as data from other parties, not instructions; structured results that carry
150
150
  other parties' words repeat that in `untrusted_content_notice`, since some hosts show the
151
151
  structured result instead of the text. Attachment types come from the host when it recorded one,
152
- otherwise from the filename extension, so every tool reports the same type.
152
+ otherwise from the filename extension, so every tool reports the same type, and every tool lists
153
+ attachments in the same order (by filename). Addresses in the text are code spans, so they copy
154
+ exactly (`@bob_mcp@example.com`, never with a Markdown escape); a backslash in an address is
155
+ rejected. A message's `size` is the body's size on the wire: the compressed length when
156
+ `compressed` is true.
157
+
158
+ ### Output schema compatibility
159
+
160
+ Some MCP hosts cache each tool's `outputSchema` when the connector is added and validate every later
161
+ result against that copy. Output schemas are therefore open and additive: objects accept unknown
162
+ properties, values that may grow (statuses, transports, resolutions, delivery state) are strings
163
+ whose current values are listed in their descriptions, and only fields present since 0.2.5 are
164
+ required. Releases add fields and values; they do not remove or rename them. Schemas published by
165
+ 0.2.6 and earlier were closed, so a host that cached one rejects results with newer fields (for
166
+ example "must NOT have additional properties"); remove and re-add the connector once to refresh it.
153
167
 
154
168
  Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
155
169
  attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply.
@@ -218,7 +232,10 @@ explain the configuration error and how to fix it; restart the MCP server after
218
232
  annotated `destructiveHint` to describe their effects. Approval behavior belongs to the AI host;
219
233
  fmsg-mcp has no additional confirmation gate.
220
234
  - Selected API-key/token formats are redacted from outbound bodies, topics and error text; the
221
- send tools report the count. This is not general data-loss prevention or binary attachment scanning.
235
+ send tools report the count. They include fmsg API keys, JWTs, private key blocks, source-hosting
236
+ tokens (`ghp_…`, `github_pat_…`, `glpat-…`), cloud access key ids (`AKIA…`, `ASIA…`) and labelled
237
+ secret access keys, `AIza…` API keys, chat-platform tokens (`xox…`), payment-platform keys
238
+ (`sk_live_…`, `rk_live_…`, `sk_test_…`, `whsec_…`) and `sk-…` API keys. This is not general data-loss prevention or binary attachment scanning.
222
239
  - Nothing about message size or acceptance is assumed: the fmsg host's own responses and delivery
223
240
  codes are surfaced verbatim.
224
241
  - The server publishes MCP `instructions` (shown to the model at session start) telling agents to use
package/SECURITY.md CHANGED
@@ -19,7 +19,8 @@ rather than a public issue.
19
19
  WebSocket announcements trigger protected message reads before their content reaches the host.
20
20
  The upstream contract authenticates sockets at handshake; it does not promise immediate closure
21
21
  of existing sockets on revocation. MCP does not infer ongoing authorization from a socket alone.
22
- - Selected key/JWT/private-key patterns are redacted from outbound message bodies, topics and errors.
22
+ - Selected credential formats (API keys and tokens with distinctive prefixes, labelled secret access
23
+ keys, JWTs and private key blocks) are redacted from outbound message bodies, topics and errors.
23
24
  This does not detect arbitrary sensitive information or scan binary attachments.
24
25
  - Message bodies and upstream error text are fenced as untrusted data. Each message body has its own
25
26
  fence; server-built headers stay outside it, with external header values escaped onto one line.
package/dist/address.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Validate an fmsg address and normalise it to `@user@domain`. The user part keeps
3
3
  * its case: the Web API compares `from` to the token's address byte for byte, and
4
4
  * hosts may treat user names case-sensitively. Only the domain is lower-cased.
5
- * Returns undefined when malformed.
5
+ * Returns undefined when malformed, including Markdown-escaped copies such as `@bob\_x@example.com`.
6
6
  */
7
7
  export declare function normalizeFmsgAddress(value: string): string | undefined;
8
8
  /** Case-insensitive address equality. */
package/dist/address.js CHANGED
@@ -1,16 +1,39 @@
1
1
  const ADDRESS = /^@([^@\s/]+)@([^@\s/]+)$/u;
2
+ /**
3
+ * fmsg specification, Addresses: the user part is Unicode letters and numbers, with `-`, `_` or `.`
4
+ * between them (never consecutive, never first or last).
5
+ */
6
+ const USER_PART = /^[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)*$/u;
7
+ /** Host names (including internationalised ones) and an optional port; no backslashes, spaces or controls. */
8
+ const DOMAIN_PART = /^[\p{L}\p{N}\p{M}](?:[\p{L}\p{N}\p{M}._:-]*[\p{L}\p{N}\p{M}])?$/u;
9
+ /** The specification limits a whole address to under 256 bytes. */
10
+ const MAX_ADDRESS_BYTES = 255;
2
11
  /**
3
12
  * Validate an fmsg address and normalise it to `@user@domain`. The user part keeps
4
13
  * its case: the Web API compares `from` to the token's address byte for byte, and
5
14
  * hosts may treat user names case-sensitively. Only the domain is lower-cased.
6
- * Returns undefined when malformed.
15
+ * Returns undefined when malformed, including Markdown-escaped copies such as `@bob\_x@example.com`.
7
16
  */
8
17
  export function normalizeFmsgAddress(value) {
9
18
  const trimmed = value.trim();
10
19
  const match = ADDRESS.exec(trimmed);
11
- if (!match)
20
+ if (!match || !USER_PART.test(match[1]) || !DOMAIN_PART.test(match[2]))
12
21
  return undefined;
13
- return `@${match[1]}@${match[2].toLowerCase()}`;
22
+ const address = `@${match[1]}@${match[2].toLowerCase()}`;
23
+ return Buffer.byteLength(address, "utf8") <= MAX_ADDRESS_BYTES ? address : undefined;
24
+ }
25
+ /** Why a value shaped like `@user@domain` is still not a valid address. */
26
+ function invalidAddressReason(value) {
27
+ const match = ADDRESS.exec(value);
28
+ if (!match)
29
+ return /^@[^@]*@[^@]*$/u.test(value) ? "it contains spaces, slashes or control characters" : undefined;
30
+ if (value.includes("\\"))
31
+ return "it contains a backslash; remove any Markdown escaping copied from formatted text";
32
+ if (!USER_PART.test(match[1]))
33
+ return "the user part may contain only letters and digits, with single . _ or - between them";
34
+ if (!DOMAIN_PART.test(match[2]))
35
+ return "the domain is not a valid host name";
36
+ return "it is longer than 255 bytes";
14
37
  }
15
38
  /** Case-insensitive address equality. */
16
39
  export function sameAddress(a, b) {
@@ -42,7 +65,13 @@ export function resolveAddress(name, resolver = {}) {
42
65
  const literal = normalizeFmsgAddress(trimmed);
43
66
  if (literal)
44
67
  return { address: literal, resolution: "literal" };
45
- if (trimmed === "" || /[@\s/]/u.test(trimmed)) {
68
+ const invalid = invalidAddressReason(trimmed);
69
+ if (invalid)
70
+ throw new Error(`"${name}" is not a valid fmsg address: ${invalid}`);
71
+ if (trimmed.includes("\\")) {
72
+ throw new Error(`"${name}" contains a backslash, which no fmsg address or short name has; remove any Markdown escaping copied from formatted text`);
73
+ }
74
+ if (trimmed === "" || /[@\s/\\\p{Cc}]/u.test(trimmed)) {
46
75
  throw new Error(`"${name}" is not an fmsg address (@user@domain) or a resolvable short name`);
47
76
  }
48
77
  const key = trimmed.toLowerCase();
@@ -1,19 +1,39 @@
1
- /** Patterns for secrets that must never leave the process in message bodies or logs. */
1
+ /** Not preceded or followed by another key character, so a match is a whole token. */
2
+ const START = String.raw `(?<![A-Za-z0-9_-])`;
3
+ const END = String.raw `(?![A-Za-z0-9_-])`;
4
+ /**
5
+ * Patterns for secrets that must never leave the process in message bodies or logs. Each is a credential format
6
+ * with a distinctive prefix (or, for secret access keys, a distinctive label), so ordinary text is not touched.
7
+ */
2
8
  const PATTERNS = [
3
9
  [/\bfmsgk_[A-Za-z0-9+/=._~-]{6,}\b/gu, "[REDACTED_FMSG_API_KEY]"],
4
10
  [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED_JWT]"],
5
11
  [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b/gu, "[REDACTED_GITHUB_TOKEN]"],
12
+ [new RegExp(`${START}glpat-[A-Za-z0-9_-]{20,}${END}`, "gu"), "[REDACTED_ACCESS_TOKEN]"],
6
13
  [/\bsk-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED_API_KEY]"],
7
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED_PRIVATE_KEY]"],
14
+ // Payment-platform secret, restricted and webhook-signing keys (sk_live_…, rk_test_…, whsec_…).
15
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bwhsec_[A-Za-z0-9+/=]{24,}/gu, "[REDACTED_API_KEY]"],
16
+ // Cloud API keys with the AIza prefix: 39 characters in all.
17
+ [new RegExp(`${START}AIza[0-9A-Za-z_-]{35}${END}`, "gu"), "[REDACTED_API_KEY]"],
18
+ // Chat-platform bot, user, app and refresh tokens (xoxb-…, xoxp-…, xapp-…).
19
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}|\bxapp-[0-9]-[A-Za-z0-9-]{10,}/gu, "[REDACTED_CHAT_TOKEN]"],
20
+ // Cloud access key ids: long-term (AKIA…) and temporary (ASIA…), always 20 upper-case letters and digits.
21
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, "[REDACTED_ACCESS_KEY_ID]"],
22
+ // A 40-character secret access key next to its usual label (aws_secret_access_key = …, "SecretAccessKey": "…").
23
+ [
24
+ /\b((?:aws_?)?secret_?access_?key|aws_?secret_?key)(["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])/giu,
25
+ (_match, label, separator) => `${label}${separator}[REDACTED_SECRET_ACCESS_KEY]`,
26
+ ],
27
+ [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/gu, "[REDACTED_PRIVATE_KEY]"],
8
28
  ];
9
29
  /** Replace secrets with placeholders and report how many were replaced. */
10
30
  export function redactSecrets(text) {
11
31
  let count = 0;
12
32
  let out = text;
13
33
  for (const [pattern, replacement] of PATTERNS) {
14
- out = out.replace(pattern, () => {
34
+ out = out.replace(pattern, (match, ...groups) => {
15
35
  count += 1;
16
- return replacement;
36
+ return typeof replacement === "string" ? replacement : replacement(match, ...groups);
17
37
  });
18
38
  }
19
39
  return { text: out, count };
@@ -76,12 +76,17 @@ export type ThreadMessage = {
76
76
  visible: boolean;
77
77
  version?: number;
78
78
  pid?: string | null;
79
+ /** Flags are omitted when false. */
80
+ no_reply?: boolean;
81
+ important?: boolean;
82
+ terminal?: boolean;
79
83
  from?: string;
80
84
  to?: string[];
81
85
  add_to?: AddToBatch[];
82
86
  time?: number | null;
83
87
  topic?: string;
84
88
  type?: string;
89
+ /** Body size on the wire: the compressed size when `deflate` is true. */
85
90
  size?: number;
86
91
  deflate?: boolean;
87
92
  message_sha256?: string;
package/dist/prompts.js CHANGED
@@ -16,7 +16,8 @@ export function registerPrompts(server) {
16
16
  const text = [
17
17
  `Chat over fmsg on my behalf (${mode === "keep" ? "keep replying within the thread" : "reply once to the next message"}).`,
18
18
  `1. Call wait_for_message${thread ? ` with thread_of "${thread}"` : ""}${from ? ` and from "${from}"` : ""}. On status "timeout" call it again with the same arguments; stop after ${minutes} minutes with nothing arriving.`,
19
- "2. When messages arrive, tell me in one line who wrote what, then compose a reply and send it with the reply tool to reply_target_id.",
19
+ "2. When messages arrive, tell me in one line who wrote what, then compose a reply and send it with the reply tool to reply_target_id. " +
20
+ "Follow the result's next step: it also names new messages on other threads (pending_ids) that the next wait will not return.",
20
21
  mode === "keep"
21
22
  ? `3. Call wait_for_message again with the returned after_id and the same thread_of, and repeat. Stop after ${replies} replies, when I interrupt, or when the other party says goodbye.`
22
23
  : "3. Then stop and report back.",
package/dist/render.d.ts CHANGED
@@ -23,7 +23,25 @@ export declare function attachmentType(filename: string, recorded?: string): str
23
23
  export declare function messageData(text: string): string;
24
24
  /** Keep external header values on one line and unable to introduce Markdown structure. */
25
25
  export declare function headerValue(value: string): string;
26
- export declare function renderMessage(message: FmsgMessage, body: string | null): string;
26
+ /**
27
+ * An address for Markdown text: in a code span, so nothing inside is escaped and a model can copy it exactly
28
+ * (`@bob_x@example.com`, never `@bob\_x@example.com`). Values a code span cannot hold safely fall back to
29
+ * escaped header text.
30
+ */
31
+ export declare function addressText(address: string): string;
32
+ /** Addresses as a comma-separated list for Markdown text. */
33
+ export declare function addressList(addresses: readonly string[]): string;
34
+ /**
35
+ * Attachments in one order everywhere (by filename). The Web API reports the wire position only on thread
36
+ * entries, and messages sent through it share one position, so filename order is the order its hosts send in.
37
+ */
38
+ export declare function sortAttachments<T extends {
39
+ filename: string;
40
+ }>(attachments: readonly T[] | undefined): T[];
41
+ /** "N bytes", noting when that is the compressed wire size. */
42
+ export declare function sizeText(size: number | undefined, compressed: boolean | undefined): string;
43
+ /** `threadTopic`: for a reply, its thread root's topic, shown after the parent id. */
44
+ export declare function renderMessage(message: FmsgMessage, body: string | null, threadTopic?: string | null): string;
27
45
  /** All addresses that participate in a message (sender, recipients, add-to batches). */
28
46
  export declare function participantsOf(message: {
29
47
  from?: string;
@@ -36,5 +54,5 @@ export declare function participantsOf(message: {
36
54
  export declare function preview(message: FmsgMessage, maxChars?: number): string;
37
55
  /** One list line per message. */
38
56
  export declare function messageLine(message: FmsgMessage, self?: string): string;
39
- export declare function messageHeader(message: FmsgMessage): string;
57
+ export declare function messageHeader(message: FmsgMessage, threadTopic?: string | null): string;
40
58
  export declare function fence(body: string): string;
package/dist/render.js CHANGED
@@ -51,11 +51,37 @@ export function headerValue(value) {
51
51
  .replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029")
52
52
  .replace(/[\\`*_{}\[\]()<>|]/gu, "\\$&");
53
53
  }
54
- export function renderMessage(message, body) {
54
+ /**
55
+ * An address for Markdown text: in a code span, so nothing inside is escaped and a model can copy it exactly
56
+ * (`@bob_x@example.com`, never `@bob\_x@example.com`). Values a code span cannot hold safely fall back to
57
+ * escaped header text.
58
+ */
59
+ export function addressText(address) {
60
+ return /^@[^\s`\\@]+@[^\s`\\@]+$/u.test(address) && !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(address)
61
+ ? `\`${address}\``
62
+ : headerValue(address);
63
+ }
64
+ /** Addresses as a comma-separated list for Markdown text. */
65
+ export function addressList(addresses) {
66
+ return addresses.map(addressText).join(", ");
67
+ }
68
+ /**
69
+ * Attachments in one order everywhere (by filename). The Web API reports the wire position only on thread
70
+ * entries, and messages sent through it share one position, so filename order is the order its hosts send in.
71
+ */
72
+ export function sortAttachments(attachments) {
73
+ return [...(attachments ?? [])].sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
74
+ }
75
+ /** "N bytes", noting when that is the compressed wire size. */
76
+ export function sizeText(size, compressed) {
77
+ return `${size ?? 0} bytes${compressed ? " compressed" : ""}`;
78
+ }
79
+ /** `threadTopic`: for a reply, its thread root's topic, shown after the parent id. */
80
+ export function renderMessage(message, body, threadTopic) {
55
81
  const content = body === null
56
- ? `[non-text body: ${headerValue(message.type ?? "?")}, ${message.size ?? 0} bytes${message.deflate ? " compressed" : ""}]`
82
+ ? `[non-text body: ${headerValue(message.type ?? "?")}, ${sizeText(message.size, message.deflate)}]`
57
83
  : `Body:\n${fence(body)}`;
58
- return `${DATA_NOT_INSTRUCTIONS}\n\n${messageHeader(message)}\n\n${content}\n\nEnd of message data.`;
84
+ return `${DATA_NOT_INSTRUCTIONS}\n\n${messageHeader(message, threadTopic)}\n\n${content}\n\nEnd of message data.`;
59
85
  }
60
86
  /** All addresses that participate in a message (sender, recipients, add-to batches). */
61
87
  export function participantsOf(message) {
@@ -81,7 +107,7 @@ export function preview(message, maxChars = 200) {
81
107
  }
82
108
  /** One list line per message. */
83
109
  export function messageLine(message, self) {
84
- const who = message.from.toLowerCase() === self?.toLowerCase() ? `to ${message.to.join(", ")}` : `from ${message.from}`;
110
+ const who = message.from.toLowerCase() === self?.toLowerCase() ? `to ${addressList(message.to)}` : `from ${addressText(message.from)}`;
85
111
  const parts = [`**${message.id}** ${who}`];
86
112
  const time = isoTime(message.time);
87
113
  parts.push(time ?? "draft");
@@ -110,21 +136,23 @@ export function messageLine(message, self) {
110
136
  const p = preview(message, 120);
111
137
  return `- ${parts.join(" · ")}${p ? `\n ${p}` : ""}`;
112
138
  }
113
- export function messageHeader(message) {
139
+ export function messageHeader(message, threadTopic) {
114
140
  const lines = [
115
141
  `**Message ${message.id}**`,
116
- `From: ${headerValue(message.from)}`,
117
- `To: ${message.to.map(headerValue).join(", ") || "(none)"}`,
142
+ `From: ${addressText(message.from)}`,
143
+ `To: ${addressList(message.to) || "(none)"}`,
118
144
  ];
119
145
  for (const batch of message.add_to ?? []) {
120
- lines.push(`Added by ${headerValue(batch.add_to_from ?? "?")}: ${(batch.to ?? []).map(headerValue).join(", ")}`);
146
+ lines.push(`Added by ${batch.add_to_from ? addressText(batch.add_to_from) : "?"}: ${addressList(batch.to ?? [])}`);
121
147
  }
122
148
  lines.push(`Time: ${isoTime(message.time) ?? "draft"}`);
123
149
  if (message.topic)
124
150
  lines.push(`Topic: ${headerValue(message.topic)}`);
125
151
  if (message.pid)
126
152
  lines.push(`Reply to: ${message.pid}`);
127
- lines.push(`Type: ${headerValue(message.type ?? "?")} (${message.size ?? 0} bytes${message.deflate ? " compressed" : ""})`);
153
+ if (message.pid && threadTopic)
154
+ lines.push(`Thread topic: ${headerValue(threadTopic)}`);
155
+ lines.push(`Type: ${headerValue(message.type ?? "?")} (${sizeText(message.size, message.deflate)})`);
128
156
  const flags = [];
129
157
  if (message.important)
130
158
  flags.push("important");
@@ -135,10 +163,10 @@ export function messageHeader(message) {
135
163
  if (flags.length)
136
164
  lines.push(`Flags: ${flags.join(", ")}`);
137
165
  if (message.attachments?.length) {
138
- lines.push(`Attachments: ${message.attachments.map((a) => `${headerValue(a.filename)} (${a.size} bytes, ${headerValue(attachmentType(a.filename))})`).join(", ")}`);
166
+ lines.push(`Attachments: ${sortAttachments(message.attachments).map((a) => `${headerValue(a.filename)} (${a.size} bytes, ${headerValue(attachmentType(a.filename))})`).join(", ")}`);
139
167
  }
140
168
  if (message.reactions?.length) {
141
- lines.push(`Reactions: ${message.reactions.map((r) => `${headerValue(r.emoji)} ${r.from.map(headerValue).join(", ")}`).join("; ")}`);
169
+ lines.push(`Reactions: ${message.reactions.map((r) => `${headerValue(r.emoji)} ${addressList(r.from)}`).join("; ")}`);
142
170
  }
143
171
  return lines.join("\n");
144
172
  }
package/dist/thread.d.ts CHANGED
@@ -14,7 +14,14 @@ export type AssembledMessage = {
14
14
  time_posix: number | null;
15
15
  topic?: string;
16
16
  type?: string;
17
+ /** Wire size: the compressed length when `compressed` is true. */
17
18
  size?: number;
19
+ compressed?: boolean;
20
+ /** Flags and add-to recipients; absent for messages you cannot see. */
21
+ no_reply?: boolean;
22
+ terminal?: boolean;
23
+ important?: boolean;
24
+ added?: string[];
18
25
  body: string | null;
19
26
  body_truncated: boolean;
20
27
  /** Decoded body length when the body text was read; `size` is the wire size. */
@@ -35,6 +42,10 @@ export type AssembledThread = {
35
42
  participants: string[];
36
43
  reply_target_id: string;
37
44
  terminal: boolean;
45
+ /** The reply target asked for no replies. */
46
+ no_reply: boolean;
47
+ /** Always "lineage": the root down to the given message, never sibling replies or later branches. */
48
+ scope: "lineage";
38
49
  messages: AssembledMessage[];
39
50
  omitted: number;
40
51
  };
@@ -44,5 +55,7 @@ export type AssembledThread = {
44
55
  */
45
56
  export declare function assembleThread(client: FmsgClient, self: string, triggerId: string, caps: ThreadCaps, signal?: AbortSignal): Promise<AssembledThread>;
46
57
  export declare function renderThread(thread: AssembledThread): string;
58
+ /** get_thread returns one lineage; say so wherever the thread is shown. */
59
+ export declare const LINEAGE_ONLY = "Only this lineage (the root down to this message) is shown: other replies in the thread are not included; list_messages shows newer messages.";
47
60
  /** Server guidance for continuing a thread; also returned as a structured `next` field. */
48
- export declare function threadNext(thread: Pick<AssembledThread, "terminal" | "reply_target_id">): string;
61
+ export declare function threadNext(thread: Pick<AssembledThread, "terminal" | "no_reply" | "reply_target_id">): string;
package/dist/thread.js CHANGED
@@ -1,6 +1,18 @@
1
1
  import { sameAddress } from "./address.js";
2
2
  import { FmsgClient, FmsgHttpError } from "./client/client.js";
3
- import { DATA_NOT_INSTRUCTIONS, attachmentType, fence, headerValue, isoTime, participantsOf, truncateUtf8, truncationNote } from "./render.js";
3
+ import { DATA_NOT_INSTRUCTIONS, addressList, addressText, attachmentType, fence, headerValue, isoTime, participantsOf, sizeText, sortAttachments, truncateUtf8, truncationNote, } from "./render.js";
4
+ /** Flags, add-to recipients and wire size of a visible message, as thread entries carry them. */
5
+ function details(m) {
6
+ return {
7
+ ...(m.type ? { type: m.type } : {}),
8
+ ...(typeof m.size === "number" ? { size: m.size } : {}),
9
+ compressed: m.deflate === true,
10
+ no_reply: m.no_reply === true,
11
+ terminal: m.terminal === true,
12
+ important: m.important === true,
13
+ added: (m.add_to ?? []).flatMap((b) => b.to ?? []),
14
+ };
15
+ }
4
16
  function nonReactions(messages) {
5
17
  // Reactions are terminal no-reply leaves; they never appear on a lineage, so nothing to filter here.
6
18
  return messages;
@@ -42,12 +54,11 @@ async function fromThreadMessages(client, thread, caps, signal) {
42
54
  time: isoTime(m.time),
43
55
  time_posix: typeof m.time === "number" ? m.time : null,
44
56
  ...(m.topic ? { topic: m.topic } : {}),
45
- ...(m.type ? { type: m.type } : {}),
46
- ...(typeof m.size === "number" ? { size: m.size } : {}),
57
+ ...(m.visible ? details(m) : {}),
47
58
  body,
48
59
  body_truncated: truncated,
49
60
  ...(bodyBytes !== undefined ? { body_bytes: bodyBytes } : {}),
50
- attachments: (m.attachments ?? []).map((a) => ({ filename: a.filename, size: a.size, type: attachmentType(a.filename, a.type) })),
61
+ attachments: sortAttachments(m.attachments).map((a) => ({ filename: a.filename, size: a.size, type: attachmentType(a.filename, a.type) })),
51
62
  });
52
63
  }
53
64
  return { messages: out, omitted, topic: root?.visible && root.id === thread.root_id ? (root.topic ?? "") : null };
@@ -97,12 +108,11 @@ async function fromPidWalk(client, triggerId, caps, signal) {
97
108
  time: isoTime(m.time),
98
109
  time_posix: typeof m.time === "number" ? m.time : null,
99
110
  ...(m.topic ? { topic: m.topic } : {}),
100
- ...(m.type ? { type: m.type } : {}),
101
- ...(typeof m.size === "number" ? { size: m.size } : {}),
111
+ ...details(m),
102
112
  body,
103
113
  body_truncated: truncated,
104
114
  ...(bodyBytes !== undefined ? { body_bytes: bodyBytes } : {}),
105
- attachments: (m.attachments ?? []).map((a) => ({ filename: a.filename, size: a.size, type: attachmentType(a.filename) })),
115
+ attachments: sortAttachments(m.attachments).map((a) => ({ filename: a.filename, size: a.size, type: attachmentType(a.filename) })),
106
116
  });
107
117
  }
108
118
  const last = chain[chain.length - 1];
@@ -128,6 +138,8 @@ export async function assembleThread(client, self, triggerId, caps, signal) {
128
138
  participants,
129
139
  reply_target_id: trigger.id,
130
140
  terminal: trigger.terminal === true,
141
+ no_reply: trigger.no_reply === true,
142
+ scope: "lineage",
131
143
  messages,
132
144
  omitted,
133
145
  };
@@ -146,6 +158,8 @@ export async function assembleThread(client, self, triggerId, caps, signal) {
146
158
  participants,
147
159
  reply_target_id: trigger.id,
148
160
  terminal: trigger.terminal === true,
161
+ no_reply: trigger.no_reply === true,
162
+ scope: "lineage",
149
163
  messages: walk.messages,
150
164
  omitted: 0,
151
165
  };
@@ -159,7 +173,7 @@ export function renderThread(thread) {
159
173
  lines.push(`Topic: ${headerValue(thread.thread_topic)}`);
160
174
  if (thread.omitted > 0)
161
175
  lines.push(`(${thread.omitted} earlier message${thread.omitted === 1 ? "" : "s"} omitted)`);
162
- lines.push(`Participants (reply-all default): ${thread.participants.map(headerValue).join(", ") || "(none)"}`);
176
+ lines.push(`Participants (reply-all default): ${addressList(thread.participants) || "(none)"}`);
163
177
  lines.push("");
164
178
  for (const m of thread.messages) {
165
179
  lines.push("");
@@ -167,11 +181,14 @@ export function renderThread(thread) {
167
181
  lines.push(`--- message ${m.id} [not visible to you] ---`);
168
182
  continue;
169
183
  }
170
- lines.push(`--- message ${m.id} from ${headerValue(m.from ?? "?")} · ${m.time ?? "draft"}${m.pid ? ` · reply to ${m.pid}` : ""} ---`);
184
+ const flags = [m.important ? "important" : "", m.no_reply ? "no-reply" : "", m.terminal ? "terminal" : ""].filter(Boolean);
185
+ lines.push(`--- message ${m.id} from ${m.from ? addressText(m.from) : "?"} · ${m.time ?? "draft"}${m.pid ? ` · reply to ${m.pid}` : ""}${flags.length ? ` · ${flags.join(" ")}` : ""} ---`);
186
+ if (m.added?.length)
187
+ lines.push(`added: ${addressList(m.added)}`);
171
188
  if (m.attachments.length)
172
189
  lines.push(`attachments: ${m.attachments.map((a) => `${headerValue(a.filename)} (${a.size} bytes, ${headerValue(a.type)})`).join(", ")}`);
173
190
  if (m.body === null) {
174
- lines.push(`[non-text body: ${headerValue(m.type ?? "?")}, ${m.size ?? 0} bytes]`);
191
+ lines.push(`[non-text body: ${headerValue(m.type ?? "?")}, ${sizeText(m.size, m.compressed)}]`);
175
192
  guidance.push(`Use get_message / download_attachment for message ${m.id}.`);
176
193
  }
177
194
  else {
@@ -183,9 +200,16 @@ export function renderThread(thread) {
183
200
  guidance.push(threadNext(thread));
184
201
  return [DATA_NOT_INSTRUCTIONS, lines.join("\n"), "End of message data.", ...guidance].join("\n\n");
185
202
  }
203
+ /** get_thread returns one lineage; say so wherever the thread is shown. */
204
+ export const LINEAGE_ONLY = "Only this lineage (the root down to this message) is shown: other replies in the thread are not included; list_messages shows newer messages.";
186
205
  /** Server guidance for continuing a thread; also returned as a structured `next` field. */
187
206
  export function threadNext(thread) {
188
- return thread.terminal
189
- ? `Message ${thread.reply_target_id} is terminal: it cannot be replied to.`
190
- : `To continue this thread, reply to message ${thread.reply_target_id} (the reply tool).`;
207
+ const id = thread.reply_target_id;
208
+ const step = thread.terminal
209
+ ? `Message ${id} is terminal: no replies are possible (the host refuses them), so do not reply to it.`
210
+ : thread.no_reply
211
+ ? `Message ${id} is marked no-reply: its sender asked for no replies, and the reply tool refuses unless allow_no_reply is true. ` +
212
+ "Do not reply unless the user explicitly asks to."
213
+ : `To continue this thread, reply to message ${id} (the reply tool).`;
214
+ return `${step} ${LINEAGE_ONLY}`;
191
215
  }