@codemem/opencode-plugin 0.40.2 → 0.42.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.
@@ -106,9 +106,17 @@ const isPinnedGitSource = (runnerFrom) => {
106
106
  }
107
107
  };
108
108
 
109
- export const resolveAutoUpdatePlan = ({ runner, runnerFrom }) => {
110
- const normalizedRunner = String(runner || "").trim();
111
- const source = String(runnerFrom || "").trim();
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
- if (normalizedRunner === "npx") {
123
- return {
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
- return {
150
- allowed: true,
151
- reason: null,
152
- command: ["npm", "install", "-g", "codemem@latest"],
153
- commandText: "npm install -g codemem@latest",
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.40.2";
19
+ const PINNED_BACKEND_VERSION = "0.42.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;
@@ -413,6 +623,31 @@ const parsePackMetrics = (stdout) => {
413
623
  }
414
624
  };
415
625
 
626
+ const buildViewerIdentityTarget = (env = process.env, cwd = process.cwd()) => {
627
+ const normalizeIdentityPath = (value) => {
628
+ const trimmed = String(value || "").trim();
629
+ if (!trimmed) return null;
630
+ const expanded = trimmed.startsWith("~/")
631
+ ? join(env.HOME?.trim() || homedir(), trimmed.slice(2))
632
+ : trimmed;
633
+ return resolve(cwd, expanded);
634
+ };
635
+ return {
636
+ device_id: env.CODEMEM_DEVICE_ID?.trim() || null,
637
+ actor_id_present: Object.hasOwn(env, "CODEMEM_ACTOR_ID"),
638
+ actor_id: env.CODEMEM_ACTOR_ID?.trim() || null,
639
+ config_path: normalizeIdentityPath(env.CODEMEM_CONFIG),
640
+ runtime_root: normalizeIdentityPath(env.CODEMEM_RUNTIME_ROOT),
641
+ workspace_id: env.CODEMEM_WORKSPACE_ID?.trim() || null,
642
+ home_dir: normalizeIdentityPath(env.HOME || homedir()),
643
+ pack_compression: env.CODEMEM_PACK_COMPRESSION?.trim() || null,
644
+ embedding_disabled: ["1", "true", "yes"].includes(
645
+ String(env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase(),
646
+ ),
647
+ embedding_model: env.CODEMEM_EMBEDDING_MODEL || "Xenova/bge-small-en-v1.5",
648
+ };
649
+ };
650
+
416
651
  const parsePackOutput = (result) => {
417
652
  const succeeded = result?.exitCode === 0;
418
653
  let payload = null;
@@ -445,6 +680,229 @@ const parsePackOutput = (result) => {
445
680
  };
446
681
  };
447
682
 
683
+ const isRecord = (value) => value != null && typeof value === "object" && !Array.isArray(value);
684
+
685
+ const isValidPackHttpPayload = (payload) => {
686
+ if (!isRecord(payload) || typeof payload.pack_text !== "string" || !isRecord(payload.metrics)) {
687
+ return false;
688
+ }
689
+ const itemCount = payload.metrics.total_items;
690
+ if (!Number.isInteger(itemCount) || itemCount < 0) {
691
+ return false;
692
+ }
693
+ if (
694
+ payload.ledger_artifact_fingerprint != null
695
+ && !/^[0-9a-f]{64}$/i.test(payload.ledger_artifact_fingerprint)
696
+ ) {
697
+ return false;
698
+ }
699
+ if (payload.ledger_outcome != null) {
700
+ return isRecord(payload.ledger_outcome)
701
+ && payload.ledger_outcome.ok === false
702
+ && payload.ledger_outcome.errorCode === "retrieval_ledger_write_failed"
703
+ && payload.ledger_outcome.reason === "idempotency_conflict";
704
+ }
705
+ return true;
706
+ };
707
+
708
+ const isValidLedgerHttpPayload = (payload) => isRecord(payload) && payload.ok === true;
709
+
710
+ const isValidLedgerFailureHttpPayload = (payload) =>
711
+ isRecord(payload)
712
+ && payload.ok === false
713
+ && (
714
+ payload.errorCode === "retrieval_ledger_write_failed"
715
+ || payload.errorCode === "retrieval_ledger_delivery_write_failed"
716
+ )
717
+ && typeof payload.reason === "string";
718
+
719
+ const isViewerDbMismatchPayload = (payload) =>
720
+ isRecord(payload)
721
+ && isRecord(payload.error)
722
+ && payload.error.code === "viewer_db_mismatch";
723
+
724
+ const isViewerIdentityMismatchPayload = (payload) =>
725
+ isRecord(payload)
726
+ && isRecord(payload.error)
727
+ && payload.error.code === "viewer_identity_mismatch";
728
+
729
+ const isViewerContractUnsupportedPayload = (payload) =>
730
+ isRecord(payload)
731
+ && isRecord(payload.error)
732
+ && payload.error.code === "viewer_contract_unsupported";
733
+
734
+ const isViewerInvalidRequestPayload = (payload) =>
735
+ isRecord(payload)
736
+ && isRecord(payload.error)
737
+ && payload.error.code === "invalid_request";
738
+
739
+ // Dependency-free port of @codemem/core prompt-transport semantics. Keep the
740
+ // range and classifier parity pinned by the plugin injection tests.
741
+ const PROMPT_TRANSPORT_PROTOCOL_RANGE = Object.freeze({
742
+ minSupportedProtocolVersion: 1,
743
+ protocolVersion: 1,
744
+ });
745
+
746
+ const normalizePromptTransportProtocolRange = (
747
+ protocolVersion,
748
+ minSupportedProtocolVersion = undefined,
749
+ ) => {
750
+ if (!Number.isSafeInteger(protocolVersion) || protocolVersion < 1) return null;
751
+ const minimum = minSupportedProtocolVersion === undefined
752
+ ? protocolVersion
753
+ : minSupportedProtocolVersion;
754
+ if (!Number.isSafeInteger(minimum) || minimum < 1 || minimum > protocolVersion) return null;
755
+ return { minSupportedProtocolVersion: minimum, protocolVersion };
756
+ };
757
+
758
+ const arePromptTransportProtocolRangesCompatible = (left, right) =>
759
+ left.minSupportedProtocolVersion <= right.protocolVersion
760
+ && right.minSupportedProtocolVersion <= left.protocolVersion;
761
+
762
+ const classifyPromptTransportFailure = ({ kind, compatibleProfile = false }) => {
763
+ if (kind === "database_mismatch" || kind === "runtime_identity_mismatch") {
764
+ return "local_fallback";
765
+ }
766
+ if (kind === "invalid_request") {
767
+ return compatibleProfile ? "terminal" : "fallback";
768
+ }
769
+ if (kind === "policy_failure" || kind === "authorization_failure") {
770
+ return "terminal";
771
+ }
772
+ if (kind === "viewer_contract_unsupported") {
773
+ return compatibleProfile ? "terminal" : "fallback";
774
+ }
775
+ return "fallback";
776
+ };
777
+
778
+ const isViewerPolicyOrAuthFailurePayload = (payload) => {
779
+ const code = isRecord(payload) && isRecord(payload.error)
780
+ ? payload.error.code
781
+ : isRecord(payload)
782
+ ? payload.error
783
+ : null;
784
+ return typeof code === "string" && [
785
+ "authorization_failed",
786
+ "forbidden",
787
+ "policy_denied",
788
+ "policy_disabled",
789
+ "unauthorized",
790
+ ].includes(code);
791
+ };
792
+
793
+ const viewerFailureClassification = (cause, disposition) => ({
794
+ cause,
795
+ disposition,
796
+ retryable: disposition !== "terminal",
797
+ });
798
+
799
+ const classifyViewerHttpFailure = ({
800
+ operation,
801
+ status = null,
802
+ error = null,
803
+ malformed = false,
804
+ body = null,
805
+ compatibleProfile = false,
806
+ }) => {
807
+ if (malformed) {
808
+ return viewerFailureClassification(
809
+ `${operation} returned malformed success`,
810
+ classifyPromptTransportFailure({ kind: "malformed_response" }),
811
+ );
812
+ }
813
+ if (isViewerDbMismatchPayload(body)) {
814
+ return viewerFailureClassification(
815
+ `${operation} viewer database mismatch`,
816
+ classifyPromptTransportFailure({ kind: "database_mismatch" }),
817
+ );
818
+ }
819
+ if (isViewerIdentityMismatchPayload(body)) {
820
+ return viewerFailureClassification(
821
+ `${operation} viewer runtime identity mismatch`,
822
+ classifyPromptTransportFailure({ kind: "runtime_identity_mismatch" }),
823
+ );
824
+ }
825
+ if (isViewerContractUnsupportedPayload(body)) {
826
+ return viewerFailureClassification(
827
+ `${operation} viewer contract unsupported`,
828
+ classifyPromptTransportFailure({
829
+ kind: "viewer_contract_unsupported",
830
+ compatibleProfile,
831
+ }),
832
+ );
833
+ }
834
+ if (operation === "prompt-pack-ledger" && isValidLedgerFailureHttpPayload(body)) {
835
+ return viewerFailureClassification(`${operation} request rejected (${status})`, "terminal");
836
+ }
837
+ if (status === 401 || status === 403 || isViewerPolicyOrAuthFailurePayload(body)) {
838
+ return viewerFailureClassification(
839
+ `${operation} policy or authorization failure (${status})`,
840
+ classifyPromptTransportFailure({
841
+ kind: status === 401 || status === 403 ? "authorization_failure" : "policy_failure",
842
+ }),
843
+ );
844
+ }
845
+ if (isViewerInvalidRequestPayload(body)) {
846
+ return viewerFailureClassification(
847
+ `${operation} request rejected (${status})`,
848
+ classifyPromptTransportFailure({ kind: "invalid_request", compatibleProfile }),
849
+ );
850
+ }
851
+ if (status === 404 || status === 405) {
852
+ return viewerFailureClassification(`${operation} endpoint unavailable (${status})`, "fallback");
853
+ }
854
+ if (Number.isInteger(status) && status >= 500) {
855
+ return viewerFailureClassification(`${operation} server failure (${status})`, "fallback");
856
+ }
857
+ if (Number.isInteger(status)) {
858
+ return viewerFailureClassification(`${operation} unexpected response (${status})`, "fallback");
859
+ }
860
+ const diagnostic = [
861
+ error?.name,
862
+ error?.code,
863
+ error?.cause?.code,
864
+ error?.message,
865
+ error,
866
+ ].filter(Boolean).join(" ");
867
+ const timedOut = /AbortError|TimeoutError|timeout|timed out|ETIMEDOUT/i.test(diagnostic);
868
+ return viewerFailureClassification(
869
+ timedOut ? `${operation} request timeout` : `${operation} connection failed`,
870
+ "fallback",
871
+ );
872
+ };
873
+
874
+ const buildPackHttpBody = ({
875
+ query,
876
+ filesModified,
877
+ injectLimit,
878
+ injectTokenBudget,
879
+ projectName,
880
+ cwd,
881
+ dbPath,
882
+ identityTarget,
883
+ attempt,
884
+ }) => ({
885
+ context: query,
886
+ limit: injectLimit !== null && Number.isFinite(injectLimit) && injectLimit > 0
887
+ ? Math.trunc(injectLimit)
888
+ : 10,
889
+ token_budget:
890
+ injectTokenBudget !== null
891
+ && Number.isFinite(injectTokenBudget)
892
+ && injectTokenBudget > 0
893
+ ? Math.trunc(injectTokenBudget)
894
+ : null,
895
+ ...(projectName ? { project: projectName } : {}),
896
+ ...(cwd ? { cwd } : {}),
897
+ db_path: dbPath,
898
+ identity_target: identityTarget,
899
+ working_set_files: Array.from(filesModified || [])
900
+ .slice(-8)
901
+ .map((value) => String(value || "").trim())
902
+ .filter((value) => value && value.length <= MAX_WORKING_SET_PATH_CHARS),
903
+ attempt,
904
+ });
905
+
448
906
  const canonicalJson = (value) => {
449
907
  if (Array.isArray(value)) {
450
908
  return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
@@ -491,7 +949,7 @@ const applyInjectedContextToOutput = async ({
491
949
  // lastPromptText hadn't yet been captured by the time
492
950
  // experimental.chat.system.transform fired) would silently re-serve
493
951
  // the first turn's pack. Recompute on every call instead — the chat
494
- // path tolerates the runCli round trip, and correctness beats the
952
+ // path tolerates the transport round trip, and correctness beats the
495
953
  // O(1) hit when the cache key isn't tied to the prompt that produced
496
954
  // the pack.
497
955
  const query = resolveInjectQuery();
@@ -1264,7 +1722,7 @@ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
1264
1722
  return runnerFromExplicit ? [runnerFrom] : [];
1265
1723
  };
1266
1724
 
1267
- export const OpencodeMemPlugin = async ({
1725
+ export const CodememPlugin = async ({
1268
1726
  project,
1269
1727
  client,
1270
1728
  directory,
@@ -1323,6 +1781,14 @@ export const OpencodeMemPlugin = async ({
1323
1781
  const viewerHost = process.env.CODEMEM_VIEWER_HOST || "127.0.0.1";
1324
1782
  const viewerPort = process.env.CODEMEM_VIEWER_PORT || "38888";
1325
1783
  const viewerDbPath = process.env.CODEMEM_DB || "";
1784
+ const expandedViewerDbPath = viewerDbPath.startsWith("~/")
1785
+ ? join(process.env.HOME?.trim() || homedir(), viewerDbPath.slice(2))
1786
+ : viewerDbPath;
1787
+ const promptPackDbPath = resolve(
1788
+ cwd,
1789
+ expandedViewerDbPath || join(homedir(), ".codemem", "mem.sqlite"),
1790
+ );
1791
+ const promptPackIdentityTarget = buildViewerIdentityTarget(process.env, cwd);
1326
1792
  const viewerConfigPath = process.env.CODEMEM_CONFIG || "";
1327
1793
  // A malformed value (e.g. "abc", "", "-1") falls back to the 20s default
1328
1794
  // instead of NaN, which would silently disable the timeout. An explicit "0"
@@ -1332,6 +1798,8 @@ export const OpencodeMemPlugin = async ({
1332
1798
  const rawCommandTimeout = String(process.env.CODEMEM_PLUGIN_CMD_TIMEOUT ?? "").trim();
1333
1799
  const commandTimeout =
1334
1800
  rawCommandTimeout === "0" ? 0 : parsePositiveInt(rawCommandTimeout, 20000);
1801
+ const promptPackHttpTimeout =
1802
+ parsePositiveInt(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S || "2", 2) * 1000;
1335
1803
  const backendUpdatePolicy = parseBackendUpdatePolicy(
1336
1804
  process.env.CODEMEM_BACKEND_UPDATE_POLICY || "notify"
1337
1805
  );
@@ -1359,6 +1827,8 @@ export const OpencodeMemPlugin = async ({
1359
1827
  let sessionStartedAt = null;
1360
1828
  let activeSessionID = null;
1361
1829
  let viewerStarted = false;
1830
+ let viewerStartInFlight = false;
1831
+ let compatibilityAutoUpdateAttempted = false;
1362
1832
  let promptCounter = 0;
1363
1833
  let skippedAttemptCounter = 0;
1364
1834
  let lastPromptText = null;
@@ -1373,8 +1843,15 @@ export const OpencodeMemPlugin = async ({
1373
1843
  const rawEventsEnabled = envNotDisabled(
1374
1844
  process.env.CODEMEM_RAW_EVENTS || "1"
1375
1845
  );
1376
- const rawEventsUrl = `http://${viewerHost}:${viewerPort}/api/raw-events`;
1377
- const rawEventsStatusUrl = `http://${viewerHost}:${viewerPort}/api/raw-events/status?limit=1`;
1846
+ const viewerUrlHost = viewerHost.includes(":") && !viewerHost.startsWith("[")
1847
+ ? `[${viewerHost}]`
1848
+ : viewerHost;
1849
+ const rawEventsUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events`;
1850
+ const rawEventsStatusUrl = `http://${viewerUrlHost}:${viewerPort}/api/raw-events/status?limit=1`;
1851
+ const packUrl = `http://${viewerUrlHost}:${viewerPort}/api/pack`;
1852
+ const promptPackProfileUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-profile`;
1853
+ const promptPackLedgerUrl = `http://${viewerUrlHost}:${viewerPort}/api/prompt-pack-ledger`;
1854
+ const viewerHealthUrl = `http://${viewerUrlHost}:${viewerPort}/api/health`;
1378
1855
  const rawEventsBackoffMs = parseNumber(
1379
1856
  process.env.CODEMEM_RAW_EVENTS_BACKOFF_MS || "10000",
1380
1857
  10000
@@ -1392,14 +1869,8 @@ export const OpencodeMemPlugin = async ({
1392
1869
  let fallbackFailureNoted = false;
1393
1870
  let lastStatusCheckAt = 0;
1394
1871
  let lastStatusAvailable = true;
1872
+ let promptPackTransportUnavailableUntil = 0;
1395
1873
 
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
1874
  const nextEventId = () => {
1404
1875
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
1405
1876
  return crypto.randomUUID();
@@ -1525,8 +1996,10 @@ export const OpencodeMemPlugin = async ({
1525
1996
  }
1526
1997
  try {
1527
1998
  if (now - lastStatusCheckAt >= Math.max(1000, rawEventsStatusCheckMs)) {
1528
- const statusResp = await fetch(rawEventsStatusUrl, { method: "GET" });
1999
+ const statusResp = await fetchRawEventsStatus(rawEventsStatusUrl);
1529
2000
  if (!statusResp.ok) {
2001
+ // Release the unread body before bailing into the backoff path.
2002
+ discardResponseBody(statusResp);
1530
2003
  throw new Error(`raw-events status failed (${statusResp.status})`);
1531
2004
  }
1532
2005
  const statusJson = await statusResp.json();
@@ -1540,10 +2013,27 @@ export const OpencodeMemPlugin = async ({
1540
2013
  const postResp = await fetch(rawEventsUrl, {
1541
2014
  method: "POST",
1542
2015
  headers: { "Content-Type": "application/json" },
1543
- body: JSON.stringify(body),
2016
+ body: JSON.stringify({
2017
+ ...body,
2018
+ db_path: promptPackDbPath,
2019
+ identity_target: promptPackIdentityTarget,
2020
+ }),
1544
2021
  });
1545
2022
  if (!postResp.ok) {
1546
- throw new Error(`raw-events post failed (${postResp.status})`);
2023
+ let responseBody = null;
2024
+ if (postResp.status === 409) {
2025
+ try {
2026
+ responseBody = await postResp.json();
2027
+ } catch {
2028
+ // Generic failure handling below remains the safe fallback.
2029
+ }
2030
+ }
2031
+ const targetMismatch = isViewerDbMismatchPayload(responseBody)
2032
+ || isViewerIdentityMismatchPayload(responseBody)
2033
+ || isViewerContractUnsupportedPayload(responseBody);
2034
+ const postError = new Error(`raw-events post failed (${postResp.status})`);
2035
+ postError.viewerTargetMismatch = targetMismatch;
2036
+ throw postError;
1547
2037
  }
1548
2038
  streamUnavailableUntil = 0;
1549
2039
  streamErrorNoted = false;
@@ -1566,6 +2056,7 @@ export const OpencodeMemPlugin = async ({
1566
2056
  type,
1567
2057
  viewerHost,
1568
2058
  viewerPort,
2059
+ viewerTargetMismatch: err?.viewerTargetMismatch === true,
1569
2060
  error: String(err),
1570
2061
  },
1571
2062
  });
@@ -1881,12 +2372,28 @@ export const OpencodeMemPlugin = async ({
1881
2372
  return { usage, id: info.id };
1882
2373
  };
1883
2374
 
1884
- const startViewer = () => {
1885
- if (!viewerEnabled || !viewerAutoStart || viewerStarted) {
2375
+ const startViewer = async () => {
2376
+ if (!viewerEnabled || !viewerAutoStart || viewerStarted || viewerStartInFlight) {
1886
2377
  if (viewerStarted) logLine("viewer already started, skipping auto-start").catch(() => {});
1887
2378
  return;
1888
2379
  }
1889
- viewerStarted = true;
2380
+ viewerStartInFlight = true;
2381
+ let existingViewer = false;
2382
+ try {
2383
+ const existing = await fetch(viewerHealthUrl, {
2384
+ method: "GET",
2385
+ redirect: "manual",
2386
+ signal: AbortSignal.timeout(1_000),
2387
+ });
2388
+ existingViewer = existing.ok;
2389
+ } catch {
2390
+ // No live viewer responded; proceed with the plugin-owned start.
2391
+ }
2392
+ if (existingViewer) {
2393
+ viewerStartInFlight = false;
2394
+ logLine("viewer already running, skipping plugin-owned auto-start").catch(() => {});
2395
+ return;
2396
+ }
1890
2397
  const viewerArgs = buildViewerCliArgs("start");
1891
2398
  const cmd = [runner, ...runnerArgs, ...viewerArgs];
1892
2399
  logLine(`auto-starting viewer: ${cmd.join(" ")}`).catch(() => {});
@@ -1897,18 +2404,24 @@ export const OpencodeMemPlugin = async ({
1897
2404
  detached: true,
1898
2405
  stdio: "ignore",
1899
2406
  });
2407
+ child.once("spawn", () => {
2408
+ viewerStarted = true;
2409
+ viewerStartInFlight = false;
2410
+ startHealthCheck();
2411
+ });
1900
2412
  child.on("error", (err) => {
2413
+ viewerStartInFlight = false;
1901
2414
  logLine(`viewer spawn error: ${err.message}`).catch(() => {});
1902
2415
  });
1903
2416
  child.unref();
1904
2417
  } catch (err) {
2418
+ viewerStartInFlight = false;
1905
2419
  logLine(`viewer spawn failed: ${err}`).catch(() => {});
1906
2420
  }
1907
- startHealthCheck();
1908
2421
  };
1909
2422
 
1910
2423
  const runCommand = async (cmd, options = {}) => {
1911
- const { stdinText = null } = options;
2424
+ const { stdinText = null, timeoutMs = commandTimeout } = options;
1912
2425
  const [command, ...args] = cmd;
1913
2426
  return new Promise((resolve) => {
1914
2427
  const proc = nodeSpawn(command, args, {
@@ -1947,19 +2460,31 @@ export const OpencodeMemPlugin = async ({
1947
2460
  return;
1948
2461
  }
1949
2462
  let timer = null;
1950
- if (Number.isFinite(commandTimeout) && commandTimeout > 0) {
2463
+ let killTimer = null;
2464
+ let timedOut = false;
2465
+ let settled = false;
2466
+ const finish = (result) => {
2467
+ if (settled) return;
2468
+ settled = true;
2469
+ if (timer) clearTimeout(timer);
2470
+ if (killTimer) clearTimeout(killTimer);
2471
+ resolve(result);
2472
+ };
2473
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
1951
2474
  timer = setTimeout(() => {
1952
- try { proc.kill(); } catch { /* ignore */ }
1953
- resolve({ exitCode: null, stdout, stderr: "timeout" });
1954
- }, commandTimeout);
2475
+ timedOut = true;
2476
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
2477
+ killTimer = setTimeout(() => {
2478
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
2479
+ }, 5_000);
2480
+ if (killTimer.unref) killTimer.unref();
2481
+ }, timeoutMs);
1955
2482
  }
1956
2483
  proc.once("exit", (exitCode) => {
1957
- if (timer) clearTimeout(timer);
1958
- resolve({ exitCode, stdout, stderr });
2484
+ finish({ exitCode: timedOut ? null : exitCode, stdout, stderr: timedOut ? "timeout" : stderr });
1959
2485
  });
1960
2486
  proc.once("error", (err) => {
1961
- if (timer) clearTimeout(timer);
1962
- resolve({ exitCode: 1, stdout: "", stderr: String(err) });
2487
+ finish({ exitCode: 1, stdout: "", stderr: String(err) });
1963
2488
  });
1964
2489
  });
1965
2490
  };
@@ -1967,6 +2492,140 @@ export const OpencodeMemPlugin = async ({
1967
2492
  const runCli = async (args, options = {}) =>
1968
2493
  runCommand([runner, ...runnerArgs, ...args], options);
1969
2494
 
2495
+ const postViewerJson = async ({ url, operation, payload, validate }) => {
2496
+ if (!viewerEnabled) {
2497
+ return {
2498
+ ok: false,
2499
+ classification: viewerFailureClassification(
2500
+ `${operation} viewer transport disabled`,
2501
+ "fallback",
2502
+ ),
2503
+ };
2504
+ }
2505
+ if (Date.now() < promptPackTransportUnavailableUntil) {
2506
+ return {
2507
+ ok: false,
2508
+ classification: viewerFailureClassification(
2509
+ `${operation} viewer transport in backoff`,
2510
+ "fallback",
2511
+ ),
2512
+ };
2513
+ }
2514
+
2515
+ let response;
2516
+ try {
2517
+ const profileResponse = await fetch(promptPackProfileUrl, {
2518
+ method: "GET",
2519
+ redirect: "manual",
2520
+ signal: AbortSignal.timeout(promptPackHttpTimeout),
2521
+ });
2522
+ let profileBody;
2523
+ try {
2524
+ profileBody = await profileResponse.json();
2525
+ } catch {
2526
+ profileBody = null;
2527
+ }
2528
+ if (!profileResponse.ok) {
2529
+ const classification = classifyViewerHttpFailure({
2530
+ operation: `${operation} profile`,
2531
+ status: profileResponse.status,
2532
+ body: profileBody,
2533
+ });
2534
+ if (classification.retryable) {
2535
+ promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2536
+ }
2537
+ return { ok: false, classification };
2538
+ }
2539
+ const viewerProtocolRange = isRecord(profileBody)
2540
+ ? normalizePromptTransportProtocolRange(
2541
+ profileBody.protocol_version,
2542
+ profileBody.min_supported_protocol_version,
2543
+ )
2544
+ : null;
2545
+ let profileFailure = null;
2546
+ if (!isRecord(profileBody) || profileBody.service !== "codemem-viewer") {
2547
+ profileFailure = viewerFailureClassification(
2548
+ `${operation} viewer profile malformed`,
2549
+ classifyPromptTransportFailure({ kind: "profile_malformed" }),
2550
+ );
2551
+ } else if (!viewerProtocolRange) {
2552
+ profileFailure = viewerFailureClassification(
2553
+ `${operation} viewer protocol range malformed`,
2554
+ classifyPromptTransportFailure({ kind: "profile_malformed" }),
2555
+ );
2556
+ } else if (!arePromptTransportProtocolRangesCompatible(
2557
+ PROMPT_TRANSPORT_PROTOCOL_RANGE,
2558
+ viewerProtocolRange,
2559
+ )) {
2560
+ profileFailure = viewerFailureClassification(
2561
+ `${operation} viewer protocol range unsupported`,
2562
+ classifyPromptTransportFailure({ kind: "protocol_range_mismatch" }),
2563
+ );
2564
+ } else if (profileBody.db_path !== promptPackDbPath) {
2565
+ profileFailure = viewerFailureClassification(
2566
+ `${operation} viewer database mismatch`,
2567
+ classifyPromptTransportFailure({ kind: "database_mismatch" }),
2568
+ );
2569
+ } else if (
2570
+ canonicalJson(profileBody.identity_target) !== canonicalJson(promptPackIdentityTarget)
2571
+ ) {
2572
+ profileFailure = viewerFailureClassification(
2573
+ `${operation} viewer runtime identity mismatch`,
2574
+ classifyPromptTransportFailure({ kind: "runtime_identity_mismatch" }),
2575
+ );
2576
+ }
2577
+ if (profileFailure) {
2578
+ promptPackTransportUnavailableUntil = Date.now() + Math.max(1000, rawEventsBackoffMs);
2579
+ return {
2580
+ ok: false,
2581
+ classification: profileFailure,
2582
+ };
2583
+ }
2584
+ response = await fetch(url, {
2585
+ method: "POST",
2586
+ redirect: "manual",
2587
+ headers: { "Content-Type": "application/json" },
2588
+ body: JSON.stringify(payload),
2589
+ signal: AbortSignal.timeout(promptPackHttpTimeout),
2590
+ });
2591
+ } catch (error) {
2592
+ const classification = classifyViewerHttpFailure({ operation, error });
2593
+ promptPackTransportUnavailableUntil =
2594
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2595
+ return { ok: false, classification };
2596
+ }
2597
+
2598
+ let body;
2599
+ try {
2600
+ body = await response.json();
2601
+ } catch {
2602
+ body = null;
2603
+ }
2604
+ if (!response.ok) {
2605
+ const classification = classifyViewerHttpFailure({
2606
+ operation,
2607
+ status: response.status,
2608
+ body,
2609
+ compatibleProfile: true,
2610
+ });
2611
+ if (classification.retryable) {
2612
+ promptPackTransportUnavailableUntil =
2613
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2614
+ }
2615
+ return { ok: false, classification };
2616
+ }
2617
+
2618
+ if (!validate(body)) {
2619
+ const classification = classifyViewerHttpFailure({ operation, malformed: true });
2620
+ promptPackTransportUnavailableUntil =
2621
+ Date.now() + Math.max(1000, rawEventsBackoffMs);
2622
+ return { ok: false, classification };
2623
+ }
2624
+
2625
+ promptPackTransportUnavailableUntil = 0;
2626
+ return { ok: true, body };
2627
+ };
2628
+
1970
2629
  const attemptMetadata = (identity, sessionID = null, promptNumber = promptCounter) => ({
1971
2630
  attempt_id: identity.attemptId,
1972
2631
  started_at: (() => {
@@ -1988,10 +2647,39 @@ export const OpencodeMemPlugin = async ({
1988
2647
  });
1989
2648
 
1990
2649
  const runPromptPackLedger = async (payload) => {
2650
+ const viewerPayload = {
2651
+ ...payload,
2652
+ db_path: promptPackDbPath,
2653
+ identity_target: promptPackIdentityTarget,
2654
+ };
2655
+ const httpResult = await postViewerJson({
2656
+ url: promptPackLedgerUrl,
2657
+ operation: "prompt-pack-ledger",
2658
+ payload: viewerPayload,
2659
+ validate: isValidLedgerHttpPayload,
2660
+ });
2661
+ if (httpResult.ok) {
2662
+ return {
2663
+ exitCode: 0,
2664
+ stdout: JSON.stringify(httpResult.body),
2665
+ stderr: "",
2666
+ transport: "viewer",
2667
+ };
2668
+ }
2669
+
2670
+ const { cause, retryable } = httpResult.classification;
2671
+ await logLine(
2672
+ `inject.ledger.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
2673
+ );
2674
+ if (!retryable) {
2675
+ return { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" };
2676
+ }
2677
+
1991
2678
  try {
1992
- return await runCli(["prompt-pack-ledger"], {
2679
+ const result = await runCli(["prompt-pack-ledger"], {
1993
2680
  stdinText: JSON.stringify(payload),
1994
2681
  });
2682
+ return { ...result, transport: "cli" };
1995
2683
  } catch {
1996
2684
  return null;
1997
2685
  }
@@ -2077,7 +2765,7 @@ export const OpencodeMemPlugin = async ({
2077
2765
  if (!viewerEnabled || !viewerAutoStart || !viewerStarted) {
2078
2766
  return { attempted: false, ok: false };
2079
2767
  }
2080
- const restartResult = await runCli(buildViewerCliArgs("restart"));
2768
+ const restartResult = await runCli(buildViewerCliArgs("restart"), { timeoutMs: 60_000 });
2081
2769
  if (restartResult?.exitCode === 0) {
2082
2770
  await logLine("compat.auto_update_viewer_restart ok");
2083
2771
  return { attempted: true, ok: true };
@@ -2157,12 +2845,14 @@ export const OpencodeMemPlugin = async ({
2157
2845
  `compat.version_mismatch current=${currentVersion} required=${minVersion} mode=${guidance.mode} note=${redactLog(guidance.note)}`
2158
2846
  );
2159
2847
 
2160
- const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom });
2161
2848
  if (backendUpdatePolicy === "auto") {
2162
- if (autoPlan.allowed && Array.isArray(autoPlan.command) && autoPlan.command.length > 0) {
2163
- const commandText = autoPlan.commandText || autoPlan.command.join(" ");
2164
- await logLine(`compat.auto_update_start cmd=${redactLog(commandText)}`);
2165
- const updateResult = await runCommand(autoPlan.command);
2849
+ compatibilityAutoUpdateAttempted = true;
2850
+ await logLine("compat.auto_update_start cmd=codemem update install --json");
2851
+ const updateResult = await runCli(
2852
+ ["update", "install", "--json"],
2853
+ { timeoutMs: 420_000 }
2854
+ );
2855
+ if (updateResult?.exitCode === 0) {
2166
2856
  await logLine(
2167
2857
  `compat.auto_update_result exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog(
2168
2858
  (updateResult?.stderr || "").trim()
@@ -2172,8 +2862,7 @@ export const OpencodeMemPlugin = async ({
2172
2862
  const refreshedResult = await runCli(["version"]);
2173
2863
  const refreshedVersion = (refreshedResult?.stdout || "").trim();
2174
2864
  if (
2175
- updateResult?.exitCode === 0
2176
- && refreshedResult?.exitCode === 0
2865
+ refreshedResult?.exitCode === 0
2177
2866
  && isVersionAtLeast(refreshedVersion, minVersion)
2178
2867
  ) {
2179
2868
  writeCompatCheckCache(cacheKey, refreshedVersion);
@@ -2193,19 +2882,31 @@ export const OpencodeMemPlugin = async ({
2193
2882
  }
2194
2883
  return;
2195
2884
  }
2196
-
2885
+ await logLine(
2886
+ `compat.auto_update_verification_failed current=${redactLog(refreshedVersion || "unknown")} required=${redactLog(minVersion)}`
2887
+ );
2197
2888
  await showToast(
2198
- `${message}. Auto-update did not resolve it. Suggested action: ${guidance.action}`,
2889
+ `${message}. Auto-update completed, but the active CLI failed verification. Suggested action: ${guidance.action}`,
2199
2890
  "warning"
2200
2891
  );
2201
2892
  return;
2202
2893
  }
2203
-
2894
+ let installError = null;
2895
+ try {
2896
+ installError = JSON.parse((updateResult?.stdout || "").trim())?.error || null;
2897
+ } catch {
2898
+ // Non-JSON output is treated as an installation failure.
2899
+ }
2900
+ const failureReason = installError === "update_install_locked"
2901
+ ? "another update is already running"
2902
+ : installError === "update_install_refused"
2903
+ ? "not eligible"
2904
+ : "installation failed";
2204
2905
  await logLine(
2205
- `compat.auto_update_skipped reason=${autoPlan.reason || "not-eligible"}`
2906
+ `compat.auto_update_skipped reason=${installError || "update_install_failed"} exit=${updateResult?.exitCode ?? "unknown"} stderr=${redactLog((updateResult?.stderr || "").trim())}`
2206
2907
  );
2207
2908
  await showToast(
2208
- `${message}. Auto-update skipped (${autoPlan.reason || "not eligible"}). Suggested action: ${guidance.action}`,
2909
+ `${message}. Auto-update skipped (${failureReason}). Suggested action: ${guidance.action}`,
2209
2910
  "warning"
2210
2911
  );
2211
2912
  return;
@@ -2214,6 +2915,53 @@ export const OpencodeMemPlugin = async ({
2214
2915
  await showToast(`${message}. Suggested action: ${guidance.action}`, "warning");
2215
2916
  };
2216
2917
 
2918
+ const checkForReleaseUpdate = async () => {
2919
+ if (backendUpdatePolicy === "off") return;
2920
+ const notification = parseReleaseNotification(
2921
+ await runCli(["update", "check", "--json"])
2922
+ );
2923
+ if (
2924
+ !notification
2925
+ || notifiedReleaseVersions.has(notification.latestVersion)
2926
+ ) {
2927
+ return;
2928
+ }
2929
+ notifiedReleaseVersions.add(notification.latestVersion);
2930
+ if (
2931
+ backendUpdatePolicy === "auto"
2932
+ && notification.autoUpdateEligible
2933
+ && !compatibilityAutoUpdateAttempted
2934
+ ) {
2935
+ const autoPlan = resolveAutoUpdatePlan({ runner, runnerFrom, runnerFromExplicit });
2936
+ if (autoPlan.allowed) {
2937
+ const installation = await runCli(
2938
+ ["update", "install", "--json"],
2939
+ { timeoutMs: 420_000 }
2940
+ );
2941
+ if (installation?.exitCode === 0) {
2942
+ const viewerRestart = await restartViewerAfterAutoUpdate();
2943
+ await showToast(`Updated codemem to ${notification.latestVersion}.`, "success");
2944
+ if (viewerRestart.attempted && !viewerRestart.ok) {
2945
+ await showToast(
2946
+ "Backend updated, but viewer restart failed. Run `codemem serve restart`.",
2947
+ "warning"
2948
+ );
2949
+ }
2950
+ return;
2951
+ }
2952
+ await logLine(
2953
+ `release.auto_update_failed exit=${installation?.exitCode ?? "unknown"} stderr=${redactLog(
2954
+ (installation?.stderr || "").trim()
2955
+ )}`
2956
+ );
2957
+ }
2958
+ }
2959
+ await showToast(
2960
+ `codemem ${notification.latestVersion} is available. ${notification.recommendedAction}`,
2961
+ "warning"
2962
+ );
2963
+ };
2964
+
2217
2965
  const resolveInjectQuery = (overrides = {}) => {
2218
2966
  const firstPrompt = hasOwn(overrides, "firstPrompt")
2219
2967
  ? overrides.firstPrompt
@@ -2317,12 +3065,51 @@ export const OpencodeMemPlugin = async ({
2317
3065
  injectTokenBudget,
2318
3066
  internalLedger: true,
2319
3067
  });
3068
+ const httpResult = await postViewerJson({
3069
+ url: packUrl,
3070
+ operation: "pack",
3071
+ payload: buildPackHttpBody({
3072
+ query,
3073
+ filesModified: sessionContext.filesModified,
3074
+ injectLimit,
3075
+ injectTokenBudget,
3076
+ projectName: normalizeProjectLabel(process.env.CODEMEM_PROJECT),
3077
+ cwd,
3078
+ dbPath: promptPackDbPath,
3079
+ identityTarget: promptPackIdentityTarget,
3080
+ attempt: metadata,
3081
+ }),
3082
+ validate: isValidPackHttpPayload,
3083
+ });
3084
+ if (httpResult.ok) {
3085
+ return {
3086
+ packArgs,
3087
+ result: {
3088
+ exitCode: 0,
3089
+ stdout: JSON.stringify(httpResult.body),
3090
+ stderr: "",
3091
+ transport: "viewer",
3092
+ },
3093
+ };
3094
+ }
3095
+
3096
+ const { cause, retryable } = httpResult.classification;
3097
+ await logLine(
3098
+ `inject.pack.http_error cause=${JSON.stringify(redactLog(cause, 200))} retryable=${retryable}`
3099
+ );
3100
+ if (!retryable) {
3101
+ return {
3102
+ packArgs,
3103
+ result: { exitCode: 1, stdout: "", stderr: cause, transport: "viewer" },
3104
+ };
3105
+ }
3106
+
2320
3107
  let result = await runCli(packArgs, { stdinText: JSON.stringify(metadata) });
2321
3108
  if (rejectsInternalLedgerFlag(result)) {
2322
3109
  packArgs = packArgs.filter((arg) => arg !== "--internal-ledger");
2323
3110
  result = await runCli(packArgs);
2324
3111
  }
2325
- return { packArgs, result };
3112
+ return { packArgs, result: { ...result, transport: "cli" } };
2326
3113
  };
2327
3114
  let { packArgs, result } = await runPack();
2328
3115
  let { packText, conflictPackText, metrics, itemCount, ledgerConflict } = parsePackOutput(result);
@@ -2333,7 +3120,7 @@ export const OpencodeMemPlugin = async ({
2333
3120
  let repairFallbackUsed = false;
2334
3121
  if (ledgerConflict) {
2335
3122
  // A restarted plugin has no artifact cache to predict this conflict. The
2336
- // CLI marker is authoritative: never attribute delivery to the stale
3123
+ // ledger marker is authoritative: never attribute delivery to the stale
2337
3124
  // identity, and retry once with a fresh deterministic identity.
2338
3125
  await log("warn", "codemem prompt-pack ledger conflict; retrying with fresh identity", {
2339
3126
  sessionID,
@@ -2606,62 +3393,15 @@ export const OpencodeMemPlugin = async ({
2606
3393
  await runCli(buildViewerCliArgs("stop"));
2607
3394
  };
2608
3395
 
2609
- const checkViewerHealth = async () => {
2610
- if (!viewerStarted || !viewerEnabled) return;
2611
- try {
2612
- const resp = await fetch(rawEventsStatusUrl, {
2613
- method: "GET",
2614
- signal: AbortSignal.timeout(5000),
2615
- });
2616
- if (resp.ok) {
2617
- if (healthConsecutiveFailures > 0) {
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
- };
3396
+ const viewerHealthMonitor = createViewerHealthMonitor({
3397
+ viewerHealthUrl,
3398
+ legacyStatusUrl: rawEventsStatusUrl,
3399
+ isActive: () => viewerStarted && viewerEnabled,
3400
+ restartViewer: () => runCli(buildViewerCliArgs("restart")),
3401
+ logLine,
3402
+ });
3403
+ const startHealthCheck = viewerHealthMonitor.start;
3404
+ const stopHealthCheck = viewerHealthMonitor.stop;
2665
3405
 
2666
3406
  // Get version info (commit hash) for debugging
2667
3407
  let version = "unknown";
@@ -2680,15 +3420,26 @@ export const OpencodeMemPlugin = async ({
2680
3420
 
2681
3421
  await log("info", "codemem plugin initialized", { cwd, version });
2682
3422
  await logLine(`plugin initialized cwd=${cwd} version=${version}`);
2683
- startViewer();
2684
- const compatCheckTimer = setTimeout(() => {
2685
- void verifyCliCompatibility().catch(async (err) => {
2686
- await logLine(
2687
- `compat.version_check_error message=${String(err?.message || err || "unknown")}`
2688
- );
2689
- });
3423
+ void startViewer();
3424
+ const updateCheckTimer = setTimeout(() => {
3425
+ void (async () => {
3426
+ try {
3427
+ await verifyCliCompatibility();
3428
+ } catch (err) {
3429
+ await logLine(
3430
+ `compat.version_check_error message=${String(err?.message || err || "unknown")}`
3431
+ );
3432
+ }
3433
+ try {
3434
+ await checkForReleaseUpdate();
3435
+ } catch (err) {
3436
+ await logLine(
3437
+ `release.update_check_error message=${String(err?.message || err || "unknown")}`
3438
+ );
3439
+ }
3440
+ })();
2690
3441
  }, COMPAT_CHECK_DELAY_MS);
2691
- if (compatCheckTimer.unref) compatCheckTimer.unref();
3442
+ if (updateCheckTimer.unref) updateCheckTimer.unref();
2692
3443
 
2693
3444
  const truncate = (value) => {
2694
3445
  if (value === undefined || value === null) {
@@ -2937,9 +3688,9 @@ export const OpencodeMemPlugin = async ({
2937
3688
  } 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
3689
  );
2939
3690
  }
2940
- // Without the old per-session cache, every transform call shells out
2941
- // through `runCli`. Swallow rejections here so a single failed pack
2942
- // build (CLI crash, sqlite locked, network blip on HTTP fallback)
3691
+ // Without the old per-session cache, every transform call rebuilds the
3692
+ // pack. Swallow rejections here so a single failed build (viewer failure,
3693
+ // CLI fallback crash, sqlite lock, or network blip)
2943
3694
  // can't take down the chat path.
2944
3695
  let applied = false;
2945
3696
  try {
@@ -3217,7 +3968,7 @@ export const OpencodeMemPlugin = async ({
3217
3968
  const stats = await runCli(["stats"]);
3218
3969
  const recent = await runCli(["recent", "--limit", "5"]);
3219
3970
  const lines = [
3220
- `viewer: http://${viewerHost}:${viewerPort}`,
3971
+ `viewer: http://${viewerUrlHost}:${viewerPort}`,
3221
3972
  `log: ${logPath || "disabled"}`,
3222
3973
  ];
3223
3974
  if (stats.exitCode === 0 && stats.stdout.trim()) {
@@ -3262,9 +4013,16 @@ export const OpencodeMemPlugin = async ({
3262
4013
  };
3263
4014
  };
3264
4015
 
3265
- export default OpencodeMemPlugin;
4016
+ export default CodememPlugin;
4017
+
4018
+ /**
4019
+ * @deprecated Use CodememPlugin.
4020
+ * Keep this reference-identical: OpenCode deduplicates plugin exports by identity.
4021
+ */
4022
+ export const OpencodeMemPlugin = CodememPlugin;
3266
4023
  export const __testUtils = {
3267
4024
  PINNED_BACKEND_VERSION,
4025
+ fetchRawEventsStatus,
3268
4026
  inferProjectFromCwd,
3269
4027
  normalizeProjectLabel,
3270
4028
  resolveProjectName,
@@ -3276,8 +4034,17 @@ export const __testUtils = {
3276
4034
  redactPackCommand,
3277
4035
  rejectsInternalLedgerFlag,
3278
4036
  classifyFallbackCommandResult,
4037
+ PROMPT_TRANSPORT_PROTOCOL_RANGE,
4038
+ normalizePromptTransportProtocolRange,
4039
+ arePromptTransportProtocolRangesCompatible,
4040
+ classifyPromptTransportFailure,
4041
+ classifyViewerHttpFailure,
4042
+ isValidPackHttpPayload,
4043
+ isValidLedgerHttpPayload,
4044
+ buildPackHttpBody,
3279
4045
  parsePackText,
3280
4046
  parsePackMetrics,
4047
+ buildViewerIdentityTarget,
3281
4048
  resolveInjectSurface,
3282
4049
  applyInjectedContextToOutput,
3283
4050
  applyInjectedContextToMessages,
@@ -3296,4 +4063,5 @@ export const __testUtils = {
3296
4063
  buildRawEventEnvelope,
3297
4064
  trimEventQueue,
3298
4065
  parsePositiveInt,
4066
+ createViewerHealthMonitor,
3299
4067
  };
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 { OpencodeMemPlugin as default } from "./.opencode/plugins/codemem.js";
2
- export { OpencodeMemPlugin } from "./.opencode/plugins/codemem.js";
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.40.2",
3
+ "version": "0.42.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.12"
21
+ "@opencode-ai/plugin": "1.18.18"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",