om-memory-system 3.5.0-next.34 → 3.5.0-next.35
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/dist/adapters/opencode/auto-capture-summary.js +9 -1
- package/dist/adapters/opencode/backfill-startup.d.ts +3 -0
- package/dist/adapters/opencode/backfill-startup.js +6 -0
- package/dist/adapters/opencode/opencode-diagnostics.js +2 -1
- package/dist/adapters/opencode/opencode-provider.js +13 -3
- package/dist/adapters/pi/capture.js +29 -5
- package/dist/adapters/pi/extension.js +18 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js +21 -1
- package/dist/core/capture-retry-policy.d.ts +36 -0
- package/dist/core/capture-retry-policy.js +77 -0
- package/dist/core/capture.d.ts +9 -0
- package/dist/core/capture.js +20 -0
- package/dist/core/host.d.ts +4 -0
- package/dist/importer/model-selection.js +12 -2
- package/dist/index.js +15 -1
- package/dist/services/ai/providers/anthropic-messages.js +6 -1
- package/dist/services/ai/providers/base-provider.d.ts +17 -0
- package/dist/services/ai/providers/base-provider.js +19 -0
- package/dist/services/ai/providers/google-gemini.js +11 -2
- package/dist/services/ai/providers/openai-chat-completion.js +6 -1
- package/dist/services/ai/providers/openai-responses.js +6 -1
- package/dist/services/auto-capture.d.ts +6 -0
- package/dist/services/auto-capture.js +36 -5
- package/dist/services/capture-retry-drain.d.ts +48 -0
- package/dist/services/capture-retry-drain.js +142 -0
- package/dist/services/capture-retry-queue.d.ts +57 -0
- package/dist/services/capture-retry-queue.js +205 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.js +4 -0
- package/dist/services/settings-snapshot.js +2 -0
- package/dist/services/web-server.js +33 -2
- package/dist/web/assets/index-D5MY5Kjs.css +2 -0
- package/dist/web/assets/{index-D5A9vMgC.js → index-DIy--9g_.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
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 }) =>
|
|
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 {
|
|
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
|
-
|
|
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
|
-
}
|
|
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,
|
|
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>;
|