opencode-claw 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +316 -0
  3. package/dist/channels/router.d.ts +18 -0
  4. package/dist/channels/router.js +188 -0
  5. package/dist/channels/slack.d.ts +4 -0
  6. package/dist/channels/slack.js +87 -0
  7. package/dist/channels/telegram.d.ts +4 -0
  8. package/dist/channels/telegram.js +79 -0
  9. package/dist/channels/types.d.ts +27 -0
  10. package/dist/channels/types.js +1 -0
  11. package/dist/channels/whatsapp.d.ts +4 -0
  12. package/dist/channels/whatsapp.js +136 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +6 -0
  15. package/dist/compat.d.ts +12 -0
  16. package/dist/compat.js +63 -0
  17. package/dist/config/loader.d.ts +2 -0
  18. package/dist/config/loader.js +57 -0
  19. package/dist/config/schema.d.ts +482 -0
  20. package/dist/config/schema.js +108 -0
  21. package/dist/config/types.d.ts +14 -0
  22. package/dist/config/types.js +1 -0
  23. package/dist/cron/scheduler.d.ts +16 -0
  24. package/dist/cron/scheduler.js +113 -0
  25. package/dist/exports.d.ts +9 -0
  26. package/dist/exports.js +4 -0
  27. package/dist/health/server.d.ts +17 -0
  28. package/dist/health/server.js +68 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +127 -0
  31. package/dist/memory/factory.d.ts +3 -0
  32. package/dist/memory/factory.js +14 -0
  33. package/dist/memory/openviking.d.ts +5 -0
  34. package/dist/memory/openviking.js +138 -0
  35. package/dist/memory/plugin-entry.d.ts +3 -0
  36. package/dist/memory/plugin-entry.js +11 -0
  37. package/dist/memory/plugin.d.ts +5 -0
  38. package/dist/memory/plugin.js +63 -0
  39. package/dist/memory/txt.d.ts +2 -0
  40. package/dist/memory/txt.js +137 -0
  41. package/dist/memory/types.d.ts +36 -0
  42. package/dist/memory/types.js +1 -0
  43. package/dist/outbox/drainer.d.ts +8 -0
  44. package/dist/outbox/drainer.js +140 -0
  45. package/dist/outbox/writer.d.ts +15 -0
  46. package/dist/outbox/writer.js +29 -0
  47. package/dist/sessions/manager.d.ts +20 -0
  48. package/dist/sessions/manager.js +68 -0
  49. package/dist/sessions/persistence.d.ts +4 -0
  50. package/dist/sessions/persistence.js +24 -0
  51. package/dist/utils/logger.d.ts +8 -0
  52. package/dist/utils/logger.js +33 -0
  53. package/dist/utils/reconnect.d.ts +16 -0
  54. package/dist/utils/reconnect.js +54 -0
  55. package/dist/utils/shutdown.d.ts +5 -0
  56. package/dist/utils/shutdown.js +27 -0
  57. package/opencode-claw.example.json +71 -0
  58. package/package.json +77 -0
@@ -0,0 +1,137 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileExists, readTextFile, writeTextFile } from "../compat.js";
4
+ const SEPARATOR = "\n---\n\n";
5
+ const HEADER_RE = /^## \[(\w+)\] (\S+) \| source:(.+)$/;
6
+ const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
7
+ function generateId() {
8
+ const ts = Date.now().toString(36);
9
+ let suffix = "";
10
+ for (let i = 0; i < 4; i++) {
11
+ suffix += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
12
+ }
13
+ return `${ts}-${suffix}`;
14
+ }
15
+ function parseMemoryFile(raw) {
16
+ const blocks = raw.split(SEPARATOR).filter((b) => b.trim());
17
+ const entries = [];
18
+ for (const block of blocks) {
19
+ const lines = block.trim().split("\n");
20
+ const header = lines[0];
21
+ if (!header)
22
+ continue;
23
+ const match = header.match(HEADER_RE);
24
+ if (!match)
25
+ continue;
26
+ const category = match[1];
27
+ const timestamp = match[2] ?? "";
28
+ const source = match[3] ?? "";
29
+ const content = lines.slice(2).join("\n").trim();
30
+ if (!content)
31
+ continue;
32
+ entries.push({
33
+ id: `${timestamp}-${source}`,
34
+ content,
35
+ category,
36
+ source,
37
+ createdAt: new Date(timestamp),
38
+ });
39
+ }
40
+ return entries;
41
+ }
42
+ function formatEntry(entry, _id, timestamp) {
43
+ return `## [${entry.category}] ${timestamp} | source:${entry.source}\n\n${entry.content}`;
44
+ }
45
+ function tokenize(text) {
46
+ return text
47
+ .toLowerCase()
48
+ .split(/\W+/)
49
+ .filter((t) => t.length > 1);
50
+ }
51
+ function relevance(query, content, category) {
52
+ const tokens = tokenize(query);
53
+ if (tokens.length === 0)
54
+ return 0;
55
+ const target = `${category} ${content}`.toLowerCase();
56
+ let hits = 0;
57
+ for (const token of tokens) {
58
+ if (target.includes(token))
59
+ hits++;
60
+ }
61
+ return hits / tokens.length;
62
+ }
63
+ export function createTxtMemoryBackend(directory) {
64
+ const filepath = join(directory, "MEMORY.md");
65
+ let initialized = false;
66
+ async function readFile() {
67
+ if (!(await fileExists(filepath)))
68
+ return "";
69
+ return readTextFile(filepath);
70
+ }
71
+ async function writeFile(content) {
72
+ await writeTextFile(filepath, content);
73
+ }
74
+ return {
75
+ async initialize() {
76
+ await mkdir(directory, { recursive: true });
77
+ initialized = true;
78
+ },
79
+ async search(query, options) {
80
+ const raw = await readFile();
81
+ if (!raw)
82
+ return [];
83
+ const entries = parseMemoryFile(raw);
84
+ const limit = options?.limit ?? 10;
85
+ const minScore = options?.minRelevance ?? 0.1;
86
+ const scored = entries
87
+ .map((entry) => ({
88
+ ...entry,
89
+ relevance: relevance(query, entry.content, entry.category),
90
+ }))
91
+ .filter((e) => e.relevance >= minScore);
92
+ if (options?.category) {
93
+ const cat = options.category;
94
+ const filtered = scored.filter((e) => e.category === cat);
95
+ filtered.sort((a, b) => (b.relevance ?? 0) - (a.relevance ?? 0));
96
+ return filtered.slice(0, limit);
97
+ }
98
+ scored.sort((a, b) => (b.relevance ?? 0) - (a.relevance ?? 0));
99
+ return scored.slice(0, limit);
100
+ },
101
+ async store(entry) {
102
+ const id = generateId();
103
+ const timestamp = new Date().toISOString();
104
+ const formatted = formatEntry(entry, id, timestamp);
105
+ const existing = await readFile();
106
+ const content = existing
107
+ ? `${existing.trimEnd()}\n\n${SEPARATOR}${formatted}\n`
108
+ : `${formatted}\n`;
109
+ await writeFile(content);
110
+ },
111
+ async delete(id) {
112
+ const raw = await readFile();
113
+ if (!raw)
114
+ return;
115
+ const entries = parseMemoryFile(raw);
116
+ const filtered = entries.filter((e) => e.id !== id);
117
+ if (filtered.length === entries.length)
118
+ return;
119
+ const content = filtered
120
+ .map((e) => formatEntry(e, e.id, e.createdAt.toISOString()))
121
+ .join(SEPARATOR);
122
+ await writeFile(content ? `${content}\n` : "");
123
+ },
124
+ async status() {
125
+ const raw = await readFile();
126
+ const entries = raw ? parseMemoryFile(raw) : [];
127
+ return {
128
+ backend: "txt",
129
+ initialized,
130
+ entryCount: entries.length,
131
+ };
132
+ },
133
+ async close() {
134
+ // No resources to release
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,36 @@
1
+ export type MemoryCategory = "project" | "experience" | "preference" | "entity" | "event" | "knowledge";
2
+ export type MemorySearchOptions = {
3
+ limit?: number;
4
+ sessionId?: string;
5
+ category?: MemoryCategory;
6
+ minRelevance?: number;
7
+ };
8
+ export type MemoryEntry = {
9
+ id: string;
10
+ content: string;
11
+ category: MemoryCategory;
12
+ source: string;
13
+ createdAt: Date;
14
+ relevance?: number;
15
+ metadata?: Record<string, unknown>;
16
+ };
17
+ export type MemoryInput = {
18
+ content: string;
19
+ category: MemoryCategory;
20
+ source: string;
21
+ metadata?: Record<string, unknown>;
22
+ };
23
+ export type MemoryStatus = {
24
+ backend: string;
25
+ initialized: boolean;
26
+ entryCount: number;
27
+ lastSync?: Date;
28
+ };
29
+ export type MemoryBackend = {
30
+ initialize(): Promise<void>;
31
+ search(query: string, options?: MemorySearchOptions): Promise<MemoryEntry[]>;
32
+ store(entry: MemoryInput): Promise<void>;
33
+ delete(id: string): Promise<void>;
34
+ status(): Promise<MemoryStatus>;
35
+ close(): Promise<void>;
36
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { ChannelAdapter, ChannelId } from "../channels/types.js";
2
+ import type { OutboxConfig } from "../config/types.js";
3
+ import type { Logger } from "../utils/logger.js";
4
+ export type OutboxDrainer = {
5
+ start(): void;
6
+ stop(): void;
7
+ };
8
+ export declare function createOutboxDrainer(config: OutboxConfig, adapters: Map<ChannelId, ChannelAdapter>, logger: Logger): OutboxDrainer;
@@ -0,0 +1,140 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { mkdir, rename, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileExists, readTextFile, writeTextFile } from "../compat.js";
5
+ const DEAD_LETTER_CHECK_INTERVAL = 60_000;
6
+ export function createOutboxDrainer(config, adapters, logger) {
7
+ let timer = null;
8
+ async function readEntries() {
9
+ const results = [];
10
+ const baseDir = config.directory;
11
+ if (!(await dirExists(baseDir)))
12
+ return results;
13
+ const channels = await readdir(baseDir, { withFileTypes: true });
14
+ for (const ch of channels) {
15
+ if (!ch.isDirectory() || ch.name === "dead")
16
+ continue;
17
+ const channelDir = join(baseDir, ch.name);
18
+ const peers = await readdir(channelDir, { withFileTypes: true });
19
+ for (const peer of peers) {
20
+ if (!peer.isDirectory())
21
+ continue;
22
+ const peerDir = join(channelDir, peer.name);
23
+ const files = await readdir(peerDir);
24
+ for (const file of files) {
25
+ if (!file.endsWith(".json"))
26
+ continue;
27
+ const filepath = join(peerDir, file);
28
+ const raw = await readTextFile(filepath);
29
+ const entry = JSON.parse(raw);
30
+ results.push({ entry, filepath });
31
+ }
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+ async function moveToDead(filepath, entry) {
37
+ const deadDir = join(config.directory, "dead", entry.channel, entry.peerId);
38
+ await mkdir(deadDir, { recursive: true });
39
+ const filename = filepath.split("/").pop() ?? `${entry.id}.json`;
40
+ const dest = join(deadDir, filename);
41
+ await rename(filepath, dest);
42
+ logger.warn("outbox: moved to dead letter", {
43
+ id: entry.id,
44
+ channel: entry.channel,
45
+ peerId: entry.peerId,
46
+ attempts: entry.attempts,
47
+ });
48
+ }
49
+ async function drain() {
50
+ const pending = await readEntries();
51
+ if (pending.length === 0)
52
+ return;
53
+ for (const { entry, filepath } of pending) {
54
+ const adapter = adapters.get(entry.channel);
55
+ if (!adapter || adapter.status() !== "connected")
56
+ continue;
57
+ try {
58
+ await adapter.send(entry.peerId, {
59
+ text: entry.text,
60
+ threadId: entry.threadId,
61
+ });
62
+ if (await fileExists(filepath)) {
63
+ await unlink(filepath);
64
+ }
65
+ logger.debug("outbox: delivered", {
66
+ id: entry.id,
67
+ channel: entry.channel,
68
+ });
69
+ }
70
+ catch (err) {
71
+ entry.attempts++;
72
+ if (entry.attempts >= config.maxAttempts) {
73
+ await moveToDead(filepath, entry);
74
+ }
75
+ else {
76
+ await writeTextFile(filepath, JSON.stringify(entry, null, 2));
77
+ logger.warn("outbox: delivery failed, will retry", {
78
+ id: entry.id,
79
+ attempts: entry.attempts,
80
+ error: err instanceof Error ? err.message : String(err),
81
+ });
82
+ }
83
+ }
84
+ }
85
+ }
86
+ async function dirExists(path) {
87
+ try {
88
+ await readdir(path);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function checkDeadLetters() {
96
+ const deadDir = join(config.directory, "dead");
97
+ try {
98
+ const entries = await readdir(deadDir, { recursive: true });
99
+ const count = entries.filter((e) => e.endsWith(".json")).length;
100
+ if (count > 0) {
101
+ logger.warn("outbox: dead letters detected", {
102
+ count,
103
+ path: deadDir,
104
+ });
105
+ }
106
+ }
107
+ catch {
108
+ // dead directory doesn't exist yet — no dead letters
109
+ }
110
+ }
111
+ let deadLetterTimer = null;
112
+ return {
113
+ start() {
114
+ timer = setInterval(() => {
115
+ drain().catch((err) => {
116
+ logger.error("outbox: drain error", {
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ });
120
+ }, config.pollIntervalMs);
121
+ deadLetterTimer = setInterval(() => {
122
+ checkDeadLetters().catch(() => { });
123
+ }, DEAD_LETTER_CHECK_INTERVAL);
124
+ logger.info("outbox: drainer started", {
125
+ pollIntervalMs: config.pollIntervalMs,
126
+ });
127
+ },
128
+ stop() {
129
+ if (timer) {
130
+ clearInterval(timer);
131
+ timer = null;
132
+ }
133
+ if (deadLetterTimer) {
134
+ clearInterval(deadLetterTimer);
135
+ deadLetterTimer = null;
136
+ }
137
+ logger.info("outbox: drainer stopped");
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,15 @@
1
+ import type { ChannelId } from "../channels/types.js";
2
+ import type { OutboxConfig } from "../config/types.js";
3
+ export type OutboxEntry = {
4
+ id: string;
5
+ channel: ChannelId;
6
+ peerId: string;
7
+ text: string;
8
+ threadId?: string;
9
+ enqueuedAt: string;
10
+ attempts: number;
11
+ };
12
+ export type OutboxWriter = {
13
+ enqueue(entry: Omit<OutboxEntry, "id" | "enqueuedAt" | "attempts">): Promise<void>;
14
+ };
15
+ export declare function createOutboxWriter(config: OutboxConfig): OutboxWriter;
@@ -0,0 +1,29 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { writeTextFile } from "../compat.js";
4
+ const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
5
+ function generateId() {
6
+ const ts = Date.now().toString(36);
7
+ let suffix = "";
8
+ for (let i = 0; i < 6; i++) {
9
+ suffix += CHARS[Math.floor(Math.random() * CHARS.length)];
10
+ }
11
+ return `${ts}-${suffix}`;
12
+ }
13
+ export function createOutboxWriter(config) {
14
+ return {
15
+ async enqueue(entry) {
16
+ const id = generateId();
17
+ const full = {
18
+ ...entry,
19
+ id,
20
+ enqueuedAt: new Date().toISOString(),
21
+ attempts: 0,
22
+ };
23
+ const dir = join(config.directory, entry.channel, entry.peerId);
24
+ await mkdir(dir, { recursive: true });
25
+ const path = join(dir, `${id}.json`);
26
+ await writeTextFile(path, JSON.stringify(full, null, 2));
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,20 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk";
2
+ import type { SessionsConfig } from "../config/types.js";
3
+ import type { Logger } from "../utils/logger.js";
4
+ export type SessionInfo = {
5
+ id: string;
6
+ key: string;
7
+ title: string;
8
+ active: boolean;
9
+ createdAt?: number;
10
+ };
11
+ export declare function buildSessionKey(channel: string, peerId: string, threadId?: string): string;
12
+ export declare function createSessionManager(client: OpencodeClient, config: SessionsConfig, map: Map<string, string>, logger: Logger): {
13
+ resolveSession: (key: string, title?: string) => Promise<string>;
14
+ switchSession: (key: string, targetId: string) => Promise<void>;
15
+ newSession: (key: string, title?: string) => Promise<string>;
16
+ listSessions: (channelPeerPrefix: string) => Promise<SessionInfo[]>;
17
+ currentSession: (key: string) => string | undefined;
18
+ persist: () => Promise<void>;
19
+ };
20
+ export type SessionManager = ReturnType<typeof createSessionManager>;
@@ -0,0 +1,68 @@
1
+ import { saveSessionMap } from "./persistence.js";
2
+ export function buildSessionKey(channel, peerId, threadId) {
3
+ const base = `opencode-claw:${channel}:${peerId}`;
4
+ if (threadId)
5
+ return `${base}:thread:${threadId}`;
6
+ return base;
7
+ }
8
+ export function createSessionManager(client, config, map, logger) {
9
+ async function persist() {
10
+ await saveSessionMap(config, map, logger);
11
+ }
12
+ async function resolveSession(key, title) {
13
+ const existing = map.get(key);
14
+ if (existing)
15
+ return existing;
16
+ const session = await client.session.create({
17
+ body: { title: title ?? key },
18
+ });
19
+ if (!session.data)
20
+ throw new Error("session.create returned no data");
21
+ map.set(key, session.data.id);
22
+ await persist();
23
+ logger.info("sessions: created new session", { key, id: session.data.id });
24
+ return session.data.id;
25
+ }
26
+ async function switchSession(key, targetId) {
27
+ map.set(key, targetId);
28
+ await persist();
29
+ logger.info("sessions: switched session", { key, targetId });
30
+ }
31
+ async function newSession(key, title) {
32
+ const session = await client.session.create({
33
+ body: { title: title ?? `New session ${new Date().toISOString()}` },
34
+ });
35
+ if (!session.data)
36
+ throw new Error("session.create returned no data");
37
+ map.set(key, session.data.id);
38
+ await persist();
39
+ logger.info("sessions: created and switched to new session", { key, id: session.data.id });
40
+ return session.data.id;
41
+ }
42
+ async function listSessions(channelPeerPrefix) {
43
+ const all = await client.session.list();
44
+ const sessions = all.data ?? [];
45
+ const entries = [...map.entries()].filter(([key]) => key.includes(channelPeerPrefix));
46
+ return entries.map(([key, id]) => {
47
+ const session = sessions.find((s) => s.id === id);
48
+ return {
49
+ id,
50
+ key,
51
+ title: session?.title ?? "(deleted)",
52
+ active: map.get(key) === id,
53
+ createdAt: session?.time.created,
54
+ };
55
+ });
56
+ }
57
+ function currentSession(key) {
58
+ return map.get(key);
59
+ }
60
+ return {
61
+ resolveSession,
62
+ switchSession,
63
+ newSession,
64
+ listSessions,
65
+ currentSession,
66
+ persist,
67
+ };
68
+ }
@@ -0,0 +1,4 @@
1
+ import type { SessionsConfig } from "../config/types.js";
2
+ import type { Logger } from "../utils/logger.js";
3
+ export declare function loadSessionMap(config: SessionsConfig, logger: Logger): Promise<Map<string, string>>;
4
+ export declare function saveSessionMap(config: SessionsConfig, map: Map<string, string>, logger: Logger): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { fileExists, readJsonFile, writeTextFile } from "../compat.js";
2
+ export async function loadSessionMap(config, logger) {
3
+ if (!(await fileExists(config.persistPath))) {
4
+ logger.debug("sessions: no persisted map found, starting fresh");
5
+ return new Map();
6
+ }
7
+ try {
8
+ const data = await readJsonFile(config.persistPath);
9
+ const map = new Map(Object.entries(data));
10
+ logger.info("sessions: loaded persisted map", { count: map.size });
11
+ return map;
12
+ }
13
+ catch (err) {
14
+ logger.warn("sessions: failed to load persisted map, starting fresh", {
15
+ error: err instanceof Error ? err.message : String(err),
16
+ });
17
+ return new Map();
18
+ }
19
+ }
20
+ export async function saveSessionMap(config, map, logger) {
21
+ const data = Object.fromEntries(map);
22
+ await writeTextFile(config.persistPath, JSON.stringify(data, null, 2));
23
+ logger.debug("sessions: persisted map", { count: map.size });
24
+ }
@@ -0,0 +1,8 @@
1
+ import type { LogConfig } from "../config/types.js";
2
+ export declare function createLogger(config: LogConfig): {
3
+ debug: (msg: string, data?: Record<string, unknown>) => void;
4
+ info: (msg: string, data?: Record<string, unknown>) => void;
5
+ warn: (msg: string, data?: Record<string, unknown>) => void;
6
+ error: (msg: string, data?: Record<string, unknown>) => void;
7
+ };
8
+ export type Logger = ReturnType<typeof createLogger>;
@@ -0,0 +1,33 @@
1
+ import { createFileWriter } from "../compat.js";
2
+ const levels = { debug: 0, info: 1, warn: 2, error: 3 };
3
+ export function createLogger(config) {
4
+ const threshold = levels[config.level];
5
+ const writer = config.file ? createFileWriter(config.file) : null;
6
+ function write(level, msg, data) {
7
+ if (levels[level] < threshold)
8
+ return;
9
+ const entry = {
10
+ ts: new Date().toISOString(),
11
+ level,
12
+ msg,
13
+ ...data,
14
+ };
15
+ const line = JSON.stringify(entry);
16
+ if (writer) {
17
+ writer.write(`${line}\n`);
18
+ writer.flush();
19
+ }
20
+ else {
21
+ if (level === "error")
22
+ console.error(line);
23
+ else
24
+ console.log(line);
25
+ }
26
+ }
27
+ return {
28
+ debug: (msg, data) => write("debug", msg, data),
29
+ info: (msg, data) => write("info", msg, data),
30
+ warn: (msg, data) => write("warn", msg, data),
31
+ error: (msg, data) => write("error", msg, data),
32
+ };
33
+ }
@@ -0,0 +1,16 @@
1
+ import type { Logger } from "./logger.js";
2
+ type ReconnectOpts = {
3
+ name: string;
4
+ connect: () => Promise<void>;
5
+ logger: Logger;
6
+ baseDelayMs?: number;
7
+ maxDelayMs?: number;
8
+ maxAttempts?: number;
9
+ };
10
+ export declare function createReconnector(opts: ReconnectOpts): {
11
+ attempt: () => Promise<void>;
12
+ reset: () => void;
13
+ stop: () => void;
14
+ };
15
+ export type Reconnector = ReturnType<typeof createReconnector>;
16
+ export {};
@@ -0,0 +1,54 @@
1
+ const DEFAULT_BASE_DELAY = 1000;
2
+ const DEFAULT_MAX_DELAY = 30_000;
3
+ const DEFAULT_MAX_ATTEMPTS = Number.POSITIVE_INFINITY;
4
+ export function createReconnector(opts) {
5
+ const base = opts.baseDelayMs ?? DEFAULT_BASE_DELAY;
6
+ const cap = opts.maxDelayMs ?? DEFAULT_MAX_DELAY;
7
+ const limit = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
8
+ let attempts = 0;
9
+ let timer = null;
10
+ let stopped = false;
11
+ function delay() {
12
+ const exponential = base * 2 ** attempts;
13
+ const jitter = Math.random() * base;
14
+ return Math.min(exponential + jitter, cap);
15
+ }
16
+ async function attempt() {
17
+ if (stopped)
18
+ return;
19
+ attempts++;
20
+ if (attempts > limit) {
21
+ opts.logger.error(`${opts.name}: max reconnect attempts (${limit}) reached, giving up`);
22
+ return;
23
+ }
24
+ const ms = delay();
25
+ opts.logger.info(`${opts.name}: reconnecting in ${Math.round(ms)}ms (attempt ${attempts})`);
26
+ timer = setTimeout(async () => {
27
+ if (stopped)
28
+ return;
29
+ try {
30
+ await opts.connect();
31
+ attempts = 0;
32
+ opts.logger.info(`${opts.name}: reconnected successfully`);
33
+ }
34
+ catch (err) {
35
+ opts.logger.warn(`${opts.name}: reconnect failed`, {
36
+ attempt: attempts,
37
+ error: err instanceof Error ? err.message : String(err),
38
+ });
39
+ await attempt();
40
+ }
41
+ }, ms);
42
+ }
43
+ function reset() {
44
+ attempts = 0;
45
+ }
46
+ function stop() {
47
+ stopped = true;
48
+ if (timer) {
49
+ clearTimeout(timer);
50
+ timer = null;
51
+ }
52
+ }
53
+ return { attempt, reset, stop };
54
+ }
@@ -0,0 +1,5 @@
1
+ import type { Logger } from "./logger.js";
2
+ type ShutdownFn = () => Promise<void> | void;
3
+ export declare function onShutdown(fn: ShutdownFn): void;
4
+ export declare function setupShutdown(logger: Logger): void;
5
+ export {};
@@ -0,0 +1,27 @@
1
+ const handlers = [];
2
+ let shuttingDown = false;
3
+ export function onShutdown(fn) {
4
+ handlers.push(fn);
5
+ }
6
+ export function setupShutdown(logger) {
7
+ const handler = async () => {
8
+ if (shuttingDown)
9
+ return;
10
+ shuttingDown = true;
11
+ logger.info("shutdown: signal received, draining...");
12
+ for (const fn of handlers.reverse()) {
13
+ try {
14
+ await fn();
15
+ }
16
+ catch (err) {
17
+ logger.error("shutdown: handler failed", {
18
+ error: err instanceof Error ? err.message : String(err),
19
+ });
20
+ }
21
+ }
22
+ logger.info("shutdown: complete");
23
+ process.exit(0);
24
+ };
25
+ process.on("SIGTERM", handler);
26
+ process.on("SIGINT", handler);
27
+ }