Merge 7f6bc537ce into 4583f88626
This commit is contained in:
commit
090fdfbf5d
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -278,6 +278,8 @@ jobs:
|
|||||||
checks-macos:
|
checks-macos:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@ -31,12 +31,22 @@ import { getDiscordRuntime } from "./runtime.js";
|
|||||||
|
|
||||||
const meta = getChatChannelMeta("discord");
|
const meta = getChatChannelMeta("discord");
|
||||||
|
|
||||||
|
function resolveDiscordMessageActions() {
|
||||||
|
try {
|
||||||
|
return getDiscordRuntime().channel.discord?.messageActions ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
|
listActions: (ctx) => resolveDiscordMessageActions()?.listActions?.(ctx) ?? [],
|
||||||
extractToolSend: (ctx) =>
|
extractToolSend: (ctx) => resolveDiscordMessageActions()?.extractToolSend?.(ctx),
|
||||||
getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
|
handleAction: async (ctx) => {
|
||||||
handleAction: async (ctx) =>
|
const actions = resolveDiscordMessageActions();
|
||||||
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
|
if (!actions?.handleAction) return null;
|
||||||
|
return await actions.handleAction(ctx);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
createReplyPrefixContext,
|
||||||
|
createTypingCallbacks,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
|
logTypingFailure,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@ -218,19 +221,153 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
privateKey: account.privateKey,
|
privateKey: account.privateKey,
|
||||||
relays: account.relays,
|
relays: account.relays,
|
||||||
onMessage: async (senderPubkey, text, reply) => {
|
onMessage: async (senderPubkey, text, reply, eventId) => {
|
||||||
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
|
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);
|
||||||
|
|
||||||
// Forward to moltbot's message pipeline
|
const cfg = runtime.config.loadConfig();
|
||||||
await runtime.channel.reply.handleInboundMessage({
|
const route = runtime.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
channel: "nostr",
|
channel: "nostr",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
senderId: senderPubkey,
|
peer: { kind: "dm", id: senderPubkey },
|
||||||
chatType: "direct",
|
});
|
||||||
chatId: senderPubkey, // For DMs, chatId is the sender's pubkey
|
|
||||||
text,
|
ctx.log?.debug(`[${account.accountId}] Route resolved: sessionKey=${route.sessionKey}, agentId=${route.agentId}`);
|
||||||
reply: async (responseText: string) => {
|
|
||||||
await reply(responseText);
|
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
|
const body = runtime.channel.reply.formatAgentEnvelope({
|
||||||
|
channel: "Nostr",
|
||||||
|
from: senderPubkey,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
body: text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create typing callbacks for this conversation
|
||||||
|
// Note: busHandle is checked at invocation time (not creation time)
|
||||||
|
// to handle the race condition during startup
|
||||||
|
const typingCallbacks = createTypingCallbacks({
|
||||||
|
start: async () => {
|
||||||
|
if (!busHandle) {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Skipping typing START (bus not ready)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Sending typing START to ${senderPubkey.slice(0, 8)}`);
|
||||||
|
return busHandle.sendTypingStart(senderPubkey);
|
||||||
|
},
|
||||||
|
stop: async () => {
|
||||||
|
if (!busHandle) {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Skipping typing STOP (bus not ready)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Sending typing STOP to ${senderPubkey.slice(0, 8)}`);
|
||||||
|
return busHandle.sendTypingStop(senderPubkey);
|
||||||
|
},
|
||||||
|
onStartError: (err) =>
|
||||||
|
logTypingFailure({
|
||||||
|
log: (msg) => ctx.log?.warn(msg),
|
||||||
|
channel: "nostr",
|
||||||
|
target: senderPubkey,
|
||||||
|
action: "start",
|
||||||
|
error: err,
|
||||||
|
}),
|
||||||
|
onStopError: (err) =>
|
||||||
|
logTypingFailure({
|
||||||
|
log: (msg) => ctx.log?.warn(msg),
|
||||||
|
channel: "nostr",
|
||||||
|
target: senderPubkey,
|
||||||
|
action: "stop",
|
||||||
|
error: err,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the inbound message context
|
||||||
|
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: text,
|
||||||
|
CommandBody: text,
|
||||||
|
From: `nostr:${senderPubkey}`,
|
||||||
|
To: `nostr:${senderPubkey}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: account.accountId,
|
||||||
|
ChatType: "direct",
|
||||||
|
ConversationLabel: senderPubkey,
|
||||||
|
SenderName: senderPubkey.slice(0, 8),
|
||||||
|
SenderId: senderPubkey,
|
||||||
|
Provider: "nostr" as const,
|
||||||
|
Surface: "nostr" as const,
|
||||||
|
Timestamp: Date.now(),
|
||||||
|
MessageSid: eventId, // Nostr event ID for deduplication
|
||||||
|
CommandAuthorized: true, // TODO: implement proper authorization
|
||||||
|
CommandSource: "text" as const,
|
||||||
|
OriginatingChannel: "nostr" as const,
|
||||||
|
OriginatingTo: `nostr:${senderPubkey}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.channel.session.recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
updateLastRoute: {
|
||||||
|
sessionKey: route.mainSessionKey,
|
||||||
|
channel: "nostr",
|
||||||
|
to: `nostr:${senderPubkey}`,
|
||||||
|
accountId: route.accountId,
|
||||||
|
},
|
||||||
|
onRecordError: (err) => {
|
||||||
|
ctx.log?.warn?.(`nostr: failed updating session meta: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get table mode for formatting
|
||||||
|
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "nostr",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create reply prefix context
|
||||||
|
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||||
|
|
||||||
|
// Create the reply dispatcher
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
|
runtime.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
|
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
const message = runtime.channel.text.convertMarkdownTables(
|
||||||
|
payload.text ?? "",
|
||||||
|
tableMode
|
||||||
|
);
|
||||||
|
if (!message) return;
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Delivering reply to ${senderPubkey.slice(0, 8)}: ${message.slice(0, 50)}...`);
|
||||||
|
await reply(message);
|
||||||
|
ctx.log?.info(`[${account.accountId}] Reply delivered to ${senderPubkey.slice(0, 8)}`);
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
ctx.log?.error(`[${account.accountId}] nostr ${info.kind} reply failed: ${String(err)}`);
|
||||||
|
},
|
||||||
|
onReplyStart: typingCallbacks?.onReplyStart,
|
||||||
|
onIdle: typingCallbacks?.onIdle,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch the reply
|
||||||
|
const { queuedFinal, counts } = await runtime.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
onModelSelected: prefixContext.onModelSelected,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -41,6 +41,11 @@ export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global";
|
|||||||
|
|
||||||
export type DecryptMetricName = "decrypt.success" | "decrypt.failure";
|
export type DecryptMetricName = "decrypt.success" | "decrypt.failure";
|
||||||
|
|
||||||
|
export type TypingMetricName =
|
||||||
|
| "typing.start.sent"
|
||||||
|
| "typing.stop.sent"
|
||||||
|
| "typing.error";
|
||||||
|
|
||||||
export type MemoryMetricName =
|
export type MemoryMetricName =
|
||||||
| "memory.seen_tracker_size"
|
| "memory.seen_tracker_size"
|
||||||
| "memory.rate_limiter_entries";
|
| "memory.rate_limiter_entries";
|
||||||
@ -50,6 +55,7 @@ export type MetricName =
|
|||||||
| RelayMetricName
|
| RelayMetricName
|
||||||
| RateLimitMetricName
|
| RateLimitMetricName
|
||||||
| DecryptMetricName
|
| DecryptMetricName
|
||||||
|
| TypingMetricName
|
||||||
| MemoryMetricName;
|
| MemoryMetricName;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -128,6 +134,13 @@ export interface MetricsSnapshot {
|
|||||||
failure: number;
|
failure: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Typing indicator stats */
|
||||||
|
typing: {
|
||||||
|
startSent: number;
|
||||||
|
stopSent: number;
|
||||||
|
errors: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** Memory/capacity stats */
|
/** Memory/capacity stats */
|
||||||
memory: {
|
memory: {
|
||||||
seenTrackerSize: number;
|
seenTrackerSize: number;
|
||||||
@ -213,6 +226,13 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|||||||
failure: 0,
|
failure: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Typing indicator stats
|
||||||
|
const typing = {
|
||||||
|
startSent: 0,
|
||||||
|
stopSent: 0,
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Memory stats (updated via gauge-style metrics)
|
// Memory stats (updated via gauge-style metrics)
|
||||||
const memory = {
|
const memory = {
|
||||||
seenTrackerSize: 0,
|
seenTrackerSize: 0,
|
||||||
@ -371,6 +391,16 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|||||||
decrypt.failure += value;
|
decrypt.failure += value;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "typing.start.sent":
|
||||||
|
typing.startSent += value;
|
||||||
|
break;
|
||||||
|
case "typing.stop.sent":
|
||||||
|
typing.stopSent += value;
|
||||||
|
break;
|
||||||
|
case "typing.error":
|
||||||
|
typing.errors += value;
|
||||||
|
break;
|
||||||
|
|
||||||
// Memory (gauge-style - value replaces, not adds)
|
// Memory (gauge-style - value replaces, not adds)
|
||||||
case "memory.seen_tracker_size":
|
case "memory.seen_tracker_size":
|
||||||
memory.seenTrackerSize = value;
|
memory.seenTrackerSize = value;
|
||||||
@ -396,6 +426,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|||||||
relays: relaysObj,
|
relays: relaysObj,
|
||||||
rateLimiting: { ...rateLimiting },
|
rateLimiting: { ...rateLimiting },
|
||||||
decrypt: { ...decrypt },
|
decrypt: { ...decrypt },
|
||||||
|
typing: { ...typing },
|
||||||
memory: { ...memory },
|
memory: { ...memory },
|
||||||
snapshotAt: Date.now(),
|
snapshotAt: Date.now(),
|
||||||
};
|
};
|
||||||
@ -422,6 +453,9 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|||||||
rateLimiting.globalHits = 0;
|
rateLimiting.globalHits = 0;
|
||||||
decrypt.success = 0;
|
decrypt.success = 0;
|
||||||
decrypt.failure = 0;
|
decrypt.failure = 0;
|
||||||
|
typing.startSent = 0;
|
||||||
|
typing.stopSent = 0;
|
||||||
|
typing.errors = 0;
|
||||||
memory.seenTrackerSize = 0;
|
memory.seenTrackerSize = 0;
|
||||||
memory.rateLimiterEntries = 0;
|
memory.rateLimiterEntries = 0;
|
||||||
}
|
}
|
||||||
@ -452,6 +486,7 @@ export function createNoopMetrics(): NostrMetrics {
|
|||||||
relays: {},
|
relays: {},
|
||||||
rateLimiting: { perSenderHits: 0, globalHits: 0 },
|
rateLimiting: { perSenderHits: 0, globalHits: 0 },
|
||||||
decrypt: { success: 0, failure: 0 },
|
decrypt: { success: 0, failure: 0 },
|
||||||
|
typing: { startSent: 0, stopSent: 0, errors: 0 },
|
||||||
memory: { seenTrackerSize: 0, rateLimiterEntries: 0 },
|
memory: { seenTrackerSize: 0, rateLimiterEntries: 0 },
|
||||||
snapshotAt: 0,
|
snapshotAt: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
100
extensions/nostr/src/nostr-bus.publish.test.ts
Normal file
100
extensions/nostr/src/nostr-bus.publish.test.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
const TEST_PUBKEY = "f".repeat(64);
|
||||||
|
|
||||||
|
let lastPool: {
|
||||||
|
publish: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeMany: ReturnType<typeof vi.fn>;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
vi.mock("nostr-tools", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("nostr-tools")>("nostr-tools");
|
||||||
|
class MockPool {
|
||||||
|
publish = vi.fn();
|
||||||
|
subscribeMany = vi.fn(() => ({ close: vi.fn() }));
|
||||||
|
constructor() {
|
||||||
|
lastPool = this as unknown as {
|
||||||
|
publish: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
SimplePool: MockPool,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("nostr-tools/nip04", async () => {
|
||||||
|
return {
|
||||||
|
encrypt: vi.fn(async () => "cipher"),
|
||||||
|
decrypt: vi.fn(async () => "plain"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./nostr-state-store.js", () => {
|
||||||
|
return {
|
||||||
|
readNostrBusState: vi.fn(async () => null),
|
||||||
|
writeNostrBusState: vi.fn(async () => undefined),
|
||||||
|
computeSinceTimestamp: vi.fn(() => 0),
|
||||||
|
readNostrProfileState: vi.fn(async () => null),
|
||||||
|
writeNostrProfileState: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startNostrBus publish handling", () => {
|
||||||
|
it("awaits publish rejections for DMs without unhandled rejection", async () => {
|
||||||
|
const { startNostrBus } = await import("./nostr-bus.js");
|
||||||
|
const onError = vi.fn();
|
||||||
|
const bus = await startNostrBus({
|
||||||
|
privateKey: TEST_HEX_KEY,
|
||||||
|
relays: ["wss://relay.test"],
|
||||||
|
onMessage: async () => {},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastPool).not.toBeNull();
|
||||||
|
lastPool?.publish.mockReturnValue([Promise.reject(new Error("rate-limited"))]);
|
||||||
|
|
||||||
|
let unhandled: unknown;
|
||||||
|
process.once("unhandledRejection", (reason) => {
|
||||||
|
unhandled = reason;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bus.sendDm(TEST_PUBKEY, "hi")).rejects.toThrow("rate-limited");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(unhandled).toBeUndefined();
|
||||||
|
expect(onError).toHaveBeenCalled();
|
||||||
|
|
||||||
|
bus.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on typing publish failures", async () => {
|
||||||
|
const { startNostrBus } = await import("./nostr-bus.js");
|
||||||
|
const onError = vi.fn();
|
||||||
|
const bus = await startNostrBus({
|
||||||
|
privateKey: TEST_HEX_KEY,
|
||||||
|
relays: ["wss://relay.test"],
|
||||||
|
onMessage: async () => {},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastPool).not.toBeNull();
|
||||||
|
lastPool?.publish.mockReturnValue([Promise.reject(new Error("rate-limited"))]);
|
||||||
|
|
||||||
|
let unhandled: unknown;
|
||||||
|
process.once("unhandledRejection", (reason) => {
|
||||||
|
unhandled = reason;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bus.sendTypingStart(TEST_PUBKEY)).resolves.toBeUndefined();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(unhandled).toBeUndefined();
|
||||||
|
expect(onError).toHaveBeenCalled();
|
||||||
|
|
||||||
|
bus.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -51,6 +51,11 @@ const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open
|
|||||||
// Health tracker configuration
|
// Health tracker configuration
|
||||||
const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
|
const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats
|
||||||
|
|
||||||
|
// Typing indicator configuration (NIP-01 ephemeral events)
|
||||||
|
const TYPING_KIND = 20001; // Community convention for typing indicators
|
||||||
|
const TYPING_TTL_SEC = 30; // 30 second expiration
|
||||||
|
const TYPING_THROTTLE_MS = 5000; // Max 1 event per 5 seconds per recipient
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -66,7 +71,8 @@ export interface NostrBusOptions {
|
|||||||
onMessage: (
|
onMessage: (
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
text: string,
|
text: string,
|
||||||
reply: (text: string) => Promise<void>
|
reply: (text: string) => Promise<void>,
|
||||||
|
eventId: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
/** Called on errors (optional) */
|
/** Called on errors (optional) */
|
||||||
onError?: (error: Error, context: string) => void;
|
onError?: (error: Error, context: string) => void;
|
||||||
@ -101,6 +107,10 @@ export interface NostrBusHandle {
|
|||||||
lastPublishedEventId: string | null;
|
lastPublishedEventId: string | null;
|
||||||
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
lastPublishResults: Record<string, "ok" | "failed" | "timeout"> | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Send typing indicator start (kind 20001) */
|
||||||
|
sendTypingStart: (toPubkey: string, conversationEventId?: string) => Promise<void>;
|
||||||
|
/** Send typing indicator stop (kind 20001) */
|
||||||
|
sendTypingStop: (toPubkey: string, conversationEventId?: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -496,7 +506,7 @@ export async function startNostrBus(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Call the message handler
|
// Call the message handler
|
||||||
await onMessage(event.pubkey, plaintext, replyTo);
|
await onMessage(event.pubkey, plaintext, replyTo, event.id);
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
metrics.emit("event.processed");
|
metrics.emit("event.processed");
|
||||||
@ -512,26 +522,21 @@ export async function startNostrBus(
|
|||||||
|
|
||||||
const sub = pool.subscribeMany(
|
const sub = pool.subscribeMany(
|
||||||
relays,
|
relays,
|
||||||
[{ kinds: [4], "#p": [pk], since }],
|
{ kinds: [4], "#p": [pk], since },
|
||||||
{
|
{
|
||||||
onevent: handleEvent,
|
onevent: handleEvent,
|
||||||
oneose: () => {
|
oneose: () => {
|
||||||
// EOSE handler - called when all stored events have been received
|
|
||||||
for (const relay of relays) {
|
for (const relay of relays) {
|
||||||
metrics.emit("relay.message.eose", 1, { relay });
|
metrics.emit("relay.message.eose", 1, { relay });
|
||||||
}
|
}
|
||||||
onEose?.(relays.join(", "));
|
onEose?.(relays.join(", "));
|
||||||
},
|
},
|
||||||
onclose: (reason) => {
|
onclose: (reason) => {
|
||||||
// Handle subscription close
|
|
||||||
for (const relay of relays) {
|
for (const relay of relays) {
|
||||||
metrics.emit("relay.message.closed", 1, { relay });
|
metrics.emit("relay.message.closed", 1, { relay });
|
||||||
options.onDisconnect?.(relay);
|
options.onDisconnect?.(relay);
|
||||||
}
|
}
|
||||||
onError?.(
|
onError?.(new Error(`Subscription closed: ${reason}`), "subscription");
|
||||||
new Error(`Subscription closed: ${reason}`),
|
|
||||||
"subscription"
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -590,6 +595,17 @@ export async function startNostrBus(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create typing controller for throttled typing indicators
|
||||||
|
const typingController = createTypingController(
|
||||||
|
pool,
|
||||||
|
sk,
|
||||||
|
relays,
|
||||||
|
metrics,
|
||||||
|
circuitBreakers,
|
||||||
|
healthTracker,
|
||||||
|
onError
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: () => {
|
close: () => {
|
||||||
sub.close();
|
sub.close();
|
||||||
@ -610,6 +626,8 @@ export async function startNostrBus(
|
|||||||
getMetrics: () => metrics.getSnapshot(),
|
getMetrics: () => metrics.getSnapshot(),
|
||||||
publishProfile,
|
publishProfile,
|
||||||
getProfileState,
|
getProfileState,
|
||||||
|
sendTypingStart: typingController.sendTypingStart,
|
||||||
|
sendTypingStop: typingController.sendTypingStop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -646,6 +664,7 @@ async function sendEncryptedDm(
|
|||||||
const sortedRelays = healthTracker.getSortedRelays(relays);
|
const sortedRelays = healthTracker.getSortedRelays(relays);
|
||||||
|
|
||||||
// Try relays in order of health, respecting circuit breakers
|
// Try relays in order of health, respecting circuit breakers
|
||||||
|
let successCount = 0;
|
||||||
let lastError: Error | undefined;
|
let lastError: Error | undefined;
|
||||||
for (const relay of sortedRelays) {
|
for (const relay of sortedRelays) {
|
||||||
const cb = circuitBreakers.get(relay);
|
const cb = circuitBreakers.get(relay);
|
||||||
@ -657,13 +676,16 @@ async function sendEncryptedDm(
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
await pool.publish([relay], reply);
|
const [publishResult] = await Promise.allSettled(pool.publish([relay], reply));
|
||||||
|
if (publishResult?.status === "rejected") {
|
||||||
|
throw publishResult.reason;
|
||||||
|
}
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
// Record success
|
// Record success
|
||||||
cb?.recordSuccess();
|
cb?.recordSuccess();
|
||||||
healthTracker.recordSuccess(relay, latency);
|
healthTracker.recordSuccess(relay, latency);
|
||||||
|
successCount++;
|
||||||
return; // Success - exit early
|
return; // Success - exit early
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err as Error;
|
lastError = err as Error;
|
||||||
@ -678,7 +700,150 @@ async function sendEncryptedDm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
if (successCount === 0) {
|
||||||
|
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typing Indicator (Kind 20001 Ephemeral Event)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typing indicator event to a pubkey
|
||||||
|
* Uses kind 20001 (community convention for typing)
|
||||||
|
* Content is NIP-04 encrypted for privacy consistency with DMs
|
||||||
|
*/
|
||||||
|
async function sendTypingIndicator(
|
||||||
|
pool: SimplePool,
|
||||||
|
sk: Uint8Array,
|
||||||
|
toPubkey: string,
|
||||||
|
action: "start" | "stop",
|
||||||
|
relays: string[],
|
||||||
|
metrics: NostrMetrics,
|
||||||
|
circuitBreakers: Map<string, CircuitBreaker>,
|
||||||
|
healthTracker: RelayHealthTracker,
|
||||||
|
conversationEventId?: string,
|
||||||
|
onError?: (error: Error, context: string) => void
|
||||||
|
): Promise<void> {
|
||||||
|
// Encrypt the action for privacy (consistent with DMs)
|
||||||
|
const ciphertext = await encrypt(sk, toPubkey, action);
|
||||||
|
|
||||||
|
// Build tags
|
||||||
|
const tags: string[][] = [
|
||||||
|
["p", toPubkey],
|
||||||
|
["t", "clawdbot-typing"], // Namespace tag for collision protection
|
||||||
|
["expiration", String(Math.floor(Date.now() / 1000) + TYPING_TTL_SEC)],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add conversation scope if provided
|
||||||
|
if (conversationEventId) {
|
||||||
|
tags.push(["e", conversationEventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: TYPING_KIND,
|
||||||
|
content: ciphertext,
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
sk
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort relays by health score
|
||||||
|
const sortedRelays = healthTracker.getSortedRelays(relays);
|
||||||
|
|
||||||
|
// Try relays in order, respecting circuit breakers
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
for (const relay of sortedRelays) {
|
||||||
|
const cb = circuitBreakers.get(relay);
|
||||||
|
if (cb && !cb.canAttempt()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
const [publishResult] = await Promise.allSettled(pool.publish([relay], event));
|
||||||
|
if (publishResult?.status === "rejected") {
|
||||||
|
throw publishResult.reason;
|
||||||
|
}
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
cb?.recordSuccess();
|
||||||
|
healthTracker.recordSuccess(relay, latency);
|
||||||
|
const metricName = action === "start" ? "typing.start.sent" : "typing.stop.sent";
|
||||||
|
metrics.emit(metricName, 1, { relay });
|
||||||
|
return; // Success - exit early
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
cb?.recordFailure();
|
||||||
|
healthTracker.recordFailure(relay);
|
||||||
|
metrics.emit("typing.error", 1, { relay });
|
||||||
|
onError?.(lastError, `typing ${action} to ${relay}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't throw for typing failures - they're non-critical
|
||||||
|
if (lastError) {
|
||||||
|
onError?.(lastError, `typing ${action} failed on all relays`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create throttled typing indicator functions
|
||||||
|
* Returns start/stop functions that respect throttling (max 1 event per 5s per recipient)
|
||||||
|
*/
|
||||||
|
function createTypingController(
|
||||||
|
pool: SimplePool,
|
||||||
|
sk: Uint8Array,
|
||||||
|
relays: string[],
|
||||||
|
metrics: NostrMetrics,
|
||||||
|
circuitBreakers: Map<string, CircuitBreaker>,
|
||||||
|
healthTracker: RelayHealthTracker,
|
||||||
|
onError?: (error: Error, context: string) => void
|
||||||
|
): {
|
||||||
|
sendTypingStart: (toPubkey: string, conversationEventId?: string) => Promise<void>;
|
||||||
|
sendTypingStop: (toPubkey: string, conversationEventId?: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
// Track last send time per recipient for throttling
|
||||||
|
const lastSendTime = new Map<string, number>();
|
||||||
|
|
||||||
|
const sendWithThrottle = async (
|
||||||
|
toPubkey: string,
|
||||||
|
action: "start" | "stop",
|
||||||
|
conversationEventId?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastSent = lastSendTime.get(toPubkey) ?? 0;
|
||||||
|
|
||||||
|
// Stop events bypass throttle for better UX
|
||||||
|
if (action === "start") {
|
||||||
|
if (now - lastSent < TYPING_THROTTLE_MS) {
|
||||||
|
return; // Throttled
|
||||||
|
}
|
||||||
|
lastSendTime.set(toPubkey, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendTypingIndicator(
|
||||||
|
pool,
|
||||||
|
sk,
|
||||||
|
toPubkey,
|
||||||
|
action,
|
||||||
|
relays,
|
||||||
|
metrics,
|
||||||
|
circuitBreakers,
|
||||||
|
healthTracker,
|
||||||
|
conversationEventId,
|
||||||
|
onError
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendTypingStart: (toPubkey: string, conversationEventId?: string) =>
|
||||||
|
sendWithThrottle(toPubkey, "start", conversationEventId),
|
||||||
|
sendTypingStop: (toPubkey: string, conversationEventId?: string) =>
|
||||||
|
sendWithThrottle(toPubkey, "stop", conversationEventId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -25,11 +25,22 @@ import {
|
|||||||
|
|
||||||
import { getSignalRuntime } from "./runtime.js";
|
import { getSignalRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
function resolveSignalMessageActions() {
|
||||||
|
try {
|
||||||
|
return getSignalRuntime().channel.signal?.messageActions ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const signalMessageActions: ChannelMessageActionAdapter = {
|
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
|
listActions: (ctx) => resolveSignalMessageActions()?.listActions?.(ctx) ?? [],
|
||||||
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
|
supportsAction: (ctx) => resolveSignalMessageActions()?.supportsAction?.(ctx),
|
||||||
handleAction: async (ctx) =>
|
handleAction: async (ctx) => {
|
||||||
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
|
const actions = resolveSignalMessageActions();
|
||||||
|
if (!actions?.handleAction) return null;
|
||||||
|
return await actions.handleAction(ctx);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = getChatChannelMeta("signal");
|
const meta = getChatChannelMeta("signal");
|
||||||
|
|||||||
@ -31,12 +31,22 @@ import { getTelegramRuntime } from "./runtime.js";
|
|||||||
|
|
||||||
const meta = getChatChannelMeta("telegram");
|
const meta = getChatChannelMeta("telegram");
|
||||||
|
|
||||||
|
function resolveTelegramMessageActions() {
|
||||||
|
try {
|
||||||
|
return getTelegramRuntime().channel.telegram?.messageActions ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const telegramMessageActions: ChannelMessageActionAdapter = {
|
const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions.listActions(ctx),
|
listActions: (ctx) => resolveTelegramMessageActions()?.listActions?.(ctx) ?? [],
|
||||||
extractToolSend: (ctx) =>
|
extractToolSend: (ctx) => resolveTelegramMessageActions()?.extractToolSend?.(ctx),
|
||||||
getTelegramRuntime().channel.telegram.messageActions.extractToolSend(ctx),
|
handleAction: async (ctx) => {
|
||||||
handleAction: async (ctx) =>
|
const actions = resolveTelegramMessageActions();
|
||||||
await getTelegramRuntime().channel.telegram.messageActions.handleAction(ctx),
|
if (!actions?.handleAction) return null;
|
||||||
|
return await actions.handleAction(ctx);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseReplyToMessageId(replyToId?: string | null) {
|
function parseReplyToMessageId(replyToId?: string | null) {
|
||||||
|
|||||||
89
pnpm-lock.yaml
generated
89
pnpm-lock.yaml
generated
@ -383,12 +383,12 @@ importers:
|
|||||||
'@microsoft/agents-hosting-extensions-teams':
|
'@microsoft/agents-hosting-extensions-teams':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
moltbot:
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../..
|
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
moltbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
proper-lockfile:
|
proper-lockfile:
|
||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2
|
version: 4.1.2
|
||||||
@ -3214,11 +3214,6 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
clawdbot@2026.1.24-3:
|
|
||||||
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
|
|
||||||
engines: {node: '>=22.12.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -9098,84 +9093,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
|
|
||||||
dependencies:
|
|
||||||
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
|
||||||
'@aws-sdk/client-bedrock': 3.975.0
|
|
||||||
'@buape/carbon': 0.14.0(hono@4.11.4)
|
|
||||||
'@clack/prompts': 0.11.0
|
|
||||||
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
|
||||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
|
||||||
'@homebridge/ciao': 1.3.4
|
|
||||||
'@line/bot-sdk': 10.6.0
|
|
||||||
'@lydell/node-pty': 1.2.0-beta.3
|
|
||||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-tui': 0.49.3
|
|
||||||
'@mozilla/readability': 0.6.0
|
|
||||||
'@sinclair/typebox': 0.34.47
|
|
||||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
|
||||||
'@slack/web-api': 7.13.0
|
|
||||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
|
||||||
ajv: 8.17.1
|
|
||||||
body-parser: 2.2.2
|
|
||||||
chalk: 5.6.2
|
|
||||||
chokidar: 5.0.0
|
|
||||||
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
|
|
||||||
cli-highlight: 2.1.11
|
|
||||||
commander: 14.0.2
|
|
||||||
croner: 9.1.0
|
|
||||||
detect-libc: 2.1.2
|
|
||||||
discord-api-types: 0.38.37
|
|
||||||
dotenv: 17.2.3
|
|
||||||
express: 5.2.1
|
|
||||||
file-type: 21.3.0
|
|
||||||
grammy: 1.39.3
|
|
||||||
hono: 4.11.4
|
|
||||||
jiti: 2.6.1
|
|
||||||
json5: 2.2.3
|
|
||||||
jszip: 3.10.1
|
|
||||||
linkedom: 0.18.12
|
|
||||||
long: 5.3.2
|
|
||||||
markdown-it: 14.1.0
|
|
||||||
node-edge-tts: 1.2.9
|
|
||||||
osc-progress: 0.3.0
|
|
||||||
pdfjs-dist: 5.4.530
|
|
||||||
playwright-core: 1.58.0
|
|
||||||
proper-lockfile: 4.1.2
|
|
||||||
qrcode-terminal: 0.12.0
|
|
||||||
sharp: 0.34.5
|
|
||||||
sqlite-vec: 0.1.7-alpha.2
|
|
||||||
tar: 7.5.4
|
|
||||||
tslog: 4.10.2
|
|
||||||
undici: 7.19.0
|
|
||||||
ws: 8.19.0
|
|
||||||
yaml: 2.8.2
|
|
||||||
zod: 4.3.6
|
|
||||||
optionalDependencies:
|
|
||||||
'@napi-rs/canvas': 0.1.88
|
|
||||||
node-llama-cpp: 3.15.0(typescript@5.9.3)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@discordjs/opus'
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- '@types/express'
|
|
||||||
- audio-decode
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- canvas
|
|
||||||
- debug
|
|
||||||
- devtools-protocol
|
|
||||||
- encoding
|
|
||||||
- ffmpeg-static
|
|
||||||
- jimp
|
|
||||||
- link-preview-js
|
|
||||||
- node-opus
|
|
||||||
- opusscript
|
|
||||||
- supports-color
|
|
||||||
- typescript
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
|
|||||||
@ -30,7 +30,8 @@ const defaultShell = isWin
|
|||||||
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
||||||
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05";
|
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05";
|
||||||
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
||||||
const longDelayCmd = isWin ? "Start-Sleep -Seconds 2" : "sleep 2";
|
// Use longer delay on Windows for reliable timeout testing (Windows scheduling is less precise)
|
||||||
|
const longDelayCmd = isWin ? "Start-Sleep -Seconds 5" : "sleep 2";
|
||||||
// Both PowerShell and bash use ; for command separation
|
// Both PowerShell and bash use ; for command separation
|
||||||
const joinCommands = (commands: string[]) => commands.join("; ");
|
const joinCommands = (commands: string[]) => commands.join("; ");
|
||||||
const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]);
|
const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
|
const isMacOS = process.platform === "darwin";
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const ciWorkers = isWindows ? 2 : 3;
|
const ciWorkers = isWindows ? 2 : 3;
|
||||||
|
|
||||||
@ -19,6 +20,8 @@ export default defineConfig({
|
|||||||
testTimeout: 120_000,
|
testTimeout: 120_000,
|
||||||
hookTimeout: isWindows ? 180_000 : 120_000,
|
hookTimeout: isWindows ? 180_000 : 120_000,
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
|
// Use singleFork on macOS CI to avoid vitest worker crash (vitest#8564)
|
||||||
|
poolOptions: isMacOS && isCI ? { forks: { singleFork: true } } : undefined,
|
||||||
maxWorkers: isCI ? ciWorkers : localWorkers,
|
maxWorkers: isCI ? ciWorkers : localWorkers,
|
||||||
include: [
|
include: [
|
||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user