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
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@ concurrency:

jobs:
checks:
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release/0.217' || inputs.dry_run
permissions:
contents: read
uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@b36a30fb6037b457001bd2db860949f64512ecdd # v0.17.3
actions: read
security-events: write
uses: WireCatLabs/cli-core/.github/workflows/release-checks.yml@1183bfb970330989ad8acec917b14b7e6187de15
with:
languages: '["actions","javascript-typescript","python","c-cpp","java-kotlin"]'

build:
needs: checks
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release/0.217'
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -92,7 +96,7 @@ jobs:

# npm, not pnpm: npm's trusted publishing is the documented path. A tarball runs no
# lifecycle scripts, and nothing is installed here.
- run: npm publish "wirecat-cli-messaging-$VERSION.tgz" --access public ${{ inputs.dry_run && '--dry-run' || '' }}
- run: npm publish "wirecat-cli-messaging-$VERSION.tgz" --access public --tag ${{ github.ref == 'refs/heads/release/0.217' && 'maintenance-0.217' || 'latest' }} ${{ inputs.dry_run && '--dry-run' || '' }}

- name: wait for npm to show the version
if: ${{ !inputs.dry_run }}
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ Notable changes to `@wirecat/cli-messaging` (`@leemour/cli-messaging` up to 0.21
version, newest first. Versions follow [semver](https://semver.org/); before `1.0.0` a minor version may
break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the shape of this file.

## 0.217.1 — 10.10.2026

### Fixed

- Long PDF documents and voice recordings no longer hit separate page/time/duration cutoffs; caller cancellation and format/allocation checks remain.
- Ordinary hidden developer folders are allowed while known credentials and application state stay protected. Markdown bodies, original Unicode writes, registry defaults, embedding redirects and interpolated reply previews retain their normal behavior.
- Windows maintenance setup uses compatible core 0.19.1.


## 0.217.0 — 10.10.2026

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ its meaning, and this table is regenerated from it. **Bold** marks a clash still
| `--all` | | every row, no paging. **chats requests accept and decline take every pending request instead — Telegram counts them against its hourly limit first; MAX has no way to answer them all at once and refuses it, naming chats requests list — so it differs on purpose (Help text rule 4)** | | `attachments list`, `chats list`, `chats members list` (planned), `chats requests accept`, `chats requests decline`, `contacts list`, `inbox` (planned), `messages download`, `review` (planned), `store export`, `store fetch` |
| `--all-bots` | | also read every other bot's copy on this machine that readOtherBots allows | | `bot contacts show`, `bot messages between`, `bot search messages` |
| `--all-can-pin` | `<on\|off>` | every member may pin messages | | `chats update` |
| `--allow-any-file` | | send a --file even from a hidden folder, ~/.ssh or the tool's own folders | | `bot messages send`, `messages send` |
| `--allow-any-file` | | send a --file even from credential folders or the tool's own folders | | `bot messages send`, `messages send` |
| `--allow-chats` | `<ids>` | replace allowed chat ids, comma-separated; empty clears | | `replies audience` |
| `--allow-dangerous` | | go ahead without the question an ask level puts before a deletion | | `bot chats moderate`, `bot mcp`, `bot mcp config`, `bot messages delete`, `chats clear` (planned), `chats delete` (planned), `chats moderate` (planned), `mcp` (planned), `mcp config` (planned), `mcp doctor` (planned), `mcp setup` (planned), `messages delete`, `store clear` |
| `--allow-delete` | | offer the tool that deletes messages for you only; it cannot be undone. **retired access flag: accepted with a warning, grants no permissions in either CLI** | | `bot mcp`, `bot mcp config`, `mcp`, `mcp config`, `mcp doctor` (planned), `mcp setup` (planned) |
Expand Down
6 changes: 2 additions & 4 deletions docs/replies.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,8 @@ Thanks, {{ sender.firstName | default: "there" }}.
{% ai %}Write a brief acknowledgement; I will answer tomorrow.{% else %}I will answer tomorrow.{% endai %}
```

The block's owner-written body is the instruction. Interpolated names, titles and other values
go separately as untrusted template data, referenced at their insertion points. Incoming message
text is separate untrusted data too. JSON previews show those values in each block’s
`templateValues` beside the instruction’s insertion references. Model output replaces only the block and is never parsed again as Liquid. Text outside the
The block's rendered body is the instruction, and incoming message text goes separately as untrusted
data. Model output replaces only the block and is never parsed again as Liquid. Text outside the
block remains the owner's text with its normal substitutions. When a provider is missing, a call
fails or consent is absent, the else branch is used. Without one the reply is skipped with a reason.
Nested AI blocks are refused. Per rendering: at most four model calls, 512 output tokens per call,
Expand Down
2 changes: 1 addition & 1 deletion docs/search/ai-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ The runner reads the same shipped `link-conversations` skill as your agent, adds
Anthropic uses the official TypeScript SDK and [Messages streaming](https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript); the runner reads the final message and checks its stop reason. The OpenAI-compatible adapter checks the first choice's finish reason and reported token usage. Both adapters are tested with local fake servers; there are no real provider calls in the test suite.

Embedding endpoints must accept requests at the configured URL directly. Set `embeddingBaseUrl` to
the API base serving `/embeddings` directly; credential-bearing embedding requests refuse redirects.
the API base serving `/embeddings`; configured gateways use normal HTTP redirect handling.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wirecat/cli-messaging",
"version": "0.217.0",
"version": "0.217.1",
"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",
"license": "Apache-2.0",
"author": "Viacheslav Ptsarev",
Expand Down Expand Up @@ -132,7 +132,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@wirecat/cli-core": "0.19.0",
"@wirecat/cli-core": "0.19.1",
"@modelcontextprotocol/client": "2.2.0",
"@napi-rs/canvas": "1.0.10",
"@types/node": "^22.20.4",
Expand Down
2 changes: 1 addition & 1 deletion parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"meaning": "every member may pin messages"
},
"--allow-any-file": {
"meaning": "send a --file even from a hidden folder, ~/.ssh or the tool's own folders"
"meaning": "send a --file even from credential folders or the tool's own folders"
},
"--allow-chats": {
"meaning": "replace allowed chat ids, comma-separated; empty clears",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions socket.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
version: 2
githubApp:
enabled: false
8 changes: 5 additions & 3 deletions src/attachments/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe("directory attachment matching", () => {
}
})

it("refuses hidden and application folders through both typed paths and directory aliases", async () => {
it("refuses credential and application folders through both typed paths and directory aliases", async () => {
const { store, folder } = await fixture()
const env = {
...process.env,
Expand All @@ -92,8 +92,10 @@ describe("directory attachment matching", () => {
for (const path of [directory, alias])
await expect(directoryPaths(store, owner, "1", path, app, env)).rejects.toThrow("cannot read")
}
writeFileSync(join(folder, ".private.txt"), "synthetic")
await expect(directoryPaths(store, owner, "1", folder, app, env)).rejects.toThrow("cannot read hidden files")
const allowed = join(folder, ".worktrees")
mkdirSync(allowed)
writeFileSync(join(allowed, ".private.txt"), "synthetic")
await expect(directoryPaths(store, owner, "1", allowed, app, env)).resolves.toBeInstanceOf(Map)
} finally {
await store.close()
}
Expand Down
4 changes: 2 additions & 2 deletions src/attachments/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const directoryPaths = async (
if (refusedPlace(directory, app, env))
throw new CliError(
"validation_error",
"--from-dir cannot read hidden folders, the CLI's own folders or the message store",
"--from-dir cannot read credential folders, the CLI's own folders or the message store",
)
const root = await realpath(directory).catch(() => {
throw new CliError("validation_error", "--from-dir must name an existing directory")
Expand All @@ -39,7 +39,7 @@ export const directoryPaths = async (
if (refusedPlace(canonical, app, env))
throw new CliError(
"validation_error",
"--from-dir cannot read hidden files, the CLI's own files or the message store",
"--from-dir cannot read credential files, the CLI's own files or the message store",
)
const inside = relative(root, canonical)
if (inside === ".." || inside.startsWith(`..${sep}`))
Expand Down
70 changes: 32 additions & 38 deletions src/attachments/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,54 +39,48 @@ describe("reading the text layer of a file", () => {
expect(extractRawText).not.toHaveBeenCalled()
})

it("refuses oversized PDF page counts before reading text and closes the document", async () => {
const destroy = vi.fn(async () => {})
const read = vi.fn()
const load: LoadEngine = async () => ({
getDocumentProxy: async () => ({ numPages: 100_000, loadingTask: { destroy } }),
extractText: read,
})
expect(await extractText(pdf(), hint("large.pdf"), load)).toEqual({ status: "too-large" })
expect(read).not.toHaveBeenCalled()
expect(destroy).toHaveBeenCalledOnce()
})

it("bounds stuck PDF text extraction and cancels a pending document load", async () => {
it("extracts a long PDF and closes its document without a fixed page/time cutoff", async () => {
vi.useFakeTimers()
try {
const destroy = vi.fn(async () => {})
const read = vi.fn(() => new Promise(() => {}))
const load: LoadEngine = async () => ({
getDocumentProxy: async () => ({ numPages: 1, loadingTask: { destroy } }),
extractText: read,
getDocumentProxy: async () => ({ numPages: 100, loadingTask: { destroy } }),
extractText: async () => {
await new Promise((resolve) => setTimeout(resolve, 31_000))
return { text: ["Synthetic long document"], totalPages: 100 }
},
})
const pending = extractText(pdf(), hint("stuck.pdf"), load)
await vi.advanceTimersByTimeAsync(30_000)
expect(await pending).toMatchObject({ status: "unreadable" })
const pending = extractText(pdf(), hint("long.pdf"), load)
await vi.advanceTimersByTimeAsync(31_000)
expect(await pending).toMatchObject({ status: "extracted", pages: 100 })
expect(destroy).toHaveBeenCalledOnce()
const abort = new AbortController()
let finish = (_value: unknown) => {}
const loading: LoadEngine = async () => ({
getDocumentProxy: () =>
new Promise((resolve) => {
finish = resolve
}),
extractText: read,
})
const cancelled = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal)
const rejected = expect(cancelled).rejects.toMatchObject({ code: "cancelled" })
await vi.advanceTimersByTimeAsync(0)
abort.abort()
await rejected
finish({ numPages: 1, loadingTask: { destroy } })
await vi.advanceTimersByTimeAsync(0)
expect(destroy).toHaveBeenCalledTimes(2)
expect(read).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})

it("cancels a pending PDF load and releases a late document", async () => {
const destroy = vi.fn(async () => {})
const read = vi.fn()
const abort = new AbortController()
let finish = (_value: unknown) => {}
const loading: LoadEngine = async () => ({
getDocumentProxy: () =>
new Promise((resolve) => {
finish = resolve
}),
extractText: read,
})
const pending = extractText(pdf(), hint("cancelled.pdf"), loading, abort.signal)
const rejected = expect(pending).rejects.toMatchObject({ code: "cancelled" })
await new Promise((resolve) => setTimeout(resolve, 0))
abort.abort()
await rejected
finish({ numPages: 1, loadingTask: { destroy } })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(destroy).toHaveBeenCalledOnce()
expect(read).not.toHaveBeenCalled()
})
it("preserves spreadsheet cell addresses and PDF page spans", async () => {
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet("Synthetic Budget")
Expand Down
10 changes: 3 additions & 7 deletions src/attachments/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,7 @@ export const extractText = async (
if (kind) return readDocument(bytes, kind, signal)
if (startsWith(bytes, "%PDF-")) {
return withEngine<Unpdf>(load, "unpdf", async (unpdf, extractor) => {
const deadline = new AbortController()
const timeout = setTimeout(() => deadline.abort(), 30_000)
timeout.unref()
const bounded = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal
const bounded = signal ?? new AbortController().signal
let pdf: PdfDocument | undefined
let closed = false
const close = () => {
Expand All @@ -158,7 +155,7 @@ export const extractText = async (
const aborted = new Promise<never>((_, reject) => {
abort = () => {
close()
reject(new CliError(signal?.aborted ? "cancelled" : "timeout", "PDF text extraction stopped"))
reject(new CliError("cancelled", "PDF text extraction stopped"))
}
bounded.addEventListener("abort", abort, { once: true })
if (bounded.aborted) abort()
Expand All @@ -175,7 +172,7 @@ export const extractText = async (
close()
bounded.throwIfAborted()
}
if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1 || pdf.numPages > 20) throw new ReaderLimit()
if (!Number.isSafeInteger(pdf.numPages) || pdf.numPages < 1) throw new ReaderLimit()
return unpdf.extractText(pdf, { mergePages: false })
}
const result = await Promise.race([reading(), aborted])
Expand All @@ -198,7 +195,6 @@ export const extractText = async (
...(text.length > MAX_TEXT_CHARS ? { truncated: true } : {}),
}
} finally {
clearTimeout(timeout)
bounded.removeEventListener("abort", abort)
close()
}
Expand Down
6 changes: 5 additions & 1 deletion src/attachments/pdf-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ describe("PDF page previews for remote agents", () => {
expect(fixture.destroy).toHaveBeenCalledTimes(1)
expect(fixture.cleanup).toHaveBeenCalledTimes(1)
})
it("previews a requested page in a long document", async () => {
const fixture = fake({ pages: 100 })
expect(await pdfPreview(pdf(), 99, undefined, fixture.load)).toMatchObject({ page: 99, pageCount: 100 })
})
it("rejects invalid pages, page counts and dimensions before rendering", async () => {
for (const page of [0, -1, 1.5, Infinity])
await expect(pdfPreview(pdf(), page)).rejects.toMatchObject({ code: "validation_error" })
for (const pages of [0, 21, 1.5]) {
for (const pages of [0, 1.5]) {
const fixture = fake({ pages })
await expect(pdfPreview(pdf(), 1, undefined, fixture.load)).rejects.toMatchObject({ code: "validation_error" })
expect(fixture.destroy).toHaveBeenCalledTimes(1)
Expand Down
4 changes: 2 additions & 2 deletions src/attachments/pdf-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export const pdfPreview = async (
})
signal?.addEventListener("abort", close, { once: true })
cancelled()
if (!Number.isSafeInteger(document.numPages) || document.numPages < 1 || document.numPages > 20)
throw new CliError("validation_error", "PDF previews support documents with 1–20 pages")
if (!Number.isSafeInteger(document.numPages) || document.numPages < 1)
throw new CliError("validation_error", "PDF page count must be a positive integer")
if (page > document.numPages) throw new CliError("validation_error", "PDF page is past the end of the document")
const selected = await document.getPage(page)
let image: Buffer
Expand Down
2 changes: 1 addition & 1 deletion src/cli/bot/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const sendCommand = (bot: BotMessenger): Command =>
.option("--photo <file>", "attach a .jpg, .png or .webp as a photo; the text becomes its caption")
.option("--as-file", "send the --file as a file to download, a video included")
.option("--voice <file>", "send an Ogg Opus file as a voice message, alone, with no text")
.option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders")
.option("--allow-any-file", "send a file even from credential folders or this CLI's own folders")
.action(async function (this: Command, chat: string, text: string | undefined) {
const context = botContext(this, bot)
const options = this.opts<{
Expand Down
2 changes: 1 addition & 1 deletion src/cli/messenger/messages-send-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const sendCommand = (messenger: Messenger): Command => {
.option("--photo <file>", "attach a .jpg, .png or .webp as a photo; the text becomes its caption")
.option("--as-file", "send the --file as a file to download, a video included")
.option("--voice <file>", "send an Ogg Opus file as a voice message, alone, with no text")
.option("--allow-any-file", "send a file even from a hidden folder, ~/.ssh or this CLI's own folders")
.option("--allow-any-file", "send a file even from credential folders or this CLI's own folders")
.option(
"--at-time <time>",
"let the messenger send it later, even with this machine off: 2026-09-25T09:00 (local time), or 30m, 2h, 1d from now",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/messenger/replies-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("replies test", () => {
const preview = await replies(["replies", "test", "--since-time", "1d", "--json"], env)
expect(JSON.parse(preview.stdout.join("")).rules[0].would[0]).toMatchObject({
text: "Later",
blocks: [{ instruction: "Greet [templateValues[0]]", fallback: "Later" }],
blocks: [{ instruction: "Greet Ana", fallback: "Later" }],
})
expect(fetcher).not.toHaveBeenCalled()
const modeled = await replies(["replies", "test", "--ai", "--json"], env)
Expand Down
Loading
Loading