om-memory-system 3.1.0-next.4 → 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 +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/importer/opencode-reader.js +28 -9
- 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
|
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import { INTERNAL_CAPTURE_SESSION_TITLES } from "../services/ai/internal-capture-sessions.js";
|
|
7
|
+
import { log } from "../services/logger.js";
|
|
8
|
+
import { withSqliteFileLockRetry } from "../services/turso/sqlite-handle-release.js";
|
|
7
9
|
function validateSchema(db) {
|
|
8
10
|
const required = {
|
|
9
11
|
session: ["id", "directory", "project_id", "parent_id", "time_created"],
|
|
@@ -117,9 +119,25 @@ function fileStamp(path) {
|
|
|
117
119
|
* way to see uncheckpointed turns while leaving every source file untouched.
|
|
118
120
|
* A copy that raced a write or checkpoint is discarded and retried.
|
|
119
121
|
*/
|
|
122
|
+
/**
|
|
123
|
+
* Remove a snapshot folder without ever failing the import. Windows can hold
|
|
124
|
+
* SQLite files briefly after close, so retry lock errors; if the lock
|
|
125
|
+
* outlasts the retries, log the leftover path instead of throwing.
|
|
126
|
+
*/
|
|
127
|
+
async function removeSnapshotDir(dir) {
|
|
128
|
+
try {
|
|
129
|
+
await withSqliteFileLockRetry(() => rmSync(dir, { recursive: true, force: true }));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
log("OpenCode import: could not remove the temporary database copy", {
|
|
133
|
+
dir,
|
|
134
|
+
error: String(error),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
120
138
|
function snapshotWalDatabase(dbPath) {
|
|
121
139
|
const dir = mkdtempSync(join(tmpdir(), "omms-opencode-"));
|
|
122
|
-
const cleanup = () =>
|
|
140
|
+
const cleanup = () => removeSnapshotDir(dir);
|
|
123
141
|
const copy = join(dir, "opencode.db");
|
|
124
142
|
try {
|
|
125
143
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
@@ -133,24 +151,24 @@ function snapshotWalDatabase(dbPath) {
|
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
catch (error) {
|
|
136
|
-
cleanup();
|
|
154
|
+
void cleanup();
|
|
137
155
|
throw error;
|
|
138
156
|
}
|
|
139
|
-
cleanup();
|
|
157
|
+
void cleanup();
|
|
140
158
|
throw new Error("OpenCode database kept changing while it was copied; retry the import");
|
|
141
159
|
}
|
|
142
160
|
function openSnapshot(dbPath) {
|
|
143
161
|
if (!existsSync(`${dbPath}-wal`)) {
|
|
144
162
|
// No WAL: every committed page is in the main file, so read it in place.
|
|
145
163
|
const uri = `${pathToFileURL(dbPath).href}?immutable=1`;
|
|
146
|
-
return { db: new DatabaseSync(uri, { readOnly: true }), cleanup: () => { } };
|
|
164
|
+
return { db: new DatabaseSync(uri, { readOnly: true }), cleanup: async () => { } };
|
|
147
165
|
}
|
|
148
166
|
const snapshot = snapshotWalDatabase(dbPath);
|
|
149
167
|
try {
|
|
150
168
|
return { db: new DatabaseSync(snapshot.path), cleanup: snapshot.cleanup };
|
|
151
169
|
}
|
|
152
170
|
catch (error) {
|
|
153
|
-
snapshot.cleanup();
|
|
171
|
+
void snapshot.cleanup();
|
|
154
172
|
throw error;
|
|
155
173
|
}
|
|
156
174
|
}
|
|
@@ -159,9 +177,9 @@ export function readOpencodeHistory(dbPath, filters = {}) {
|
|
|
159
177
|
if (!existsSync(dbPath))
|
|
160
178
|
throw new Error(`OpenCode database not found: ${dbPath}`);
|
|
161
179
|
const { db, cleanup } = openSnapshot(dbPath);
|
|
162
|
-
const close = () => {
|
|
180
|
+
const close = async () => {
|
|
163
181
|
db.close();
|
|
164
|
-
cleanup();
|
|
182
|
+
await cleanup();
|
|
165
183
|
};
|
|
166
184
|
try {
|
|
167
185
|
validateSchema(db);
|
|
@@ -201,14 +219,15 @@ export function readOpencodeHistory(dbPath, filters = {}) {
|
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
finally {
|
|
204
|
-
close();
|
|
222
|
+
await close();
|
|
205
223
|
}
|
|
206
224
|
},
|
|
207
225
|
};
|
|
208
226
|
return { childSessions, topLevelSessions, sessions };
|
|
209
227
|
}
|
|
210
228
|
catch (error) {
|
|
211
|
-
|
|
229
|
+
// Cleanup never throws, so the original error is what the caller sees.
|
|
230
|
+
void close();
|
|
212
231
|
throw error;
|
|
213
232
|
}
|
|
214
233
|
}
|
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.
|