om-memory-system 3.5.0-next.34 → 3.5.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 (37) hide show
  1. package/dist/adapters/opencode/auto-capture-summary.js +9 -1
  2. package/dist/adapters/opencode/backfill-startup.d.ts +3 -0
  3. package/dist/adapters/opencode/backfill-startup.js +6 -0
  4. package/dist/adapters/opencode/opencode-diagnostics.js +2 -1
  5. package/dist/adapters/opencode/opencode-provider.js +13 -3
  6. package/dist/adapters/pi/capture.js +29 -5
  7. package/dist/adapters/pi/extension.js +18 -1
  8. package/dist/config.d.ts +7 -1
  9. package/dist/config.js +21 -1
  10. package/dist/core/capture-retry-policy.d.ts +36 -0
  11. package/dist/core/capture-retry-policy.js +77 -0
  12. package/dist/core/capture.d.ts +9 -0
  13. package/dist/core/capture.js +20 -0
  14. package/dist/core/host.d.ts +4 -0
  15. package/dist/importer/model-selection.js +12 -2
  16. package/dist/index.js +15 -1
  17. package/dist/services/ai/providers/anthropic-messages.js +6 -1
  18. package/dist/services/ai/providers/base-provider.d.ts +17 -0
  19. package/dist/services/ai/providers/base-provider.js +19 -0
  20. package/dist/services/ai/providers/google-gemini.js +11 -2
  21. package/dist/services/ai/providers/openai-chat-completion.js +6 -1
  22. package/dist/services/ai/providers/openai-responses.js +6 -1
  23. package/dist/services/auto-capture.d.ts +6 -0
  24. package/dist/services/auto-capture.js +36 -5
  25. package/dist/services/capture-retry-drain.d.ts +48 -0
  26. package/dist/services/capture-retry-drain.js +142 -0
  27. package/dist/services/capture-retry-queue.d.ts +57 -0
  28. package/dist/services/capture-retry-queue.js +205 -0
  29. package/dist/services/cleanup-service.js +9 -0
  30. package/dist/services/global-config-writer.js +4 -0
  31. package/dist/services/settings-snapshot.js +2 -0
  32. package/dist/services/web-server.js +33 -2
  33. package/dist/web/assets/index-D5MY5Kjs.css +2 -0
  34. package/dist/web/assets/{index-D5A9vMgC.js → index-DIy--9g_.js} +1 -1
  35. package/dist/web/index.html +2 -2
  36. package/package.json +1 -1
  37. package/dist/web/assets/index-DvZhF1BJ.css +0 -2
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { CONFIG } from "../config.js";
3
3
  import { buildBoundedSummaryPrompt } from "../core/capture-context.js";
4
- import { buildCaptureSystemPrompt, captureSummaryToolSchema, parseCaptureSummary, } from "../core/extraction.js";
4
+ import { httpStatusError } from "../core/capture-retry-policy.js";
5
+ import { buildCaptureSystemPrompt, captureSummaryToolSchema, normalizeStopReason, parseCaptureSummary, } from "../core/extraction.js";
5
6
  import { AIProviderFactory } from "../services/ai/ai-provider-factory.js";
7
+ import { toolCallFailureReason } from "../services/ai/providers/base-provider.js";
6
8
  import { buildMemoryProviderConfig } from "../services/ai/provider-config.js";
7
9
  import { resolveSecretValue } from "../services/secret-resolver.js";
8
10
  /** Merge import-only model settings without changing the saved omms config. */
@@ -51,7 +53,15 @@ export function selectImportModel(flags) {
51
53
  };
52
54
  const result = await provider.executeToolCall(system, buildBoundedSummaryPrompt(request.context, system, tool), tool, `history-capture-${randomUUID()}`);
53
55
  if (!result.success || !result.data) {
54
- throw new Error("History capture model call failed");
56
+ if (request.diagnostics) {
57
+ // Only an HTTP error or a request with no reply is worth a later retry.
58
+ request.diagnostics.failureReason = toolCallFailureReason(result, normalizeStopReason(result.stopReason) === "length");
59
+ request.diagnostics.httpStatus = result.httpStatus;
60
+ request.diagnostics.retryAfterMs = result.retryAfterMs;
61
+ }
62
+ if (result.httpStatus === undefined)
63
+ throw new Error("History capture model call failed");
64
+ throw httpStatusError("History capture model call failed", result.httpStatus, result.retryAfterMs);
55
65
  }
56
66
  const raw = typeof result.data === "string" ? result.data : JSON.stringify(result.data);
57
67
  const parsed = parseCaptureSummary(raw);
package/dist/index.js CHANGED
@@ -215,7 +215,10 @@ export const OmmsPlugin = async (ctx) => {
215
215
  // Web imports, Health, and Settings served by this process can list and use OpenCode's models.
216
216
  // Awaited so the web server never serves those routes before the registry is filled.
217
217
  await import("./adapters/opencode/backfill-startup.js")
218
- .then(({ registerOpencodeImportModels }) => registerOpencodeImportModels())
218
+ .then(({ registerOpencodeImportModels, registerOpencodeCaptureRetryDrain }) => {
219
+ registerOpencodeImportModels();
220
+ registerOpencodeCaptureRetryDrain(autoCaptureHost, directory);
221
+ })
219
222
  .catch(() => { });
220
223
  (async () => {
221
224
  let connected = [];
@@ -590,6 +593,17 @@ export const OmmsPlugin = async (ctx) => {
590
593
  },
591
594
  event: async (input) => {
592
595
  const event = input.event;
596
+ if (event.type === "session.created") {
597
+ if (!isConfigured() || !CONFIG.autoCaptureEnabled)
598
+ return;
599
+ const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
600
+ // OMMS's own structured-output sessions must not start a retry pass.
601
+ if (sessionID && (await isInternalCaptureSession(ctx.client, sessionID)))
602
+ return;
603
+ const { startCaptureRetryDrain } = await import("./services/capture-retry-drain.js");
604
+ startCaptureRetryDrain("opencode");
605
+ return;
606
+ }
593
607
  if (event.type === "session.idle") {
594
608
  if (!isConfigured() || !CONFIG.autoCaptureEnabled)
595
609
  return;
@@ -1,8 +1,9 @@
1
- import { applySafeExtraParams, BaseAIProvider, describeValidationError, } from "./base-provider.js";
1
+ import { applySafeExtraParams, BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { ToolSchemaConverter } from "../tools/tool-schema.js";
4
4
  import { log } from "../../logger.js";
5
5
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
6
+ import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
6
7
  /**
7
8
  * Base implementation for providers that speak the Anthropic Messages API
8
9
  * (Anthropic itself plus Anthropic-compatible gateways such as MiniMax).
@@ -124,6 +125,8 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
124
125
  stopReason,
125
126
  success: false,
126
127
  error: `API error: ${response.status} - ${errorText}`,
128
+ httpStatus: response.status,
129
+ retryAfterMs: parseRetryAfter(response.headers?.get?.("retry-after")),
127
130
  iterations,
128
131
  };
129
132
  }
@@ -195,6 +198,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
195
198
  return {
196
199
  stopReason,
197
200
  success: false,
201
+ transportError: true,
198
202
  error: `${this.timeoutLabel()} (${this.config.iterationTimeout}ms)`,
199
203
  iterations,
200
204
  };
@@ -203,6 +207,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
203
207
  stopReason,
204
208
  success: false,
205
209
  error: String(error),
210
+ transportError: isTransportError(error),
206
211
  iterations,
207
212
  };
208
213
  }
@@ -5,6 +5,12 @@ export interface ToolCallResult {
5
5
  iterations?: number;
6
6
  /** The provider's last reported stop reason, e.g. a length limit. */
7
7
  stopReason?: string;
8
+ /** The HTTP status of a failed reply, so capture can tell whether a retry may help. */
9
+ httpStatus?: number;
10
+ /** The wait a failed reply asked for in its `Retry-After` header. */
11
+ retryAfterMs?: number;
12
+ /** True when the request got no reply at all: a timeout or a network failure. */
13
+ transportError?: boolean;
8
14
  }
9
15
  export interface ProviderConfig {
10
16
  model: string;
@@ -16,6 +22,17 @@ export interface ProviderConfig {
16
22
  memoryTemperature?: number | false;
17
23
  extraParams?: Record<string, unknown>;
18
24
  }
25
+ /**
26
+ * True for a request that never got a reply: an abort by timeout, or the
27
+ * `TypeError` that `fetch` throws for a network failure.
28
+ */
29
+ export declare function isTransportError(error: unknown): boolean;
30
+ /**
31
+ * The capture reason code for a failed tool call. Only an HTTP error or a
32
+ * request with no reply is a call error; any other failure means the model
33
+ * answered with something unusable, which a retry does not fix.
34
+ */
35
+ export declare function toolCallFailureReason(result: ToolCallResult, lengthStop: boolean): "truncated" | "call-error" | "schema-mismatch";
19
36
  /**
20
37
  * A validation error message that is safe to log and return. A JSON
21
38
  * `SyntaxError` message quotes the start of the model's reply, which can carry
@@ -1,3 +1,22 @@
1
+ /**
2
+ * True for a request that never got a reply: an abort by timeout, or the
3
+ * `TypeError` that `fetch` throws for a network failure.
4
+ */
5
+ export function isTransportError(error) {
6
+ return error instanceof Error && (error.name === "AbortError" || error instanceof TypeError);
7
+ }
8
+ /**
9
+ * The capture reason code for a failed tool call. Only an HTTP error or a
10
+ * request with no reply is a call error; any other failure means the model
11
+ * answered with something unusable, which a retry does not fix.
12
+ */
13
+ export function toolCallFailureReason(result, lengthStop) {
14
+ if (lengthStop)
15
+ return "truncated";
16
+ if (result.httpStatus !== undefined || result.transportError)
17
+ return "call-error";
18
+ return "schema-mismatch";
19
+ }
1
20
  /**
2
21
  * A validation error message that is safe to log and return. A JSON
3
22
  * `SyntaxError` message quotes the start of the model's reply, which can carry
@@ -1,7 +1,8 @@
1
- import { BaseAIProvider, describeValidationError } from "./base-provider.js";
1
+ import { BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { log } from "../../logger.js";
4
4
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
5
+ import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
5
6
  /**
6
7
  * Google Gemini Provider
7
8
  * Supports Google's Gemini models (e.g. gemini-1.5-flash) via Google AI Studio API.
@@ -157,6 +158,8 @@ export class GoogleGeminiProvider extends BaseAIProvider {
157
158
  stopReason,
158
159
  success: false,
159
160
  error: `Gemini API error: ${response.status} - ${errorText}`,
161
+ httpStatus: response.status,
162
+ retryAfterMs: parseRetryAfter(response.headers?.get?.("retry-after")),
160
163
  iterations,
161
164
  };
162
165
  }
@@ -229,7 +232,13 @@ export class GoogleGeminiProvider extends BaseAIProvider {
229
232
  }
230
233
  catch (error) {
231
234
  clearTimeout(timeout);
232
- return { stopReason, success: false, error: String(error), iterations };
235
+ return {
236
+ stopReason,
237
+ success: false,
238
+ error: String(error),
239
+ transportError: isTransportError(error),
240
+ iterations,
241
+ };
233
242
  }
234
243
  }
235
244
  return {
@@ -1,6 +1,7 @@
1
- import { BaseAIProvider, applySafeExtraParams, describeValidationError, } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, describeValidationError, isTransportError, } from "./base-provider.js";
2
2
  import { log } from "../../logger.js";
3
3
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
4
+ import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
4
5
  function isErrorResponseBody(data) {
5
6
  return (typeof data === "object" &&
6
7
  data !== null &&
@@ -223,6 +224,8 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
223
224
  stopReason,
224
225
  success: false,
225
226
  error: errorMessage,
227
+ httpStatus: response.status,
228
+ retryAfterMs: parseRetryAfter(response.headers?.get?.("retry-after")),
226
229
  iterations,
227
230
  };
228
231
  }
@@ -378,6 +381,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
378
381
  return {
379
382
  stopReason,
380
383
  success: false,
384
+ transportError: true,
381
385
  error: `API request timeout (${iterationTimeout}ms)`,
382
386
  iterations,
383
387
  };
@@ -386,6 +390,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
386
390
  stopReason,
387
391
  success: false,
388
392
  error: String(error),
393
+ transportError: isTransportError(error),
389
394
  iterations,
390
395
  };
391
396
  }
@@ -1,7 +1,8 @@
1
- import { BaseAIProvider, applySafeExtraParams } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, isTransportError, } from "./base-provider.js";
2
2
  import { AISessionManager } from "../session/ai-session-manager.js";
3
3
  import { ToolSchemaConverter } from "../tools/tool-schema.js";
4
4
  import { log } from "../../logger.js";
5
+ import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
5
6
  export class OpenAIResponsesProvider extends BaseAIProvider {
6
7
  aiSessionManager;
7
8
  constructor(config, aiSessionManager) {
@@ -72,6 +73,8 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
72
73
  stopReason,
73
74
  success: false,
74
75
  error: `API error: ${response.status} - ${errorText}`,
76
+ httpStatus: response.status,
77
+ retryAfterMs: parseRetryAfter(response.headers?.get?.("retry-after")),
75
78
  iterations,
76
79
  };
77
80
  }
@@ -110,6 +113,7 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
110
113
  return {
111
114
  stopReason,
112
115
  success: false,
116
+ transportError: true,
113
117
  error: `API request timeout (${this.config.iterationTimeout}ms)`,
114
118
  iterations,
115
119
  };
@@ -118,6 +122,7 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
118
122
  stopReason,
119
123
  success: false,
120
124
  error: String(error),
125
+ transportError: isTransportError(error),
121
126
  iterations,
122
127
  };
123
128
  }
@@ -1,3 +1,9 @@
1
+ import { type CaptureResult, type CaptureWorkUnit } from "../core/capture.js";
1
2
  import type { AutoCaptureHost } from "../core/host.js";
3
+ import { type CaptureRetryDrainResult } from "./capture-retry-drain.js";
2
4
  export { buildBoundedSummaryPrompt, buildMarkdownContext, getAutoCaptureMarkdownBudget, } from "../core/capture-context.js";
3
5
  export declare function performAutoCapture(host: AutoCaptureHost, sessionID: string, directory: string): Promise<void>;
6
+ /** A retried OpenCode turn updates its prompt row the same way a live capture does. */
7
+ export declare function settleOpencodeRetriedPrompt(unit: CaptureWorkUnit, result: CaptureResult): Promise<void>;
8
+ /** Retry OpenCode's queued turns with its current capture model choice. */
9
+ export declare function drainOpencodeCaptureRetries(host: AutoCaptureHost, directory: string): Promise<CaptureRetryDrainResult>;
@@ -1,9 +1,10 @@
1
- import { CONFIG } from "../config.js";
1
+ import { CONFIG, refreshConfigIfChanged } from "../config.js";
2
2
  import { captureConversation } from "../core/capture.js";
3
+ import { quickRetryDelayMs } from "../core/capture-retry-policy.js";
4
+ import { drainCaptureRetries, queueFailedCapture, startCaptureRetryDrain, } from "./capture-retry-drain.js";
3
5
  import { log } from "./logger.js";
4
6
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
5
7
  export { buildBoundedSummaryPrompt, buildMarkdownContext, getAutoCaptureMarkdownBudget, } from "../core/capture-context.js";
6
- const RETRY_BASE_DELAY_MS = 2000;
7
8
  let isCaptureRunning = false;
8
9
  async function notifySafely(host, notification) {
9
10
  try {
@@ -33,6 +34,7 @@ export async function performAutoCapture(host, sessionID, directory) {
33
34
  async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
34
35
  let claimedPromptId = null;
35
36
  let attempt = prompt.capture_attempts || 0;
37
+ let workUnit = null;
36
38
  try {
37
39
  if (!(await userPromptManager.claimPrompt(prompt.id)))
38
40
  return;
@@ -45,7 +47,7 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
45
47
  return;
46
48
  if (conversation.textResponses.length === 0 && conversation.toolCalls.length === 0)
47
49
  return;
48
- const captureResult = await captureConversation({
50
+ workUnit = {
49
51
  host: host.host,
50
52
  hostSessionId: sessionID,
51
53
  sourceType: "live-capture",
@@ -58,7 +60,8 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
58
60
  modelId: prompt.modelId,
59
61
  },
60
62
  ...conversation,
61
- }, host);
63
+ };
64
+ const captureResult = await captureConversation(workUnit, host);
62
65
  if (captureResult.status === "skipped") {
63
66
  log("Auto-capture skipped", {
64
67
  promptId: prompt.id,
@@ -78,6 +81,8 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
78
81
  memoryId: captureResult.memoryId,
79
82
  host: host.host,
80
83
  });
84
+ // The capture model answers again, so earlier failed turns may go through now.
85
+ startCaptureRetryDrain("opencode");
81
86
  if (CONFIG.showAutoCaptureToasts) {
82
87
  await notifySafely(host, {
83
88
  title: "Memory Captured",
@@ -93,7 +98,7 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
93
98
  await userPromptManager.recordFailedAttempt(prompt.id);
94
99
  if (attempt < maxRetries) {
95
100
  log(`Auto-capture warning (attempt ${attempt}/${maxRetries})`, { error: errMsg });
96
- await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)));
101
+ await new Promise((resolve) => setTimeout(resolve, quickRetryDelayMs(attempt)));
97
102
  }
98
103
  else {
99
104
  throw error;
@@ -104,6 +109,9 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
104
109
  catch (error) {
105
110
  const errMsg = error instanceof Error ? error.message : String(error);
106
111
  log(`Auto-capture final error after ${attempt} attempts`, { error: errMsg });
112
+ // Only after the quick retries fail; the queue decides whether a retry can help.
113
+ if (workUnit)
114
+ await queueFailedCapture(workUnit, error, CONFIG);
107
115
  if (CONFIG.showErrorToasts) {
108
116
  const shortReason = errMsg.length > 100 ? errMsg.substring(0, 100) + "..." : errMsg;
109
117
  await notifySafely(host, {
@@ -125,3 +133,26 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
125
133
  }
126
134
  }
127
135
  }
136
+ /** A retried OpenCode turn updates its prompt row the same way a live capture does. */
137
+ export async function settleOpencodeRetriedPrompt(unit, result) {
138
+ if (!unit.promptId)
139
+ return;
140
+ if (result.status === "skipped") {
141
+ await userPromptManager.deletePrompt(unit.promptId);
142
+ return;
143
+ }
144
+ await userPromptManager.linkMemoryToPrompt(unit.promptId, result.memoryId);
145
+ await userPromptManager.markAsCaptured(unit.promptId);
146
+ }
147
+ /** Retry OpenCode's queued turns with its current capture model choice. */
148
+ export function drainOpencodeCaptureRetries(host, directory) {
149
+ // A retention change in the config file applies from this pass on.
150
+ refreshConfigIfChanged(directory);
151
+ return drainCaptureRetries({
152
+ host: "opencode",
153
+ provider: host,
154
+ config: CONFIG,
155
+ isReady: () => host.isCaptureReady(),
156
+ onSettled: settleOpencodeRetriedPrompt,
157
+ });
158
+ }
@@ -0,0 +1,48 @@
1
+ import { type CaptureResult, type CaptureWorkUnit } from "../core/capture.js";
2
+ import { type CaptureFailureInfo } from "../core/capture-retry-policy.js";
3
+ import type { CaptureSummaryProvider } from "../core/host.js";
4
+ import type { MemoryHost } from "../types/index.js";
5
+ import { type CaptureRetryConfig } from "./capture-retry-queue.js";
6
+ export interface CaptureRetryDrainOptions {
7
+ host: MemoryHost;
8
+ provider: CaptureSummaryProvider;
9
+ config: CaptureRetryConfig;
10
+ now?: () => number;
11
+ /** False skips the pass, e.g. while the embedding model is not ready. */
12
+ isReady?: () => boolean;
13
+ /** Runs after a retry saves or skips, so a host can update its own records. */
14
+ onSettled?: (unit: CaptureWorkUnit, result: CaptureResult) => Promise<void>;
15
+ }
16
+ export interface CaptureRetryDrainResult {
17
+ status: "done" | "running" | "off" | "not-ready" | "error";
18
+ captured: number;
19
+ skipped: number;
20
+ dropped: number;
21
+ /** True when the pass stopped at a failure a retry can fix. */
22
+ stopped: boolean;
23
+ }
24
+ /** What a thrown capture error says about the failure. */
25
+ export declare function captureFailureOf(error: unknown): CaptureFailureInfo;
26
+ /**
27
+ * Queue a turn whose live capture failed, when a retry can fix the failure.
28
+ * Never throws: a queue error must not change the live capture outcome.
29
+ */
30
+ export declare function queueFailedCapture(unit: CaptureWorkUnit, error: unknown, config: CaptureRetryConfig): Promise<boolean>;
31
+ /**
32
+ * Retry this host's due turns, oldest first, one at a time. Deletes a turn
33
+ * when its retry saves, skips or fails for good, and stops at the first
34
+ * failure a retry can fix. One pass runs per host in each process.
35
+ */
36
+ export declare function drainCaptureRetries(options: CaptureRetryDrainOptions): Promise<CaptureRetryDrainResult>;
37
+ type HostDrain = () => Promise<CaptureRetryDrainResult>;
38
+ /** A host registers its drain in its own process, so shared code never imports an adapter. */
39
+ export declare function registerCaptureRetryDrain(host: MemoryHost, drain: HostDrain | null): void;
40
+ /** Start the host's registered drain in the background, if this process has one. */
41
+ export declare function startCaptureRetryDrain(host: MemoryHost): void;
42
+ export type RetryNowResult = "started" | "scheduled" | "running";
43
+ /**
44
+ * Make every queued turn of one host due now. Starts a pass when this process
45
+ * runs that host; otherwise the turns retry at the host's next session start.
46
+ */
47
+ export declare function requestCaptureRetryNow(host: MemoryHost, config: CaptureRetryConfig): Promise<RetryNowResult>;
48
+ export {};
@@ -0,0 +1,142 @@
1
+ import { CaptureAttemptError, captureConversation, } from "../core/capture.js";
2
+ import { classifyCaptureFailure, errorHttpStatus, errorRetryAfterMs, } from "../core/capture-retry-policy.js";
3
+ import { claimCaptureRetry, deleteCaptureRetry, enqueueCaptureRetry, isCaptureRetryEnabled, listDueCaptureRetries, makeCaptureRetriesDue, pruneCaptureRetries, rescheduleCaptureRetry, } from "./capture-retry-queue.js";
4
+ import { log } from "./logger.js";
5
+ function errorCode(error) {
6
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
7
+ return error.code;
8
+ }
9
+ return error instanceof Error ? error.name : "unknown";
10
+ }
11
+ /** What a thrown capture error says about the failure. */
12
+ export function captureFailureOf(error) {
13
+ if (error instanceof CaptureAttemptError)
14
+ return error.failure;
15
+ return {
16
+ reason: "call-error",
17
+ httpStatus: errorHttpStatus(error),
18
+ retryAfterMs: errorRetryAfterMs(error),
19
+ };
20
+ }
21
+ /**
22
+ * Queue a turn whose live capture failed, when a retry can fix the failure.
23
+ * Never throws: a queue error must not change the live capture outcome.
24
+ */
25
+ export async function queueFailedCapture(unit, error, config) {
26
+ try {
27
+ const failure = captureFailureOf(error);
28
+ if (classifyCaptureFailure(failure) !== "retryable")
29
+ return false;
30
+ const result = await enqueueCaptureRetry(unit, failure, config);
31
+ if (result === "queued") {
32
+ log("Capture queued for retry", {
33
+ host: unit.host,
34
+ sessionID: unit.hostSessionId,
35
+ reason: failure.reason,
36
+ httpStatus: failure.httpStatus ?? null,
37
+ });
38
+ }
39
+ return result === "queued";
40
+ }
41
+ catch (queueError) {
42
+ log("Capture retry queue write failed", { code: errorCode(queueError) });
43
+ return false;
44
+ }
45
+ }
46
+ const running = new Set();
47
+ /**
48
+ * Retry this host's due turns, oldest first, one at a time. Deletes a turn
49
+ * when its retry saves, skips or fails for good, and stops at the first
50
+ * failure a retry can fix. One pass runs per host in each process.
51
+ */
52
+ export async function drainCaptureRetries(options) {
53
+ const result = {
54
+ status: "done",
55
+ captured: 0,
56
+ skipped: 0,
57
+ dropped: 0,
58
+ stopped: false,
59
+ };
60
+ const { host, config } = options;
61
+ const now = options.now ?? Date.now;
62
+ if (running.has(host))
63
+ return { ...result, status: "running" };
64
+ running.add(host);
65
+ try {
66
+ // Expired rows go first, so they are never retried. At 0 every row goes.
67
+ await pruneCaptureRetries(config, now());
68
+ if (!isCaptureRetryEnabled(config))
69
+ return { ...result, status: "off" };
70
+ if (options.isReady && !options.isReady())
71
+ return { ...result, status: "not-ready" };
72
+ for (const row of await listDueCaptureRetries(host, config, now())) {
73
+ if (!(await claimCaptureRetry(row.id, config, now())))
74
+ continue;
75
+ let outcome;
76
+ try {
77
+ outcome = await captureConversation(row.workUnit, options.provider);
78
+ }
79
+ catch (error) {
80
+ const failure = captureFailureOf(error);
81
+ if (classifyCaptureFailure(failure) === "retryable") {
82
+ await rescheduleCaptureRetry(row, failure, config, now());
83
+ result.stopped = true;
84
+ break;
85
+ }
86
+ await deleteCaptureRetry(row.id, config);
87
+ result.dropped++;
88
+ continue;
89
+ }
90
+ await deleteCaptureRetry(row.id, config);
91
+ if (outcome.status === "captured")
92
+ result.captured++;
93
+ else
94
+ result.skipped++;
95
+ try {
96
+ await options.onSettled?.(row.workUnit, outcome);
97
+ }
98
+ catch (error) {
99
+ log("Capture retry follow-up failed", { host, code: errorCode(error) });
100
+ }
101
+ }
102
+ if (result.captured + result.skipped + result.dropped > 0 || result.stopped) {
103
+ log("Capture retry pass finished", { host, ...result });
104
+ }
105
+ return result;
106
+ }
107
+ catch (error) {
108
+ log("Capture retry pass failed", { host, code: errorCode(error) });
109
+ return { ...result, status: "error" };
110
+ }
111
+ finally {
112
+ running.delete(host);
113
+ }
114
+ }
115
+ const registeredDrains = new Map();
116
+ /** A host registers its drain in its own process, so shared code never imports an adapter. */
117
+ export function registerCaptureRetryDrain(host, drain) {
118
+ if (drain)
119
+ registeredDrains.set(host, drain);
120
+ else
121
+ registeredDrains.delete(host);
122
+ }
123
+ /** Start the host's registered drain in the background, if this process has one. */
124
+ export function startCaptureRetryDrain(host) {
125
+ const drain = registeredDrains.get(host);
126
+ if (!drain)
127
+ return;
128
+ void drain().catch((error) => log("Capture retry pass failed", { host, code: errorCode(error) }));
129
+ }
130
+ /**
131
+ * Make every queued turn of one host due now. Starts a pass when this process
132
+ * runs that host; otherwise the turns retry at the host's next session start.
133
+ */
134
+ export async function requestCaptureRetryNow(host, config) {
135
+ await makeCaptureRetriesDue(host, config);
136
+ if (running.has(host))
137
+ return "running";
138
+ if (!registeredDrains.has(host))
139
+ return "scheduled";
140
+ startCaptureRetryDrain(host);
141
+ return "started";
142
+ }
@@ -0,0 +1,57 @@
1
+ import type { CaptureWorkUnit } from "../core/capture.js";
2
+ import { type CaptureFailureInfo } from "../core/capture-retry-policy.js";
3
+ import type { MemoryHost } from "../types/index.js";
4
+ import { type CaptureDiagnosticsConfig } from "./capture-diagnostics.js";
5
+ /**
6
+ * The config fields this module reads. Callers pass `CONFIG`, so tests that
7
+ * stub `../config.js` do not need the new key.
8
+ */
9
+ export interface CaptureRetryConfig extends CaptureDiagnosticsConfig {
10
+ storagePath: string;
11
+ captureRetryRetentionHours?: number;
12
+ }
13
+ export interface QueuedCaptureRetry {
14
+ id: number;
15
+ host: MemoryHost;
16
+ sessionId: string;
17
+ turnId: string;
18
+ workUnit: CaptureWorkUnit;
19
+ createdAt: number;
20
+ attempts: number;
21
+ }
22
+ export type EnqueueResult = "queued" | "off" | "private" | "too-large" | "no-turn-id";
23
+ export declare const DEFAULT_CAPTURE_RETRY_RETENTION_HOURS = 72;
24
+ export declare const MAX_QUEUED_TURN_BYTES: number;
25
+ export declare const MAX_QUEUE_BYTES: number;
26
+ /** Whole hours from 0 to 720; a stub config without the key gets the default. */
27
+ export declare function captureRetryRetentionHours(config: CaptureRetryConfig): number;
28
+ export declare function isCaptureRetryEnabled(config: CaptureRetryConfig): boolean;
29
+ /**
30
+ * Remove `<private>` text and redact secrets with the trace rules. Returns
31
+ * null for a turn with no text left, which must not be queued.
32
+ */
33
+ export declare function cleanCaptureWorkUnit(unit: CaptureWorkUnit, config: CaptureRetryConfig): CaptureWorkUnit | null;
34
+ /**
35
+ * Save a cleaned copy of a turn whose live capture failed with a retryable
36
+ * error. Queueing the same turn again updates its row.
37
+ */
38
+ export declare function enqueueCaptureRetry(unit: CaptureWorkUnit, failure: CaptureFailureInfo, config: CaptureRetryConfig, now?: number): Promise<EnqueueResult>;
39
+ /** This host's rows that are due, oldest first. */
40
+ export declare function listDueCaptureRetries(host: MemoryHost, config: CaptureRetryConfig, now?: number): Promise<QueuedCaptureRetry[]>;
41
+ /**
42
+ * Claim a row for one retry. Only one process can hold a live claim; a
43
+ * crashed process releases it when the lease runs out.
44
+ */
45
+ export declare function claimCaptureRetry(id: number, config: CaptureRetryConfig, now?: number): Promise<boolean>;
46
+ /** Record one more failed try, set the next try time and release the claim. */
47
+ export declare function rescheduleCaptureRetry(row: Pick<QueuedCaptureRetry, "id" | "attempts">, failure: CaptureFailureInfo, config: CaptureRetryConfig, now?: number): Promise<void>;
48
+ export declare function deleteCaptureRetry(id: number, config: CaptureRetryConfig): Promise<void>;
49
+ /** Queued turns per host. */
50
+ export declare function countCaptureRetries(config: CaptureRetryConfig): Promise<Record<MemoryHost, number>>;
51
+ /** Make every row of one host due now. Returns the number of rows. */
52
+ export declare function makeCaptureRetriesDue(host: MemoryHost, config: CaptureRetryConfig, now?: number): Promise<number>;
53
+ /**
54
+ * Delete rows older than the retention. With retention 0 the queue is off,
55
+ * so every row goes.
56
+ */
57
+ export declare function pruneCaptureRetries(config: CaptureRetryConfig, now?: number): Promise<number>;