@codemem/opencode-plugin 0.44.0-alpha.1 → 0.44.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.
@@ -31,7 +31,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
31
31
  return {
32
32
  mode: "global",
33
33
  action:
34
- "Run `npm install -g codemem @codemem/embeddings` to update both the CLI and the optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
34
+ "Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
35
35
  note: "detected global codemem runner mode",
36
36
  };
37
37
  }
@@ -40,7 +40,7 @@ export const resolveUpgradeGuidance = ({ runner, runnerFrom }) => {
40
40
  return {
41
41
  mode: "npx",
42
42
  action:
43
- "Run `npm install -g codemem @codemem/embeddings` to update the CLI and optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
43
+ "Run `npm install -g codemem` to update the CLI and its optional semantic runtime, then restart OpenCode. On Linux, prefix with `ONNXRUNTIME_NODE_INSTALL=skip` to avoid the unused GPU provider download.",
44
44
  note: "detected npx runner mode",
45
45
  };
46
46
  }
@@ -117,6 +117,8 @@ const isPinnedGitSource = (runnerFrom) => {
117
117
  };
118
118
 
119
119
  const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
120
+ const UPDATE_TARGET_VERSION =
121
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc)(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
120
122
 
121
123
  const createNpmUpdatePlan = (targetVersion) => {
122
124
  const command = [
@@ -136,7 +138,7 @@ export const resolveAutoUpdatePlan = ({
136
138
  runner,
137
139
  runnerFrom,
138
140
  runnerFromExplicit = false,
139
- targetVersion = "latest",
141
+ targetVersion,
140
142
  platform = process.platform,
141
143
  }) => {
142
144
  const normalizedRunner = String(runner || "").trim();
@@ -145,9 +147,6 @@ export const resolveAutoUpdatePlan = ({
145
147
  if (platform === "win32") {
146
148
  return { allowed: false, reason: "unsupported-platform", command: null, commandText: null };
147
149
  }
148
- if (normalizedTargetVersion !== "latest" && !/^\d+\.\d+\.\d+$/.test(normalizedTargetVersion)) {
149
- return { allowed: false, reason: "invalid-version", command: null, commandText: null };
150
- }
151
150
  if (isPinnedGitSource(source) || /^codemem@(?!latest$|next$|\*$)[^\s]+$/i.test(source)) {
152
151
  return {
153
152
  allowed: false,
@@ -175,6 +174,9 @@ export const resolveAutoUpdatePlan = ({
175
174
  commandText: null,
176
175
  };
177
176
  }
177
+ if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
178
+ return { allowed: false, reason: "invalid-version", command: null, commandText: null };
179
+ }
178
180
  return createNpmUpdatePlan(normalizedTargetVersion);
179
181
  }
180
182
 
@@ -197,6 +199,9 @@ export const resolveAutoUpdatePlan = ({
197
199
  }
198
200
 
199
201
  if (normalizedRunner === "codemem") {
202
+ if (!UPDATE_TARGET_VERSION.test(normalizedTargetVersion)) {
203
+ return { allowed: false, reason: "invalid-version", command: null, commandText: null };
204
+ }
200
205
  return createNpmUpdatePlan(normalizedTargetVersion);
201
206
  }
202
207
 
@@ -0,0 +1,197 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ link,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const DEFAULT_DRAIN_LIMIT = 20;
16
+ const DEFAULT_MAX_ENTRIES = 2000;
17
+ const RAW_EVENT_SPOOL_FULL_CODE = "raw_event_spool_full";
18
+ const SPOOL_DIRECTORY_NAME = "opencode-raw-event-spool";
19
+ const spoolWritesInFlight = new Map();
20
+
21
+ const resolveSpoolDirectory = (homeDir = homedir()) =>
22
+ join(homeDir, ".codemem", SPOOL_DIRECTORY_NAME);
23
+
24
+ const spoolFilename = (eventId) => {
25
+ const digest = createHash("sha256").update(String(eventId)).digest("hex");
26
+ return `${digest}.json`;
27
+ };
28
+
29
+ const requireEventId = (envelope) => {
30
+ const eventId = typeof envelope?.event_id === "string"
31
+ ? envelope.event_id.trim()
32
+ : "";
33
+ if (!eventId) {
34
+ throw new Error("raw event spool requires event_id");
35
+ }
36
+ return eventId;
37
+ };
38
+
39
+ const ensurePrivateDirectory = async (directory) => {
40
+ await mkdir(directory, { recursive: true, mode: 0o700 });
41
+ await chmod(directory, 0o700);
42
+ };
43
+
44
+ const writeRawEventSpoolEntryUnlocked = async ({
45
+ envelope,
46
+ serialized,
47
+ homeDir = homedir(),
48
+ maxEntries = DEFAULT_MAX_ENTRIES,
49
+ }) => {
50
+ const eventId = requireEventId(envelope);
51
+ const directory = resolveSpoolDirectory(homeDir);
52
+ const bytes = typeof serialized === "string" ? serialized : JSON.stringify(envelope);
53
+ const normalizedMaxEntries = Number.isFinite(maxEntries) && maxEntries > 0
54
+ ? Math.trunc(maxEntries)
55
+ : DEFAULT_MAX_ENTRIES;
56
+ const destination = join(directory, spoolFilename(eventId));
57
+ const temporary = join(
58
+ directory,
59
+ `.${spoolFilename(eventId)}.${process.pid}.${randomUUID()}.tmp`,
60
+ );
61
+
62
+ await ensurePrivateDirectory(directory);
63
+ try {
64
+ const existing = await readFile(destination, "utf8");
65
+ if (existing !== bytes) {
66
+ throw new Error("raw event spool entry conflicts with existing event_id");
67
+ }
68
+ await chmod(destination, 0o600);
69
+ return { eventId, serialized: existing };
70
+ } catch (error) {
71
+ if (error?.code !== "ENOENT") {
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ const directoryEntries = await readdir(directory, { withFileTypes: true });
77
+ const entryCount = directoryEntries.filter(
78
+ (entry) => entry.isFile() && entry.name.endsWith(".json"),
79
+ ).length;
80
+ if (entryCount >= normalizedMaxEntries) {
81
+ const error = new Error("raw event spool is full");
82
+ error.code = RAW_EVENT_SPOOL_FULL_CODE;
83
+ throw error;
84
+ }
85
+
86
+ try {
87
+ await writeFile(temporary, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
88
+ try {
89
+ await link(temporary, destination);
90
+ } catch (error) {
91
+ if (error?.code !== "EEXIST") {
92
+ throw error;
93
+ }
94
+ const existing = await readFile(destination, "utf8");
95
+ if (existing !== bytes) {
96
+ throw new Error("raw event spool entry conflicts with existing event_id");
97
+ }
98
+ await chmod(destination, 0o600);
99
+ return { eventId, serialized: existing };
100
+ }
101
+ await chmod(destination, 0o600);
102
+ } finally {
103
+ await rm(temporary, { force: true }).catch(() => {});
104
+ }
105
+
106
+ return { eventId, serialized: bytes };
107
+ };
108
+
109
+ const writeRawEventSpoolEntry = async (options) => {
110
+ const directory = resolveSpoolDirectory(options?.homeDir);
111
+ const previousWrite = spoolWritesInFlight.get(directory);
112
+ const writePromise = (previousWrite ? previousWrite.catch(() => {}) : Promise.resolve())
113
+ .then(() => writeRawEventSpoolEntryUnlocked(options));
114
+ const trackedWrite = writePromise.finally(() => {
115
+ if (spoolWritesInFlight.get(directory) === trackedWrite) {
116
+ spoolWritesInFlight.delete(directory);
117
+ }
118
+ });
119
+ spoolWritesInFlight.set(directory, trackedWrite);
120
+ return trackedWrite;
121
+ };
122
+
123
+ const removeRawEventSpoolEntry = async ({ eventId, homeDir = homedir() }) => {
124
+ const normalizedEventId = requireEventId({ event_id: eventId });
125
+ await rm(
126
+ join(resolveSpoolDirectory(homeDir), spoolFilename(normalizedEventId)),
127
+ { force: true },
128
+ );
129
+ };
130
+
131
+ const loadRawEventSpoolEntries = async ({
132
+ homeDir = homedir(),
133
+ limit = DEFAULT_DRAIN_LIMIT,
134
+ } = {}) => {
135
+ const directory = resolveSpoolDirectory(homeDir);
136
+ let directoryEntries;
137
+ try {
138
+ directoryEntries = await readdir(directory, { withFileTypes: true });
139
+ } catch (error) {
140
+ if (error?.code === "ENOENT") {
141
+ return { entries: [], corruptCount: 0 };
142
+ }
143
+ throw error;
144
+ }
145
+
146
+ const candidates = [];
147
+ for (const directoryEntry of directoryEntries) {
148
+ if (!directoryEntry.isFile() || !directoryEntry.name.endsWith(".json")) {
149
+ continue;
150
+ }
151
+ const path = join(directory, directoryEntry.name);
152
+ try {
153
+ const details = await stat(path);
154
+ candidates.push({ path, mtimeMs: details.mtimeMs, name: directoryEntry.name });
155
+ } catch {
156
+ // A concurrently removed entry needs no recovery attempt.
157
+ }
158
+ }
159
+ candidates.sort(
160
+ (left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name),
161
+ );
162
+
163
+ const entries = [];
164
+ let corruptCount = 0;
165
+ const normalizedLimit = Number.isFinite(limit) && limit > 0
166
+ ? Math.trunc(limit)
167
+ : 0;
168
+ for (const candidate of candidates) {
169
+ if (entries.length >= normalizedLimit) {
170
+ break;
171
+ }
172
+ try {
173
+ const serialized = await readFile(candidate.path, "utf8");
174
+ const envelope = JSON.parse(serialized);
175
+ const eventId = requireEventId(envelope);
176
+ if (candidate.name !== spoolFilename(eventId)) {
177
+ corruptCount += 1;
178
+ continue;
179
+ }
180
+ entries.push({ eventId, envelope, serialized });
181
+ } catch {
182
+ corruptCount += 1;
183
+ }
184
+ }
185
+
186
+ return { entries, corruptCount };
187
+ };
188
+
189
+ export {
190
+ DEFAULT_DRAIN_LIMIT,
191
+ DEFAULT_MAX_ENTRIES,
192
+ RAW_EVENT_SPOOL_FULL_CODE,
193
+ loadRawEventSpoolEntries,
194
+ removeRawEventSpoolEntry,
195
+ resolveSpoolDirectory,
196
+ writeRawEventSpoolEntry,
197
+ };