@codemem/opencode-plugin 0.46.0-rc.4 → 0.46.1

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.
@@ -1,8 +1,11 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
3
+ import { join } from "node:path";
2
4
  import {
3
5
  DEFAULT_DRAIN_LIMIT,
4
6
  DEFAULT_MAX_ENTRIES,
5
7
  loadRawEventSpoolEntries,
8
+ RAW_EVENT_SPOOL_CONFLICT_CODE,
6
9
  RAW_EVENT_SPOOL_FULL_CODE,
7
10
  removeRawEventSpoolEntry,
8
11
  resolveSpoolDirectory,
@@ -95,6 +98,24 @@ const warnSpoolPersistenceFailure = async (state, sessionID, reason) => {
95
98
  await bestEffortNotify(state, { message: `codemem: ${message}`, variant: "error" });
96
99
  };
97
100
 
101
+ const SPOOL_STAGES = new Set(["directory", "existing_entry", "capacity", "temporary_write", "publish", "directory_sync"]);
102
+ const SPOOL_ERRNOS = new Set(["EACCES", "EPERM", "ENOSPC", "EROFS", "ENOTDIR", "EEXIST", "EIO", "EINVAL", "EMFILE", "ENFILE"]);
103
+
104
+ const spoolFailureDiagnostic = (error) => {
105
+ const stage = SPOOL_STAGES.has(error?.spoolStage) ? error.spoolStage : "unknown";
106
+ let code = "other";
107
+ if (error?.code === RAW_EVENT_SPOOL_CONFLICT_CODE) code = "conflict";
108
+ else if (error?.code === RAW_EVENT_SPOOL_FULL_CODE) code = "full";
109
+ else if (SPOOL_ERRNOS.has(error?.code)) code = error.code;
110
+ return `raw_events.spool.failure stage=${stage} code=${code} category=persistence`;
111
+ };
112
+
113
+ const persistSafeSpoolDiagnostic = async (state, line) => {
114
+ const directory = join(state.options.spoolHome, ".codemem");
115
+ await mkdir(directory, { recursive: true, mode: 0o700 });
116
+ await appendFile(join(directory, "plugin.log"), `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
117
+ };
118
+
98
119
  const persistForRetry = async (state, { body, serialized, payload, sessionID }) => {
99
120
  try {
100
121
  await writeRawEventSpoolEntry({
@@ -109,6 +130,14 @@ const persistForRetry = async (state, { body, serialized, payload, sessionID })
109
130
  } catch (error) {
110
131
  const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
111
132
  await state.options.logLine(`raw_events.spool.${reason} category=persistence`);
133
+ const diagnostic = spoolFailureDiagnostic(error);
134
+ try {
135
+ await persistSafeSpoolDiagnostic(state, diagnostic);
136
+ } catch {
137
+ // The same disk/permission failure can block the local log. Keep the
138
+ // category visible in the host's stderr without exposing event content.
139
+ console.error(`codemem: ${diagnostic}`);
140
+ }
112
141
  await warnSpoolPersistenceFailure(state, sessionID, reason);
113
142
  return false;
114
143
  }
@@ -11,10 +11,12 @@ import {
11
11
  } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
14
15
 
15
16
  const DEFAULT_DRAIN_LIMIT = 20;
16
17
  const DEFAULT_MAX_ENTRIES = 2000;
17
18
  const RAW_EVENT_SPOOL_FULL_CODE = "raw_event_spool_full";
19
+ const RAW_EVENT_SPOOL_CONFLICT_CODE = "raw_event_spool_conflict";
18
20
  const SPOOL_DIRECTORY_NAME = "opencode-raw-event-spool";
19
21
  const spoolWritesInFlight = new Map();
20
22
 
@@ -36,6 +38,28 @@ const requireEventId = (envelope) => {
36
38
  return eventId;
37
39
  };
38
40
 
41
+ /** Delivery timestamps can change when the same host event is captured again. */
42
+ const sameEventApartFromDeliveryTime = (existingBytes, incomingBytes) => {
43
+ if (existingBytes === incomingBytes) return true;
44
+ try {
45
+ const existing = JSON.parse(existingBytes);
46
+ const incoming = JSON.parse(incomingBytes);
47
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) return false;
48
+ if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return false;
49
+ const withoutDeliveryTime = ({ ts_wall_ms: _wall, ts_mono_ms: _mono, ...event }) => event;
50
+ return isDeepStrictEqual(withoutDeliveryTime(existing), withoutDeliveryTime(incoming));
51
+ } catch {
52
+ // Malformed retained data is not proof that the new event is already durable.
53
+ return false;
54
+ }
55
+ };
56
+
57
+ const conflictingEntry = () => {
58
+ const error = new Error("raw event spool entry conflicts with existing event_id");
59
+ error.code = RAW_EVENT_SPOOL_CONFLICT_CODE;
60
+ return error;
61
+ };
62
+
39
63
  const ensurePrivateDirectory = async (directory) => {
40
64
  const firstCreated = await mkdir(directory, { recursive: true, mode: 0o700 });
41
65
  await chmod(directory, 0o700);
@@ -75,6 +99,61 @@ const syncDirectory = async (path) => {
75
99
  }
76
100
  };
77
101
 
102
+ const atSpoolStage = async (stage, action) => {
103
+ try {
104
+ return await action();
105
+ } catch (error) {
106
+ if (error && typeof error === "object") error.spoolStage = stage;
107
+ throw error;
108
+ }
109
+ };
110
+
111
+ const readExistingEntry = async (directory, destination, bytes, eventId) => {
112
+ try {
113
+ return await atSpoolStage("existing_entry", async () => {
114
+ const existing = await readFile(destination, "utf8");
115
+ if (!sameEventApartFromDeliveryTime(existing, bytes)) throw conflictingEntry();
116
+ await chmod(destination, 0o600);
117
+ await syncDirectory(directory);
118
+ return { eventId, serialized: existing };
119
+ });
120
+ } catch (error) {
121
+ if (error?.code !== "ENOENT") throw error;
122
+ return null;
123
+ }
124
+ };
125
+
126
+ const requireSpoolCapacity = async (directory, maxEntries) =>
127
+ atSpoolStage("capacity", async () => {
128
+ const directoryEntries = await readdir(directory, { withFileTypes: true });
129
+ const entryCount = directoryEntries.filter(
130
+ (entry) => entry.isFile() && entry.name.endsWith(".json"),
131
+ ).length;
132
+ if (entryCount < maxEntries) return;
133
+ const error = new Error("raw event spool is full");
134
+ error.code = RAW_EVENT_SPOOL_FULL_CODE;
135
+ throw error;
136
+ });
137
+
138
+ const publishSpoolEntry = async (temporary, destination, bytes) => {
139
+ try {
140
+ await atSpoolStage("temporary_write", () => writeDurableFile(temporary, bytes));
141
+ await atSpoolStage("publish", async () => {
142
+ try {
143
+ await link(temporary, destination);
144
+ } catch (error) {
145
+ if (error?.code !== "EEXIST") throw error;
146
+ const existing = await readFile(destination, "utf8");
147
+ if (!sameEventApartFromDeliveryTime(existing, bytes)) throw conflictingEntry();
148
+ await chmod(destination, 0o600);
149
+ }
150
+ await chmod(destination, 0o600);
151
+ });
152
+ } finally {
153
+ await rm(temporary, { force: true }).catch(() => {});
154
+ }
155
+ };
156
+
78
157
  const writeRawEventSpoolEntryUnlocked = async ({
79
158
  envelope,
80
159
  serialized,
@@ -93,50 +172,12 @@ const writeRawEventSpoolEntryUnlocked = async ({
93
172
  `.${spoolFilename(eventId)}.${process.pid}.${randomUUID()}.tmp`,
94
173
  );
95
174
 
96
- await ensurePrivateDirectory(directory);
97
- try {
98
- const existing = await readFile(destination, "utf8");
99
- if (existing !== bytes) {
100
- throw new Error("raw event spool entry conflicts with existing event_id");
101
- }
102
- await chmod(destination, 0o600);
103
- await syncDirectory(directory);
104
- return { eventId, serialized: existing };
105
- } catch (error) {
106
- if (error?.code !== "ENOENT") {
107
- throw error;
108
- }
109
- }
110
-
111
- const directoryEntries = await readdir(directory, { withFileTypes: true });
112
- const entryCount = directoryEntries.filter(
113
- (entry) => entry.isFile() && entry.name.endsWith(".json"),
114
- ).length;
115
- if (entryCount >= normalizedMaxEntries) {
116
- const error = new Error("raw event spool is full");
117
- error.code = RAW_EVENT_SPOOL_FULL_CODE;
118
- throw error;
119
- }
120
-
121
- try {
122
- await writeDurableFile(temporary, bytes);
123
- try {
124
- await link(temporary, destination);
125
- } catch (error) {
126
- if (error?.code !== "EEXIST") {
127
- throw error;
128
- }
129
- const existing = await readFile(destination, "utf8");
130
- if (existing !== bytes) {
131
- throw new Error("raw event spool entry conflicts with existing event_id");
132
- }
133
- await chmod(destination, 0o600);
134
- }
135
- await chmod(destination, 0o600);
136
- } finally {
137
- await rm(temporary, { force: true }).catch(() => {});
138
- }
139
- await syncDirectory(directory);
175
+ await atSpoolStage("directory", () => ensurePrivateDirectory(directory));
176
+ const existing = await readExistingEntry(directory, destination, bytes, eventId);
177
+ if (existing) return existing;
178
+ await requireSpoolCapacity(directory, normalizedMaxEntries);
179
+ await publishSpoolEntry(temporary, destination, bytes);
180
+ await atSpoolStage("directory_sync", () => syncDirectory(directory));
140
181
 
141
182
  return { eventId, serialized: bytes };
142
183
  };
@@ -225,6 +266,7 @@ export {
225
266
  DEFAULT_DRAIN_LIMIT,
226
267
  DEFAULT_MAX_ENTRIES,
227
268
  loadRawEventSpoolEntries,
269
+ RAW_EVENT_SPOOL_CONFLICT_CODE,
228
270
  RAW_EVENT_SPOOL_FULL_CODE,
229
271
  removeRawEventSpoolEntry,
230
272
  resolveSpoolDirectory,
@@ -30,7 +30,7 @@ import {
30
30
 
31
31
  const TRUTHY_VALUES = ["1", "true", "yes"];
32
32
  const DISABLED_VALUES = ["0", "false", "off"];
33
- const PINNED_BACKEND_VERSION = "0.46.0-rc.4";
33
+ const PINNED_BACKEND_VERSION = "0.46.1";
34
34
  const COMPAT_CHECK_DELAY_MS = 1500;
35
35
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
36
36
  const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
@@ -1948,6 +1948,7 @@ const buildRawEventEnvelope = ({
1948
1948
  nowMono,
1949
1949
  nextEventId,
1950
1950
  captureContext,
1951
+ hostGeneration,
1951
1952
  }) => ({
1952
1953
  session_stream_id: sessionID,
1953
1954
  session_id: sessionID,
@@ -1960,6 +1961,7 @@ const buildRawEventEnvelope = ({
1960
1961
  cwd,
1961
1962
  project,
1962
1963
  started_at: startedAt,
1964
+ ...(hostGeneration === "v2" ? { host_generation: "v2" } : {}),
1963
1965
  ...(captureContext ? { capture_context: captureContext } : {}),
1964
1966
  });
1965
1967
 
@@ -2169,7 +2171,7 @@ const buildRunnerArgs = ({ runner, runnerFrom, runnerFromExplicit }) => {
2169
2171
  return runnerFromExplicit ? [runnerFrom] : [];
2170
2172
  };
2171
2173
 
2172
- export const createCodememRuntime = async ({ location, host }) => {
2174
+ export const createCodememRuntime = async ({ location, host, hostGeneration = null }) => {
2173
2175
  const { project, directory, worktree } = location;
2174
2176
  const hostLog = typeof host?.log === "function" ? host.log : async () => {};
2175
2177
  const hostNotify = typeof host?.notify === "function" ? host.notify : null;
@@ -2803,7 +2805,7 @@ export const createCodememRuntime = async ({ location, host }) => {
2803
2805
 
2804
2806
  rawEventDelivery = createRawEventDelivery({
2805
2807
  backoffMs: rawEventsBackoffMs,
2806
- buildEnvelope: buildRawEventEnvelope,
2808
+ buildEnvelope: (input) => buildRawEventEnvelope({ ...input, hostGeneration }),
2807
2809
  classifyViewerFailure: classifyRawEventViewerFailure,
2808
2810
  cwd,
2809
2811
  discardResponseBody,
package/index.js CHANGED
@@ -2,12 +2,15 @@ import {
2
2
  CodememPlugin,
3
3
  OpencodeMemPlugin,
4
4
  } from "./.opencode/plugins/codemem.js";
5
- import { setupOpenCodeV2 } from "./.opencode/lib/opencode-v2-adapter.js";
5
+ import { createOpenCodeV2Adapter } from "./.opencode/lib/opencode-v2-adapter.js";
6
+ import { createCodememRuntime } from "./.opencode/lib/runtime.js";
6
7
 
7
8
  const CodememDualPlugin = {
8
9
  id: "codemem",
9
10
  server: CodememPlugin,
10
- setup: setupOpenCodeV2,
11
+ setup: createOpenCodeV2Adapter({
12
+ createRuntime: (options) => createCodememRuntime({ ...options, hostGeneration: "v2" }),
13
+ }),
11
14
  };
12
15
 
13
16
  export default CodememDualPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.46.0-rc.4",
3
+ "version": "0.46.1",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",