om-memory-system 3.1.1 → 3.2.0-next.10

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.
@@ -1,4 +1,4 @@
1
- import { applySafeExtraParams, BaseAIProvider } from "./base-provider.js";
1
+ import { applySafeExtraParams, BaseAIProvider, describeValidationError, } 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";
@@ -48,6 +48,8 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
48
48
  return "Anthropic API request timeout";
49
49
  }
50
50
  async executeToolCall(systemPrompt, userPrompt, toolSchema, sessionId) {
51
+ // Last stop reason the provider reported, so capture diagnostics can see length limits.
52
+ let stopReason;
51
53
  const providerTag = this.sessionProviderTag();
52
54
  let session = await this.aiSessionManager.getSession(sessionId, providerTag);
53
55
  if (!session) {
@@ -119,12 +121,14 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
119
121
  iteration: iterations,
120
122
  });
121
123
  return {
124
+ stopReason,
122
125
  success: false,
123
126
  error: `API error: ${response.status} - ${errorText}`,
124
127
  iterations,
125
128
  };
126
129
  }
127
130
  const data = (await response.json());
131
+ stopReason = data.stop_reason;
128
132
  const assistantSequence = (await this.aiSessionManager.getLastSequence(session.id)) + 1;
129
133
  await this.aiSessionManager.addMessage({
130
134
  aiSessionId: session.id,
@@ -145,26 +149,27 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
145
149
  throw new Error(result.errors.join(", "));
146
150
  }
147
151
  return {
152
+ stopReason,
148
153
  success: true,
149
154
  data: result.data,
150
155
  iterations,
151
156
  };
152
157
  }
153
158
  catch (validationError) {
154
- const errorStack = validationError instanceof Error ? validationError.stack : undefined;
155
159
  log(this.toolValidationErrorLogLabel(), {
156
- error: String(validationError),
157
- stack: errorStack,
160
+ error: describeValidationError(validationError),
158
161
  errorType: validationError instanceof Error
159
162
  ? validationError.constructor.name
160
163
  : typeof validationError,
161
164
  toolName: toolSchema.function.name,
162
165
  iteration: iterations,
163
- rawData: JSON.stringify(toolUse).slice(0, 500),
166
+ // Log only the size: the tool input is the model's reply.
167
+ inputChars: JSON.stringify(toolUse)?.length ?? 0,
164
168
  });
165
169
  return {
170
+ stopReason,
166
171
  success: false,
167
- error: `Validation failed: ${String(validationError)}`,
172
+ error: `Validation failed: ${describeValidationError(validationError)}`,
168
173
  iterations,
169
174
  };
170
175
  }
@@ -188,12 +193,14 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
188
193
  clearTimeout(timeout);
189
194
  if (error instanceof Error && error.name === "AbortError") {
190
195
  return {
196
+ stopReason,
191
197
  success: false,
192
198
  error: `${this.timeoutLabel()} (${this.config.iterationTimeout}ms)`,
193
199
  iterations,
194
200
  };
195
201
  }
196
202
  return {
203
+ stopReason,
197
204
  success: false,
198
205
  error: String(error),
199
206
  iterations,
@@ -201,6 +208,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
201
208
  }
202
209
  }
203
210
  return {
211
+ stopReason,
204
212
  success: false,
205
213
  error: `Max iterations (${maxIterations}) reached without tool use`,
206
214
  iterations,
@@ -3,6 +3,8 @@ export interface ToolCallResult {
3
3
  data?: any;
4
4
  error?: string;
5
5
  iterations?: number;
6
+ /** The provider's last reported stop reason, e.g. a length limit. */
7
+ stopReason?: string;
6
8
  }
7
9
  export interface ProviderConfig {
8
10
  model: string;
@@ -14,6 +16,12 @@ export interface ProviderConfig {
14
16
  memoryTemperature?: number | false;
15
17
  extraParams?: Record<string, unknown>;
16
18
  }
19
+ /**
20
+ * A validation error message that is safe to log and return. A JSON
21
+ * `SyntaxError` message quotes the start of the model's reply, which can carry
22
+ * conversation content, so it is replaced; validator messages name fields only.
23
+ */
24
+ export declare function describeValidationError(error: unknown): string;
17
25
  export declare function applySafeExtraParams(requestBody: Record<string, any>, extraParams: Record<string, unknown>): void;
18
26
  export declare abstract class BaseAIProvider {
19
27
  protected config: ProviderConfig;
@@ -1,3 +1,15 @@
1
+ /**
2
+ * A validation error message that is safe to log and return. A JSON
3
+ * `SyntaxError` message quotes the start of the model's reply, which can carry
4
+ * conversation content, so it is replaced; validator messages name fields only.
5
+ */
6
+ export function describeValidationError(error) {
7
+ if (error instanceof SyntaxError)
8
+ return "tool arguments are not valid JSON";
9
+ if (error instanceof Error)
10
+ return error.message;
11
+ return "unknown validation error";
12
+ }
1
13
  const PROTECTED_KEYS = new Set([
2
14
  "model",
3
15
  "messages",
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider } from "./base-provider.js";
1
+ import { BaseAIProvider, describeValidationError } 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";
@@ -41,6 +41,8 @@ export class GoogleGeminiProvider extends BaseAIProvider {
41
41
  });
42
42
  }
43
43
  async executeToolCall(systemPrompt, userPrompt, toolSchema, sessionId) {
44
+ // Last stop reason the provider reported, so capture diagnostics can see length limits.
45
+ let stopReason;
44
46
  let session = await this.aiSessionManager.getSession(sessionId, "google-gemini");
45
47
  if (!session) {
46
48
  session = await this.aiSessionManager.createSession({
@@ -152,6 +154,7 @@ export class GoogleGeminiProvider extends BaseAIProvider {
152
154
  iteration: iterations,
153
155
  });
154
156
  return {
157
+ stopReason,
155
158
  success: false,
156
159
  error: `Gemini API error: ${response.status} - ${errorText}`,
157
160
  iterations,
@@ -159,8 +162,14 @@ export class GoogleGeminiProvider extends BaseAIProvider {
159
162
  }
160
163
  const data = (await response.json());
161
164
  const candidate = data.candidates?.[0];
165
+ stopReason = candidate?.finishReason;
162
166
  if (!candidate || !candidate.content) {
163
- return { success: false, error: "Invalid Gemini API response format", iterations };
167
+ return {
168
+ stopReason,
169
+ success: false,
170
+ error: "Invalid Gemini API response format",
171
+ iterations,
172
+ };
164
173
  }
165
174
  const modelMsg = candidate.content;
166
175
  const assistantSequence = (await this.aiSessionManager.getLastSequence(session.id)) + 1;
@@ -197,12 +206,12 @@ export class GoogleGeminiProvider extends BaseAIProvider {
197
206
  if (!result.valid)
198
207
  throw new Error(result.errors.join(", "));
199
208
  await this.addToolResponse(session.id, contents, toolCall.id, JSON.stringify({ success: true }));
200
- return { success: true, data: result.data, iterations };
209
+ return { stopReason, success: true, data: result.data, iterations };
201
210
  }
202
211
  catch (validationError) {
203
- const errorMessage = `Validation failed: ${String(validationError)}`;
212
+ const errorMessage = `Validation failed: ${describeValidationError(validationError)}`;
204
213
  await this.addToolResponse(session.id, contents, toolCall.id, JSON.stringify({ success: false, error: errorMessage }));
205
- return { success: false, error: errorMessage, iterations };
214
+ return { stopReason, success: false, error: errorMessage, iterations };
206
215
  }
207
216
  }
208
217
  }
@@ -220,9 +229,14 @@ export class GoogleGeminiProvider extends BaseAIProvider {
220
229
  }
221
230
  catch (error) {
222
231
  clearTimeout(timeout);
223
- return { success: false, error: String(error), iterations };
232
+ return { stopReason, success: false, error: String(error), iterations };
224
233
  }
225
234
  }
226
- return { success: false, error: `Max iterations (${maxIterations}) reached`, iterations };
235
+ return {
236
+ stopReason,
237
+ success: false,
238
+ error: `Max iterations (${maxIterations}) reached`,
239
+ iterations,
240
+ };
227
241
  }
228
242
  }
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider, applySafeExtraParams, } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, describeValidationError, } from "./base-provider.js";
2
2
  import { log } from "../../logger.js";
3
3
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
4
4
  function isErrorResponseBody(data) {
@@ -126,6 +126,8 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
126
126
  return result;
127
127
  }
128
128
  async executeToolCall(systemPrompt, userPrompt, toolSchema, sessionId) {
129
+ // Last stop reason the provider reported, so capture diagnostics can see length limits.
130
+ let stopReason;
129
131
  let session = await this.aiSessionManager.getSession(sessionId, this.sessionProviderTag());
130
132
  if (!session) {
131
133
  session = await this.aiSessionManager.createSession({
@@ -218,6 +220,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
218
220
  'Your model does not support the temperature parameter. Add "memoryTemperature": false to your config file to disable it.';
219
221
  }
220
222
  return {
223
+ stopReason,
221
224
  success: false,
222
225
  error: errorMessage,
223
226
  iterations,
@@ -232,6 +235,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
232
235
  msg: data.msg,
233
236
  });
234
237
  return {
238
+ stopReason,
235
239
  success: false,
236
240
  error: `API error: ${data.status} - ${data.msg}`,
237
241
  iterations,
@@ -244,19 +248,24 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
244
248
  log("Invalid API response format", {
245
249
  provider: this.getProviderName(),
246
250
  model: this.config.model,
247
- response: JSON.stringify(data).slice(0, 1000),
251
+ // The body can carry reply text, so log only its shape and size.
252
+ responseKeys: typeof data === "object" && data !== null ? Object.keys(data).slice(0, 20) : [],
253
+ responseChars: JSON.stringify(data)?.length ?? 0,
248
254
  hasChoices: Array.isArray(choices),
249
255
  choicesLength: Array.isArray(choices) ? choices.length : undefined,
250
256
  });
251
257
  return {
258
+ stopReason,
252
259
  success: false,
253
260
  error: "Invalid API response format",
254
261
  iterations,
255
262
  };
256
263
  }
257
264
  const choice = data.choices[0];
265
+ stopReason = choice?.finish_reason ?? undefined;
258
266
  if (!choice) {
259
267
  return {
268
+ stopReason,
260
269
  success: false,
261
270
  error: "Invalid API response format",
262
271
  iterations,
@@ -318,27 +327,28 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
318
327
  }
319
328
  await this.addToolResponse(session.id, messages, toolCallId, JSON.stringify({ success: true }));
320
329
  return {
330
+ stopReason,
321
331
  success: true,
322
332
  data: result.data,
323
333
  iterations,
324
334
  };
325
335
  }
326
336
  catch (validationError) {
327
- const errorStack = validationError instanceof Error ? validationError.stack : undefined;
328
337
  log("OpenAI tool response validation failed", {
329
- error: String(validationError),
330
- stack: errorStack,
338
+ error: describeValidationError(validationError),
331
339
  errorType: validationError instanceof Error
332
340
  ? validationError.constructor.name
333
341
  : typeof validationError,
334
342
  toolName: toolSchema.function.name,
335
343
  iteration: iterations,
336
- rawArguments: toolCall.function.arguments.slice(0, 500),
344
+ // Log only the size: the arguments are the model's reply.
345
+ argumentsChars: toolCall.function.arguments.length,
337
346
  });
338
- const errorMessage = `Validation failed: ${String(validationError)}`;
347
+ const errorMessage = `Validation failed: ${describeValidationError(validationError)}`;
339
348
  lastErrorMessage = errorMessage;
340
349
  await this.addToolResponse(session.id, messages, toolCallId, JSON.stringify({ success: false, error: errorMessage }));
341
350
  return {
351
+ stopReason,
342
352
  success: false,
343
353
  error: errorMessage,
344
354
  iterations,
@@ -366,12 +376,14 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
366
376
  clearTimeout(timeout);
367
377
  if (error instanceof Error && error.name === "AbortError") {
368
378
  return {
379
+ stopReason,
369
380
  success: false,
370
381
  error: `API request timeout (${iterationTimeout}ms)`,
371
382
  iterations,
372
383
  };
373
384
  }
374
385
  return {
386
+ stopReason,
375
387
  success: false,
376
388
  error: String(error),
377
389
  iterations,
@@ -379,6 +391,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
379
391
  }
380
392
  }
381
393
  return {
394
+ stopReason,
382
395
  success: false,
383
396
  error: `Max iterations (${maxIterations}) reached without tool call`,
384
397
  iterations,
@@ -15,6 +15,8 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
15
15
  return true;
16
16
  }
17
17
  async executeToolCall(systemPrompt, userPrompt, toolSchema, sessionId) {
18
+ // Last stop reason the provider reported, so capture diagnostics can see length limits.
19
+ let stopReason;
18
20
  let session = await this.aiSessionManager.getSession(sessionId, "openai-responses");
19
21
  if (!session) {
20
22
  session = await this.aiSessionManager.createSession({
@@ -67,12 +69,17 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
67
69
  iteration: iterations,
68
70
  });
69
71
  return {
72
+ stopReason,
70
73
  success: false,
71
74
  error: `API error: ${response.status} - ${errorText}`,
72
75
  iterations,
73
76
  };
74
77
  }
75
78
  const data = (await response.json());
79
+ stopReason =
80
+ data.status === "incomplete"
81
+ ? (data.incomplete_details?.reason ?? "incomplete")
82
+ : data.status;
76
83
  conversationId = data.conversation || conversationId;
77
84
  if (iterations === 1) {
78
85
  const userSeq = (await this.aiSessionManager.getLastSequence(session.id)) + 1;
@@ -89,6 +96,7 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
89
96
  conversationId,
90
97
  });
91
98
  return {
99
+ stopReason,
92
100
  success: true,
93
101
  data: this.validateResponse(toolCall),
94
102
  iterations,
@@ -100,12 +108,14 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
100
108
  clearTimeout(timeout);
101
109
  if (error instanceof Error && error.name === "AbortError") {
102
110
  return {
111
+ stopReason,
103
112
  success: false,
104
113
  error: `API request timeout (${this.config.iterationTimeout}ms)`,
105
114
  iterations,
106
115
  };
107
116
  }
108
117
  return {
118
+ stopReason,
109
119
  success: false,
110
120
  error: String(error),
111
121
  iterations,
@@ -113,6 +123,7 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
113
123
  }
114
124
  }
115
125
  return {
126
+ stopReason,
116
127
  success: false,
117
128
  error: `Max iterations (${this.config.maxIterations}) reached without tool call`,
118
129
  iterations,
@@ -0,0 +1,66 @@
1
+ import type { CaptureAttemptDiagnostics, CaptureAttemptOutcome, CaptureExtractionPath, CaptureFailureReason } from "../core/host.js";
2
+ import type { MemoryHost, MemorySourceType } from "../types/index.js";
3
+ /**
4
+ * The config fields this module reads. Callers pass `CONFIG`; tests pass a
5
+ * plain object, so no import of `../config.js` is needed here.
6
+ */
7
+ export interface CaptureDiagnosticsConfig {
8
+ captureTrace?: boolean;
9
+ captureTraceRetentionDays?: number;
10
+ memoryApiKey?: string;
11
+ embeddingApiKey?: string;
12
+ webServerApiToken?: string;
13
+ webServerAuthPassword?: string;
14
+ }
15
+ export interface CaptureAttemptContext {
16
+ host: MemoryHost;
17
+ sourceType: MemorySourceType;
18
+ sessionId: string;
19
+ }
20
+ /**
21
+ * The metadata-only record written to the OMMS log for every attempt. Every
22
+ * key is always present so records from both hosts have one shape; a value a
23
+ * path could not observe is null.
24
+ */
25
+ export interface CaptureAttemptRecord {
26
+ host: MemoryHost;
27
+ sourceType: MemorySourceType;
28
+ sessionId: string;
29
+ path: CaptureExtractionPath | null;
30
+ provider: string | null;
31
+ model: string | null;
32
+ stopReason: string | null;
33
+ blockTypes: string[] | null;
34
+ promptChars: number | null;
35
+ replyChars: number | null;
36
+ durationMs: number;
37
+ outcome: CaptureAttemptOutcome;
38
+ reason: CaptureFailureReason | null;
39
+ }
40
+ export declare const DEFAULT_CAPTURE_TRACE_RETENTION_DAYS = 7;
41
+ export declare function buildCaptureAttemptRecord(context: CaptureAttemptContext, diagnostics: CaptureAttemptDiagnostics, outcome: CaptureAttemptOutcome, durationMs: number): CaptureAttemptRecord;
42
+ /**
43
+ * Prepare prompt or reply text for the trace file: drop `<private>` regions the
44
+ * same way memory storage does, then configured secrets, then common key and
45
+ * token formats.
46
+ */
47
+ export declare function redactTraceText(text: string, config: CaptureDiagnosticsConfig): string;
48
+ export declare function getTraceDirectory(): string;
49
+ /**
50
+ * Delete trace files dated more than `captureTraceRetentionDays` before `now`.
51
+ * The date comes from the file name, not the modification time, and files
52
+ * that do not match the trace name pattern are never touched.
53
+ */
54
+ export declare function pruneTraces(config: CaptureDiagnosticsConfig, now?: Date): number;
55
+ /**
56
+ * Append one trace entry to the day's JSON Lines file. A single append per
57
+ * entry keeps lines whole when Pi and OpenCode write at the same time.
58
+ */
59
+ export declare function writeTraceEntry(record: CaptureAttemptRecord, diagnostics: CaptureAttemptDiagnostics, config: CaptureDiagnosticsConfig, now?: Date): void;
60
+ /**
61
+ * Write the attempt's metadata record to the log and, when tracing is on, its
62
+ * trace entry. Never throws: diagnostics must not change a capture outcome.
63
+ */
64
+ export declare function emitCaptureAttempt(record: CaptureAttemptRecord, diagnostics: CaptureAttemptDiagnostics, config: CaptureDiagnosticsConfig, now?: Date): void;
65
+ /** Test hook: forget which day the last trace was written. */
66
+ export declare function resetTraceStateForTests(): void;
@@ -0,0 +1,172 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { getLogDirPath } from "./log-path.js";
4
+ import { log } from "./logger.js";
5
+ import { stripPrivateContent } from "./privacy.js";
6
+ export const DEFAULT_CAPTURE_TRACE_RETENTION_DAYS = 7;
7
+ const TRACE_SCHEMA_VERSION = 1;
8
+ const TRACE_FILE = /^capture-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
9
+ const REDACTED = "[REDACTED]";
10
+ export function buildCaptureAttemptRecord(context, diagnostics, outcome, durationMs) {
11
+ const promptChars = diagnostics.systemPrompt === undefined && diagnostics.userPrompt === undefined
12
+ ? null
13
+ : (diagnostics.systemPrompt?.length ?? 0) + (diagnostics.userPrompt?.length ?? 0);
14
+ return {
15
+ host: context.host,
16
+ sourceType: context.sourceType,
17
+ sessionId: context.sessionId,
18
+ path: diagnostics.path ?? null,
19
+ provider: diagnostics.provider ?? null,
20
+ model: diagnostics.model ?? null,
21
+ stopReason: diagnostics.stopReason ?? null,
22
+ blockTypes: diagnostics.blockTypes ?? null,
23
+ promptChars,
24
+ replyChars: diagnostics.rawReply?.length ?? null,
25
+ durationMs,
26
+ outcome,
27
+ reason: outcome === "failed" ? (diagnostics.failureReason ?? "call-error") : null,
28
+ };
29
+ }
30
+ // Common key and token formats. Each match is replaced whole, except the
31
+ // Bearer rule, which keeps the scheme so the trace still reads naturally.
32
+ const SECRET_PATTERNS = [
33
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
34
+ [/\bsk-ant-[A-Za-z0-9_-]{10,}/g, REDACTED],
35
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, REDACTED],
36
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REDACTED],
37
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED],
38
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
39
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
40
+ [/\bAIza[0-9A-Za-z_-]{35}/g, REDACTED],
41
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, REDACTED],
42
+ [/(\bBearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, `$1${REDACTED}`],
43
+ ];
44
+ function configuredSecrets(config) {
45
+ return [
46
+ config.memoryApiKey,
47
+ config.embeddingApiKey,
48
+ config.webServerApiToken,
49
+ config.webServerAuthPassword,
50
+ ].filter((value) => typeof value === "string" &&
51
+ // Short values would redact ordinary words; unresolved references are not secrets.
52
+ value.length >= 8 &&
53
+ !value.startsWith("env://") &&
54
+ !value.startsWith("file://"));
55
+ }
56
+ /**
57
+ * Prepare prompt or reply text for the trace file: drop `<private>` regions the
58
+ * same way memory storage does, then configured secrets, then common key and
59
+ * token formats.
60
+ */
61
+ export function redactTraceText(text, config) {
62
+ let result = stripPrivateContent(text);
63
+ for (const secret of configuredSecrets(config)) {
64
+ result = result.replaceAll(secret, REDACTED);
65
+ }
66
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
67
+ result = result.replace(pattern, replacement);
68
+ }
69
+ return result;
70
+ }
71
+ function formatDate(d) {
72
+ const pad = (n) => String(n).padStart(2, "0");
73
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
74
+ }
75
+ export function getTraceDirectory() {
76
+ return join(getLogDirPath(), "traces");
77
+ }
78
+ function retentionDays(config) {
79
+ const days = config.captureTraceRetentionDays;
80
+ if (typeof days !== "number" || !Number.isFinite(days)) {
81
+ return DEFAULT_CAPTURE_TRACE_RETENTION_DAYS;
82
+ }
83
+ return Math.max(1, Math.floor(days));
84
+ }
85
+ /**
86
+ * Delete trace files dated more than `captureTraceRetentionDays` before `now`.
87
+ * The date comes from the file name, not the modification time, and files
88
+ * that do not match the trace name pattern are never touched.
89
+ */
90
+ export function pruneTraces(config, now = new Date()) {
91
+ const dir = getTraceDirectory();
92
+ let removed = 0;
93
+ try {
94
+ if (!existsSync(dir))
95
+ return 0;
96
+ const cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
97
+ cutoff.setDate(cutoff.getDate() - retentionDays(config));
98
+ for (const file of readdirSync(dir)) {
99
+ const match = TRACE_FILE.exec(file);
100
+ if (!match)
101
+ continue;
102
+ const fileDate = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
103
+ if (fileDate < cutoff) {
104
+ unlinkSync(join(dir, file));
105
+ removed++;
106
+ }
107
+ }
108
+ }
109
+ catch (error) {
110
+ log("Capture trace cleanup failed", { code: errorCode(error) });
111
+ }
112
+ return removed;
113
+ }
114
+ let lastTraceDate = null;
115
+ /**
116
+ * Append one trace entry to the day's JSON Lines file. A single append per
117
+ * entry keeps lines whole when Pi and OpenCode write at the same time.
118
+ */
119
+ export function writeTraceEntry(record, diagnostics, config, now = new Date()) {
120
+ try {
121
+ const today = formatDate(now);
122
+ if (lastTraceDate !== today) {
123
+ lastTraceDate = today;
124
+ pruneTraces(config, now);
125
+ }
126
+ const dir = getTraceDirectory();
127
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
128
+ chmodSync(dir, 0o700);
129
+ const redact = (value) => value === undefined ? null : redactTraceText(value, config);
130
+ const entry = {
131
+ schemaVersion: TRACE_SCHEMA_VERSION,
132
+ timestamp: now.toISOString(),
133
+ ...record,
134
+ systemPrompt: redact(diagnostics.systemPrompt),
135
+ userPrompt: redact(diagnostics.userPrompt),
136
+ reply: redact(diagnostics.rawReply),
137
+ };
138
+ const file = join(dir, `capture-${today}.jsonl`);
139
+ appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
140
+ // The mode above applies only when the append creates the file; a file
141
+ // that already existed with wider permissions is narrowed here.
142
+ chmodSync(file, 0o600);
143
+ }
144
+ catch (error) {
145
+ log("Capture trace write failed", { code: errorCode(error) });
146
+ }
147
+ }
148
+ /**
149
+ * Write the attempt's metadata record to the log and, when tracing is on, its
150
+ * trace entry. Never throws: diagnostics must not change a capture outcome.
151
+ */
152
+ export function emitCaptureAttempt(record, diagnostics, config, now = new Date()) {
153
+ try {
154
+ log("Capture attempt", record);
155
+ }
156
+ catch {
157
+ // The log is best effort here, as everywhere else.
158
+ }
159
+ if (config.captureTrace === true) {
160
+ writeTraceEntry(record, diagnostics, config, now);
161
+ }
162
+ }
163
+ function errorCode(error) {
164
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
165
+ return error.code;
166
+ }
167
+ return error instanceof Error ? error.name : "unknown";
168
+ }
169
+ /** Test hook: forget which day the last trace was written. */
170
+ export function resetTraceStateForTests() {
171
+ lastTraceDate = null;
172
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getLogFilePath(): string;
2
+ export declare function getLogDirPath(): string;
@@ -0,0 +1,15 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ // Kept apart from logger.ts: many tests replace logger.js with a stub that
4
+ // only has `log`, so modules that need the paths import them from here.
5
+ export function getLogFilePath() {
6
+ return (process.env.OMMS_LOG_FILE ||
7
+ // Legacy override, still honoured from the opencode-mem days.
8
+ process.env.OPENCODE_MEM_LOG_FILE ||
9
+ join(homedir(), ".omms", "omms.log"));
10
+ }
11
+ export function getLogDirPath() {
12
+ const logFile = getLogFilePath();
13
+ const lastSlash = Math.max(logFile.lastIndexOf("/"), logFile.lastIndexOf("\\"));
14
+ return lastSlash === -1 ? "." : logFile.slice(0, lastSlash);
15
+ }
@@ -1,17 +1,6 @@
1
1
  import { appendFileSync, writeFileSync, existsSync, mkdirSync, statSync, renameSync, unlinkSync, readdirSync, } from "fs";
2
- import { homedir } from "os";
3
2
  import { join } from "path";
4
- function getLogFilePath() {
5
- return (process.env.OMMS_LOG_FILE ||
6
- // Legacy override, still honoured from the opencode-mem days.
7
- process.env.OPENCODE_MEM_LOG_FILE ||
8
- join(homedir(), ".omms", "omms.log"));
9
- }
10
- function getLogDirPath() {
11
- const logFile = getLogFilePath();
12
- const lastSlash = Math.max(logFile.lastIndexOf("/"), logFile.lastIndexOf("\\"));
13
- return lastSlash === -1 ? "." : logFile.slice(0, lastSlash);
14
- }
3
+ import { getLogDirPath, getLogFilePath } from "./log-path.js";
15
4
  const MAX_LOG_SIZE = 5 * 1024 * 1024;
16
5
  const MAX_LOG_DAYS = 30;
17
6
  const GLOBAL_LOGGER_KEY = Symbol.for("omms.logger.initialized");
@@ -222,8 +222,17 @@ export function toLegacyEvent(raw) {
222
222
  const envelope = raw?.payload ?? raw;
223
223
  const source = envelope?.type === "sync" && envelope.syncEvent ? envelope.syncEvent : envelope;
224
224
  const rawType = typeof source?.type === "string" ? source.type.replace(/\.1$/, "") : source?.type;
225
- const type = rawType === "session.compaction.ended" ? "session.compacted" : rawType;
226
225
  const data = source?.data ?? {};
226
+ // OpenCode 2.0.14 ends a turn with `session.execution.succeeded` and emits no
227
+ // `session.idle`; some builds report idleness through `session.status`. The
228
+ // V1 capture path listens for `session.idle`, so both map onto it.
229
+ // Subscribed events carry their payload in `data`; bus-style ones in `properties`.
230
+ const payload = source?.data ?? source?.properties ?? {};
231
+ if (rawType === "session.execution.succeeded" ||
232
+ (rawType === "session.status" && payload?.status?.type === "idle")) {
233
+ return { type: "session.idle", properties: { sessionID: payload.sessionID } };
234
+ }
235
+ const type = rawType === "session.compaction.ended" ? "session.compacted" : rawType;
227
236
  if (source && typeof source === "object" && "properties" in source) {
228
237
  return { type, properties: source.properties };
229
238
  }