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.
package/README.md CHANGED
@@ -122,7 +122,10 @@ import guides, and the [CLI reference](docs/cli.md).
122
122
  Pi shows a notice when a new version is out; update with
123
123
  `pi update npm:om-memory-system`. On OpenCode v2, run `opencode plugin check`
124
124
  and `opencode plugin update om-memory-system`. Restart the agent afterwards.
125
- Coming from `opencode-mem`? Your memories move over automatically, with a
125
+ To stay on one version, install it with the number, for example
126
+ `pi install npm:om-memory-system@3.1.1` or
127
+ `opencode plugin add om-memory-system@3.1.1`; a pinned install is never
128
+ updated. Coming from `opencode-mem`? Your memories move over automatically, with a
126
129
  backup first. See [Updating and upgrading](docs/upgrading.md) and
127
130
  [CHANGELOG.md](CHANGELOG.md).
128
131
 
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { CONFIG } from "../../config.js";
3
3
  import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js";
4
4
  import { buildBoundedSummaryPrompt } from "../../core/capture-context.js";
5
- import { parseCaptureSummary } from "../../core/extraction.js";
5
+ import { classifyCaptureReply, normalizeStopReason, parseCaptureSummary, } from "../../core/extraction.js";
6
6
  import { log } from "../../services/logger.js";
7
7
  import { loadOpencodeProvider } from "../../services/ai/opencode-provider-loader.js";
8
8
  function buildSystemPrompt(languageName) {
@@ -26,8 +26,19 @@ FORMAT:
26
26
  SKIP if: greetings, casual chat, no code/decisions made
27
27
  CAPTURE if: code changed, bug fixed, feature added, decision made`;
28
28
  }
29
+ /** Reason code for an error thrown on the OpenCode host-model path. */
30
+ function hostModelFailureReason(error) {
31
+ if (error instanceof Error && error.name === "ZodError")
32
+ return "schema-mismatch";
33
+ if (String(error).includes("returned no structured output"))
34
+ return "empty-text";
35
+ return "call-error";
36
+ }
29
37
  export async function generateOpenCodeAutoCaptureSummary(request, notify) {
30
38
  let opencodeProviderError;
39
+ // Filled per path; when the host model falls back, the external API attempt
40
+ // overwrites it, so the record describes the call that decided the outcome.
41
+ const diagnostics = request.diagnostics ?? {};
31
42
  const hostModel = resolveOpencodeHostModel(CONFIG);
32
43
  if (hostModel) {
33
44
  try {
@@ -40,6 +51,9 @@ export async function generateOpenCodeAutoCaptureSummary(request, notify) {
40
51
  ...hostModel,
41
52
  prompt: request.prompt,
42
53
  });
54
+ diagnostics.path = "host-model";
55
+ diagnostics.provider = providerID;
56
+ diagnostics.model = modelID;
43
57
  if (!isProviderConnected(providerID)) {
44
58
  throw new Error(`opencode provider '${providerID}' is not connected. Check your opencode provider configuration.`);
45
59
  }
@@ -59,6 +73,8 @@ export async function generateOpenCodeAutoCaptureSummary(request, notify) {
59
73
  tags: z.array(z.string()),
60
74
  });
61
75
  const aiPrompt = buildBoundedSummaryPrompt(request.context, systemPrompt, z.toJSONSchema(schema));
76
+ diagnostics.systemPrompt = systemPrompt;
77
+ diagnostics.userPrompt = aiPrompt;
62
78
  const result = await generateStructuredOutput({
63
79
  client: v2Client,
64
80
  providerID,
@@ -66,6 +82,12 @@ export async function generateOpenCodeAutoCaptureSummary(request, notify) {
66
82
  systemPrompt,
67
83
  userPrompt: aiPrompt,
68
84
  schema,
85
+ onReply: (reply) => {
86
+ diagnostics.stopReason = normalizeStopReason(reply.finish);
87
+ diagnostics.blockTypes = reply.partTypes;
88
+ diagnostics.rawReply =
89
+ reply.structuredOutput === undefined ? "" : JSON.stringify(reply.structuredOutput);
90
+ },
69
91
  });
70
92
  return {
71
93
  summary: result.summary,
@@ -75,6 +97,7 @@ export async function generateOpenCodeAutoCaptureSummary(request, notify) {
75
97
  }
76
98
  catch (error) {
77
99
  opencodeProviderError = error;
100
+ diagnostics.failureReason = hostModelFailureReason(error);
78
101
  log("auto-capture: opencode provider failed, falling back to external API", {
79
102
  error: String(error),
80
103
  });
@@ -134,19 +157,38 @@ export async function generateOpenCodeAutoCaptureSummary(request, notify) {
134
157
  };
135
158
  const aiPrompt = buildBoundedSummaryPrompt(request.context, systemPrompt, toolSchema);
136
159
  const captureSessionID = `auto-capture-${request.prompt?.id ?? request.sessionId}-${randomUUID()}`;
137
- const result = await provider.executeToolCall(systemPrompt, aiPrompt, toolSchema, captureSessionID);
160
+ // Replace whatever the failed host-model attempt recorded.
161
+ Object.assign(diagnostics, {
162
+ path: "external-api",
163
+ provider: CONFIG.memoryProvider,
164
+ model: CONFIG.memoryModel,
165
+ systemPrompt,
166
+ userPrompt: aiPrompt,
167
+ stopReason: undefined,
168
+ blockTypes: undefined,
169
+ rawReply: undefined,
170
+ failureReason: undefined,
171
+ });
172
+ let result;
173
+ try {
174
+ result = await provider.executeToolCall(systemPrompt, aiPrompt, toolSchema, captureSessionID);
175
+ }
176
+ catch (error) {
177
+ diagnostics.failureReason = "call-error";
178
+ throw error;
179
+ }
180
+ diagnostics.stopReason = normalizeStopReason(result.stopReason);
138
181
  if (!result.success || !result.data) {
182
+ diagnostics.failureReason = diagnostics.stopReason === "length" ? "truncated" : "call-error";
139
183
  throw new Error(result.error || "Failed to generate summary");
140
184
  }
141
185
  const rawReply = JSON.stringify(result.data);
186
+ diagnostics.rawReply = rawReply;
142
187
  const summary = parseCaptureSummary(rawReply);
143
188
  if (!summary) {
144
- log("OpenCode capture: model reply was not a valid capture summary", {
145
- provider: CONFIG.memoryProvider,
146
- modelId: CONFIG.memoryModel,
147
- // The reply can carry conversation content, so log only its size.
148
- replyLength: rawReply.length,
149
- });
189
+ // The capture pipeline logs the attempt's metadata; never the reply.
190
+ diagnostics.failureReason =
191
+ classifyCaptureReply({ text: rawReply, stopReason: result.stopReason }) ?? "schema-mismatch";
150
192
  throw new Error("omms: OpenCode extraction returned an invalid summary payload");
151
193
  }
152
194
  return summary;
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { CONFIG, initConfigWithLegacyMigration, isConfigured } from "../../config.js";
3
+ import { pruneTraces } from "../../services/capture-diagnostics.js";
3
4
  import { executeMemoryOperation } from "../../core/memory-operations.js";
4
5
  import { getLanguageName } from "../../services/language-detector.js";
5
6
  import { log } from "../../services/logger.js";
@@ -97,6 +98,8 @@ export default function ommsPiExtension(pi) {
97
98
  try {
98
99
  latestCtx = ctx;
99
100
  initConfigWithLegacyMigration(ctx.cwd);
101
+ // Runs even with tracing off, so turning it off does not leave old traces behind.
102
+ pruneTraces(CONFIG);
100
103
  captureState = createPiCaptureState();
101
104
  const globalScope = globalThis;
102
105
  if (!globalScope[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) {
@@ -1,8 +1,7 @@
1
1
  import { CONFIG } from "../../config.js";
2
2
  import { buildBoundedSummaryPrompt } from "../../core/capture-context.js";
3
- import { buildCaptureSystemPrompt, captureSummaryToolSchema, parseCaptureSummary, } from "../../core/extraction.js";
3
+ import { buildCaptureReplyInstruction, buildCaptureSystemPrompt, captureSummaryToolSchema, classifyCaptureReply, normalizeStopReason, parseCaptureSummary, } from "../../core/extraction.js";
4
4
  import { detectLanguage, getLanguageName } from "../../services/language-detector.js";
5
- import { log } from "../../services/logger.js";
6
5
  /**
7
6
  * Active-model-first resolution: the explicit `piProvider`/`piModel`
8
7
  * configuration when both are set, otherwise the active `ctx.model`.
@@ -80,23 +79,51 @@ export function createPiCaptureProvider(resolveModel) {
80
79
  ? detectLanguage(request.userPrompt)
81
80
  : CONFIG.autoCaptureLanguage;
82
81
  const systemPrompt = buildCaptureSystemPrompt(getLanguageName(targetLang));
83
- const userPrompt = buildBoundedSummaryPrompt(request.context, systemPrompt, captureSummaryToolSchema);
84
- const reply = await model.complete({
85
- systemPrompt,
86
- messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
87
- });
82
+ const replyInstruction = buildCaptureReplyInstruction();
83
+ // The instruction is counted as schema bytes so the context budget leaves room for it.
84
+ const userPrompt = `${buildBoundedSummaryPrompt(request.context, systemPrompt, {
85
+ schema: captureSummaryToolSchema,
86
+ replyInstruction,
87
+ })}\n\n${replyInstruction}`;
88
+ const diagnostics = request.diagnostics;
89
+ if (diagnostics) {
90
+ diagnostics.path = "host-model";
91
+ diagnostics.provider = model.provider;
92
+ diagnostics.model = model.modelId;
93
+ diagnostics.systemPrompt = systemPrompt;
94
+ diagnostics.userPrompt = userPrompt;
95
+ }
96
+ let reply;
97
+ try {
98
+ reply = await model.complete({
99
+ systemPrompt,
100
+ messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
101
+ });
102
+ }
103
+ catch (error) {
104
+ if (diagnostics)
105
+ diagnostics.failureReason = "call-error";
106
+ throw error;
107
+ }
108
+ const rawReply = replyText(reply);
109
+ if (diagnostics) {
110
+ diagnostics.stopReason = normalizeStopReason(reply.stopReason);
111
+ diagnostics.blockTypes = reply.content.map((block) => block.type);
112
+ diagnostics.rawReply = rawReply;
113
+ }
88
114
  if (reply.stopReason === "error") {
115
+ if (diagnostics)
116
+ diagnostics.failureReason = "call-error";
89
117
  throw new Error(`omms: Pi extraction call failed: ${reply.errorMessage || "unknown error"}`);
90
118
  }
91
- const rawReply = replyText(reply);
92
119
  const summary = parseCaptureSummary(rawReply);
93
120
  if (!summary) {
94
- log("Pi capture: model reply was not a valid capture summary", {
95
- provider: model.provider,
96
- modelId: model.modelId,
97
- // The reply can carry conversation content, so log only its size.
98
- replyLength: rawReply.length,
99
- });
121
+ // The capture pipeline logs the attempt's metadata; never the reply.
122
+ if (diagnostics) {
123
+ diagnostics.failureReason =
124
+ classifyCaptureReply({ text: rawReply, stopReason: reply.stopReason }) ??
125
+ "schema-mismatch";
126
+ }
100
127
  throw new Error("omms: Pi extraction returned an invalid summary payload");
101
128
  }
102
129
  return summary;
package/dist/config.d.ts CHANGED
@@ -38,6 +38,9 @@ interface OmmsConfig {
38
38
  piProvider?: string;
39
39
  piModel?: string;
40
40
  aiSessionRetentionDays?: number;
41
+ /** Write full capture prompts and replies to ~/.omms/traces. Global config only. */
42
+ captureTrace?: boolean;
43
+ captureTraceRetentionDays?: number;
41
44
  webServerEnabled?: boolean;
42
45
  webServerPort?: number;
43
46
  webServerHost?: string;
@@ -119,6 +122,8 @@ declare function buildConfig(fileConfig: OmmsConfig): {
119
122
  piModel: string | undefined;
120
123
  autoCaptureProviderStatus: import("./config.js").AutoCaptureProviderStatus;
121
124
  aiSessionRetentionDays: number;
125
+ captureTrace: boolean;
126
+ captureTraceRetentionDays: number;
122
127
  webServerEnabled: boolean;
123
128
  webServerPort: number;
124
129
  webServerHost: string;
@@ -201,6 +206,8 @@ export declare let CONFIG: {
201
206
  piModel: string | undefined;
202
207
  autoCaptureProviderStatus: import("./config.js").AutoCaptureProviderStatus;
203
208
  aiSessionRetentionDays: number;
209
+ captureTrace: boolean;
210
+ captureTraceRetentionDays: number;
204
211
  webServerEnabled: boolean;
205
212
  webServerPort: number;
206
213
  webServerHost: string;
package/dist/config.js CHANGED
@@ -48,6 +48,8 @@ const DEFAULTS = {
48
48
  autoCaptureMaxRetries: 3,
49
49
  autoCaptureMaxContextBytes: 131072,
50
50
  aiSessionRetentionDays: 7,
51
+ captureTrace: false,
52
+ captureTraceRetentionDays: 7,
51
53
  webServerEnabled: true,
52
54
  webServerPort: 4747,
53
55
  webServerHost: "127.0.0.1",
@@ -352,6 +354,16 @@ const CONFIG_TEMPLATE = `{
352
354
  // Days to keep AI session history before cleanup
353
355
  "aiSessionRetentionDays": 7,
354
356
 
357
+ // Capture diagnostics: every capture attempt always writes one metadata line
358
+ // (model, stop reason, sizes, outcome) to ~/.omms/omms.log, with no
359
+ // conversation text. Set captureTrace to true to also write each attempt's
360
+ // prompt and raw reply to ~/.omms/traces/ for debugging. Traces can contain
361
+ // conversation content: <private> text and common API key formats are
362
+ // redacted, files are readable only by you, and they are deleted after
363
+ // captureTraceRetentionDays. Only this global file can turn tracing on.
364
+ // "captureTrace": false,
365
+ // "captureTraceRetentionDays": 7,
366
+
355
367
  // Temperature for AI API requests (set to false to omit parameter for models that don't support it)
356
368
  // Some reasoning models (like o1, o3, gpt-5) don't support temperature parameter
357
369
  // Set to false and add "memoryTemperature": false in config when using such models
@@ -580,6 +592,8 @@ function buildConfig(fileConfig) {
580
592
  memoryApiKey,
581
593
  }),
582
594
  aiSessionRetentionDays: fileConfig.aiSessionRetentionDays ?? DEFAULTS.aiSessionRetentionDays,
595
+ captureTrace: fileConfig.captureTrace === true,
596
+ captureTraceRetentionDays: Math.max(1, Math.floor(fileConfig.captureTraceRetentionDays ?? DEFAULTS.captureTraceRetentionDays)),
583
597
  webServerEnabled: fileConfig.webServerEnabled ?? DEFAULTS.webServerEnabled,
584
598
  webServerPort: fileConfig.webServerPort ?? DEFAULTS.webServerPort,
585
599
  webServerHost: fileConfig.webServerHost ?? DEFAULTS.webServerHost,
@@ -660,6 +674,13 @@ export function initConfig(directory) {
660
674
  const projectOverrides = { ...projectConfig };
661
675
  delete projectOverrides.autoCleanupEnabled;
662
676
  delete projectOverrides.autoCleanupRetentionDays;
677
+ // A checked-in project file must not start recording conversations; it may
678
+ // only turn tracing off for its project.
679
+ if (projectOverrides.captureTrace === true) {
680
+ log("Project config cannot turn on captureTrace; the value was ignored", { directory });
681
+ delete projectOverrides.captureTrace;
682
+ }
683
+ delete projectOverrides.captureTraceRetentionDays;
663
684
  const merged = { ...globalConfig, ...projectOverrides };
664
685
  lastFileConfig = merged;
665
686
  CONFIG = buildConfig(merged);
@@ -4,7 +4,7 @@ const DEFAULT_AUTO_CAPTURE_MAX_CONTEXT_BYTES = 131072;
4
4
  const CONTEXT_TRUNCATION_MARKER = "\n[... truncated to autoCaptureMaxContextBytes ...]\n";
5
5
  const SUMMARY_REQUEST_OVERHEAD_BYTES = 1024;
6
6
  const SUMMARY_OUTPUT_RESERVE_BYTES = 16384;
7
- const SUMMARY_ANALYSIS_SUFFIX = 'Analyze this conversation. If it contains technical work (code, bugs, features, decisions), create a concise summary and relevant tags. If it\'s non-technical (greetings, casual chat, incomplete requests), return type="skip" with empty summary.';
7
+ const SUMMARY_ANALYSIS_SUFFIX = 'Analyze this conversation. If it contains technical work (code, bugs, features, decisions), create a concise summary and relevant tags. If it\'s non-technical (greetings, casual chat, incomplete requests), set "type" to "skip" with an empty "summary".';
8
8
  function fitTextResponses(textResponses, maxBytes) {
9
9
  if (textResponses.length === 0 || maxBytes <= 0)
10
10
  return "";
@@ -12,4 +12,8 @@ export type CaptureResult = {
12
12
  status: "skipped";
13
13
  type?: string;
14
14
  };
15
+ /**
16
+ * Run one capture attempt and write exactly one diagnostics record for it,
17
+ * whether it is saved, skipped, or fails.
18
+ */
15
19
  export declare function captureConversation(workUnit: CaptureWorkUnit, provider: CaptureSummaryProvider): Promise<CaptureResult>;
@@ -1,3 +1,5 @@
1
+ import { CONFIG } from "../config.js";
2
+ import { buildCaptureAttemptRecord, emitCaptureAttempt } from "../services/capture-diagnostics.js";
1
3
  import { memoryClient } from "../services/client.js";
2
4
  import { getTags } from "../services/tags.js";
3
5
  import { buildMarkdownContext, getAutoCaptureMarkdownBudget } from "./capture-context.js";
@@ -15,7 +17,29 @@ async function getLatestProjectMemory(containerTag) {
15
17
  return null;
16
18
  }
17
19
  }
20
+ /**
21
+ * Run one capture attempt and write exactly one diagnostics record for it,
22
+ * whether it is saved, skipped, or fails.
23
+ */
18
24
  export async function captureConversation(workUnit, provider) {
25
+ const diagnostics = {};
26
+ const startedAt = Date.now();
27
+ let outcome = "failed";
28
+ try {
29
+ const result = await runCapture(workUnit, provider, diagnostics);
30
+ outcome = result.status === "captured" ? "saved" : "skipped";
31
+ return result;
32
+ }
33
+ finally {
34
+ const record = buildCaptureAttemptRecord({
35
+ host: workUnit.host,
36
+ sourceType: workUnit.sourceType,
37
+ sessionId: workUnit.hostSessionId,
38
+ }, diagnostics, outcome, Date.now() - startedAt);
39
+ emitCaptureAttempt(record, diagnostics, CONFIG);
40
+ }
41
+ }
42
+ async function runCapture(workUnit, provider, diagnostics) {
19
43
  const tags = getTags(workUnit.projectDirectory);
20
44
  const latestMemory = await getLatestProjectMemory(tags.project.tag);
21
45
  const context = buildMarkdownContext(workUnit.userPrompt, workUnit.textResponses, workUnit.toolCalls, latestMemory, getAutoCaptureMarkdownBudget());
@@ -27,9 +51,11 @@ export async function captureConversation(workUnit, provider) {
27
51
  projectDirectory: workUnit.projectDirectory,
28
52
  userPrompt: workUnit.userPrompt,
29
53
  prompt: workUnit.prompt,
54
+ diagnostics,
30
55
  });
31
56
  }
32
57
  catch (error) {
58
+ diagnostics.failureReason ??= "call-error";
33
59
  const message = error instanceof Error ? error.message : String(error);
34
60
  throw new Error(`Summary generation failed: ${message}`, { cause: error });
35
61
  }
@@ -40,6 +66,8 @@ export async function captureConversation(workUnit, provider) {
40
66
  ? `${summaryResult.summary}\n\nTags: ${summaryResult.tags.join(", ")}`
41
67
  : summaryResult.summary;
42
68
  const source = workUnit.sourceType === "history-import" ? "import" : "auto-capture";
69
+ // Anything that goes wrong from here on is a storage failure, thrown or reported.
70
+ diagnostics.failureReason = "persist-error";
43
71
  const result = await memoryClient.addMemory(summaryWithTags, tags.project.tag, {
44
72
  source,
45
73
  type: summaryResult.type,
@@ -64,5 +92,6 @@ export async function captureConversation(workUnit, provider) {
64
92
  if (!result.success) {
65
93
  throw new Error(`Memory persistence failed: ${result.error || "database write failed"}`);
66
94
  }
95
+ diagnostics.failureReason = undefined;
67
96
  return { status: "captured", memoryId: result.id };
68
97
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { CaptureSummary } from "./host.js";
2
+ import type { CaptureFailureReason, CaptureSummary } from "./host.js";
3
3
  /**
4
4
  * Shared structured-extraction contract for automatic capture.
5
5
  *
@@ -38,6 +38,13 @@ export declare const captureSummaryToolSchema: {
38
38
  };
39
39
  required: string[];
40
40
  };
41
+ /**
42
+ * Reply-format instruction for paths that get plain text back (the Pi model
43
+ * bridge). Structured-output and tool-call paths enforce the shape themselves,
44
+ * so without this the model only sees the Markdown layout of the summary field
45
+ * and `type="skip"`, and tends to answer in that form instead of JSON.
46
+ */
47
+ export declare function buildCaptureReplyInstruction(): string;
41
48
  export declare function buildCaptureSystemPrompt(languageName: string): string;
42
49
  /** Upper bound for LLM-inferred preference confidence (0–1 scale). */
43
50
  export declare const USER_PROFILE_LLM_CONFIDENCE_MAX = 1;
@@ -78,3 +85,18 @@ export declare function extractJsonObject(raw: string): unknown;
78
85
  * blocks and surrounding prose. Returns null when no valid payload is present.
79
86
  */
80
87
  export declare function parseCaptureSummary(raw: string): CaptureSummary | null;
88
+ /**
89
+ * Map provider-specific stop reasons onto one vocabulary so a length limit
90
+ * reads as `length` on every extraction path. Other values pass through in
91
+ * lower case.
92
+ */
93
+ export declare function normalizeStopReason(stopReason: string | null | undefined): string | undefined;
94
+ /**
95
+ * Explain why a reply is not a usable capture summary. Returns null when the
96
+ * reply parses (including a skip). Codes are checked in the spec's order, so a
97
+ * cut-off reply reads as `truncated` rather than `invalid-json`.
98
+ */
99
+ export declare function classifyCaptureReply(reply: {
100
+ text: string;
101
+ stopReason?: string | null;
102
+ }): CaptureFailureReason | null;
@@ -40,6 +40,18 @@ export const captureSummaryToolSchema = {
40
40
  },
41
41
  required: ["summary", "type", "tags"],
42
42
  };
43
+ /**
44
+ * Reply-format instruction for paths that get plain text back (the Pi model
45
+ * bridge). Structured-output and tool-call paths enforce the shape themselves,
46
+ * so without this the model only sees the Markdown layout of the summary field
47
+ * and `type="skip"`, and tends to answer in that form instead of JSON.
48
+ */
49
+ export function buildCaptureReplyInstruction() {
50
+ return `Reply with only one JSON object and nothing else: no prose, no code fence. It must match this JSON schema:
51
+ ${JSON.stringify(captureSummaryToolSchema)}
52
+ Put the Markdown summary (## Request / ## Outcome) inside the "summary" string.
53
+ For a non-technical conversation reply exactly: {"type":"skip","summary":"","tags":[]}`;
54
+ }
43
55
  export function buildCaptureSystemPrompt(languageName) {
44
56
  return `You are a technical memory recorder for a software development project.
45
57
 
@@ -143,3 +155,32 @@ export function parseCaptureSummary(raw) {
143
155
  tags: parsed.data.tags.map((tag) => tag.toLowerCase().trim()).filter(Boolean),
144
156
  };
145
157
  }
158
+ const LENGTH_STOP_REASONS = new Set(["length", "max_tokens", "max_output_tokens"]);
159
+ /**
160
+ * Map provider-specific stop reasons onto one vocabulary so a length limit
161
+ * reads as `length` on every extraction path. Other values pass through in
162
+ * lower case.
163
+ */
164
+ export function normalizeStopReason(stopReason) {
165
+ if (!stopReason)
166
+ return undefined;
167
+ const lower = stopReason.toLowerCase();
168
+ return LENGTH_STOP_REASONS.has(lower) ? "length" : lower;
169
+ }
170
+ /**
171
+ * Explain why a reply is not a usable capture summary. Returns null when the
172
+ * reply parses (including a skip). Codes are checked in the spec's order, so a
173
+ * cut-off reply reads as `truncated` rather than `invalid-json`.
174
+ */
175
+ export function classifyCaptureReply(reply) {
176
+ if (parseCaptureSummary(reply.text))
177
+ return null;
178
+ if (reply.text.trim().length === 0)
179
+ return "empty-text";
180
+ if (normalizeStopReason(reply.stopReason) === "length")
181
+ return "truncated";
182
+ const json = extractJsonObject(reply.text);
183
+ if (json === null || typeof json !== "object" || Array.isArray(json))
184
+ return "invalid-json";
185
+ return "schema-mismatch";
186
+ }
@@ -19,12 +19,35 @@ export interface CaptureSummary {
19
19
  type: string;
20
20
  tags: string[];
21
21
  }
22
+ export type CaptureAttemptOutcome = "saved" | "skipped" | "failed";
23
+ /** Fixed failure codes, in the order classification checks them. */
24
+ export declare const CAPTURE_FAILURE_REASONS: readonly ["call-error", "empty-text", "truncated", "invalid-json", "schema-mismatch", "persist-error"];
25
+ export type CaptureFailureReason = (typeof CAPTURE_FAILURE_REASONS)[number];
26
+ export type CaptureExtractionPath = "host-model" | "external-api";
27
+ /**
28
+ * Filled in by the extraction path during one capture attempt. The capture
29
+ * pipeline owns the object and emits it once the outcome is known. Fields a
30
+ * path cannot observe stay undefined. The prompt and reply fields are only
31
+ * ever written to the opt-in trace file, never to the log.
32
+ */
33
+ export interface CaptureAttemptDiagnostics {
34
+ path?: CaptureExtractionPath;
35
+ provider?: string;
36
+ model?: string;
37
+ stopReason?: string;
38
+ blockTypes?: string[];
39
+ systemPrompt?: string;
40
+ userPrompt?: string;
41
+ rawReply?: string;
42
+ failureReason?: CaptureFailureReason;
43
+ }
22
44
  export interface CaptureSummaryRequest {
23
45
  context: string;
24
46
  sessionId: string;
25
47
  projectDirectory: string;
26
48
  userPrompt: string;
27
49
  prompt?: CapturePromptContext;
50
+ diagnostics?: CaptureAttemptDiagnostics;
28
51
  }
29
52
  export interface CaptureSummaryProvider {
30
53
  summarize(request: CaptureSummaryRequest): Promise<CaptureSummary | null>;
package/dist/core/host.js CHANGED
@@ -1 +1,9 @@
1
- export {};
1
+ /** Fixed failure codes, in the order classification checks them. */
2
+ export const CAPTURE_FAILURE_REASONS = [
3
+ "call-error",
4
+ "empty-text",
5
+ "truncated",
6
+ "invalid-json",
7
+ "schema-mismatch",
8
+ "persist-error",
9
+ ];
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { formatMemoriesForCompaction } from "./core/retrieval.js";
11
11
  import { performUserProfileLearning } from "./services/user-memory-learning.js";
12
12
  import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
13
13
  import { startWebServer, WebServer } from "./services/web-server.js";
14
+ import { pruneTraces } from "./services/capture-diagnostics.js";
14
15
  import { ensureTursoReady } from "./services/turso/ready.js";
15
16
  import { tursoConnectionManager } from "./services/turso/connection-manager.js";
16
17
  import { WebAuth } from "./services/web-auth.js";
@@ -180,6 +181,8 @@ function logAutoCaptureProviderStatus() {
180
181
  export const OmmsPlugin = async (ctx) => {
181
182
  const { directory } = ctx;
182
183
  initConfigWithLegacyMigration(directory);
184
+ // Runs even with tracing off, so turning it off does not leave old traces behind.
185
+ pruneTraces(CONFIG);
183
186
  logAutoCaptureProviderStatus();
184
187
  const tags = getTags(directory);
185
188
  const autoCaptureHost = createOpenCodeAutoCaptureHost(ctx);
@@ -46,6 +46,8 @@ export declare const STRUCTURED_OUTPUT_METADATA: {
46
46
  };
47
47
  export declare function setHostFetch(customFetch: typeof fetch): void;
48
48
  export declare function resetHostFetch(): void;
49
+ /** Test helper: forget the server URL, as in the native V2 plugin. */
50
+ export declare function resetV2BaseUrlForTests(): void;
49
51
  export declare function setConnectedProviders(providers: string[]): void;
50
52
  export declare function isProviderConnected(providerName: string): boolean;
51
53
  export declare function setV2Client(client: OpencodeClient): void;
@@ -64,6 +66,13 @@ export interface StructuredOutputOptions<T> {
64
66
  schema: z.ZodType<T>;
65
67
  directory?: string;
66
68
  retryCount?: number;
69
+ /** Called with the raw reply before it is parsed, for capture diagnostics. */
70
+ onReply?: (reply: StructuredOutputReply) => void;
71
+ }
72
+ export interface StructuredOutputReply {
73
+ finish?: string;
74
+ partTypes: string[];
75
+ structuredOutput: unknown;
67
76
  }
68
77
  /**
69
78
  * Resolve `opencodeModel: "inherit"` to a concrete provider/model.
@@ -60,6 +60,11 @@ export function setHostFetch(customFetch) {
60
60
  export function resetHostFetch() {
61
61
  _hostFetch = undefined;
62
62
  }
63
+ /** Test helper: forget the server URL, as in the native V2 plugin. */
64
+ export function resetV2BaseUrlForTests() {
65
+ _v2BaseUrl = undefined;
66
+ _useSdkTransport = false;
67
+ }
63
68
  export function setConnectedProviders(providers) {
64
69
  _connectedProviders = new Set(providers);
65
70
  }
@@ -117,6 +122,22 @@ function sessionPromptFields(args) {
117
122
  },
118
123
  };
119
124
  }
125
+ function reportReply(onReply, data) {
126
+ if (!onReply)
127
+ return;
128
+ try {
129
+ onReply({
130
+ ...(typeof data.info.finish === "string" ? { finish: data.info.finish } : {}),
131
+ partTypes: (Array.isArray(data.parts) ? data.parts : [])
132
+ .map((part) => typeof part === "object" && part !== null ? part.type : undefined)
133
+ .filter((type) => typeof type === "string"),
134
+ structuredOutput: data.info.structured_output ?? data.info.structured,
135
+ });
136
+ }
137
+ catch {
138
+ // Diagnostics must never change the call's result.
139
+ }
140
+ }
120
141
  /**
121
142
  * Resolve `opencodeModel: "inherit"` to a concrete provider/model.
122
143
  *
@@ -167,10 +188,12 @@ export async function generateStructuredOutput(opts) {
167
188
  providerID: opts.providerID,
168
189
  modelID: opts.modelID,
169
190
  });
170
- const { client, systemPrompt, userPrompt, schema, directory, retryCount } = opts;
191
+ const { client, systemPrompt, userPrompt, schema, directory, retryCount, onReply } = opts;
171
192
  const { providerID, modelID } = resolved;
172
193
  const jsonSchema = schema.toJSONSchema?.() ?? (await import("zod")).z.toJSONSchema(schema);
173
- if (_useSdkTransport && hasV2SessionClient(client)) {
194
+ // The native V2 plugin has no server URL: it hands over a client whose
195
+ // session API wraps the plugin context, so that client is the only transport.
196
+ if ((_useSdkTransport || !_v2BaseUrl) && hasV2SessionClient(client)) {
174
197
  return generateViaSdkClient(client, {
175
198
  providerID,
176
199
  modelID,
@@ -180,6 +203,7 @@ export async function generateStructuredOutput(opts) {
180
203
  retryCount,
181
204
  jsonSchema,
182
205
  schema,
206
+ onReply,
183
207
  });
184
208
  }
185
209
  const baseUrl = _v2BaseUrl;
@@ -190,7 +214,7 @@ export async function generateStructuredOutput(opts) {
190
214
  const sessionID = await createSession(base, directory);
191
215
  markInternalSession(sessionID);
192
216
  try {
193
- const info = await withStructuredOutputTimeout(() => promptSession(base, {
217
+ const reply = await withStructuredOutputTimeout(() => promptSession(base, {
194
218
  sessionID,
195
219
  directory,
196
220
  providerID,
@@ -200,6 +224,8 @@ export async function generateStructuredOutput(opts) {
200
224
  jsonSchema,
201
225
  retryCount,
202
226
  }), () => abortSession(base, sessionID, directory));
227
+ reportReply(onReply, reply);
228
+ const info = reply.info;
203
229
  if (info.error) {
204
230
  throw new Error(`omms: opencode reported ${info.error.name}: ${formatAssistantError(info.error)}`);
205
231
  }
@@ -258,6 +284,7 @@ async function generateViaSdkClient(client, args) {
258
284
  if (!data.info) {
259
285
  throw new Error("omms: prompt response missing `info`");
260
286
  }
287
+ reportReply(args.onReply, data);
261
288
  if (data.info.error) {
262
289
  throw new Error(`omms: opencode reported ${data.info.error.name}: ${formatAssistantError(data.info.error)}`);
263
290
  }
@@ -387,7 +414,7 @@ async function promptSession(base, args) {
387
414
  if (!data.info) {
388
415
  throw new Error("omms: prompt response missing `info`");
389
416
  }
390
- return data.info;
417
+ return data;
391
418
  }
392
419
  async function abortSession(base, sessionID, directory) {
393
420
  const url = `${base}/session/${encodeURIComponent(sessionID)}/abort${buildQuery(directory)}`;