Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ All notable changes to this project will be documented in this file.

## Unreleased

## 0.5.23 - 2026-08-02
## 0.5.24 - 2026-08-05

### Added
- **`--sender` is the unambiguous spelling of the sender filter on `read`, `search`, and `export`.** `--from` keeps working and keeps its exact meaning on those verbs, so no existing caller's result set changes; the two disagreeing is a hard error rather than a silent precedence rule.

### Fixed
- **A sender filter on `read`, `search`, or `export` can no longer produce a silent false absence.** `--from` names the CALLER on 26 subcommands and filters on `from_agent` on those three, so the canonical liveness probe `search <token> --channel <c> --from <me>` appended `AND from_agent = <me>` and became unsatisfiable by construction — a dispatched sub-agent is a different sender, so the one message being looked for is the one the filter removed. It answered `No messages found.` at rc=0 with an empty stderr. `--from` now always announces on stderr that it was applied as a sender filter, and an empty result from any of the three names the filters that produced it, so a zero caused by the caller's own query is distinguishable from a genuinely empty store (#807d355d, #e60b8820).
- **A blank `--sender` / `--from` is refused instead of silently dropping the filter.** `--sender "$WHO"` with `WHO` unset would otherwise return every sender's messages at exit code 0 and read as one sender's — the wrong-full direction, which is acted on rather than noticed.

### Known gaps
- The disclosure covers the sender/recipient/channel/session/since dimensions. `--limit`, `--cursor`, and `--unread` are **not** yet named in it, so `read --cursor 999` against a populated channel is still a bare zero, and `read --channel X --cursor 999` prints an applied-filter line that omits the cursor. Tracked separately.
- The MCP surface (`src/mcp/tools/messaging.ts`) is unchanged and still carries the original ambiguity, including `read_messages` using `from` as caller identity and sender filter in the same call. Tracked separately.

### Added
- **`conversations watch` can opt into full redacted channel content and monitor several identities in one process.** `--full-content` preserves actionable identifiers that the compact preview strips, while comma-separated `--from` values union independent inboxes without changing which identity owns writes (#74).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hasna/conversations",
"version": "0.5.23",
"version": "0.5.24",
"description": "Real-time CLI messaging for AI agents",
"type": "module",
"bin": {
Expand Down
80 changes: 73 additions & 7 deletions src/cli/commands/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ import type { DigestResult } from "../../lib/messages.js";
import { printErrorLine, printJson, printJsonLine, printLine } from "../../lib/stdout.js";
import { normalizeChannelName } from "../../lib/channel-names.js";
import { parseMessageReference } from "../../lib/message-reference.js";
import {
discloseEmptyResult,
FROM_ALIAS_HELP,
noteSenderFilterAlias,
resolveSenderFilter,
SENDER_HELP,
} from "../sender-filter.js";

function quoteDigestCommandArg(value: string): string {
return /^[A-Za-z0-9._:/@=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
Expand Down Expand Up @@ -126,7 +133,8 @@ export function registerMessagingCommands(program: Command): void {
.command("read")
.description("Read messages")
.option("--session <id>", "Filter by session ID")
.option("--from <agent>", "Filter by sender")
.option("--sender <agent>", SENDER_HELP)
.option("--from <agent>", FROM_ALIAS_HELP)
.option("--to <agent>", "Filter by recipient")
.option("--channel <name>", "Filter by channel")
.option("--since <timestamp>", "Messages after this ISO timestamp")
Expand All @@ -138,10 +146,12 @@ export function registerMessagingCommands(program: Command): void {
.option("--verbose", "Show full message bodies")
.option("-j, --json", "Output as JSON")
.action(async (opts) => {
const senderFilter = resolveSenderFilter(opts);
if (senderFilter.viaFromAlias) noteSenderFilterAlias(senderFilter.sender as string);
const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor });
const query = {
session_id: opts.session,
from: opts.from,
from: senderFilter.sender,
to: opts.to,
channel: opts.channel,
since: opts.since,
Expand All @@ -160,6 +170,16 @@ export function registerMessagingCommands(program: Command): void {
if (ids.length > 0) await await getStore().markReadByIds(ids, reader);
}

if (messages.length === 0) {
discloseEmptyResult({
channel: opts.channel,
sender: senderFilter.sender,
to: opts.to,
session: opts.session,
since: opts.since,
}, { senderFlag: senderFilter.flag });
}

if (opts.json) {
printJson(messages);
warnIfPageFull(messages.length, query.limit);
Expand Down Expand Up @@ -292,7 +312,8 @@ export function registerMessagingCommands(program: Command): void {
.description("Search messages by content")
.argument("<query>", "Search query string")
.option("--channel <name>", "Filter by channel")
.option("--from <agent>", "Filter by sender")
.option("--sender <agent>", SENDER_HELP)
.option("--from <agent>", FROM_ALIAS_HELP)
.option("--to <agent>", "Filter by recipient")
.option("--limit <n>", "Max results to return (the server caps a single page at 500)", parseInt)
.option("--cursor <n>", "Skip first N results for pagination", parseInt)
Expand Down Expand Up @@ -325,12 +346,26 @@ channel, which is an ABSENCE claim.
together.

To enumerate a sender exhaustively, page a listing verb; do not infer a
population from a content search.`)
population from a content search.

3. --from IS A SENDER FILTER HERE, NOT YOUR IDENTITY. On nearly every other
subcommand --from names the caller; on search, read and export it appends
"AND from_agent = <value>" to your query. So the liveness probe

conversations search <token> --channel <c> --from <me>

is unsatisfiable by construction — a dispatched sub-agent is a DIFFERENT
sender, so the one message you are looking for is the one the filter
removes. It answered "No messages found." at rc=0 with an empty stderr
(todos 807d355d). --from still filters, and now always says so; --sender is
the unambiguous spelling. For identity, set CONVERSATIONS_AGENT_ID.`)
.action(async (query, opts) => {
const q = typeof query === "string" ? query.trim() : "";
if (!q) {
emitCliError("Search query cannot be empty.", opts);
}
const senderFilter = resolveSenderFilter(opts);
if (senderFilter.viaFromAlias) noteSenderFilterAlias(senderFilter.sender as string);
const window = getCliWindow({ limit: opts.limit, cursor: opts.cursor });

// The store pages this verb now. `--json` used to pass the raw limit and
Expand All @@ -339,11 +374,19 @@ channel, which is an ABSENCE claim.
const result = await getStore().searchMessagesPage({
query: q,
channel: opts.channel,
from: opts.from,
from: senderFilter.sender,
to: opts.to,
limit: opts.json ? opts.limit : window.limit,
offset: opts.json ? opts.cursor : window.offset,
});
if (result.items.length === 0) {
discloseEmptyResult({
query: q,
channel: opts.channel,
sender: senderFilter.sender,
to: opts.to,
}, { senderFlag: senderFilter.flag });
}
const disclosure = {
shown: result.items.length,
hasMore: result.has_more,
Expand Down Expand Up @@ -576,20 +619,43 @@ channel, which is an ABSENCE claim.
.description("Export messages as JSON or CSV")
.option("--channel <name>", "Filter by channel")
.option("--session <id>", "Filter by session ID")
.option("--from <agent>", "Filter by sender")
.option("--sender <agent>", SENDER_HELP)
.option("--from <agent>", FROM_ALIAS_HELP)
.option("--since <date>", "Messages after this ISO date")
.option("--until <date>", "Messages before this ISO date")
.option("--format <format>", "Output format: json or csv", "json")
.action(async (opts) => {
const senderFilter = resolveSenderFilter(opts);
if (senderFilter.viaFromAlias) noteSenderFilterAlias(senderFilter.sender as string);
const format = opts.format === "csv" ? "csv" : "json";
const result = await getStore().exportMessages({
channel: opts.channel,
session_id: opts.session,
from: opts.from,
from: senderFilter.sender,
since: normalizeSince(opts.since),
until: opts.until,
format,
});

// An export emptied by the caller's own filter is the same silent false
// absence this change exists to remove, and it reached review as a live
// defect: `export --sender <nobody>` printed "[]" with 0 bytes on stderr
// at rc=0, which made --sender on this verb strictly MORE silent than the
// --from it is offered as an improvement on. Emptiness is read off the
// rendered payload rather than re-querying: "[]" for json, headers with no
// data row for csv.
const exportedNothing = format === "csv"
? !result.includes("\n")
: result.trim() === "[]";
if (exportedNothing) {
discloseEmptyResult({
channel: opts.channel,
sender: senderFilter.sender,
session: opts.session,
since: opts.since,
}, { senderFlag: senderFilter.flag });
}

printLine(result);
closeDb();
});
Expand Down
Loading
Loading