@nordbyte/nordrelay 0.5.2 → 0.7.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 (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -0,0 +1,95 @@
1
+ import { friendlyErrorText } from "./error-messages.js";
2
+ import { RemoteRelayClient } from "./peer-client.js";
3
+ import { peerPromptProxyPayload } from "./remote-prompt.js";
4
+ export async function runChannelPeerPrompt(options) {
5
+ if (!options.targetPeerId) {
6
+ return false;
7
+ }
8
+ const client = options.remoteClient ?? new RemoteRelayClient();
9
+ let responseMessageId;
10
+ let accumulated = "";
11
+ let lastEditAt = 0;
12
+ let completed = false;
13
+ let closeSubscription = () => { };
14
+ const typing = setInterval(() => {
15
+ void options.sendTyping().catch(() => { });
16
+ }, options.typingIntervalMs);
17
+ typing.unref?.();
18
+ void options.sendTyping().catch(() => { });
19
+ const flush = async (force = false) => {
20
+ if (!accumulated.trim()) {
21
+ return;
22
+ }
23
+ const now = Date.now();
24
+ if (!force && now - lastEditAt < options.editMinIntervalMs) {
25
+ return;
26
+ }
27
+ if (responseMessageId === undefined) {
28
+ responseMessageId = await options.sendResponse(accumulated);
29
+ }
30
+ else {
31
+ await options.editResponse(responseMessageId, accumulated);
32
+ }
33
+ lastEditAt = now;
34
+ };
35
+ const done = new Promise((resolve) => {
36
+ const timeout = setTimeout(resolve, 30 * 60 * 1000);
37
+ timeout.unref?.();
38
+ let subscription;
39
+ const finish = () => {
40
+ clearTimeout(timeout);
41
+ subscription?.close();
42
+ resolve();
43
+ };
44
+ subscription = client.subscribe(options.targetPeerId, (event) => {
45
+ if (event.type === "turn_start") {
46
+ void options.sendTurnStart(event.prompt).catch(() => { });
47
+ }
48
+ else if (event.type === "text_delta") {
49
+ accumulated += event.delta;
50
+ void flush(false).catch(() => { });
51
+ }
52
+ else if (event.type === "tool_start") {
53
+ void options.sendToolStart(event.toolName).catch(() => { });
54
+ }
55
+ else if (event.type === "turn_complete") {
56
+ completed = true;
57
+ finish();
58
+ }
59
+ else if (event.type === "turn_error") {
60
+ accumulated += `\n\nError: ${event.error}`;
61
+ completed = true;
62
+ finish();
63
+ }
64
+ }, (error) => {
65
+ accumulated += `\n\nRemote event stream failed: ${error.message}`;
66
+ finish();
67
+ }, options.contextKey);
68
+ closeSubscription = () => subscription?.close();
69
+ });
70
+ try {
71
+ const result = await client.webProxy(options.targetPeerId, await peerPromptProxyPayload(options.prompt), options.prompt.activityActor, options.contextKey);
72
+ if (isQueuedRemoteResult(result)) {
73
+ closeSubscription();
74
+ await options.sendQueued(String(result.queueId ?? ""));
75
+ return true;
76
+ }
77
+ await done;
78
+ await flush(true);
79
+ if (!accumulated.trim() && completed) {
80
+ await options.sendCompleted();
81
+ }
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ await options.sendFailure(friendlyErrorText(error));
86
+ return true;
87
+ }
88
+ finally {
89
+ clearInterval(typing);
90
+ closeSubscription();
91
+ }
92
+ }
93
+ function isQueuedRemoteResult(value) {
94
+ return Boolean(value && typeof value === "object" && "queued" in value && value.queued);
95
+ }
@@ -1,13 +1,19 @@
1
1
  export class ChannelCommandRouter {
2
2
  handlers = new Map();
3
3
  command(name, handler) {
4
- const normalized = normalizeCommandName(name);
4
+ const normalized = normalizeChannelCommandName(name);
5
5
  if (!normalized) {
6
6
  throw new Error("Channel command name is required.");
7
7
  }
8
8
  this.handlers.set(normalized, handler);
9
9
  return this;
10
10
  }
11
+ commands(names, handler) {
12
+ for (const name of names) {
13
+ this.command(name, handler);
14
+ }
15
+ return this;
16
+ }
11
17
  async dispatch(message) {
12
18
  const parsed = parseChannelCommand(message.text ?? "");
13
19
  if (!parsed) {
@@ -36,13 +42,14 @@ export async function deliverChannelAction(runtime, context, response) {
36
42
  buttons: response.buttons,
37
43
  });
38
44
  }
39
- export function parseChannelCommand(text) {
40
- const match = text.trimStart().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
45
+ export function parseChannelCommand(text, options = {}) {
46
+ const mention = options.allowBotMention === false ? "" : "(?:@\\w+)?";
47
+ const match = text.trimStart().match(new RegExp(`^/([a-zA-Z0-9_-]+)${mention}(?:\\s+([\\s\\S]*))?$`));
41
48
  if (!match?.[1]) {
42
49
  return null;
43
50
  }
44
51
  return {
45
- command: normalizeCommandName(match[1]),
52
+ command: normalizeChannelCommandName(match[1]),
46
53
  argument: match[2]?.trim() ?? "",
47
54
  };
48
55
  }
@@ -84,6 +91,6 @@ export class InMemoryChannelRuntime {
84
91
  return { messageId };
85
92
  }
86
93
  }
87
- function normalizeCommandName(name) {
94
+ export function normalizeChannelCommandName(name) {
88
95
  return name.trim().replace(/^\//, "").toLowerCase();
89
96
  }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CODEX_AGENT_CAPABILITIES, agentLabel, } from "./agent.js";
3
+ import { friendlyErrorText } from "./error-messages.js";
4
+ export class ChannelTurnService {
5
+ options;
6
+ constructor(options) {
7
+ this.options = options;
8
+ }
9
+ async run(session, envelope) {
10
+ const actor = envelope.activityActor;
11
+ await this.options.ensureActiveThread(session);
12
+ const info = session.getInfo();
13
+ if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
14
+ const auth = await this.options.checkAuth(info);
15
+ if (!auth.authenticated) {
16
+ throw new Error(`${agentLabel(info.agentId)} is not authenticated: ${auth.detail}`);
17
+ }
18
+ }
19
+ const turnId = randomUUID().slice(0, 12);
20
+ const startedMs = Date.now();
21
+ this.options.setCurrentTurn(turnId, startedMs, "");
22
+ this.options.setCurrentProgress({
23
+ id: turnId,
24
+ source: this.options.source,
25
+ status: "running",
26
+ prompt: envelope.description,
27
+ agentId: info.agentId,
28
+ agentLabel: info.agentLabel,
29
+ threadId: info.threadId,
30
+ workspace: info.workspace,
31
+ startedAt: new Date(startedMs).toISOString(),
32
+ updatedAt: new Date(startedMs).toISOString(),
33
+ durationMs: 0,
34
+ outputChars: 0,
35
+ tools: [],
36
+ });
37
+ this.options.setLastPrompt(envelope);
38
+ const startedDate = new Date();
39
+ const startedAt = startedDate.toISOString();
40
+ this.options.chatStore.append({
41
+ threadId: info.threadId ?? "pending",
42
+ role: "user",
43
+ text: envelope.description,
44
+ source: this.options.source,
45
+ turnId,
46
+ timestamp: startedAt,
47
+ });
48
+ this.options.appendActivity({
49
+ source: this.options.source,
50
+ status: "running",
51
+ type: "prompt_started",
52
+ threadId: info.threadId,
53
+ workspace: info.workspace,
54
+ agentId: info.agentId,
55
+ actor,
56
+ prompt: envelope.description,
57
+ });
58
+ this.options.appendAudit({
59
+ action: "prompt_started",
60
+ status: "ok",
61
+ contextKey: this.options.contextKey,
62
+ agentId: info.agentId,
63
+ threadId: info.threadId,
64
+ workspace: info.workspace,
65
+ actor,
66
+ description: envelope.description,
67
+ });
68
+ this.options.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: this.options.source });
69
+ try {
70
+ await session.prompt(envelope.input, this.callbacks(turnId, info, envelope, actor));
71
+ this.options.updateSession(session);
72
+ await this.options.artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, startedDate);
73
+ const text = this.options.getAccumulatedText();
74
+ if (text.trim()) {
75
+ this.options.chatStore.append({
76
+ threadId: info.threadId ?? "pending",
77
+ role: "agent",
78
+ text,
79
+ source: this.options.source,
80
+ turnId,
81
+ });
82
+ }
83
+ this.options.appendActivity({
84
+ source: this.options.source,
85
+ status: "completed",
86
+ type: "prompt_completed",
87
+ threadId: info.threadId,
88
+ workspace: info.workspace,
89
+ agentId: info.agentId,
90
+ actor,
91
+ prompt: envelope.description,
92
+ durationMs: Date.now() - this.options.getCurrentTurnStartedAt(),
93
+ });
94
+ this.options.appendAudit({
95
+ action: "prompt_completed",
96
+ status: "ok",
97
+ contextKey: this.options.contextKey,
98
+ agentId: info.agentId,
99
+ threadId: info.threadId,
100
+ workspace: info.workspace,
101
+ actor,
102
+ description: envelope.description,
103
+ });
104
+ this.updateCurrentProgress({ status: "completed" });
105
+ this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
106
+ this.options.broadcast({ type: "chat_history", messages: await this.options.chatHistory() });
107
+ }
108
+ catch (error) {
109
+ const errorText = friendlyErrorText(error);
110
+ this.options.chatStore.append({
111
+ threadId: info.threadId ?? "pending",
112
+ role: "system",
113
+ text: `Error: ${errorText}`,
114
+ source: this.options.source,
115
+ turnId,
116
+ });
117
+ this.options.appendActivity({
118
+ source: this.options.source,
119
+ status: "failed",
120
+ type: "prompt_failed",
121
+ threadId: info.threadId,
122
+ workspace: info.workspace,
123
+ agentId: info.agentId,
124
+ actor,
125
+ prompt: envelope.description,
126
+ detail: errorText,
127
+ durationMs: Date.now() - this.options.getCurrentTurnStartedAt(),
128
+ });
129
+ this.options.appendAudit({
130
+ action: "prompt_failed",
131
+ status: "failed",
132
+ contextKey: this.options.contextKey,
133
+ agentId: info.agentId,
134
+ threadId: info.threadId,
135
+ workspace: info.workspace,
136
+ actor,
137
+ description: envelope.description,
138
+ detail: errorText,
139
+ });
140
+ this.updateCurrentProgress({ status: "failed", detail: errorText });
141
+ this.options.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
142
+ this.options.broadcast({ type: "chat_history", messages: await this.options.chatHistory() });
143
+ throw error;
144
+ }
145
+ finally {
146
+ const progress = this.options.getCurrentProgress();
147
+ if (progress) {
148
+ progress.durationMs = Date.now() - this.options.getCurrentTurnStartedAt();
149
+ progress.updatedAt = new Date().toISOString();
150
+ }
151
+ this.options.setCurrentTurn(null);
152
+ }
153
+ }
154
+ callbacks(turnId, info, envelope, actor) {
155
+ return {
156
+ onTextDelta: (delta) => {
157
+ const nextText = this.options.getAccumulatedText() + delta;
158
+ this.options.setAccumulatedText(nextText);
159
+ this.updateCurrentProgress({ outputChars: nextText.length });
160
+ this.options.broadcast({ type: "text_delta", id: turnId, delta });
161
+ },
162
+ onToolStart: (toolName, toolCallId) => {
163
+ this.addCurrentTool(toolName);
164
+ this.options.appendActivity({
165
+ source: this.options.source,
166
+ status: "running",
167
+ type: "tool_started",
168
+ threadId: info.threadId,
169
+ workspace: info.workspace,
170
+ agentId: info.agentId,
171
+ actor,
172
+ prompt: envelope.description,
173
+ detail: toolName,
174
+ });
175
+ this.options.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
176
+ },
177
+ onToolUpdate: (toolCallId, partialResult) => {
178
+ this.updateCurrentProgress();
179
+ this.options.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
180
+ },
181
+ onToolEnd: (toolCallId, isError) => {
182
+ const progress = this.options.getCurrentProgress();
183
+ const toolName = progress?.currentTool ?? progress?.lastTool ?? toolCallId;
184
+ this.updateCurrentProgress({ currentTool: undefined });
185
+ this.options.appendActivity({
186
+ source: this.options.source,
187
+ status: isError ? "failed" : "completed",
188
+ type: isError ? "tool_failed" : "tool_completed",
189
+ threadId: info.threadId,
190
+ workspace: info.workspace,
191
+ agentId: info.agentId,
192
+ actor,
193
+ prompt: envelope.description,
194
+ detail: toolName,
195
+ });
196
+ this.options.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
197
+ },
198
+ onTodoUpdate: (items) => {
199
+ this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
200
+ this.options.broadcast({ type: "todo_update", id: turnId, items });
201
+ },
202
+ onTurnComplete: () => { },
203
+ onAgentEnd: () => this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
204
+ };
205
+ }
206
+ updateCurrentProgress(patch = {}) {
207
+ const progress = this.options.getCurrentProgress();
208
+ if (!progress) {
209
+ return;
210
+ }
211
+ if ("currentTool" in patch) {
212
+ progress.currentTool = patch.currentTool;
213
+ const { currentTool: _currentTool, ...rest } = patch;
214
+ Object.assign(progress, rest);
215
+ }
216
+ else {
217
+ Object.assign(progress, patch);
218
+ }
219
+ progress.durationMs = Date.now() - this.options.getCurrentTurnStartedAt();
220
+ progress.updatedAt = new Date().toISOString();
221
+ this.options.setCurrentProgress(progress);
222
+ }
223
+ addCurrentTool(toolName) {
224
+ const progress = this.options.getCurrentProgress();
225
+ if (!progress) {
226
+ return;
227
+ }
228
+ const existing = progress.tools.find((tool) => tool.name === toolName);
229
+ if (existing) {
230
+ existing.count += 1;
231
+ }
232
+ else {
233
+ progress.tools.push({ name: toolName, count: 1 });
234
+ }
235
+ this.updateCurrentProgress({ currentTool: toolName, lastTool: toolName });
236
+ }
237
+ }
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isCodexApprovalPolicy, isCodexSandboxMode, } from "./codex-launch.js";
4
+ const ROLLOUT_CACHE_MAX_EVENTS = 200;
5
+ const rolloutSnapshotCache = new Map();
4
6
  export const FALLBACK_MODELS = [
5
7
  { slug: "gpt-5.5", displayName: "GPT-5.5" },
6
8
  { slug: "gpt-5.4", displayName: "GPT-5.4" },
@@ -74,31 +76,7 @@ export function getThreadUsage(id) {
74
76
  }
75
77
  }
76
78
  export function getThreadActivity(id, options = {}) {
77
- const rolloutPath = getThreadRolloutPath(id);
78
- if (!rolloutPath || !existsSync(rolloutPath)) {
79
- return null;
80
- }
81
- try {
82
- const parsed = parseActivityFromRollout(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
83
- const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
84
- const updatedAtMs = Math.max(parsed.updatedAt?.getTime() ?? 0, fileModifiedAtMs);
85
- const updatedAt = updatedAtMs > 0 ? new Date(updatedAtMs) : parsed.updatedAt;
86
- const staleAfterMs = options.staleAfterMs ?? 5 * 60 * 1000;
87
- const nowMs = options.nowMs ?? Date.now();
88
- const stale = Boolean(parsed.active &&
89
- updatedAt &&
90
- staleAfterMs > 0 &&
91
- nowMs - updatedAt.getTime() > staleAfterMs);
92
- return {
93
- ...parsed,
94
- updatedAt,
95
- stale,
96
- active: parsed.active && !stale,
97
- };
98
- }
99
- catch {
100
- return null;
101
- }
79
+ return getThreadRolloutSnapshot(id, { ...options, maxEvents: 0 })?.activity ?? null;
102
80
  }
103
81
  export function getThreadRolloutSnapshot(id, options = {}) {
104
82
  const rolloutPath = getThreadRolloutPath(id);
@@ -106,8 +84,9 @@ export function getThreadRolloutSnapshot(id, options = {}) {
106
84
  return null;
107
85
  }
108
86
  try {
109
- const parsed = parseRolloutSnapshot(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
110
- return finalizeRolloutSnapshot(parsed, statSync(rolloutPath).mtimeMs, options);
87
+ const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
88
+ const parsed = readCachedRolloutSnapshot(id, rolloutPath);
89
+ return finalizeRolloutSnapshot(parsed, fileModifiedAtMs, options);
111
90
  }
112
91
  catch {
113
92
  return null;
@@ -167,13 +146,7 @@ export function getThreadRolloutPath(id) {
167
146
  }) ?? null);
168
147
  }
169
148
  function parseUsageFromRollout(contents) {
170
- let contextWindow = null;
171
- let contextUsedPercent = null;
172
- let lastTokenUsage = null;
173
- let totalTokenUsage = null;
174
- let rateLimits = null;
175
- let updatedAt = null;
176
- for (const line of contents.split(/\r?\n/)) {
149
+ for (const line of iterateLinesReverse(contents)) {
177
150
  if (!line.includes('"token_count"')) {
178
151
  continue;
179
152
  }
@@ -188,6 +161,7 @@ function parseUsageFromRollout(contents) {
188
161
  if (payload?.type !== "token_count") {
189
162
  continue;
190
163
  }
164
+ let updatedAt = null;
191
165
  const timestamp = readString(readObject(event)?.timestamp);
192
166
  if (timestamp) {
193
167
  const parsedTimestamp = new Date(timestamp);
@@ -196,54 +170,116 @@ function parseUsageFromRollout(contents) {
196
170
  }
197
171
  }
198
172
  const info = readObject(payload.info);
199
- const parsedTotal = parseTokenUsage(readObject(info?.total_token_usage));
200
- const parsedLast = parseTokenUsage(readObject(info?.last_token_usage));
173
+ const totalTokenUsage = parseTokenUsage(readObject(info?.total_token_usage));
174
+ const lastTokenUsage = parseTokenUsage(readObject(info?.last_token_usage));
201
175
  const parsedContextWindow = readNumber(info?.model_context_window);
202
- if (parsedTotal) {
203
- totalTokenUsage = parsedTotal;
176
+ const contextWindow = parsedContextWindow !== null && parsedContextWindow > 0
177
+ ? parsedContextWindow
178
+ : null;
179
+ const contextUsedPercent = lastTokenUsage && contextWindow
180
+ ? Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100)
181
+ : null;
182
+ const rateLimits = parseRateLimits(readObject(payload.rate_limits));
183
+ if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
184
+ continue;
204
185
  }
205
- if (parsedLast) {
206
- lastTokenUsage = parsedLast;
186
+ return {
187
+ contextWindow,
188
+ contextUsedPercent,
189
+ lastTokenUsage,
190
+ totalTokenUsage,
191
+ rateLimits,
192
+ updatedAt,
193
+ };
194
+ }
195
+ return null;
196
+ }
197
+ function readCachedRolloutSnapshot(threadId, rolloutPath) {
198
+ const size = statSync(rolloutPath).size;
199
+ const cached = rolloutSnapshotCache.get(rolloutPath);
200
+ if (cached && size >= cached.byteOffset) {
201
+ const suffix = size > cached.byteOffset
202
+ ? readFileRangeUtf8(rolloutPath, cached.byteOffset, size - cached.byteOffset)
203
+ : "";
204
+ if (!suffix.trim()) {
205
+ return cached.parsed;
207
206
  }
208
- if (parsedContextWindow !== null && parsedContextWindow > 0) {
209
- contextWindow = parsedContextWindow;
207
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, suffix, {
208
+ base: cached.parsed,
209
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
210
+ });
211
+ rolloutSnapshotCache.set(rolloutPath, { byteOffset: size, parsed });
212
+ return parsed;
213
+ }
214
+ const contents = readFileSync(rolloutPath, "utf8");
215
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, contents, {
216
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
217
+ });
218
+ rolloutSnapshotCache.set(rolloutPath, {
219
+ byteOffset: Buffer.byteLength(contents),
220
+ parsed,
221
+ });
222
+ return parsed;
223
+ }
224
+ function readFileRangeUtf8(filePath, position, length) {
225
+ if (length <= 0) {
226
+ return "";
227
+ }
228
+ const fd = openSync(filePath, "r");
229
+ try {
230
+ const buffer = Buffer.allocUnsafe(length);
231
+ const bytesRead = readSync(fd, buffer, 0, length, position);
232
+ return buffer.subarray(0, bytesRead).toString("utf8");
233
+ }
234
+ finally {
235
+ closeSync(fd);
236
+ }
237
+ }
238
+ function* iterateLinesReverse(contents) {
239
+ let end = contents.length;
240
+ while (end > 0) {
241
+ let start = contents.lastIndexOf("\n", end - 1);
242
+ const lineStart = start === -1 ? 0 : start + 1;
243
+ let line = contents.slice(lineStart, end);
244
+ if (line.endsWith("\r")) {
245
+ line = line.slice(0, -1);
210
246
  }
211
- if (lastTokenUsage && contextWindow) {
212
- contextUsedPercent = Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100);
247
+ if (line.trim()) {
248
+ yield line;
213
249
  }
214
- const parsedRateLimits = parseRateLimits(readObject(payload.rate_limits));
215
- if (parsedRateLimits) {
216
- rateLimits = parsedRateLimits;
250
+ if (start === -1) {
251
+ break;
217
252
  }
253
+ end = start;
218
254
  }
219
- if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
220
- return null;
221
- }
222
- return {
223
- contextWindow,
224
- contextUsedPercent,
225
- lastTokenUsage,
226
- totalTokenUsage,
227
- rateLimits,
228
- updatedAt,
229
- };
230
255
  }
231
- function parseActivityFromRollout(threadId, rolloutPath, contents) {
232
- return parseRolloutSnapshot(threadId, rolloutPath, contents).activity;
233
- }
234
- function parseRolloutSnapshot(threadId, rolloutPath, contents) {
235
- let activeTurnId = null;
236
- let startedAt = null;
237
- let updatedAt = null;
238
- let latestAgentMessage = null;
239
- let latestUserMessage = null;
240
- let latestToolName = null;
241
- const events = [];
256
+ function parseRolloutSnapshot(threadId, rolloutPath, contents, options = {}) {
257
+ let activeTurnId = options.base?.activity.active ? options.base.activity.turnId : null;
258
+ let startedAt = options.base?.activity.active ? options.base.activity.startedAt : null;
259
+ let updatedAt = options.base?.activity.updatedAt ?? null;
260
+ let latestAgentMessage = options.base?.latestAgentMessage ?? null;
261
+ let latestUserMessage = options.base?.latestUserMessage ?? null;
262
+ let latestToolName = options.base?.latestToolName ?? null;
263
+ const events = [...(options.base?.events ?? [])];
242
264
  const lines = contents.split(/\r?\n/);
265
+ const lineNumberOffset = options.base?.lineCount ?? 0;
266
+ let lineCount = lineNumberOffset;
267
+ const afterLine = options.afterLine ?? 0;
268
+ const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
269
+ const pushEvent = (event) => {
270
+ if (maxEvents <= 0 || event.lineNumber <= afterLine) {
271
+ return;
272
+ }
273
+ events.push(event);
274
+ if (Number.isFinite(maxEvents) && events.length > maxEvents) {
275
+ events.splice(0, events.length - maxEvents);
276
+ }
277
+ };
243
278
  for (const [index, line] of lines.entries()) {
244
279
  if (!line.trim()) {
245
280
  continue;
246
281
  }
282
+ lineCount += 1;
247
283
  if (!line.includes('"task_') &&
248
284
  !line.includes('"turn_') &&
249
285
  !line.includes('"user_message"') &&
@@ -262,7 +298,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
262
298
  const eventObject = readObject(event);
263
299
  const payload = readObject(eventObject?.payload);
264
300
  const eventTimestamp = parseTimestamp(readString(eventObject?.timestamp));
265
- const lineNumber = index + 1;
301
+ const lineNumber = lineNumberOffset + index + 1;
266
302
  if (activeTurnId && eventTimestamp) {
267
303
  updatedAt = eventTimestamp;
268
304
  }
@@ -274,7 +310,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
274
310
  activeTurnId = readString(payload?.turn_id);
275
311
  startedAt = parseUnixSeconds(readNumber(payload?.started_at)) ?? eventTimestamp;
276
312
  updatedAt = eventTimestamp ?? startedAt;
277
- events.push({
313
+ pushEvent({
278
314
  lineNumber,
279
315
  kind: "task",
280
316
  timestamp: eventTimestamp,
@@ -289,7 +325,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
289
325
  }
290
326
  if (isTaskTerminalEvent(type)) {
291
327
  const turnId = readString(payload?.turn_id);
292
- events.push({
328
+ pushEvent({
293
329
  lineNumber,
294
330
  kind: "task",
295
331
  timestamp: eventTimestamp,
@@ -309,7 +345,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
309
345
  }
310
346
  if (type === "user_message") {
311
347
  latestUserMessage = readString(payload?.message);
312
- events.push({
348
+ pushEvent({
313
349
  lineNumber,
314
350
  kind: "user",
315
351
  timestamp: eventTimestamp,
@@ -324,7 +360,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
324
360
  }
325
361
  if (type === "agent_message") {
326
362
  latestAgentMessage = readString(payload?.message);
327
- events.push({
363
+ pushEvent({
328
364
  lineNumber,
329
365
  kind: "agent",
330
366
  timestamp: eventTimestamp,
@@ -339,7 +375,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
339
375
  }
340
376
  if (type === "function_call") {
341
377
  latestToolName = readString(payload?.name);
342
- events.push({
378
+ pushEvent({
343
379
  lineNumber,
344
380
  kind: "tool",
345
381
  timestamp: eventTimestamp,
@@ -353,7 +389,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
353
389
  continue;
354
390
  }
355
391
  if (type === "function_call_output") {
356
- events.push({
392
+ pushEvent({
357
393
  lineNumber,
358
394
  kind: "tool",
359
395
  timestamp: eventTimestamp,
@@ -369,7 +405,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
369
405
  return {
370
406
  threadId,
371
407
  rolloutPath,
372
- lineCount: lines.filter((line) => line.trim()).length,
408
+ lineCount,
373
409
  activity: {
374
410
  threadId,
375
411
  rolloutPath,