opencode-memory-pro 1.3.0

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.
@@ -0,0 +1,260 @@
1
+ import { log } from "./logger.js";
2
+ let globalEmbedderHealth = {
3
+ status: "healthy",
4
+ lastError: null,
5
+ lastSuccess: null,
6
+ retryCount: 0,
7
+ fallbackActive: false,
8
+ };
9
+ export function getEmbedderHealth() {
10
+ return globalEmbedderHealth;
11
+ }
12
+ export function setEmbedderHealth(health) {
13
+ globalEmbedderHealth = { ...globalEmbedderHealth, ...health };
14
+ }
15
+ export function resetEmbedderHealth() {
16
+ globalEmbedderHealth = {
17
+ status: "healthy",
18
+ lastError: null,
19
+ lastSuccess: null,
20
+ retryCount: 0,
21
+ fallbackActive: false,
22
+ };
23
+ }
24
+ async function sleep(ms) {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+ async function embedWithRetry(embedder, config, text) {
28
+ const retry = config.retry ?? {
29
+ enabled: true,
30
+ maxAttempts: 3,
31
+ initialDelayMs: 1000,
32
+ backoffMultiplier: 2,
33
+ };
34
+ if (!retry.enabled) {
35
+ return embedder.embed(text);
36
+ }
37
+ let lastError = null;
38
+ let attempt = 0;
39
+ while (attempt < retry.maxAttempts) {
40
+ attempt++;
41
+ try {
42
+ const result = await embedder.embed(text);
43
+ globalEmbedderHealth.lastSuccess = Date.now();
44
+ globalEmbedderHealth.lastError = null;
45
+ if (globalEmbedderHealth.status === "degraded") {
46
+ globalEmbedderHealth.status = "healthy";
47
+ log("info", "Embedder recovered, resuming normal mode");
48
+ }
49
+ return result;
50
+ }
51
+ catch (error) {
52
+ lastError = error instanceof Error ? error : new Error(String(error));
53
+ globalEmbedderHealth.retryCount++;
54
+ globalEmbedderHealth.lastError = lastError.message;
55
+ if (attempt >= retry.maxAttempts) {
56
+ break;
57
+ }
58
+ const delay = Math.floor(retry.initialDelayMs * Math.pow(retry.backoffMultiplier, attempt - 1));
59
+ log("warn", `Embedder failed (attempt ${attempt}/${retry.maxAttempts}), retrying in ${delay}ms: ${lastError.message}`);
60
+ await sleep(delay);
61
+ }
62
+ }
63
+ globalEmbedderHealth.status = "degraded";
64
+ globalEmbedderHealth.fallbackActive = true;
65
+ log("warn", `Embedder unavailable after ${retry.maxAttempts} attempts, falling back to BM25-only search`);
66
+ throw lastError;
67
+ }
68
+ async function dimWithRetry(embedder, config) {
69
+ const retry = config.retry ?? {
70
+ enabled: true,
71
+ maxAttempts: 3,
72
+ initialDelayMs: 1000,
73
+ backoffMultiplier: 2,
74
+ };
75
+ if (!retry.enabled) {
76
+ return embedder.dim();
77
+ }
78
+ let lastError = null;
79
+ let attempt = 0;
80
+ while (attempt < retry.maxAttempts) {
81
+ attempt++;
82
+ try {
83
+ const result = await embedder.dim();
84
+ globalEmbedderHealth.lastSuccess = Date.now();
85
+ globalEmbedderHealth.lastError = null;
86
+ return result;
87
+ }
88
+ catch (error) {
89
+ lastError = error instanceof Error ? error : new Error(String(error));
90
+ globalEmbedderHealth.retryCount++;
91
+ globalEmbedderHealth.lastError = lastError.message;
92
+ if (attempt >= retry.maxAttempts) {
93
+ break;
94
+ }
95
+ const delay = Math.floor(retry.initialDelayMs * Math.pow(retry.backoffMultiplier, attempt - 1));
96
+ await sleep(delay);
97
+ }
98
+ }
99
+ globalEmbedderHealth.status = "degraded";
100
+ globalEmbedderHealth.fallbackActive = true;
101
+ throw lastError;
102
+ }
103
+ const KNOWN_MODEL_DIMS = {
104
+ "nomic-embed-text": 768,
105
+ "mxbai-embed-large": 1024,
106
+ "all-minilm": 384,
107
+ "snowflake-arctic-embed": 1024,
108
+ "text-embedding-3-small": 1536,
109
+ "text-embedding-3-large": 3072,
110
+ "text-embedding-ada-002": 1536,
111
+ };
112
+ function fallbackDim(model) {
113
+ const normalized = model.toLowerCase().replace(/:.*$/, "");
114
+ for (const [prefix, dim] of Object.entries(KNOWN_MODEL_DIMS)) {
115
+ if (normalized === prefix || normalized.startsWith(`${prefix}:`))
116
+ return dim;
117
+ }
118
+ return null;
119
+ }
120
+ export class OllamaEmbedder {
121
+ config;
122
+ model;
123
+ cachedDim = null;
124
+ constructor(config) {
125
+ this.config = config;
126
+ this.model = config.model;
127
+ }
128
+ async embed(text) {
129
+ const endpoint = `${this.config.baseUrl ?? "http://127.0.0.1:11434"}/api/embeddings`;
130
+ const controller = new AbortController();
131
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 6000);
132
+ try {
133
+ const response = await fetch(endpoint, {
134
+ method: "POST",
135
+ headers: {
136
+ "content-type": "application/json",
137
+ },
138
+ body: JSON.stringify({
139
+ model: this.config.model,
140
+ prompt: text,
141
+ }),
142
+ signal: controller.signal,
143
+ });
144
+ if (!response.ok) {
145
+ throw new Error(`Ollama embedding request failed: HTTP ${response.status}`);
146
+ }
147
+ const data = (await response.json());
148
+ if (!Array.isArray(data.embedding) || data.embedding.length === 0) {
149
+ throw new Error("Ollama embedding response missing embedding vector");
150
+ }
151
+ if (this.cachedDim === null) {
152
+ this.cachedDim = data.embedding.length;
153
+ }
154
+ return data.embedding;
155
+ }
156
+ finally {
157
+ clearTimeout(timeout);
158
+ }
159
+ }
160
+ async dim() {
161
+ if (this.cachedDim !== null)
162
+ return this.cachedDim;
163
+ try {
164
+ const probe = await this.embed("dimension probe");
165
+ this.cachedDim = probe.length;
166
+ return this.cachedDim;
167
+ }
168
+ catch {
169
+ const fb = fallbackDim(this.model);
170
+ if (fb !== null) {
171
+ log("warn", `Ollama unreachable, using fallback dim ${fb} for model "${this.model}"`);
172
+ return fb;
173
+ }
174
+ throw new Error(`Ollama unreachable and no known fallback dimension for model "${this.model}"`);
175
+ }
176
+ }
177
+ }
178
+ export class OpenAIEmbedder {
179
+ config;
180
+ model;
181
+ cachedDim = null;
182
+ constructor(config) {
183
+ this.config = config;
184
+ this.model = config.model;
185
+ }
186
+ async embed(text) {
187
+ if (!this.config.apiKey) {
188
+ throw new Error("OpenAI embedding request failed: missing apiKey. Set embedding.apiKey or OPENCODE_MEMORY_PRO_OPENAI_API_KEY.");
189
+ }
190
+ const baseUrl = (this.config.baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
191
+ const endpoint = `${baseUrl}/embeddings`;
192
+ const controller = new AbortController();
193
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 6000);
194
+ try {
195
+ const response = await fetch(endpoint, {
196
+ method: "POST",
197
+ headers: {
198
+ "content-type": "application/json",
199
+ authorization: `Bearer ${this.config.apiKey}`,
200
+ },
201
+ body: JSON.stringify({
202
+ model: this.config.model,
203
+ input: text,
204
+ encoding_format: "float",
205
+ }),
206
+ signal: controller.signal,
207
+ });
208
+ if (!response.ok) {
209
+ const details = await response.text().catch(() => "");
210
+ const suffix = details ? ` - ${details.slice(0, 240)}` : "";
211
+ throw new Error(`OpenAI embedding request failed: HTTP ${response.status}${suffix}`);
212
+ }
213
+ const data = (await response.json());
214
+ const vector = data.data?.[0]?.embedding;
215
+ if (!Array.isArray(vector) || vector.length === 0) {
216
+ throw new Error("OpenAI embedding response missing embedding vector");
217
+ }
218
+ if (this.cachedDim === null) {
219
+ this.cachedDim = vector.length;
220
+ }
221
+ return vector;
222
+ }
223
+ finally {
224
+ clearTimeout(timeout);
225
+ }
226
+ }
227
+ async dim() {
228
+ if (this.cachedDim !== null)
229
+ return this.cachedDim;
230
+ try {
231
+ const probe = await this.embed("dimension probe");
232
+ this.cachedDim = probe.length;
233
+ return this.cachedDim;
234
+ }
235
+ catch {
236
+ const fb = fallbackDim(this.model);
237
+ if (fb !== null) {
238
+ log("warn", `OpenAI embedding probe failed, using fallback dim ${fb} for model "${this.model}"`);
239
+ return fb;
240
+ }
241
+ throw new Error(`OpenAI embedding probe failed and no known fallback dimension for model "${this.model}"`);
242
+ }
243
+ }
244
+ }
245
+ export function createEmbedder(config) {
246
+ const inner = config.provider === "openai"
247
+ ? new OpenAIEmbedder(config)
248
+ : new OllamaEmbedder(config);
249
+ return {
250
+ get model() {
251
+ return inner.model;
252
+ },
253
+ async embed(text) {
254
+ return embedWithRetry(inner, config, text);
255
+ },
256
+ async dim() {
257
+ return dimWithRetry(inner, config);
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,4 @@
1
+ import type { CaptureCandidateResult } from "./types.js";
2
+ export declare function extractCaptureCandidate(text: string, minChars: number): CaptureCandidateResult;
3
+ export declare function detectGlobalWorthiness(content: string): number;
4
+ export declare function isGlobalCandidate(content: string, threshold: number): boolean;
@@ -0,0 +1,181 @@
1
+ const POSITIVE_SIGNALS = [
2
+ // original
3
+ "fixed",
4
+ "resolved",
5
+ "works now",
6
+ "successful",
7
+ "done",
8
+ "完成",
9
+ "已解決",
10
+ "修復",
11
+ "成功",
12
+ // expanded: completion / success synonyms
13
+ "successfully",
14
+ "completed",
15
+ "complete",
16
+ "confirmed",
17
+ "verified",
18
+ "validated",
19
+ "passed",
20
+ "working now",
21
+ "corrected",
22
+ "solved",
23
+ "solution",
24
+ "addressed",
25
+ "implemented",
26
+ "configured",
27
+ "installed",
28
+ "deployed",
29
+ "updated",
30
+ "migrated",
31
+ "upgraded",
32
+ "enabled",
33
+ "operational",
34
+ "up and running",
35
+ "no errors",
36
+ "no issues",
37
+ "finished",
38
+ "ready",
39
+ "created",
40
+ "wrote the file",
41
+ "here's how",
42
+ "the reason",
43
+ "the cause",
44
+ "the answer is",
45
+ "in summary",
46
+ "to summarize",
47
+ "已完成",
48
+ "已修復",
49
+ "已驗證",
50
+ "已確認",
51
+ "已部署",
52
+ ];
53
+ const DECISION_SIGNALS = ["decide", "decision", "tradeoff", "architecture", "採用", "決定", "架構"];
54
+ const FACT_SIGNALS = ["because", "root cause", "原因", "由於"];
55
+ const PREF_SIGNALS = ["prefer", "preference", "偏好", "習慣"];
56
+ // Gate now considers every signal category, not just POSITIVE_SIGNALS,
57
+ // so decisions/facts/preferences can also trigger auto-capture.
58
+ const ALL_CAPTURE_SIGNALS = [
59
+ ...POSITIVE_SIGNALS,
60
+ ...DECISION_SIGNALS,
61
+ ...FACT_SIGNALS,
62
+ ...PREF_SIGNALS,
63
+ ];
64
+ // Exported so graph.js can reuse the infra lexicon for entity extraction.
65
+ export const GLOBAL_KEYWORDS = [
66
+ // Distributions
67
+ "alpine",
68
+ "debian",
69
+ "ubuntu",
70
+ "centos",
71
+ "fedora",
72
+ "arch",
73
+ // Containers
74
+ "docker",
75
+ "dockerfile",
76
+ "docker-compose",
77
+ "containerd",
78
+ // Orchestration
79
+ "kubernetes",
80
+ "k8s",
81
+ "helm",
82
+ "kubectl",
83
+ // Shells/Systems
84
+ "bash",
85
+ "shell",
86
+ "linux",
87
+ "unix",
88
+ "posix",
89
+ "busybox",
90
+ // Web servers
91
+ "nginx",
92
+ "apache",
93
+ "caddy",
94
+ // Databases
95
+ "postgres",
96
+ "postgresql",
97
+ "mysql",
98
+ "redis",
99
+ "mongodb",
100
+ "sqlite",
101
+ // Cloud
102
+ "aws",
103
+ "gcp",
104
+ "azure",
105
+ "digitalocean",
106
+ // VCS
107
+ "git",
108
+ "github",
109
+ "gitlab",
110
+ "bitbucket",
111
+ // Protocols
112
+ "api",
113
+ "rest",
114
+ "graphql",
115
+ "grpc",
116
+ "http",
117
+ "https",
118
+ // Package managers
119
+ "npm",
120
+ "yarn",
121
+ "pnpm",
122
+ "pip",
123
+ "cargo",
124
+ "make",
125
+ "cmake",
126
+ // GRAPH_STORE_PHASE2: high-frequency app/runtime terms so typed-relation
127
+ // extraction can anchor on the nouns this user actually writes about.
128
+ "opencode",
129
+ "lancedb",
130
+ "systemd",
131
+ "journalctl",
132
+ "plugin",
133
+ "graph",
134
+ "memory",
135
+ ];
136
+ export function extractCaptureCandidate(text, minChars) {
137
+ const normalized = text.trim();
138
+ if (normalized.length < minChars) {
139
+ return { candidate: null, skipReason: "below-min-chars" };
140
+ }
141
+ const lower = normalized.toLowerCase();
142
+ if (!ALL_CAPTURE_SIGNALS.some((signal) => lower.includes(signal.toLowerCase()))) {
143
+ return { candidate: null, skipReason: "no-positive-signal" };
144
+ }
145
+ const category = classifyCategory(lower);
146
+ const importance = category === "decision" ? 0.9 : category === "fact" ? 0.75 : 0.65;
147
+ return {
148
+ candidate: {
149
+ text: clipText(normalized, 1200),
150
+ category,
151
+ importance,
152
+ },
153
+ };
154
+ }
155
+ function classifyCategory(text) {
156
+ if (DECISION_SIGNALS.some((signal) => text.includes(signal.toLowerCase())))
157
+ return "decision";
158
+ if (FACT_SIGNALS.some((signal) => text.includes(signal.toLowerCase())))
159
+ return "fact";
160
+ if (PREF_SIGNALS.some((signal) => text.includes(signal.toLowerCase())))
161
+ return "preference";
162
+ return "other";
163
+ }
164
+ function clipText(text, maxLen) {
165
+ if (text.length <= maxLen)
166
+ return text;
167
+ return `${text.slice(0, maxLen - 3)}...`;
168
+ }
169
+ export function detectGlobalWorthiness(content) {
170
+ const lower = content.toLowerCase();
171
+ let matches = 0;
172
+ for (const keyword of GLOBAL_KEYWORDS) {
173
+ if (lower.includes(keyword)) {
174
+ matches += 1;
175
+ }
176
+ }
177
+ return matches;
178
+ }
179
+ export function isGlobalCandidate(content, threshold) {
180
+ return detectGlobalWorthiness(content) >= threshold;
181
+ }