@nordbyte/nordrelay 0.2.1
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/.env.example +88 -0
- package/Dockerfile +19 -0
- package/LICENSE +21 -0
- package/README.md +749 -0
- package/dist/access-control.js +146 -0
- package/dist/agent-factory.js +22 -0
- package/dist/agent.js +57 -0
- package/dist/artifacts.js +515 -0
- package/dist/attachments.js +69 -0
- package/dist/bot-preferences.js +146 -0
- package/dist/bot-ui.js +161 -0
- package/dist/bot.js +4520 -0
- package/dist/codex-auth.js +150 -0
- package/dist/codex-cli.js +79 -0
- package/dist/codex-config.js +50 -0
- package/dist/codex-launch.js +109 -0
- package/dist/codex-session.js +591 -0
- package/dist/codex-state.js +573 -0
- package/dist/config.js +385 -0
- package/dist/context-key.js +23 -0
- package/dist/error-messages.js +73 -0
- package/dist/format.js +121 -0
- package/dist/index.js +140 -0
- package/dist/logger.js +27 -0
- package/dist/operations.js +133 -0
- package/dist/persistence.js +65 -0
- package/dist/pi-cli.js +19 -0
- package/dist/pi-rpc.js +158 -0
- package/dist/pi-session.js +573 -0
- package/dist/pi-state.js +226 -0
- package/dist/prompt-store.js +241 -0
- package/dist/redaction.js +47 -0
- package/dist/session-format.js +191 -0
- package/dist/session-registry.js +195 -0
- package/dist/telegram-rate-limit.js +136 -0
- package/dist/voice.js +373 -0
- package/dist/workspace-policy.js +41 -0
- package/docker-compose.yml +17 -0
- package/launchd/start.sh +8 -0
- package/package.json +69 -0
- package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
- package/plugins/nordrelay/assets/nordrelay.svg +5 -0
- package/plugins/nordrelay/commands/remote.md +33 -0
- package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { CODEX_AGENT_CAPABILITIES, agentReasoningLabel } from "./agent.js";
|
|
2
|
+
import { escapeHTML } from "./format.js";
|
|
3
|
+
export function renderSessionInfoPlain(info) {
|
|
4
|
+
const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
5
|
+
const agentId = info.agentId ?? "codex";
|
|
6
|
+
return [
|
|
7
|
+
`Agent: ${info.agentLabel ?? "Codex"}`,
|
|
8
|
+
`Thread ID: ${info.threadId ?? "(not started yet)"}`,
|
|
9
|
+
`Workspace: ${info.workspace}`,
|
|
10
|
+
capabilities.launchProfiles
|
|
11
|
+
? `Launch profile: ${info.launchProfileLabel} (${info.launchProfileBehavior})${info.unsafeLaunch ? " [unsafe]" : ""}`
|
|
12
|
+
: `Mode: ${info.launchProfileLabel} (${info.launchProfileBehavior})`,
|
|
13
|
+
info.nextLaunchProfileId
|
|
14
|
+
? `Next launch profile: ${info.nextLaunchProfileLabel} (${info.nextLaunchProfileBehavior})${info.nextUnsafeLaunch ? " [unsafe]" : ""}`
|
|
15
|
+
: undefined,
|
|
16
|
+
`Model: ${info.model ?? "(default)"}`,
|
|
17
|
+
capabilities.fastMode
|
|
18
|
+
? `Reasoning/Fast: ${info.reasoningEffort ?? "(model default)"} / ${info.fastMode ? "on" : "off"}`
|
|
19
|
+
: `${agentReasoningLabel(agentId)}: ${info.reasoningEffort ?? "(model default)"}`,
|
|
20
|
+
...renderCodexUsagePlain(info),
|
|
21
|
+
...renderAgentUsagePlain(info),
|
|
22
|
+
info.sessionTokens ? formatSessionTokensPlain(info.sessionTokens) : undefined,
|
|
23
|
+
]
|
|
24
|
+
.filter((line) => Boolean(line))
|
|
25
|
+
.join("\n");
|
|
26
|
+
}
|
|
27
|
+
export function renderSessionInfoHTML(info) {
|
|
28
|
+
const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
29
|
+
const agentId = info.agentId ?? "codex";
|
|
30
|
+
return [
|
|
31
|
+
`<b>Agent:</b> <code>${escapeHTML(info.agentLabel ?? "Codex")}</code>`,
|
|
32
|
+
`<b>Thread ID:</b> <code>${escapeHTML(info.threadId ?? "(not started yet)")}</code>`,
|
|
33
|
+
`<b>Workspace:</b> <code>${escapeHTML(info.workspace)}</code>`,
|
|
34
|
+
capabilities.launchProfiles
|
|
35
|
+
? `<b>Launch profile:</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`
|
|
36
|
+
: `<b>Mode:</b> <code>${escapeHTML(info.launchProfileLabel)}</code>`,
|
|
37
|
+
`<b>Launch behavior:</b> <code>${escapeHTML(info.launchProfileBehavior)}</code>${info.unsafeLaunch ? " ⚠️" : ""}`,
|
|
38
|
+
info.nextLaunchProfileId
|
|
39
|
+
? `<b>Next launch profile:</b> <code>${escapeHTML(info.nextLaunchProfileLabel ?? "")}</code> <i>(${escapeHTML(info.nextLaunchProfileBehavior ?? "")})</i>${info.nextUnsafeLaunch ? " ⚠️" : ""}`
|
|
40
|
+
: undefined,
|
|
41
|
+
`<b>Model:</b> <code>${escapeHTML(info.model ?? "(default)")}</code>`,
|
|
42
|
+
capabilities.fastMode
|
|
43
|
+
? `<b>Reasoning/Fast:</b> <code>${escapeHTML(info.reasoningEffort ?? "(model default)")} / ${info.fastMode ? "on" : "off"}</code>`
|
|
44
|
+
: `<b>${escapeHTML(agentReasoningLabel(agentId))}:</b> <code>${escapeHTML(info.reasoningEffort ?? "(model default)")}</code>`,
|
|
45
|
+
...renderCodexUsageHTML(info),
|
|
46
|
+
...renderAgentUsageHTML(info),
|
|
47
|
+
info.sessionTokens ? `<b>Session tokens:</b> <code>${escapeHTML(formatSessionTokensValue(info.sessionTokens))}</code>` : undefined,
|
|
48
|
+
]
|
|
49
|
+
.filter((line) => Boolean(line))
|
|
50
|
+
.join("\n");
|
|
51
|
+
}
|
|
52
|
+
export function renderLaunchSummaryPlain(info) {
|
|
53
|
+
const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
54
|
+
return `${capabilities.launchProfiles ? "Launch" : "Mode"}: ${info.launchProfileLabel} (${info.launchProfileBehavior})${info.unsafeLaunch ? " [unsafe]" : ""}`;
|
|
55
|
+
}
|
|
56
|
+
export function renderLaunchSummaryHTML(info) {
|
|
57
|
+
const suffix = info.unsafeLaunch ? " ⚠️" : "";
|
|
58
|
+
const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
59
|
+
return `<b>${capabilities.launchProfiles ? "Launch" : "Mode"}:</b> <code>${escapeHTML(info.launchProfileLabel)}</code> <i>(${escapeHTML(info.launchProfileBehavior)})</i>${suffix}`;
|
|
60
|
+
}
|
|
61
|
+
export function formatFileSize(bytes) {
|
|
62
|
+
if (bytes < 1024) {
|
|
63
|
+
return `${bytes} B`;
|
|
64
|
+
}
|
|
65
|
+
if (bytes < 1024 * 1024) {
|
|
66
|
+
return `${(bytes / 1024).toFixed(1).replace(/\.0$/, "")} KB`;
|
|
67
|
+
}
|
|
68
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")} MB`;
|
|
69
|
+
}
|
|
70
|
+
function renderCodexUsagePlain(info) {
|
|
71
|
+
const usage = info.codexUsage;
|
|
72
|
+
if (!usage) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const lines = [];
|
|
76
|
+
if (usage.contextUsedPercent !== null && usage.contextWindow !== null && usage.lastTokenUsage) {
|
|
77
|
+
lines.push(`Context used: ${formatPercent(usage.contextUsedPercent)} (${formatCompactTokenCount(usage.lastTokenUsage.totalTokens)} / ${formatCompactTokenCount(usage.contextWindow)})`);
|
|
78
|
+
}
|
|
79
|
+
if (usage.totalTokenUsage) {
|
|
80
|
+
lines.push([
|
|
81
|
+
`Tokens in: ${formatCompactTokenCount(usage.totalTokenUsage.inputTokens)}`,
|
|
82
|
+
`cached: ${formatCompactTokenCount(usage.totalTokenUsage.cachedInputTokens)}`,
|
|
83
|
+
`out: ${formatCompactTokenCount(usage.totalTokenUsage.outputTokens)}`,
|
|
84
|
+
`reasoning out: ${formatCompactTokenCount(usage.totalTokenUsage.reasoningOutputTokens)}`,
|
|
85
|
+
].join(" · "));
|
|
86
|
+
}
|
|
87
|
+
const limits = formatLimitsLeft(usage);
|
|
88
|
+
if (limits) {
|
|
89
|
+
lines.push(`Limits left: ${limits}`);
|
|
90
|
+
}
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
function renderCodexUsageHTML(info) {
|
|
94
|
+
const usage = info.codexUsage;
|
|
95
|
+
if (!usage) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const lines = [];
|
|
99
|
+
if (usage.contextUsedPercent !== null && usage.contextWindow !== null && usage.lastTokenUsage) {
|
|
100
|
+
lines.push(`<b>Context used:</b> <code>${escapeHTML(formatPercent(usage.contextUsedPercent))}</code> <i>(${escapeHTML(formatCompactTokenCount(usage.lastTokenUsage.totalTokens))} / ${escapeHTML(formatCompactTokenCount(usage.contextWindow))})</i>`);
|
|
101
|
+
}
|
|
102
|
+
if (usage.totalTokenUsage) {
|
|
103
|
+
lines.push(`<b>Tokens:</b> <code>${escapeHTML([
|
|
104
|
+
`in ${formatCompactTokenCount(usage.totalTokenUsage.inputTokens)}`,
|
|
105
|
+
`cached ${formatCompactTokenCount(usage.totalTokenUsage.cachedInputTokens)}`,
|
|
106
|
+
`out ${formatCompactTokenCount(usage.totalTokenUsage.outputTokens)}`,
|
|
107
|
+
`reasoning out ${formatCompactTokenCount(usage.totalTokenUsage.reasoningOutputTokens)}`,
|
|
108
|
+
].join(" · "))}</code>`);
|
|
109
|
+
}
|
|
110
|
+
const limits = formatLimitsLeft(usage);
|
|
111
|
+
if (limits) {
|
|
112
|
+
lines.push(`<b>Limits left:</b> <code>${escapeHTML(limits)}</code>`);
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
function renderAgentUsagePlain(info) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
if (info.contextUsage?.percent !== undefined && info.contextUsage.percent !== null) {
|
|
119
|
+
const contextWindow = info.contextUsage.contextWindow !== null && info.contextUsage.contextWindow !== undefined
|
|
120
|
+
? ` (${formatCompactTokenCount(info.contextUsage.tokens ?? 0)} / ${formatCompactTokenCount(info.contextUsage.contextWindow)})`
|
|
121
|
+
: "";
|
|
122
|
+
lines.push(`Context used: ${formatPercent(info.contextUsage.percent)}${contextWindow}`);
|
|
123
|
+
}
|
|
124
|
+
if (info.sessionUsage) {
|
|
125
|
+
lines.push([
|
|
126
|
+
`Tokens in: ${formatCompactTokenCount(info.sessionUsage.input)}`,
|
|
127
|
+
`cache read: ${formatCompactTokenCount(info.sessionUsage.cacheRead)}`,
|
|
128
|
+
`cache write: ${formatCompactTokenCount(info.sessionUsage.cacheWrite)}`,
|
|
129
|
+
`out: ${formatCompactTokenCount(info.sessionUsage.output)}`,
|
|
130
|
+
].join(" · "));
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
function renderAgentUsageHTML(info) {
|
|
135
|
+
const lines = [];
|
|
136
|
+
if (info.contextUsage?.percent !== undefined && info.contextUsage.percent !== null) {
|
|
137
|
+
const contextWindow = info.contextUsage.contextWindow !== null && info.contextUsage.contextWindow !== undefined
|
|
138
|
+
? ` <i>(${escapeHTML(formatCompactTokenCount(info.contextUsage.tokens ?? 0))} / ${escapeHTML(formatCompactTokenCount(info.contextUsage.contextWindow))})</i>`
|
|
139
|
+
: "";
|
|
140
|
+
lines.push(`<b>Context used:</b> <code>${escapeHTML(formatPercent(info.contextUsage.percent))}</code>${contextWindow}`);
|
|
141
|
+
}
|
|
142
|
+
if (info.sessionUsage) {
|
|
143
|
+
lines.push(`<b>Tokens:</b> <code>${escapeHTML([
|
|
144
|
+
`in ${formatCompactTokenCount(info.sessionUsage.input)}`,
|
|
145
|
+
`cache read ${formatCompactTokenCount(info.sessionUsage.cacheRead)}`,
|
|
146
|
+
`cache write ${formatCompactTokenCount(info.sessionUsage.cacheWrite)}`,
|
|
147
|
+
`out ${formatCompactTokenCount(info.sessionUsage.output)}`,
|
|
148
|
+
].join(" · "))}</code>`);
|
|
149
|
+
}
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
function formatLimitsLeft(usage) {
|
|
153
|
+
const parts = [];
|
|
154
|
+
if (usage.rateLimits?.primary) {
|
|
155
|
+
parts.push(`5h ${formatPercent(usage.rateLimits.primary.remainingPercent)}`);
|
|
156
|
+
}
|
|
157
|
+
if (usage.rateLimits?.secondary) {
|
|
158
|
+
parts.push(`weekly ${formatPercent(usage.rateLimits.secondary.remainingPercent)}`);
|
|
159
|
+
}
|
|
160
|
+
return parts.join(" · ");
|
|
161
|
+
}
|
|
162
|
+
function formatCompactTokenCount(value) {
|
|
163
|
+
const abs = Math.abs(value);
|
|
164
|
+
const units = [
|
|
165
|
+
{ threshold: 1_000_000_000_000, suffix: "T" },
|
|
166
|
+
{ threshold: 1_000_000_000, suffix: "B" },
|
|
167
|
+
{ threshold: 1_000_000, suffix: "M" },
|
|
168
|
+
{ threshold: 1_000, suffix: "K" },
|
|
169
|
+
];
|
|
170
|
+
const unit = units.find((candidate) => abs >= candidate.threshold);
|
|
171
|
+
if (!unit) {
|
|
172
|
+
return Math.round(value).toLocaleString("en-US");
|
|
173
|
+
}
|
|
174
|
+
const scaled = value / unit.threshold;
|
|
175
|
+
const decimals = Math.abs(scaled) < 100 ? 1 : 0;
|
|
176
|
+
return `${scaled.toFixed(decimals).replace(/\.0$/, "")}${unit.suffix}`;
|
|
177
|
+
}
|
|
178
|
+
function formatPercent(value) {
|
|
179
|
+
const rounded = Math.round(value * 10) / 10;
|
|
180
|
+
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
|
|
181
|
+
}
|
|
182
|
+
function formatSessionTokensValue(tokens) {
|
|
183
|
+
return [
|
|
184
|
+
`in: ${formatCompactTokenCount(tokens.input)}`,
|
|
185
|
+
`cached: ${formatCompactTokenCount(tokens.cached)}`,
|
|
186
|
+
`out: ${formatCompactTokenCount(tokens.output)}`,
|
|
187
|
+
].join(" · ");
|
|
188
|
+
}
|
|
189
|
+
function formatSessionTokensPlain(tokens) {
|
|
190
|
+
return `Session tokens: ${formatSessionTokensValue(tokens)}`;
|
|
191
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createAgentSessionService } from "./agent-factory.js";
|
|
3
|
+
import { CODEX_AGENT_CAPABILITIES } from "./agent.js";
|
|
4
|
+
import { findLaunchProfile } from "./codex-launch.js";
|
|
5
|
+
import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
|
|
6
|
+
export class SessionRegistry {
|
|
7
|
+
config;
|
|
8
|
+
sessions = new Map();
|
|
9
|
+
metadata = new Map();
|
|
10
|
+
persistPath;
|
|
11
|
+
onRemoveCallback;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.persistPath = path.join(config.workspace, ".nordrelay", "contexts.json");
|
|
15
|
+
this.loadPersistedMetadata();
|
|
16
|
+
}
|
|
17
|
+
async getOrCreate(contextKey, options) {
|
|
18
|
+
let session = this.sessions.get(contextKey);
|
|
19
|
+
if (session && (!options?.agentId || session.getInfo().agentId === options.agentId)) {
|
|
20
|
+
return session;
|
|
21
|
+
}
|
|
22
|
+
if (session && options?.agentId && session.getInfo().agentId !== options.agentId) {
|
|
23
|
+
session.dispose();
|
|
24
|
+
this.sessions.delete(contextKey);
|
|
25
|
+
}
|
|
26
|
+
const meta = this.metadata.get(contextKey);
|
|
27
|
+
const agentId = options?.agentId ?? meta?.agentId ?? this.config.defaultAgent ?? "codex";
|
|
28
|
+
const launchProfileId = resolveLaunchProfileId(this.config, meta);
|
|
29
|
+
session = await createAgentSessionService(this.config, agentId, {
|
|
30
|
+
workspace: meta?.workspace,
|
|
31
|
+
model: meta?.model,
|
|
32
|
+
reasoningEffort: meta?.reasoningEffort,
|
|
33
|
+
launchProfileId,
|
|
34
|
+
deferThreadStart: options?.deferThreadStart && !meta?.threadId,
|
|
35
|
+
resumeThreadId: meta?.threadId ?? undefined,
|
|
36
|
+
sessionPath: meta?.sessionPath,
|
|
37
|
+
});
|
|
38
|
+
this.sessions.set(contextKey, session);
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
get(contextKey) {
|
|
42
|
+
return this.sessions.get(contextKey);
|
|
43
|
+
}
|
|
44
|
+
has(contextKey) {
|
|
45
|
+
return this.sessions.has(contextKey);
|
|
46
|
+
}
|
|
47
|
+
hasMetadata(contextKey) {
|
|
48
|
+
return this.metadata.has(contextKey);
|
|
49
|
+
}
|
|
50
|
+
async switchAgent(contextKey, agentId) {
|
|
51
|
+
const current = this.sessions.get(contextKey);
|
|
52
|
+
if (current?.getInfo().agentId === agentId) {
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
current?.dispose();
|
|
56
|
+
this.sessions.delete(contextKey);
|
|
57
|
+
const previous = this.metadata.get(contextKey);
|
|
58
|
+
const next = {
|
|
59
|
+
contextKey,
|
|
60
|
+
agentId,
|
|
61
|
+
threadId: null,
|
|
62
|
+
workspace: previous?.workspace ?? this.config.workspace,
|
|
63
|
+
pinnedThreadIds: previous?.pinnedThreadIds,
|
|
64
|
+
updatedAt: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
this.metadata.set(contextKey, next);
|
|
67
|
+
this.persistMetadata();
|
|
68
|
+
return this.getOrCreate(contextKey, { deferThreadStart: true, agentId });
|
|
69
|
+
}
|
|
70
|
+
updateMetadata(contextKey, session) {
|
|
71
|
+
const info = session.getInfo();
|
|
72
|
+
const previous = this.metadata.get(contextKey);
|
|
73
|
+
const pinnedThreadIds = previous?.pinnedThreadIds ?? [];
|
|
74
|
+
const next = {
|
|
75
|
+
contextKey,
|
|
76
|
+
threadId: info.threadId,
|
|
77
|
+
workspace: info.workspace,
|
|
78
|
+
model: info.model,
|
|
79
|
+
reasoningEffort: info.reasoningEffort,
|
|
80
|
+
launchProfileId: info.nextLaunchProfileId ?? info.launchProfileId,
|
|
81
|
+
updatedAt: Date.now(),
|
|
82
|
+
};
|
|
83
|
+
if (info.agentId && info.agentId !== "codex") {
|
|
84
|
+
next.agentId = info.agentId;
|
|
85
|
+
}
|
|
86
|
+
if (info.sessionPath) {
|
|
87
|
+
next.sessionPath = info.sessionPath;
|
|
88
|
+
}
|
|
89
|
+
if (pinnedThreadIds.length > 0) {
|
|
90
|
+
next.pinnedThreadIds = pinnedThreadIds;
|
|
91
|
+
}
|
|
92
|
+
this.metadata.set(contextKey, next);
|
|
93
|
+
this.persistMetadata();
|
|
94
|
+
}
|
|
95
|
+
pinThread(contextKey, threadId) {
|
|
96
|
+
const meta = this.metadata.get(contextKey) ?? this.createEmptyMetadata(contextKey);
|
|
97
|
+
const pinned = new Set(meta.pinnedThreadIds ?? []);
|
|
98
|
+
pinned.add(threadId);
|
|
99
|
+
meta.pinnedThreadIds = [...pinned];
|
|
100
|
+
meta.updatedAt = Date.now();
|
|
101
|
+
this.metadata.set(contextKey, meta);
|
|
102
|
+
this.persistMetadata();
|
|
103
|
+
return meta.pinnedThreadIds;
|
|
104
|
+
}
|
|
105
|
+
unpinThread(contextKey, threadId) {
|
|
106
|
+
const meta = this.metadata.get(contextKey) ?? this.createEmptyMetadata(contextKey);
|
|
107
|
+
meta.pinnedThreadIds = (meta.pinnedThreadIds ?? []).filter((id) => id !== threadId);
|
|
108
|
+
meta.updatedAt = Date.now();
|
|
109
|
+
this.metadata.set(contextKey, meta);
|
|
110
|
+
this.persistMetadata();
|
|
111
|
+
return meta.pinnedThreadIds;
|
|
112
|
+
}
|
|
113
|
+
listPinnedThreadIds(contextKey) {
|
|
114
|
+
return [...(this.metadata.get(contextKey)?.pinnedThreadIds ?? [])];
|
|
115
|
+
}
|
|
116
|
+
listContexts() {
|
|
117
|
+
return [...this.metadata.values()].sort((left, right) => right.updatedAt - left.updatedAt);
|
|
118
|
+
}
|
|
119
|
+
syncAllFromCodexState(options = {}) {
|
|
120
|
+
const results = [];
|
|
121
|
+
for (const [contextKey, session] of this.sessions.entries()) {
|
|
122
|
+
if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).externalActivity) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const result = session.syncFromCodexState(options);
|
|
126
|
+
if (result.changed) {
|
|
127
|
+
this.updateMetadata(contextKey, session);
|
|
128
|
+
}
|
|
129
|
+
results.push({ contextKey, result });
|
|
130
|
+
}
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
onRemove(callback) {
|
|
134
|
+
this.onRemoveCallback = callback;
|
|
135
|
+
}
|
|
136
|
+
remove(contextKey) {
|
|
137
|
+
const session = this.sessions.get(contextKey);
|
|
138
|
+
session?.dispose();
|
|
139
|
+
this.sessions.delete(contextKey);
|
|
140
|
+
this.metadata.delete(contextKey);
|
|
141
|
+
this.onRemoveCallback?.(contextKey);
|
|
142
|
+
this.persistMetadata();
|
|
143
|
+
}
|
|
144
|
+
disposeAll() {
|
|
145
|
+
for (const session of this.sessions.values()) {
|
|
146
|
+
session.dispose();
|
|
147
|
+
}
|
|
148
|
+
this.sessions.clear();
|
|
149
|
+
}
|
|
150
|
+
persistMetadata() {
|
|
151
|
+
try {
|
|
152
|
+
const data = [...this.metadata.values()];
|
|
153
|
+
writeJsonFileAtomic(this.persistPath, data);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.warn("Failed to persist context metadata:", error instanceof Error ? error.message : String(error));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
loadPersistedMetadata() {
|
|
160
|
+
try {
|
|
161
|
+
const data = readJsonFileWithBackup(this.persistPath).value;
|
|
162
|
+
if (!Array.isArray(data)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
for (const entry of data) {
|
|
166
|
+
if (entry.contextKey) {
|
|
167
|
+
this.metadata.set(entry.contextKey, entry);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Silently ignore load errors.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
createEmptyMetadata(contextKey) {
|
|
176
|
+
return {
|
|
177
|
+
contextKey,
|
|
178
|
+
threadId: null,
|
|
179
|
+
workspace: this.config.workspace,
|
|
180
|
+
launchProfileId: this.config.defaultLaunchProfileId,
|
|
181
|
+
pinnedThreadIds: [],
|
|
182
|
+
updatedAt: Date.now(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function resolveLaunchProfileId(config, meta) {
|
|
187
|
+
if (!meta?.launchProfileId) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (findLaunchProfile(config.launchProfiles, meta.launchProfileId)) {
|
|
191
|
+
return meta.launchProfileId;
|
|
192
|
+
}
|
|
193
|
+
console.warn(`Unknown persisted launch profile "${meta.launchProfileId}" for ${meta.contextKey}. Falling back to ${config.defaultLaunchProfileId}.`);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
minIntervalMs: 80,
|
|
3
|
+
editMinIntervalMs: 1200,
|
|
4
|
+
maxRetries: 5,
|
|
5
|
+
};
|
|
6
|
+
export class TelegramRateLimiter {
|
|
7
|
+
options;
|
|
8
|
+
buckets = new Map();
|
|
9
|
+
queued = 0;
|
|
10
|
+
running = 0;
|
|
11
|
+
completed = 0;
|
|
12
|
+
failed = 0;
|
|
13
|
+
retries = 0;
|
|
14
|
+
rateLimitHits = 0;
|
|
15
|
+
lastRateLimitAt;
|
|
16
|
+
lastRetryAfterSeconds;
|
|
17
|
+
constructor(options = DEFAULT_OPTIONS) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
configure(options) {
|
|
21
|
+
this.options = { ...this.options, ...options };
|
|
22
|
+
}
|
|
23
|
+
async run(bucket, method, operation) {
|
|
24
|
+
const normalizedBucket = `${method}:${bucket}`;
|
|
25
|
+
const state = this.getBucket(normalizedBucket);
|
|
26
|
+
this.queued += 1;
|
|
27
|
+
let releasePrevious;
|
|
28
|
+
const previous = state.chain;
|
|
29
|
+
state.chain = new Promise((resolve) => {
|
|
30
|
+
releasePrevious = resolve;
|
|
31
|
+
});
|
|
32
|
+
await previous.catch(() => { });
|
|
33
|
+
this.queued = Math.max(0, this.queued - 1);
|
|
34
|
+
this.running += 1;
|
|
35
|
+
try {
|
|
36
|
+
await this.waitForBucket(state, method);
|
|
37
|
+
const result = await this.runWithRetries(operation);
|
|
38
|
+
this.completed += 1;
|
|
39
|
+
state.lastRunAtMs = Date.now();
|
|
40
|
+
state.queuedUntilMs = Math.max(state.queuedUntilMs, state.lastRunAtMs + this.intervalForMethod(method));
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.failed += 1;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
this.running = Math.max(0, this.running - 1);
|
|
49
|
+
releasePrevious();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
getMetrics() {
|
|
53
|
+
return {
|
|
54
|
+
queued: this.queued,
|
|
55
|
+
running: this.running,
|
|
56
|
+
completed: this.completed,
|
|
57
|
+
failed: this.failed,
|
|
58
|
+
retries: this.retries,
|
|
59
|
+
rateLimitHits: this.rateLimitHits,
|
|
60
|
+
lastRateLimitAt: this.lastRateLimitAt,
|
|
61
|
+
lastRetryAfterSeconds: this.lastRetryAfterSeconds,
|
|
62
|
+
buckets: [...this.buckets.entries()]
|
|
63
|
+
.filter(([, state]) => state.queuedUntilMs > Date.now() || state.lastRunAtMs > 0)
|
|
64
|
+
.slice(0, 12)
|
|
65
|
+
.map(([bucket, state]) => ({
|
|
66
|
+
bucket,
|
|
67
|
+
queuedUntilMs: state.queuedUntilMs,
|
|
68
|
+
lastRunAtMs: state.lastRunAtMs,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
getBucket(bucket) {
|
|
73
|
+
let state = this.buckets.get(bucket);
|
|
74
|
+
if (!state) {
|
|
75
|
+
state = {
|
|
76
|
+
chain: Promise.resolve(),
|
|
77
|
+
queuedUntilMs: 0,
|
|
78
|
+
lastRunAtMs: 0,
|
|
79
|
+
};
|
|
80
|
+
this.buckets.set(bucket, state);
|
|
81
|
+
}
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
async waitForBucket(state, method) {
|
|
85
|
+
const interval = this.intervalForMethod(method);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const earliest = Math.max(state.queuedUntilMs, state.lastRunAtMs + interval);
|
|
88
|
+
if (earliest > now) {
|
|
89
|
+
await delay(earliest - now);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async runWithRetries(operation) {
|
|
93
|
+
let attempt = 0;
|
|
94
|
+
while (true) {
|
|
95
|
+
try {
|
|
96
|
+
return await operation();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const retryAfterSeconds = getRetryAfterSeconds(error);
|
|
100
|
+
if (retryAfterSeconds === undefined || attempt >= this.options.maxRetries) {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
attempt += 1;
|
|
104
|
+
this.retries += 1;
|
|
105
|
+
this.rateLimitHits += 1;
|
|
106
|
+
this.lastRateLimitAt = new Date().toISOString();
|
|
107
|
+
this.lastRetryAfterSeconds = retryAfterSeconds;
|
|
108
|
+
await delay((retryAfterSeconds * 1000) + 250);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
intervalForMethod(method) {
|
|
113
|
+
return method.startsWith("edit") ? this.options.editMinIntervalMs : this.options.minIntervalMs;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export const telegramRateLimiter = new TelegramRateLimiter();
|
|
117
|
+
export function getTelegramRateLimitMetrics() {
|
|
118
|
+
return telegramRateLimiter.getMetrics();
|
|
119
|
+
}
|
|
120
|
+
function getRetryAfterSeconds(error) {
|
|
121
|
+
const candidate = error;
|
|
122
|
+
const direct = candidate.parameters?.retry_after ?? candidate.error?.parameters?.retry_after;
|
|
123
|
+
if (typeof direct === "number" && Number.isFinite(direct)) {
|
|
124
|
+
return Math.max(1, direct);
|
|
125
|
+
}
|
|
126
|
+
const text = `${typeof candidate.description === "string" ? candidate.description : ""} ${typeof candidate.message === "string" ? candidate.message : ""}`;
|
|
127
|
+
const match = text.match(/retry after\s+(\d+)/i);
|
|
128
|
+
if (!match) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const parsed = Number(match[1]);
|
|
132
|
+
return Number.isFinite(parsed) ? Math.max(1, parsed) : undefined;
|
|
133
|
+
}
|
|
134
|
+
function delay(ms) {
|
|
135
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
136
|
+
}
|