om-memory-system 3.1.1 → 3.2.0-next.11
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 +4 -1
- package/dist/adapters/opencode/auto-capture-summary.js +50 -8
- package/dist/adapters/pi/extension.js +3 -0
- package/dist/adapters/pi/provider.js +41 -14
- package/dist/config.d.ts +7 -0
- package/dist/config.js +21 -0
- package/dist/core/capture-context.js +1 -1
- package/dist/core/capture.d.ts +4 -0
- package/dist/core/capture.js +29 -0
- package/dist/core/extraction.d.ts +23 -1
- package/dist/core/extraction.js +41 -0
- package/dist/core/host.d.ts +23 -0
- package/dist/core/host.js +9 -1
- package/dist/index.js +3 -0
- package/dist/services/ai/opencode-provider.d.ts +9 -0
- package/dist/services/ai/opencode-provider.js +31 -4
- package/dist/services/ai/providers/anthropic-messages.js +14 -6
- package/dist/services/ai/providers/base-provider.d.ts +8 -0
- package/dist/services/ai/providers/base-provider.js +12 -0
- package/dist/services/ai/providers/google-gemini.js +21 -7
- package/dist/services/ai/providers/openai-chat-completion.js +20 -7
- package/dist/services/ai/providers/openai-responses.js +11 -0
- package/dist/services/capture-diagnostics.d.ts +66 -0
- package/dist/services/capture-diagnostics.js +172 -0
- package/dist/services/log-path.d.ts +2 -0
- package/dist/services/log-path.js +15 -0
- package/dist/services/logger.js +1 -12
- package/dist/v2/legacy-client.js +10 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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),
|
|
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 "";
|
package/dist/core/capture.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/capture.js
CHANGED
|
@@ -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;
|
package/dist/core/extraction.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/host.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|
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
|
|
417
|
+
return data;
|
|
391
418
|
}
|
|
392
419
|
async function abortSession(base, sessionID, directory) {
|
|
393
420
|
const url = `${base}/session/${encodeURIComponent(sessionID)}/abort${buildQuery(directory)}`;
|