@wirecat/cli-core 0.573.0 → 0.574.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 +10 -0
- package/dist/botcheck/registries.js +10 -7
- package/dist/calendar/feeds.js +7 -3
- package/dist/cli/messenger/serve-catch-up.js +3 -2
- package/dist/http/time-limit.d.ts +11 -0
- package/dist/http/time-limit.js +20 -0
- package/dist/models/openai.js +4 -3
- package/dist/oauth/requests.js +10 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.574.0 — 05.11.2026
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A finished request no longer keeps the command in memory until its time limit ends.** The limit was a timer that
|
|
15
|
+
ran to the end even after the answer came, and it held everything the command had loaded; tg-cli and max-cli
|
|
16
|
+
already fixed the same thing in their own requests, and tg ran out of memory on macOS before that. This covers the
|
|
17
|
+
OAuth token and revoke requests, the OpenAI model request, the bot-registry checks, calendar feeds and serve's
|
|
18
|
+
catch-up on start. `timeLimit` and `withTimeLimit` (`src/http/time-limit.ts`) are the stoppable limit now.
|
|
19
|
+
|
|
10
20
|
## 0.573.0 — 05.11.2026
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withTimeLimit } from "../http/time-limit.js";
|
|
1
2
|
/** Where each one is documented, for the help text and the MCP description. */
|
|
2
3
|
export const REGISTRIES = {
|
|
3
4
|
cas: { title: "Combot Anti-Spam (CAS)", docs: "https://cas.chat/api" },
|
|
@@ -12,17 +13,19 @@ const TIMEOUT_MS = 10_000;
|
|
|
12
13
|
export const askRegistries = async (id, options = {}) => Promise.all([askCas(id, options), askLols(id, options)]);
|
|
13
14
|
const getJson = async (url, headers, { fetch: fetchFn = fetch, timeoutMs = TIMEOUT_MS }) => {
|
|
14
15
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const { status, text } = await withTimeLimit(timeoutMs, async (signal) => {
|
|
17
|
+
const response = await fetchFn(url, {
|
|
18
|
+
headers: { accept: "application/json", ...headers },
|
|
19
|
+
signal,
|
|
20
|
+
redirect: "error",
|
|
21
|
+
});
|
|
22
|
+
return { status: response.status, text: await response.text() };
|
|
19
23
|
});
|
|
20
|
-
const text = await response.text();
|
|
21
24
|
try {
|
|
22
|
-
return { status
|
|
25
|
+
return { status, body: JSON.parse(text) };
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
|
-
return { failed: `answered ${
|
|
28
|
+
return { failed: `answered ${status} with something that is not JSON` };
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
catch (error) {
|
package/dist/calendar/feeds.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
3
3
|
import { zonedMoment } from "@wirecat/cli-base/query";
|
|
4
4
|
import { runBudget } from "../background/budget.js";
|
|
5
|
+
import { timeLimit } from "../http/time-limit.js";
|
|
5
6
|
import { calendarText, joinUrlWithoutQuery } from "../meetings/index.js";
|
|
6
7
|
import { expandSeries, unexpandable } from "./expand.js";
|
|
7
8
|
import { readIcs } from "./ics.js";
|
|
@@ -72,8 +73,8 @@ export const fetchFeedIfChanged = async (address, options) => {
|
|
|
72
73
|
...(since?.lastModified ? { "if-modified-since": since.lastModified } : {}),
|
|
73
74
|
};
|
|
74
75
|
const run = options.fetch ?? fetch;
|
|
75
|
-
const timeout =
|
|
76
|
-
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
76
|
+
const timeout = timeLimit(limits.timeoutMs);
|
|
77
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal;
|
|
77
78
|
let url = feedAddress(address);
|
|
78
79
|
try {
|
|
79
80
|
for (let hop = 0;; hop++) {
|
|
@@ -122,7 +123,7 @@ export const fetchFeedIfChanged = async (address, options) => {
|
|
|
122
123
|
throw error;
|
|
123
124
|
if (options.signal?.aborted)
|
|
124
125
|
throw new CliError("cancelled", `reading ${name} was cancelled`);
|
|
125
|
-
if (timeout.aborted)
|
|
126
|
+
if (timeout.signal.aborted)
|
|
126
127
|
throw new CliError("timeout", `${name} did not answer within ${Math.round(limits.timeoutMs / 1000)} s`, {
|
|
127
128
|
reason: "feed.timeout",
|
|
128
129
|
retryable: true,
|
|
@@ -134,6 +135,9 @@ export const fetchFeedIfChanged = async (address, options) => {
|
|
|
134
135
|
retryable: true,
|
|
135
136
|
});
|
|
136
137
|
}
|
|
138
|
+
finally {
|
|
139
|
+
timeout.clear();
|
|
140
|
+
}
|
|
137
141
|
};
|
|
138
142
|
const SPAN = { start: 0, end: 0 };
|
|
139
143
|
const firstLink = (text) => text?.match(/https?:\/\/[^\s<>"')\]]+/iu)?.[0];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { failureKey } from "../../errors/failures.js";
|
|
2
|
+
import { withTimeLimit } from "../../http/time-limit.js";
|
|
2
3
|
/** The provider's own catch-up lands first; this pass then finds most chats already current. */
|
|
3
4
|
export const START_SYNC_DELAY_MS = 30_000;
|
|
4
5
|
export const START_SYNC_BUDGET_MS = 5 * 60_000;
|
|
@@ -19,8 +20,8 @@ export const startSync = ({ chats, newestHeld, fetch, note, warn, delayMs = STAR
|
|
|
19
20
|
let pass = Promise.resolve();
|
|
20
21
|
let started = false;
|
|
21
22
|
let done;
|
|
22
|
-
const run =
|
|
23
|
-
|
|
23
|
+
const run = () => withTimeLimit(budgetMs, (deadline) => catchUp(AbortSignal.any([controller.signal, deadline])));
|
|
24
|
+
const catchUp = async (stop) => {
|
|
24
25
|
const floor = now() - windowMs;
|
|
25
26
|
const moved = [];
|
|
26
27
|
for (const chat of await chats(stop)) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TimeLimit {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
clear: () => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A time limit that can be stopped. `AbortSignal.timeout` cannot: its timer runs to the deadline even after the
|
|
7
|
+
* request is done, and keeps the run's whole async context (everything the command loaded) alive until then.
|
|
8
|
+
*/
|
|
9
|
+
export declare const timeLimit: (ms: number) => TimeLimit;
|
|
10
|
+
/** Runs `run` under a time limit and stops the timer when it settles. Whatever `run` reads from the response belongs inside. */
|
|
11
|
+
export declare const withTimeLimit: <T>(ms: number, run: (deadline: AbortSignal) => Promise<T>) => Promise<T>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A time limit that can be stopped. `AbortSignal.timeout` cannot: its timer runs to the deadline even after the
|
|
3
|
+
* request is done, and keeps the run's whole async context (everything the command loaded) alive until then.
|
|
4
|
+
*/
|
|
5
|
+
export const timeLimit = (ms) => {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms);
|
|
8
|
+
timer.unref();
|
|
9
|
+
return { signal: controller.signal, clear: () => clearTimeout(timer) };
|
|
10
|
+
};
|
|
11
|
+
/** Runs `run` under a time limit and stops the timer when it settles. Whatever `run` reads from the response belongs inside. */
|
|
12
|
+
export const withTimeLimit = async (ms, run) => {
|
|
13
|
+
const limit = timeLimit(ms);
|
|
14
|
+
try {
|
|
15
|
+
return await run(limit.signal);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
limit.clear();
|
|
19
|
+
}
|
|
20
|
+
};
|
package/dist/models/openai.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as v from "valibot";
|
|
2
|
+
import { withTimeLimit } from "../http/time-limit.js";
|
|
2
3
|
import { checkedOptions, range } from "./options.js";
|
|
3
4
|
import { messagesFor, systemFor } from "./types.js";
|
|
4
5
|
const options = v.strictObject({
|
|
@@ -14,7 +15,7 @@ export const openai = {
|
|
|
14
15
|
images: true,
|
|
15
16
|
baseUrl: "https://api.openai.com/v1",
|
|
16
17
|
validate: (given) => checkedOptions("openai", options, given),
|
|
17
|
-
complete:
|
|
18
|
+
complete: (target, request, checked, apiKey, fetcher) => withTimeLimit(120_000, async (deadline) => {
|
|
18
19
|
const response = await fetcher(`${target.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
|
19
20
|
method: "POST",
|
|
20
21
|
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
|
|
@@ -40,7 +41,7 @@ export const openai = {
|
|
|
40
41
|
? { max_completion_tokens: request.maxTokens }
|
|
41
42
|
: { max_tokens: request.maxTokens }),
|
|
42
43
|
}),
|
|
43
|
-
signal:
|
|
44
|
+
signal: deadline,
|
|
44
45
|
redirect: "error",
|
|
45
46
|
});
|
|
46
47
|
if (!response.ok)
|
|
@@ -53,5 +54,5 @@ export const openai = {
|
|
|
53
54
|
(body.usage?.total_tokens ?? 0) < 1)
|
|
54
55
|
throw new Error("incomplete response");
|
|
55
56
|
return { text: choice.message.content, tokens: body.usage?.total_tokens };
|
|
56
|
-
},
|
|
57
|
+
}),
|
|
57
58
|
};
|
package/dist/oauth/requests.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
2
|
+
import { withTimeLimit } from "../http/time-limit.js";
|
|
2
3
|
const REQUEST_LIMIT_MS = 30_000;
|
|
3
4
|
export const TOKEN_LOG_EVENT = "oauth.token";
|
|
4
5
|
/** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
|
|
@@ -8,20 +9,20 @@ const object = (value) => {
|
|
|
8
9
|
throw new Error("not an object");
|
|
9
10
|
return value;
|
|
10
11
|
};
|
|
11
|
-
const requestSignal = (signal) => signal ? AbortSignal.any([signal,
|
|
12
|
+
const requestSignal = (deadline, signal) => signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
12
13
|
const cancelledBy = (signal) => {
|
|
13
14
|
if (!signal?.aborted)
|
|
14
15
|
return;
|
|
15
16
|
throw isCliError(signal.reason) ? signal.reason : new CliError("cancelled", "sign-in was cancelled");
|
|
16
17
|
};
|
|
17
|
-
const post = async (url, body, context) => {
|
|
18
|
+
const post = async (url, body, context, deadline) => {
|
|
18
19
|
cancelledBy(context.signal);
|
|
19
20
|
try {
|
|
20
21
|
return await context.fetch(url, {
|
|
21
22
|
method: "POST",
|
|
22
23
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
23
24
|
body,
|
|
24
|
-
signal: requestSignal(context.signal),
|
|
25
|
+
signal: requestSignal(deadline, context.signal),
|
|
25
26
|
redirect: "error",
|
|
26
27
|
});
|
|
27
28
|
}
|
|
@@ -38,11 +39,12 @@ const clientFields = ({ client }) => ({
|
|
|
38
39
|
* The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
|
|
39
40
|
* a log record: both carry codes and tokens.
|
|
40
41
|
*/
|
|
41
|
-
export const requestTokens =
|
|
42
|
+
export const requestTokens = (provider, params, context) => withTimeLimit(REQUEST_LIMIT_MS, (deadline) => exchangeTokens(provider, params, context, deadline));
|
|
43
|
+
const exchangeTokens = async (provider, params, context, deadline) => {
|
|
42
44
|
const operation = params.grant_type ?? "unknown";
|
|
43
45
|
const record = (level, fields) => context.log?.[level]({ event: TOKEN_LOG_EVENT, provider: provider.id, operation, pid: process.pid, ...fields });
|
|
44
46
|
const again = `run ${context.signInCommand} again`;
|
|
45
|
-
const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context);
|
|
47
|
+
const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context, deadline);
|
|
46
48
|
if (!response) {
|
|
47
49
|
record("warn", { outcome: "no_response" });
|
|
48
50
|
throw new CliError("outcome_unknown", `${provider.name} token request did not finish; ${again} if it keeps failing`, {
|
|
@@ -112,7 +114,9 @@ export const revokeToken = async (provider, tokens, context) => {
|
|
|
112
114
|
return provider.revoke(tokens, context);
|
|
113
115
|
if (!provider.revokeUrl)
|
|
114
116
|
throw new CliError("validation_error", `${provider.name} declares no way to revoke a sign-in`);
|
|
115
|
-
const
|
|
117
|
+
const { revokeUrl } = provider;
|
|
118
|
+
const form = new URLSearchParams({ token: tokens.refreshToken, ...clientFields(context) });
|
|
119
|
+
const response = await withTimeLimit(REQUEST_LIMIT_MS, (deadline) => post(revokeUrl, form, context, deadline));
|
|
116
120
|
context.log?.[response?.ok ? "info" : "warn"]({
|
|
117
121
|
event: TOKEN_LOG_EVENT,
|
|
118
122
|
provider: provider.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.574.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",
|