@codemem/opencode-plugin 0.40.1 → 0.41.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.
- package/.opencode/lib/compat.js +31 -11
- package/.opencode/plugins/codemem.js +734 -111
- package/README.md +7 -0
- package/index.js +5 -2
- package/package.json +2 -2
package/.opencode/lib/compat.js
CHANGED
|
@@ -106,9 +106,17 @@ const isPinnedGitSource = (runnerFrom) => {
|
|
|
106
106
|
}
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
export const resolveAutoUpdatePlan = ({ runner, runnerFrom, runnerFromExplicit = false }) => {
|
|
110
|
+
const normalizedRunner = String(runner || "").trim();
|
|
111
|
+
const source = String(runnerFrom || "").trim();
|
|
112
|
+
if (isPinnedGitSource(source) || /^codemem@(?!latest$|next$|\*$)[^\s]+$/i.test(source)) {
|
|
113
|
+
return {
|
|
114
|
+
allowed: false,
|
|
115
|
+
reason: "pinned-source",
|
|
116
|
+
command: null,
|
|
117
|
+
commandText: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
112
120
|
|
|
113
121
|
if (normalizedRunner === "node") {
|
|
114
122
|
return {
|
|
@@ -119,8 +127,16 @@ export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
|
|
|
119
127
|
};
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
if (normalizedRunner === "npx") {
|
|
131
|
+
if (!runnerFromExplicit || !/^(?:codemem|codemem@(?:latest|next|\*))$/i.test(source)) {
|
|
132
|
+
return {
|
|
133
|
+
allowed: false,
|
|
134
|
+
reason: !runnerFromExplicit ? "implicit-pinned-source" : "custom-source",
|
|
135
|
+
command: null,
|
|
136
|
+
commandText: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
124
140
|
allowed: true,
|
|
125
141
|
reason: null,
|
|
126
142
|
command: ["npm", "install", "-g", "codemem@latest"],
|
|
@@ -146,10 +162,14 @@ export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
|
|
|
146
162
|
};
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
if (normalizedRunner === "codemem") {
|
|
166
|
+
return {
|
|
167
|
+
allowed: true,
|
|
168
|
+
reason: null,
|
|
169
|
+
command: ["npm", "install", "-g", "codemem@latest"],
|
|
170
|
+
commandText: "npm install -g codemem@latest",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { allowed: false, reason: "unknown-runner", command: null, commandText: null };
|
|
155
175
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, posix, resolve, win32 } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { spawn as nodeSpawn, execSync } from "node:child_process";
|
|
6
7
|
import { tool } from "@opencode-ai/plugin";
|
|
@@ -15,16 +16,48 @@ import {
|
|
|
15
16
|
|
|
16
17
|
const TRUTHY_VALUES = ["1", "true", "yes"];
|
|
17
18
|
const DISABLED_VALUES = ["0", "false", "off"];
|
|
18
|
-
const PINNED_BACKEND_VERSION = "0.
|
|
19
|
+
const PINNED_BACKEND_VERSION = "0.41.0";
|
|
19
20
|
const COMPAT_CHECK_DELAY_MS = 1500;
|
|
20
21
|
const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
22
|
+
const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
|
|
23
|
+
const MAX_UPDATE_ACTION_CHARS = 1000;
|
|
24
|
+
const MAX_UPDATE_VERSION_CHARS = 128;
|
|
25
|
+
const STABLE_RELEASE_VERSION =
|
|
26
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
21
27
|
const CODEMEM_CONTEXT_PART_ID_PREFIX = "codemem-context-";
|
|
22
28
|
const MAX_MESSAGE_INJECTION_CACHE_SESSIONS = 20;
|
|
23
29
|
const MAX_MESSAGE_INJECTION_CACHE_MESSAGES = 100;
|
|
24
30
|
const COMPACTION_INJECTION_SKIP_TTL_MS = 30 * 1000;
|
|
25
31
|
const MAX_WORKING_SET_PATH_CHARS = 400;
|
|
32
|
+
const VIEWER_HEALTH_CHECK_INTERVAL_MS = 60_000;
|
|
33
|
+
const VIEWER_HEALTH_TIMEOUT_MS = 5_000;
|
|
34
|
+
const VIEWER_HEALTH_RESTART_THRESHOLD = 3;
|
|
35
|
+
const VIEWER_HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
|
|
36
|
+
const RAW_EVENTS_STATUS_TIMEOUT_MS = 5_000;
|
|
26
37
|
|
|
27
38
|
let compatCheckCache = null;
|
|
39
|
+
const notifiedReleaseVersions = new Set();
|
|
40
|
+
|
|
41
|
+
// Release an unread response body without surfacing cancellation failures.
|
|
42
|
+
const discardResponseBody = (response) => {
|
|
43
|
+
try {
|
|
44
|
+
const cancelled = response?.body?.cancel?.();
|
|
45
|
+
if (cancelled && typeof cancelled.catch === "function") {
|
|
46
|
+
cancelled.catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Best effort — a locked or already-errored stream is fine to abandon.
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Bounded ingest-availability preflight: a hung viewer socket must not stall
|
|
54
|
+
// raw-event delivery indefinitely. Failures fall into the existing stream
|
|
55
|
+
// backoff + CLI enqueue fallback path.
|
|
56
|
+
const fetchRawEventsStatus = (url, fetchFn = fetch) =>
|
|
57
|
+
fetchFn(url, {
|
|
58
|
+
method: "GET",
|
|
59
|
+
signal: AbortSignal.timeout(RAW_EVENTS_STATUS_TIMEOUT_MS),
|
|
60
|
+
});
|
|
28
61
|
|
|
29
62
|
const normalizeEnvValue = (value) => (value || "").toLowerCase();
|
|
30
63
|
const envHasValue = (value, truthyValues) =>
|
|
@@ -32,6 +65,149 @@ const envHasValue = (value, truthyValues) =>
|
|
|
32
65
|
const envNotDisabled = (value) =>
|
|
33
66
|
!DISABLED_VALUES.includes(normalizeEnvValue(value));
|
|
34
67
|
|
|
68
|
+
const createViewerHealthMonitor = ({
|
|
69
|
+
viewerHealthUrl,
|
|
70
|
+
legacyStatusUrl,
|
|
71
|
+
isActive,
|
|
72
|
+
restartViewer,
|
|
73
|
+
logLine,
|
|
74
|
+
fetchFn = fetch,
|
|
75
|
+
now = Date.now,
|
|
76
|
+
setIntervalFn = setInterval,
|
|
77
|
+
clearIntervalFn = clearInterval,
|
|
78
|
+
timeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs),
|
|
79
|
+
}) => {
|
|
80
|
+
let timer = null;
|
|
81
|
+
let consecutiveFailures = 0;
|
|
82
|
+
let lastRestartAttempt = 0;
|
|
83
|
+
|
|
84
|
+
const boundedFetch = (url) => fetchFn(url, {
|
|
85
|
+
method: "GET",
|
|
86
|
+
signal: timeoutSignal(VIEWER_HEALTH_TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const probe = async () => {
|
|
90
|
+
let response;
|
|
91
|
+
try {
|
|
92
|
+
response = await boundedFetch(viewerHealthUrl);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { live: false, detail: `error: ${String(error).slice(0, 200)}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (response.status === 404) {
|
|
98
|
+
discardResponseBody(response);
|
|
99
|
+
try {
|
|
100
|
+
// Old-viewer compatibility: released viewers serving this route
|
|
101
|
+
// always include the `ingest` availability object, so require that
|
|
102
|
+
// identifying evidence rather than trusting any 2xx from an
|
|
103
|
+
// arbitrary local service.
|
|
104
|
+
const fallbackResponse = await boundedFetch(legacyStatusUrl);
|
|
105
|
+
if (!fallbackResponse.ok) {
|
|
106
|
+
discardResponseBody(fallbackResponse);
|
|
107
|
+
return { live: false, detail: `fallback status=${fallbackResponse.status}` };
|
|
108
|
+
}
|
|
109
|
+
const fallbackPayload = await fallbackResponse.json();
|
|
110
|
+
const looksLikeViewer =
|
|
111
|
+
fallbackPayload &&
|
|
112
|
+
typeof fallbackPayload === "object" &&
|
|
113
|
+
fallbackPayload.ingest &&
|
|
114
|
+
typeof fallbackPayload.ingest === "object";
|
|
115
|
+
return looksLikeViewer
|
|
116
|
+
? { live: true }
|
|
117
|
+
: { live: false, detail: "fallback unexpected payload" };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { live: false, detail: `fallback error: ${String(error).slice(0, 200)}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
// Release the unread body so a persistently failing viewer does not
|
|
125
|
+
// pin connections across the 60s monitor interval.
|
|
126
|
+
discardResponseBody(response);
|
|
127
|
+
return { live: false, detail: `status=${response.status}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let payload;
|
|
131
|
+
try {
|
|
132
|
+
payload = await response.json();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { live: false, detail: `invalid JSON: ${String(error).slice(0, 200)}` };
|
|
135
|
+
}
|
|
136
|
+
if (!payload || typeof payload !== "object" || payload.service !== "codemem-viewer") {
|
|
137
|
+
return { live: false, detail: "unexpected service" };
|
|
138
|
+
}
|
|
139
|
+
return { live: true };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const check = async () => {
|
|
143
|
+
if (!isActive()) return;
|
|
144
|
+
const result = await probe();
|
|
145
|
+
if (result.live) {
|
|
146
|
+
if (consecutiveFailures > 0) {
|
|
147
|
+
await logLine(`viewer.health recovered after ${consecutiveFailures} failure(s)`);
|
|
148
|
+
}
|
|
149
|
+
consecutiveFailures = 0;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
consecutiveFailures += 1;
|
|
154
|
+
await logLine(
|
|
155
|
+
`viewer.health check failed (${result.detail}, consecutive=${consecutiveFailures})`
|
|
156
|
+
);
|
|
157
|
+
if (
|
|
158
|
+
consecutiveFailures < VIEWER_HEALTH_RESTART_THRESHOLD ||
|
|
159
|
+
now() - lastRestartAttempt < VIEWER_HEALTH_RESTART_COOLDOWN_MS
|
|
160
|
+
) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Re-check after the awaited probe: a stop requested while the probe was
|
|
165
|
+
// in flight must not be undone by a restart.
|
|
166
|
+
if (!isActive()) return;
|
|
167
|
+
|
|
168
|
+
lastRestartAttempt = now();
|
|
169
|
+
await logLine(`viewer.health restarting viewer after ${consecutiveFailures} consecutive failures`);
|
|
170
|
+
try {
|
|
171
|
+
const restartResult = await restartViewer();
|
|
172
|
+
const restarted = restartResult?.exitCode === 0;
|
|
173
|
+
await logLine(
|
|
174
|
+
`viewer.health restart ${restarted ? "succeeded" : "failed"} (exit=${restartResult?.exitCode ?? "unknown"})`
|
|
175
|
+
);
|
|
176
|
+
if (restarted) consecutiveFailures = 0;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await logLine(`viewer.health restart error: ${String(error).slice(0, 200)}`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const start = () => {
|
|
183
|
+
if (timer) return;
|
|
184
|
+
consecutiveFailures = 0;
|
|
185
|
+
timer = setIntervalFn(() => {
|
|
186
|
+
check().catch(() => {});
|
|
187
|
+
}, VIEWER_HEALTH_CHECK_INTERVAL_MS);
|
|
188
|
+
if (timer?.unref) timer.unref();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const stop = () => {
|
|
192
|
+
if (timer) {
|
|
193
|
+
clearIntervalFn(timer);
|
|
194
|
+
timer = null;
|
|
195
|
+
}
|
|
196
|
+
consecutiveFailures = 0;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
check,
|
|
201
|
+
start,
|
|
202
|
+
stop,
|
|
203
|
+
state: () => ({
|
|
204
|
+
consecutiveFailures,
|
|
205
|
+
lastRestartAttempt,
|
|
206
|
+
running: timer !== null,
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
35
211
|
const resolveInjectSurface = (value) => {
|
|
36
212
|
const normalized = String(value || "message").trim().toLowerCase();
|
|
37
213
|
if (normalized === "system") {
|
|
@@ -176,6 +352,40 @@ const clearCompatCheckCache = () => {
|
|
|
176
352
|
compatCheckCache = null;
|
|
177
353
|
};
|
|
178
354
|
|
|
355
|
+
const isStableReleaseVersion = (value) => {
|
|
356
|
+
if (typeof value !== "string" || value.length > MAX_UPDATE_VERSION_CHARS) return false;
|
|
357
|
+
const match = STABLE_RELEASE_VERSION.exec(value);
|
|
358
|
+
return Boolean(match && match.slice(1, 4).map(Number).every(Number.isSafeInteger));
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const parseReleaseNotification = (result) => {
|
|
362
|
+
if (result?.exitCode !== 0) return null;
|
|
363
|
+
const stdout = String(result?.stdout || "").trim();
|
|
364
|
+
if (!stdout || Buffer.byteLength(stdout, "utf8") > MAX_UPDATE_STATUS_BYTES) return null;
|
|
365
|
+
try {
|
|
366
|
+
const status = JSON.parse(stdout);
|
|
367
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return null;
|
|
368
|
+
if (status.update_available !== true) return null;
|
|
369
|
+
if (!isStableReleaseVersion(status.latest_version)) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
typeof status.recommended_action !== "string"
|
|
374
|
+
|| !status.recommended_action.trim()
|
|
375
|
+
|| status.recommended_action.length > MAX_UPDATE_ACTION_CHARS
|
|
376
|
+
) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
latestVersion: status.latest_version,
|
|
381
|
+
recommendedAction: status.recommended_action.trim(),
|
|
382
|
+
autoUpdateEligible: status.auto_update_eligible === true,
|
|
383
|
+
};
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
179
389
|
const createLogLine = (logPath) => async (line) => {
|
|
180
390
|
if (!logPath) {
|
|
181
391
|
return;
|
|
@@ -445,6 +655,141 @@ const parsePackOutput = (result) => {
|
|
|
445
655
|
};
|
|
446
656
|
};
|
|
447
657
|
|
|
658
|
+
const isRecord = (value) => value != null && typeof value === "object" && !Array.isArray(value);
|
|
659
|
+
|
|
660
|
+
const isValidPackHttpPayload = (payload) => {
|
|
661
|
+
if (!isRecord(payload) || typeof payload.pack_text !== "string" || !isRecord(payload.metrics)) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const itemCount = payload.metrics.total_items;
|
|
665
|
+
if (!Number.isInteger(itemCount) || itemCount < 0) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
if (
|
|
669
|
+
payload.ledger_artifact_fingerprint != null
|
|
670
|
+
&& !/^[0-9a-f]{64}$/i.test(payload.ledger_artifact_fingerprint)
|
|
671
|
+
) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
if (payload.ledger_outcome != null) {
|
|
675
|
+
return isRecord(payload.ledger_outcome)
|
|
676
|
+
&& payload.ledger_outcome.ok === false
|
|
677
|
+
&& payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
|
|
678
|
+
&& payload.ledger_outcome.reason === "idempotency_conflict";
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const isValidLedgerHttpPayload = (payload) => isRecord(payload) && payload.ok === true;
|
|
684
|
+
|
|
685
|
+
const isValidLedgerFailureHttpPayload = (payload) =>
|
|
686
|
+
isRecord(payload)
|
|
687
|
+
&& payload.ok === false
|
|
688
|
+
&& (
|
|
689
|
+
payload.errorCode === "retrieval_ledger_write_failed"
|
|
690
|
+
|| payload.errorCode === "retrieval_ledger_delivery_write_failed"
|
|
691
|
+
)
|
|
692
|
+
&& typeof payload.reason === "string";
|
|
693
|
+
|
|
694
|
+
const isViewerDbMismatchPayload = (payload) =>
|
|
695
|
+
isRecord(payload)
|
|
696
|
+
&& isRecord(payload.error)
|
|
697
|
+
&& payload.error.code === "viewer_db_mismatch";
|
|
698
|
+
|
|
699
|
+
const isViewerIdentityMismatchPayload = (payload) =>
|
|
700
|
+
isRecord(payload)
|
|
701
|
+
&& isRecord(payload.error)
|
|
702
|
+
&& payload.error.code === "viewer_identity_mismatch";
|
|
703
|
+
|
|
704
|
+
const isViewerContractUnsupportedPayload = (payload) =>
|
|
705
|
+
isRecord(payload)
|
|
706
|
+
&& isRecord(payload.error)
|
|
707
|
+
&& payload.error.code === "viewer_contract_unsupported";
|
|
708
|
+
|
|
709
|
+
const isViewerInvalidRequestPayload = (payload) =>
|
|
710
|
+
isRecord(payload)
|
|
711
|
+
&& isRecord(payload.error)
|
|
712
|
+
&& payload.error.code === "invalid_request"
|
|
713
|
+
&& typeof payload.error.message === "string";
|
|
714
|
+
|
|
715
|
+
const classifyViewerHttpFailure = ({
|
|
716
|
+
operation,
|
|
717
|
+
status = null,
|
|
718
|
+
error = null,
|
|
719
|
+
malformed = false,
|
|
720
|
+
body = null,
|
|
721
|
+
}) => {
|
|
722
|
+
if (malformed) {
|
|
723
|
+
return { cause: `${operation} returned malformed success`, retryable: true };
|
|
724
|
+
}
|
|
725
|
+
if (
|
|
726
|
+
isViewerDbMismatchPayload(body)
|
|
727
|
+
|| isViewerIdentityMismatchPayload(body)
|
|
728
|
+
|| isViewerContractUnsupportedPayload(body)
|
|
729
|
+
) {
|
|
730
|
+
return { cause: `${operation} viewer profile mismatch`, retryable: true };
|
|
731
|
+
}
|
|
732
|
+
if (operation === "prompt-pack-ledger" && isValidLedgerFailureHttpPayload(body)) {
|
|
733
|
+
return { cause: `${operation} request rejected (${status})`, retryable: false };
|
|
734
|
+
}
|
|
735
|
+
if (isViewerInvalidRequestPayload(body) && !operation.endsWith(" profile")) {
|
|
736
|
+
return { cause: `${operation} request rejected (${status})`, retryable: false };
|
|
737
|
+
}
|
|
738
|
+
if (status === 404 || status === 405) {
|
|
739
|
+
return { cause: `${operation} endpoint unavailable (${status})`, retryable: true };
|
|
740
|
+
}
|
|
741
|
+
if (Number.isInteger(status) && status >= 500) {
|
|
742
|
+
return { cause: `${operation} server failure (${status})`, retryable: true };
|
|
743
|
+
}
|
|
744
|
+
if (Number.isInteger(status)) {
|
|
745
|
+
return { cause: `${operation} unexpected response (${status})`, retryable: true };
|
|
746
|
+
}
|
|
747
|
+
const diagnostic = [
|
|
748
|
+
error?.name,
|
|
749
|
+
error?.code,
|
|
750
|
+
error?.cause?.code,
|
|
751
|
+
error?.message,
|
|
752
|
+
error,
|
|
753
|
+
].filter(Boolean).join(" ");
|
|
754
|
+
const timedOut = /AbortError|TimeoutError|timeout|timed out|ETIMEDOUT/i.test(diagnostic);
|
|
755
|
+
return {
|
|
756
|
+
cause: timedOut ? `${operation} request timeout` : `${operation} connection failed`,
|
|
757
|
+
retryable: true,
|
|
758
|
+
};
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const buildPackHttpBody = ({
|
|
762
|
+
query,
|
|
763
|
+
filesModified,
|
|
764
|
+
injectLimit,
|
|
765
|
+
injectTokenBudget,
|
|
766
|
+
projectName,
|
|
767
|
+
cwd,
|
|
768
|
+
dbPath,
|
|
769
|
+
identityTarget,
|
|
770
|
+
attempt,
|
|
771
|
+
}) => ({
|
|
772
|
+
context: query,
|
|
773
|
+
limit: injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0
|
|
774
|
+
? Math.trunc(injectLimit)
|
|
775
|
+
: 10,
|
|
776
|
+
token_budget:
|
|
777
|
+
injectTokenBudget !== null
|
|
778
|
+
&& Number.isFinite(injectTokenBudget)
|
|
779
|
+
&& injectTokenBudget > 0
|
|
780
|
+
? Math.trunc(injectTokenBudget)
|
|
781
|
+
: null,
|
|
782
|
+
...(projectName ? { project: projectName } : {}),
|
|
783
|
+
...(cwd ? { cwd } : {}),
|
|
784
|
+
db_path: dbPath,
|
|
785
|
+
identity_target: identityTarget,
|
|
786
|
+
working_set_files: Array.from(filesModified || [])
|
|
787
|
+
.slice(-8)
|
|
788
|
+
.map((value) => String(value || "").trim())
|
|
789
|
+
.filter((value) => value && value.length <= MAX_WORKING_SET_PATH_CHARS),
|
|
790
|
+
attempt,
|
|
791
|
+
});
|
|
792
|
+
|
|
448
793
|
const canonicalJson = (value) => {
|
|
449
794
|
if (Array.isArray(value)) {
|
|
450
795
|
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
@@ -491,7 +836,7 @@ const applyInjectedContextToOutput = async ({
|
|
|
491
836
|
// lastPromptText hadn't yet been captured by the time
|
|
492
837
|
// experimental.chat.system.transform fired) would silently re-serve
|
|
493
838
|
// the first turn's pack. Recompute on every call instead — the chat
|
|
494
|
-
// path tolerates the
|
|
839
|
+
// path tolerates the transport round trip, and correctness beats the
|
|
495
840
|
// O(1) hit when the cache key isn't tied to the prompt that produced
|
|
496
841
|
// the pack.
|
|
497
842
|
const query = resolveInjectQuery();
|
|
@@ -1264,7 +1609,7 @@ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
|
|
|
1264
1609
|
return runnerFromExplicit ? [runnerFrom] : [];
|
|
1265
1610
|
};
|
|
1266
1611
|
|
|
1267
|
-
export const
|
|
1612
|
+
export const CodememPlugin = async ({
|
|
1268
1613
|
project,
|
|
1269
1614
|
client,
|
|
1270
1615
|
directory,
|
|
@@ -1323,6 +1668,35 @@ export const OpencodeMemPlugin = async ({
|
|
|
1323
1668
|
const viewerHost = process.env.CODEMEM_VIEWER_HOST || "127.0.0.1";
|
|
1324
1669
|
const viewerPort = process.env.CODEMEM_VIEWER_PORT || "38888";
|
|
1325
1670
|
const viewerDbPath = process.env.CODEMEM_DB || "";
|
|
1671
|
+
const expandedViewerDbPath = viewerDbPath.startsWith("~/")
|
|
1672
|
+
? join(process.env.HOME?.trim() || homedir(), viewerDbPath.slice(2))
|
|
1673
|
+
: viewerDbPath;
|
|
1674
|
+
const promptPackDbPath = resolve(
|
|
1675
|
+
cwd,
|
|
1676
|
+
expandedViewerDbPath || join(homedir(), ".codemem", "mem.sqlite"),
|
|
1677
|
+
);
|
|
1678
|
+
const normalizeIdentityPath = (value) => {
|
|
1679
|
+
const trimmed = String(value || "").trim();
|
|
1680
|
+
if (!trimmed) return null;
|
|
1681
|
+
const expanded = trimmed.startsWith("~/")
|
|
1682
|
+
? join(process.env.HOME?.trim() || homedir(), trimmed.slice(2))
|
|
1683
|
+
: trimmed;
|
|
1684
|
+
return resolve(cwd, expanded);
|
|
1685
|
+
};
|
|
1686
|
+
const promptPackIdentityTarget = {
|
|
1687
|
+
device_id: process.env.CODEMEM_DEVICE_ID?.trim() || null,
|
|
1688
|
+
actor_id_present: Object.hasOwn(process.env, "CODEMEM_ACTOR_ID"),
|
|
1689
|
+
actor_id: process.env.CODEMEM_ACTOR_ID?.trim() || null,
|
|
1690
|
+
config_path: normalizeIdentityPath(process.env.CODEMEM_CONFIG),
|
|
1691
|
+
runtime_root: normalizeIdentityPath(process.env.CODEMEM_RUNTIME_ROOT),
|
|
1692
|
+
workspace_id: process.env.CODEMEM_WORKSPACE_ID?.trim() || null,
|
|
1693
|
+
home_dir: normalizeIdentityPath(process.env.HOME || homedir()),
|
|
1694
|
+
pack_compression: process.env.CODEMEM_PACK_COMPRESSION?.trim() || null,
|
|
1695
|
+
embedding_disabled: ["1", "true", "yes"].includes(
|
|
1696
|
+
String(process.env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase(),
|
|
1697
|
+
),
|
|
1698
|
+
embedding_model: process.env.CODEMEM_EMBEDDING_MODEL || "Xenova/bge-small-en-v1.5",
|
|
1699
|
+
};
|
|
1326
1700
|
const viewerConfigPath = process.env.CODEMEM_CONFIG || "";
|
|
1327
1701
|
// A malformed value (e.g. "abc", "", "-1") falls back to the 20s default
|
|
1328
1702
|
// instead of NaN, which would silently disable the timeout. An explicit "0"
|
|
@@ -1332,6 +1706,8 @@ export const OpencodeMemPlugin = async ({
|
|
|
1332
1706
|
const rawCommandTimeout = String(process.env.CODEMEM_PLUGIN_CMD_TIMEOUT ?? "").trim();
|
|
1333
1707
|
const commandTimeout =
|
|
1334
1708
|
rawCommandTimeout === "0" ? 0 : parsePositiveInt(rawCommandTimeout, 20000);
|
|
1709
|
+
const promptPackHttpTimeout =
|
|
1710
|
+
parsePositiveInt(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S || "2", 2) * 1000;
|
|
1335
1711
|
const backendUpdatePolicy = parseBackendUpdatePolicy(
|
|
1336
1712
|
process.env.CODEMEM_BACKEND_UPDATE_POLICY || "notify"
|
|
1337
1713
|
);
|
|
@@ -1359,6 +1735,8 @@ export const OpencodeMemPlugin = async ({
|
|
|
1359
1735
|
let sessionStartedAt = null;
|
|
1360
1736
|
let activeSessionID = null;
|
|
1361
1737
|
let viewerStarted = false;
|
|
1738
|
+
let viewerStartInFlight = false;
|
|
1739
|
+
let compatibilityAutoUpdateAttempted = false;
|
|
1362
1740
|
let promptCounter = 0;
|
|
1363
1741
|
let skippedAttemptCounter = 0;
|
|
1364
1742
|
let lastPromptText = null;
|
|
@@ -1373,8 +1751,15 @@ export const OpencodeMemPlugin = async ({
|
|
|
1373
1751
|
const rawEventsEnabled = envNotDisabled(
|
|
1374
1752
|
process.env.CODEMEM_RAW_EVENTS || "1"
|
|
1375
1753
|
);
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1754
|
+
const viewerUrlHost = viewerHost.includes(":") && !viewerHost.startsWith("[")
|
|
1755
|
+
? `[${viewerHost}]`
|
|
1756
|
+
: viewerHost;
|
|
1757
|
+
const rawEventsUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events`;
|
|
1758
|
+
const rawEventsStatusUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events/status?limit=1`;
|
|
1759
|
+
const packUrl = `http://${viewerUrlHost}:${viewerPort}/api/pack`;
|
|
1760
|
+
const promptPackProfileUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-profile`;
|
|
1761
|
+
const promptPackLedgerUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-ledger`;
|
|
1762
|
+
const viewerHealthUrl = `http://${viewerUrlHost}:${viewerPort}/api/health`;
|
|
1378
1763
|
const rawEventsBackoffMs = parseNumber(
|
|
1379
1764
|
process.env.CODEMEM_RAW_EVENTS_BACKOFF_MS || "10000",
|
|
1380
1765
|
10000
|
|
@@ -1392,14 +1777,8 @@ export const OpencodeMemPlugin = async ({
|
|
|
1392
1777
|
let fallbackFailureNoted = false;
|
|
1393
1778
|
let lastStatusCheckAt = 0;
|
|
1394
1779
|
let lastStatusAvailable = true;
|
|
1780
|
+
let promptPackTransportUnavailableUntil = 0;
|
|
1395
1781
|
|
|
1396
|
-
// Viewer health-check state
|
|
1397
|
-
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
|
1398
|
-
const HEALTH_CONSECUTIVE_FAILURES_BEFORE_RESTART = 3;
|
|
1399
|
-
const HEALTH_RESTART_COOLDOWN_MS = 5 * 60_000;
|
|
1400
|
-
let healthCheckTimer = null;
|
|
1401
|
-
let healthConsecutiveFailures = 0;
|
|
1402
|
-
let healthLastRestartAttempt = 0;
|
|
1403
1782
|
const nextEventId = () => {
|
|
1404
1783
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
1405
1784
|
return crypto.randomUUID();
|
|
@@ -1525,8 +1904,10 @@ export const OpencodeMemPlugin = async ({
|
|
|
1525
1904
|
}
|
|
1526
1905
|
try {
|
|
1527
1906
|
if (now - lastStatusCheckAt >= Math.max(1000, rawEventsStatusCheckMs)) {
|
|
1528
|
-
const statusResp = await
|
|
1907
|
+
const statusResp = await fetchRawEventsStatus(rawEventsStatusUrl);
|
|
1529
1908
|
if (!statusResp.ok) {
|
|
1909
|
+
// Release the unread body before bailing into the backoff path.
|
|
1910
|
+
discardResponseBody(statusResp);
|
|
1530
1911
|
throw new Error(`raw-events status failed (${statusResp.status})`);
|
|
1531
1912
|
}
|
|
1532
1913
|
const statusJson = await statusResp.json();
|
|
@@ -1881,12 +2262,28 @@ export const OpencodeMemPlugin = async ({
|
|
|
1881
2262
|
return { usage, id: info.id };
|
|
1882
2263
|
};
|
|
1883
2264
|
|
|
1884
|
-
const startViewer = () => {
|
|
1885
|
-
if (!viewerEnabled || !viewerAutoStart || viewerStarted) {
|
|
2265
|
+
const startViewer = async () => {
|
|
2266
|
+
if (!viewerEnabled || !viewerAutoStart || viewerStarted || viewerStartInFlight) {
|
|
1886
2267
|
if (viewerStarted) logLine("viewer already started, skipping auto-start").catch(() => {});
|
|
1887
2268
|
return;
|
|
1888
2269
|
}
|
|
1889
|
-
|
|
2270
|
+
viewerStartInFlight = true;
|
|
2271
|
+
let existingViewer = false;
|
|
2272
|
+
try {
|
|
2273
|
+
const existing = await fetch(viewerHealthUrl, {
|
|
2274
|
+
method: "GET",
|
|
2275
|
+
redirect: "manual",
|
|
2276
|
+
signal: AbortSignal.timeout(1_000),
|
|
2277
|
+
});
|
|
2278
|
+
existingViewer = existing.ok;
|
|
2279
|
+
} catch {
|
|
2280
|
+
// No live viewer responded; proceed with the plugin-owned start.
|
|
2281
|
+
}
|
|
2282
|
+
if (existingViewer) {
|
|
2283
|
+
viewerStartInFlight = false;
|
|
2284
|
+
logLine("viewer already running, skipping plugin-owned auto-start").catch(() => {});
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
1890
2287
|
const viewerArgs = buildViewerCliArgs("start");
|
|
1891
2288
|
const cmd = [runner, ...runnerArgs, ...viewerArgs];
|
|
1892
2289
|
logLine(`auto-starting viewer: ${cmd.join(" ")}`).catch(() => {});
|
|
@@ -1897,18 +2294,24 @@ export const OpencodeMemPlugin = async ({
|
|
|
1897
2294
|
detached: true,
|
|
1898
2295
|
stdio: "ignore",
|
|
1899
2296
|
});
|
|
2297
|
+
child.once("spawn", () => {
|
|
2298
|
+
viewerStarted = true;
|
|
2299
|
+
viewerStartInFlight = false;
|
|
2300
|
+
startHealthCheck();
|
|
2301
|
+
});
|
|
1900
2302
|
child.on("error", (err) => {
|
|
2303
|
+
viewerStartInFlight = false;
|
|
1901
2304
|
logLine(`viewer spawn error: ${err.message}`).catch(() => {});
|
|
1902
2305
|
});
|
|
1903
2306
|
child.unref();
|
|
1904
2307
|
} catch (err) {
|
|
2308
|
+
viewerStartInFlight = false;
|
|
1905
2309
|
logLine(`viewer spawn failed: ${err}`).catch(() => {});
|
|
1906
2310
|
}
|
|
1907
|
-
startHealthCheck();
|
|
1908
2311
|
};
|
|
1909
2312
|
|
|
1910
2313
|
const runCommand = async (cmd, options = {}) => {
|
|
1911
|
-
const { stdinText = null } = options;
|
|
2314
|
+
const { stdinText = null, timeoutMs = commandTimeout } = options;
|
|
1912
2315
|
const [command, ...args] = cmd;
|
|
1913
2316
|
return new Promise((resolve) => {
|
|
1914
2317
|
const proc = nodeSpawn(command, args, {
|
|
@@ -1947,19 +2350,31 @@ export const OpencodeMemPlugin = async ({
|
|
|
1947
2350
|
return;
|
|
1948
2351
|
}
|
|
1949
2352
|
let timer = null;
|
|
1950
|
-
|
|
2353
|
+
let killTimer = null;
|
|
2354
|
+
let timedOut = false;
|
|
2355
|
+
let settled = false;
|
|
2356
|
+
const finish = (result) => {
|
|
2357
|
+
if (settled) return;
|
|
2358
|
+
settled = true;
|
|
2359
|
+
if (timer) clearTimeout(timer);
|
|
2360
|
+
if (killTimer) clearTimeout(killTimer);
|
|
2361
|
+
resolve(result);
|
|
2362
|
+
};
|
|
2363
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
1951
2364
|
timer = setTimeout(() => {
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2365
|
+
timedOut = true;
|
|
2366
|
+
try { proc.kill("SIGTERM"); } catch { /* ignore */ }
|
|
2367
|
+
killTimer = setTimeout(() => {
|
|
2368
|
+
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
|
2369
|
+
}, 5_000);
|
|
2370
|
+
if (killTimer.unref) killTimer.unref();
|
|
2371
|
+
}, timeoutMs);
|
|
1955
2372
|
}
|
|
1956
2373
|
proc.once("exit", (exitCode) => {
|
|
1957
|
-
|
|
1958
|
-
resolve({ exitCode, stdout, stderr });
|
|
2374
|
+
finish({ exitCode: timedOut ? null : exitCode, stdout, stderr: timedOut ? "timeout" : stderr });
|
|
1959
2375
|
});
|
|
1960
2376
|
proc.once("error", (err) => {
|
|
1961
|
-
|
|
1962
|
-
resolve({ exitCode: 1, stdout: "", stderr: String(err) });
|
|
2377
|
+
finish({ exitCode: 1, stdout: "", stderr: String(err) });
|
|
1963
2378
|
});
|
|
1964
2379
|
});
|
|
1965
2380
|
};
|
|
@@ -1967,6 +2382,110 @@ export const OpencodeMemPlugin = async ({
|
|
|
1967
2382
|
const runCli = async (args, options = {}) =>
|
|
1968
2383
|
runCommand([runner, ...runnerArgs, ...args], options);
|
|
1969
2384
|
|
|
2385
|
+
const postViewerJson = async ({ url, operation, payload, validate }) => {
|
|
2386
|
+
if (!viewerEnabled) {
|
|
2387
|
+
return {
|
|
2388
|
+
ok: false,
|
|
2389
|
+
classification: {
|
|
2390
|
+
cause: `${operation} viewer transport disabled`,
|
|
2391
|
+
retryable: true,
|
|
2392
|
+
},
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
if (Date.now() < promptPackTransportUnavailableUntil) {
|
|
2396
|
+
return {
|
|
2397
|
+
ok: false,
|
|
2398
|
+
classification: {
|
|
2399
|
+
cause: `${operation} viewer transport in backoff`,
|
|
2400
|
+
retryable: true,
|
|
2401
|
+
},
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
let response;
|
|
2406
|
+
try {
|
|
2407
|
+
const profileResponse = await fetch(promptPackProfileUrl, {
|
|
2408
|
+
method: "GET",
|
|
2409
|
+
redirect: "manual",
|
|
2410
|
+
signal: AbortSignal.timeout(promptPackHttpTimeout),
|
|
2411
|
+
});
|
|
2412
|
+
let profileBody;
|
|
2413
|
+
try {
|
|
2414
|
+
profileBody = await profileResponse.json();
|
|
2415
|
+
} catch {
|
|
2416
|
+
profileBody = null;
|
|
2417
|
+
}
|
|
2418
|
+
if (!profileResponse.ok) {
|
|
2419
|
+
const classification = classifyViewerHttpFailure({
|
|
2420
|
+
operation: `${operation} profile`,
|
|
2421
|
+
status: profileResponse.status,
|
|
2422
|
+
body: profileBody,
|
|
2423
|
+
});
|
|
2424
|
+
if (classification.retryable) {
|
|
2425
|
+
promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2426
|
+
}
|
|
2427
|
+
return { ok: false, classification };
|
|
2428
|
+
}
|
|
2429
|
+
if (
|
|
2430
|
+
!isRecord(profileBody)
|
|
2431
|
+
|| profileBody.service !== "codemem-viewer"
|
|
2432
|
+
|| profileBody.protocol_version !== 1
|
|
2433
|
+
|| profileBody.db_path !== promptPackDbPath
|
|
2434
|
+
|| canonicalJson(profileBody.identity_target) !== canonicalJson(promptPackIdentityTarget)
|
|
2435
|
+
) {
|
|
2436
|
+
promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2437
|
+
return {
|
|
2438
|
+
ok: false,
|
|
2439
|
+
classification: {
|
|
2440
|
+
cause: `${operation} viewer profile handshake mismatch`,
|
|
2441
|
+
retryable: true,
|
|
2442
|
+
},
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
response = await fetch(url, {
|
|
2446
|
+
method: "POST",
|
|
2447
|
+
redirect: "manual",
|
|
2448
|
+
headers: { "Content-Type": "application/json" },
|
|
2449
|
+
body: JSON.stringify(payload),
|
|
2450
|
+
signal: AbortSignal.timeout(promptPackHttpTimeout),
|
|
2451
|
+
});
|
|
2452
|
+
} catch (error) {
|
|
2453
|
+
const classification = classifyViewerHttpFailure({ operation, error });
|
|
2454
|
+
promptPackTransportUnavailableUntil =
|
|
2455
|
+
Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2456
|
+
return { ok: false, classification };
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
let body;
|
|
2460
|
+
try {
|
|
2461
|
+
body = await response.json();
|
|
2462
|
+
} catch {
|
|
2463
|
+
body = null;
|
|
2464
|
+
}
|
|
2465
|
+
if (!response.ok) {
|
|
2466
|
+
const classification = classifyViewerHttpFailure({
|
|
2467
|
+
operation,
|
|
2468
|
+
status: response.status,
|
|
2469
|
+
body,
|
|
2470
|
+
});
|
|
2471
|
+
if (classification.retryable) {
|
|
2472
|
+
promptPackTransportUnavailableUntil =
|
|
2473
|
+
Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2474
|
+
}
|
|
2475
|
+
return { ok: false, classification };
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (!validate(body)) {
|
|
2479
|
+
const classification = classifyViewerHttpFailure({ operation, malformed: true });
|
|
2480
|
+
promptPackTransportUnavailableUntil =
|
|
2481
|
+
Date.now() + Math.max(1000, rawEventsBackoffMs);
|
|
2482
|
+
return { ok: false, classification };
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
promptPackTransportUnavailableUntil = 0;
|
|
2486
|
+
return { ok: true, body };
|
|
2487
|
+
};
|
|
2488
|
+
|
|
1970
2489
|
const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
|
|
1971
2490
|
attempt_id: identity.attemptId,
|
|
1972
2491
|
started_at: (() => {
|
|
@@ -1988,10 +2507,39 @@ export const OpencodeMemPlugin = async ({
|
|
|
1988
2507
|
});
|
|
1989
2508
|
|
|
1990
2509
|
const runPromptPackLedger = async (payload) => {
|
|
2510
|
+
const viewerPayload = {
|
|
2511
|
+
...payload,
|
|
2512
|
+
db_path: promptPackDbPath,
|
|
2513
|
+
identity_target: promptPackIdentityTarget,
|
|
2514
|
+
};
|
|
2515
|
+
const httpResult = await postViewerJson({
|
|
2516
|
+
url: promptPackLedgerUrl,
|
|
2517
|
+
operation: "prompt-pack-ledger",
|
|
2518
|
+
payload: viewerPayload,
|
|
2519
|
+
validate: isValidLedgerHttpPayload,
|
|
2520
|
+
});
|
|
2521
|
+
if (httpResult.ok) {
|
|
2522
|
+
return {
|
|
2523
|
+
exitCode: 0,
|
|
2524
|
+
stdout: JSON.stringify(httpResult.body),
|
|
2525
|
+
stderr: "",
|
|
2526
|
+
transport: "viewer",
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const { cause, retryable } = httpResult.classification;
|
|
2531
|
+
await logLine(
|
|
2532
|
+
`inject.ledger.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
|
|
2533
|
+
);
|
|
2534
|
+
if (!retryable) {
|
|
2535
|
+
return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" };
|
|
2536
|
+
}
|
|
2537
|
+
|
|
1991
2538
|
try {
|
|
1992
|
-
|
|
2539
|
+
const result = await runCli(["prompt-pack-ledger"], {
|
|
1993
2540
|
stdinText: JSON.stringify(payload),
|
|
1994
2541
|
});
|
|
2542
|
+
return { ...result, transport: "cli" };
|
|
1995
2543
|
} catch {
|
|
1996
2544
|
return null;
|
|
1997
2545
|
}
|
|
@@ -2077,7 +2625,7 @@ export const OpencodeMemPlugin = async ({
|
|
|
2077
2625
|
if (!viewerEnabled || !viewerAutoStart || !viewerStarted) {
|
|
2078
2626
|
return { attempted: false, ok: false };
|
|
2079
2627
|
}
|
|
2080
|
-
const restartResult = await runCli(buildViewerCliArgs("restart"));
|
|
2628
|
+
const restartResult = await runCli(buildViewerCliArgs("restart"), { timeoutMs: 60_000 });
|
|
2081
2629
|
if (restartResult?.exitCode === 0) {
|
|
2082
2630
|
await logLine("compat.auto_update_viewer_restart ok");
|
|
2083
2631
|
return { attempted: true, ok: true };
|
|
@@ -2157,12 +2705,14 @@ export const OpencodeMemPlugin = async ({
|
|
|
2157
2705
|
`compat.version_mismatch current=${currentVersion} required=${minVersion} mode=${guidance.mode} note=${redactLog(guidance.note)}`
|
|
2158
2706
|
);
|
|
2159
2707
|
|
|
2160
|
-
const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom });
|
|
2161
2708
|
if (backendUpdatePolicy === "auto") {
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2709
|
+
compatibilityAutoUpdateAttempted = true;
|
|
2710
|
+
await logLine("compat.auto_update_start cmd=codemem update install --json");
|
|
2711
|
+
const updateResult = await runCli(
|
|
2712
|
+
["update", "install", "--json"],
|
|
2713
|
+
{ timeoutMs: 420_000 }
|
|
2714
|
+
);
|
|
2715
|
+
if (updateResult?.exitCode === 0) {
|
|
2166
2716
|
await logLine(
|
|
2167
2717
|
`compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
|
|
2168
2718
|
(updateResult?.stderr || "").trim()
|
|
@@ -2172,8 +2722,7 @@ export const OpencodeMemPlugin = async ({
|
|
|
2172
2722
|
const refreshedResult = await runCli(["version"]);
|
|
2173
2723
|
const refreshedVersion = (refreshedResult?.stdout || "").trim();
|
|
2174
2724
|
if (
|
|
2175
|
-
|
|
2176
|
-
&& refreshedResult?.exitCode === 0
|
|
2725
|
+
refreshedResult?.exitCode === 0
|
|
2177
2726
|
&& isVersionAtLeast(refreshedVersion, minVersion)
|
|
2178
2727
|
) {
|
|
2179
2728
|
writeCompatCheckCache(cacheKey, refreshedVersion);
|
|
@@ -2193,19 +2742,31 @@ export const OpencodeMemPlugin = async ({
|
|
|
2193
2742
|
}
|
|
2194
2743
|
return;
|
|
2195
2744
|
}
|
|
2196
|
-
|
|
2745
|
+
await logLine(
|
|
2746
|
+
`compat.auto_update_verification_failed current=${redactLog(refreshedVersion || "unknown")} required=${redactLog(minVersion)}`
|
|
2747
|
+
);
|
|
2197
2748
|
await showToast(
|
|
2198
|
-
`${message}. Auto-update
|
|
2749
|
+
`${message}. Auto-update completed, but the active CLI failed verification. Suggested action: ${guidance.action}`,
|
|
2199
2750
|
"warning"
|
|
2200
2751
|
);
|
|
2201
2752
|
return;
|
|
2202
2753
|
}
|
|
2203
|
-
|
|
2754
|
+
let installError = null;
|
|
2755
|
+
try {
|
|
2756
|
+
installError = JSON.parse((updateResult?.stdout || "").trim())?.error || null;
|
|
2757
|
+
} catch {
|
|
2758
|
+
// Non-JSON output is treated as an installation failure.
|
|
2759
|
+
}
|
|
2760
|
+
const failureReason = installError === "update_install_locked"
|
|
2761
|
+
? "another update is already running"
|
|
2762
|
+
: installError === "update_install_refused"
|
|
2763
|
+
? "not eligible"
|
|
2764
|
+
: "installation failed";
|
|
2204
2765
|
await logLine(
|
|
2205
|
-
`compat.auto_update_skipped reason=${
|
|
2766
|
+
`compat.auto_update_skipped reason=${installError || "update_install_failed"} exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
|
|
2206
2767
|
);
|
|
2207
2768
|
await showToast(
|
|
2208
|
-
`${message}. Auto-update skipped (${
|
|
2769
|
+
`${message}. Auto-update skipped (${failureReason}). Suggested action: ${guidance.action}`,
|
|
2209
2770
|
"warning"
|
|
2210
2771
|
);
|
|
2211
2772
|
return;
|
|
@@ -2214,6 +2775,53 @@ export const OpencodeMemPlugin = async ({
|
|
|
2214
2775
|
await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
|
|
2215
2776
|
};
|
|
2216
2777
|
|
|
2778
|
+
const checkForReleaseUpdate = async () => {
|
|
2779
|
+
if (backendUpdatePolicy === "off") return;
|
|
2780
|
+
const notification = parseReleaseNotification(
|
|
2781
|
+
await runCli(["update", "check", "--json"])
|
|
2782
|
+
);
|
|
2783
|
+
if (
|
|
2784
|
+
!notification
|
|
2785
|
+
|| notifiedReleaseVersions.has(notification.latestVersion)
|
|
2786
|
+
) {
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
notifiedReleaseVersions.add(notification.latestVersion);
|
|
2790
|
+
if (
|
|
2791
|
+
backendUpdatePolicy === "auto"
|
|
2792
|
+
&& notification.autoUpdateEligible
|
|
2793
|
+
&& !compatibilityAutoUpdateAttempted
|
|
2794
|
+
) {
|
|
2795
|
+
const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom, runnerFromExplicit });
|
|
2796
|
+
if (autoPlan.allowed) {
|
|
2797
|
+
const installation = await runCli(
|
|
2798
|
+
["update", "install", "--json"],
|
|
2799
|
+
{ timeoutMs: 420_000 }
|
|
2800
|
+
);
|
|
2801
|
+
if (installation?.exitCode === 0) {
|
|
2802
|
+
const viewerRestart = await restartViewerAfterAutoUpdate();
|
|
2803
|
+
await showToast(`Updated codemem to ${notification.latestVersion}.`, "success");
|
|
2804
|
+
if (viewerRestart.attempted && !viewerRestart.ok) {
|
|
2805
|
+
await showToast(
|
|
2806
|
+
"Backend updated, but viewer restart failed. Run `codemem serve restart`.",
|
|
2807
|
+
"warning"
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
await logLine(
|
|
2813
|
+
`release.auto_update_failed exit=${installation?.exitCode ?? "unknown"} stderr=${redactLog(
|
|
2814
|
+
(installation?.stderr || "").trim()
|
|
2815
|
+
)}`
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
await showToast(
|
|
2820
|
+
`codemem ${notification.latestVersion} is available. ${notification.recommendedAction}`,
|
|
2821
|
+
"warning"
|
|
2822
|
+
);
|
|
2823
|
+
};
|
|
2824
|
+
|
|
2217
2825
|
const resolveInjectQuery = (overrides = {}) => {
|
|
2218
2826
|
const firstPrompt = hasOwn(overrides, "firstPrompt")
|
|
2219
2827
|
? overrides.firstPrompt
|
|
@@ -2317,12 +2925,51 @@ export const OpencodeMemPlugin = async ({
|
|
|
2317
2925
|
injectTokenBudget,
|
|
2318
2926
|
internalLedger: true,
|
|
2319
2927
|
});
|
|
2928
|
+
const httpResult = await postViewerJson({
|
|
2929
|
+
url: packUrl,
|
|
2930
|
+
operation: "pack",
|
|
2931
|
+
payload: buildPackHttpBody({
|
|
2932
|
+
query,
|
|
2933
|
+
filesModified: sessionContext.filesModified,
|
|
2934
|
+
injectLimit,
|
|
2935
|
+
injectTokenBudget,
|
|
2936
|
+
projectName: normalizeProjectLabel(process.env.CODEMEM_PROJECT),
|
|
2937
|
+
cwd,
|
|
2938
|
+
dbPath: promptPackDbPath,
|
|
2939
|
+
identityTarget: promptPackIdentityTarget,
|
|
2940
|
+
attempt: metadata,
|
|
2941
|
+
}),
|
|
2942
|
+
validate: isValidPackHttpPayload,
|
|
2943
|
+
});
|
|
2944
|
+
if (httpResult.ok) {
|
|
2945
|
+
return {
|
|
2946
|
+
packArgs,
|
|
2947
|
+
result: {
|
|
2948
|
+
exitCode: 0,
|
|
2949
|
+
stdout: JSON.stringify(httpResult.body),
|
|
2950
|
+
stderr: "",
|
|
2951
|
+
transport: "viewer",
|
|
2952
|
+
},
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
const { cause, retryable } = httpResult.classification;
|
|
2957
|
+
await logLine(
|
|
2958
|
+
`inject.pack.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
|
|
2959
|
+
);
|
|
2960
|
+
if (!retryable) {
|
|
2961
|
+
return {
|
|
2962
|
+
packArgs,
|
|
2963
|
+
result: { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" },
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2320
2967
|
let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
|
|
2321
2968
|
if (rejectsInternalLedgerFlag(result)) {
|
|
2322
2969
|
packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
|
|
2323
2970
|
result = await runCli(packArgs);
|
|
2324
2971
|
}
|
|
2325
|
-
return { packArgs, result };
|
|
2972
|
+
return { packArgs, result: { ...result, transport: "cli" } };
|
|
2326
2973
|
};
|
|
2327
2974
|
let { packArgs, result } = await runPack();
|
|
2328
2975
|
let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
|
|
@@ -2333,7 +2980,7 @@ export const OpencodeMemPlugin = async ({
|
|
|
2333
2980
|
let repairFallbackUsed = false;
|
|
2334
2981
|
if (ledgerConflict) {
|
|
2335
2982
|
// A restarted plugin has no artifact cache to predict this conflict. The
|
|
2336
|
-
//
|
|
2983
|
+
// ledger marker is authoritative: never attribute delivery to the stale
|
|
2337
2984
|
// identity, and retry once with a fresh deterministic identity.
|
|
2338
2985
|
await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
|
|
2339
2986
|
sessionID,
|
|
@@ -2606,62 +3253,15 @@ export const OpencodeMemPlugin = async ({
|
|
|
2606
3253
|
await runCli(buildViewerCliArgs("stop"));
|
|
2607
3254
|
};
|
|
2608
3255
|
|
|
2609
|
-
const
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
await logLine(`viewer.health recovered after ${healthConsecutiveFailures} failure(s)`);
|
|
2619
|
-
}
|
|
2620
|
-
healthConsecutiveFailures = 0;
|
|
2621
|
-
return;
|
|
2622
|
-
}
|
|
2623
|
-
healthConsecutiveFailures++;
|
|
2624
|
-
await logLine(`viewer.health check failed (status=${resp.status}, consecutive=${healthConsecutiveFailures})`);
|
|
2625
|
-
} catch (err) {
|
|
2626
|
-
healthConsecutiveFailures++;
|
|
2627
|
-
await logLine(`viewer.health check error (consecutive=${healthConsecutiveFailures}): ${String(err).slice(0, 200)}`);
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
if (
|
|
2631
|
-
healthConsecutiveFailures >= HEALTH_CONSECUTIVE_FAILURES_BEFORE_RESTART &&
|
|
2632
|
-
Date.now() - healthLastRestartAttempt >= HEALTH_RESTART_COOLDOWN_MS
|
|
2633
|
-
) {
|
|
2634
|
-
healthLastRestartAttempt = Date.now();
|
|
2635
|
-
await logLine(`viewer.health restarting viewer after ${healthConsecutiveFailures} consecutive failures`);
|
|
2636
|
-
try {
|
|
2637
|
-
const result = await runCli(buildViewerCliArgs("restart"));
|
|
2638
|
-
const ok = result?.exitCode === 0;
|
|
2639
|
-
await logLine(`viewer.health restart ${ok ? "succeeded" : "failed"} (exit=${result?.exitCode ?? "unknown"})`);
|
|
2640
|
-
if (ok) {
|
|
2641
|
-
healthConsecutiveFailures = 0;
|
|
2642
|
-
}
|
|
2643
|
-
} catch (restartErr) {
|
|
2644
|
-
await logLine(`viewer.health restart error: ${String(restartErr).slice(0, 200)}`);
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
};
|
|
2648
|
-
|
|
2649
|
-
const startHealthCheck = () => {
|
|
2650
|
-
if (healthCheckTimer) return;
|
|
2651
|
-
healthConsecutiveFailures = 0;
|
|
2652
|
-
healthCheckTimer = setInterval(() => {
|
|
2653
|
-
checkViewerHealth().catch(() => {});
|
|
2654
|
-
}, HEALTH_CHECK_INTERVAL_MS);
|
|
2655
|
-
if (healthCheckTimer.unref) healthCheckTimer.unref();
|
|
2656
|
-
};
|
|
2657
|
-
|
|
2658
|
-
const stopHealthCheck = () => {
|
|
2659
|
-
if (healthCheckTimer) {
|
|
2660
|
-
clearInterval(healthCheckTimer);
|
|
2661
|
-
healthCheckTimer = null;
|
|
2662
|
-
}
|
|
2663
|
-
healthConsecutiveFailures = 0;
|
|
2664
|
-
};
|
|
3256
|
+
const viewerHealthMonitor = createViewerHealthMonitor({
|
|
3257
|
+
viewerHealthUrl,
|
|
3258
|
+
legacyStatusUrl: rawEventsStatusUrl,
|
|
3259
|
+
isActive: () => viewerStarted && viewerEnabled,
|
|
3260
|
+
restartViewer: () => runCli(buildViewerCliArgs("restart")),
|
|
3261
|
+
logLine,
|
|
3262
|
+
});
|
|
3263
|
+
const startHealthCheck = viewerHealthMonitor.start;
|
|
3264
|
+
const stopHealthCheck = viewerHealthMonitor.stop;
|
|
2665
3265
|
|
|
2666
3266
|
// Get version info (commit hash) for debugging
|
|
2667
3267
|
let version = "unknown";
|
|
@@ -2680,15 +3280,26 @@ export const OpencodeMemPlugin = async ({
|
|
|
2680
3280
|
|
|
2681
3281
|
await log("info", "codemem plugin initialized", { cwd, version });
|
|
2682
3282
|
await logLine(`plugin initialized cwd=${cwd} version=${version}`);
|
|
2683
|
-
startViewer();
|
|
2684
|
-
const
|
|
2685
|
-
void
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
)
|
|
2689
|
-
|
|
3283
|
+
void startViewer();
|
|
3284
|
+
const updateCheckTimer = setTimeout(() => {
|
|
3285
|
+
void (async () => {
|
|
3286
|
+
try {
|
|
3287
|
+
await verifyCliCompatibility();
|
|
3288
|
+
} catch (err) {
|
|
3289
|
+
await logLine(
|
|
3290
|
+
`compat.version_check_error message=${String(err?.message || err || "unknown")}`
|
|
3291
|
+
);
|
|
3292
|
+
}
|
|
3293
|
+
try {
|
|
3294
|
+
await checkForReleaseUpdate();
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
await logLine(
|
|
3297
|
+
`release.update_check_error message=${String(err?.message || err || "unknown")}`
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
})();
|
|
2690
3301
|
}, COMPAT_CHECK_DELAY_MS);
|
|
2691
|
-
if (
|
|
3302
|
+
if (updateCheckTimer.unref) updateCheckTimer.unref();
|
|
2692
3303
|
|
|
2693
3304
|
const truncate = (value) => {
|
|
2694
3305
|
if (value === undefined || value === null) {
|
|
@@ -2937,9 +3548,9 @@ export const OpencodeMemPlugin = async ({
|
|
|
2937
3548
|
} inject_enabled=${injectEnabled} tui_toast=${Boolean(client.tui?.showToast)} query=${JSON.stringify(safeQuery)} first_prompt_len=${firstPromptLen} last_prompt_len=${lastPromptLen} project=${JSON.stringify(projectName)} files_modified=${filesModifiedCount}`
|
|
2938
3549
|
);
|
|
2939
3550
|
}
|
|
2940
|
-
// Without the old per-session cache, every transform call
|
|
2941
|
-
//
|
|
2942
|
-
//
|
|
3551
|
+
// Without the old per-session cache, every transform call rebuilds the
|
|
3552
|
+
// pack. Swallow rejections here so a single failed build (viewer failure,
|
|
3553
|
+
// CLI fallback crash, sqlite lock, or network blip)
|
|
2943
3554
|
// can't take down the chat path.
|
|
2944
3555
|
let applied = false;
|
|
2945
3556
|
try {
|
|
@@ -3217,7 +3828,7 @@ export const OpencodeMemPlugin = async ({
|
|
|
3217
3828
|
const stats = await runCli(["stats"]);
|
|
3218
3829
|
const recent = await runCli(["recent", "--limit", "5"]);
|
|
3219
3830
|
const lines = [
|
|
3220
|
-
`viewer: http://${
|
|
3831
|
+
`viewer: http://${viewerUrlHost}:${viewerPort}`,
|
|
3221
3832
|
`log: ${logPath || "disabled"}`,
|
|
3222
3833
|
];
|
|
3223
3834
|
if (stats.exitCode === 0 && stats.stdout.trim()) {
|
|
@@ -3262,9 +3873,16 @@ export const OpencodeMemPlugin = async ({
|
|
|
3262
3873
|
};
|
|
3263
3874
|
};
|
|
3264
3875
|
|
|
3265
|
-
export default
|
|
3876
|
+
export default CodememPlugin;
|
|
3877
|
+
|
|
3878
|
+
/**
|
|
3879
|
+
* @deprecated Use CodememPlugin.
|
|
3880
|
+
* Keep this reference-identical: OpenCode deduplicates plugin exports by identity.
|
|
3881
|
+
*/
|
|
3882
|
+
export const OpencodeMemPlugin = CodememPlugin;
|
|
3266
3883
|
export const __testUtils = {
|
|
3267
3884
|
PINNED_BACKEND_VERSION,
|
|
3885
|
+
fetchRawEventsStatus,
|
|
3268
3886
|
inferProjectFromCwd,
|
|
3269
3887
|
normalizeProjectLabel,
|
|
3270
3888
|
resolveProjectName,
|
|
@@ -3276,6 +3894,10 @@ export const __testUtils = {
|
|
|
3276
3894
|
redactPackCommand,
|
|
3277
3895
|
rejectsInternalLedgerFlag,
|
|
3278
3896
|
classifyFallbackCommandResult,
|
|
3897
|
+
classifyViewerHttpFailure,
|
|
3898
|
+
isValidPackHttpPayload,
|
|
3899
|
+
isValidLedgerHttpPayload,
|
|
3900
|
+
buildPackHttpBody,
|
|
3279
3901
|
parsePackText,
|
|
3280
3902
|
parsePackMetrics,
|
|
3281
3903
|
resolveInjectSurface,
|
|
@@ -3296,4 +3918,5 @@ export const __testUtils = {
|
|
|
3296
3918
|
buildRawEventEnvelope,
|
|
3297
3919
|
trimEventQueue,
|
|
3298
3920
|
parsePositiveInt,
|
|
3921
|
+
createViewerHealthMonitor,
|
|
3299
3922
|
};
|
package/README.md
CHANGED
|
@@ -21,6 +21,13 @@ Manual config also works. Add the package name to your OpenCode config:
|
|
|
21
21
|
|
|
22
22
|
OpenCode installs npm plugins automatically with Bun at startup.
|
|
23
23
|
|
|
24
|
+
## Exports
|
|
25
|
+
|
|
26
|
+
`CodememPlugin` is the canonical named export and the package default export.
|
|
27
|
+
`OpencodeMemPlugin` remains available as a deprecated, reference-identical alias
|
|
28
|
+
for compatibility with integrations created before the Codemem rename. No removal
|
|
29
|
+
version is currently scheduled.
|
|
30
|
+
|
|
24
31
|
## Documentation
|
|
25
32
|
|
|
26
33
|
- Repository: https://github.com/kunickiaj/codemem
|
package/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
CodememPlugin as default,
|
|
3
|
+
CodememPlugin,
|
|
4
|
+
OpencodeMemPlugin,
|
|
5
|
+
} from "./.opencode/plugins/codemem.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codemem/opencode-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "CodeMem plugin for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
".opencode/lib/"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@opencode-ai/plugin": "1.18.
|
|
21
|
+
"@opencode-ai/plugin": "1.18.16"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|