@remembrance-ai/opencode-plugin 0.1.77 → 0.2.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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Connects Remembrance to [opencode](https://opencode.ai): the MCP server that
4
4
  exposes the Remembrance tools, plus a plugin that records activation health,
5
- observes Remembrance tool use, and asks for redacted feedback when a session goes
6
- idle.
5
+ observes Remembrance tool use, and records unfinished feedback/capture work
6
+ silently when a session goes idle so the next prompt can recover it.
7
7
 
8
8
  ## Install
9
9
 
@@ -18,8 +18,18 @@ server to `~/.config/opencode/opencode.json`. It preserves comments and unrelate
18
18
  settings and creates a new config with mode `0600` when none exists.
19
19
 
20
20
  Restart opencode. Remembrance then appears in its tools list, adds bounded
21
- matching guidance to eligible model turns, observes completed use, and asks once
22
- for concise, redacted feedback.
21
+ matching guidance to eligible model turns, observes completed use, and silently
22
+ queues any missing feedback or capture action for the next real prompt.
23
+
24
+ **Node.js runtime.** The MCP entry that setup writes runs `npx`, so Node.js
25
+ 18.17.0 or newer and npm must be on the PATH opencode starts with. A host
26
+ started from the Dock, Finder or an IDE may not have your shell's PATH, so a
27
+ Node.js installed by nvm, fnm, volta, asdf, nodenv or mise can be missing from
28
+ it; the symptom is `spawn node ENOENT` or "No such file or directory". `npx`
29
+ starts `node` through PATH, so an absolute `npx` alone is not enough: if the
30
+ host cannot find them, set `command` to the absolute `npx` (`command -v npx` in
31
+ a terminal) and put that directory first in the entry's environment `PATH`, for
32
+ example `/Users/you/.nvm/versions/node/v22.12.0/bin:/usr/bin:/bin`.
23
33
 
24
34
  At session creation, the plugin performs a bounded, credential-free release
25
35
  check. If a newer verified version exists, opencode shows a notice and gives the
@@ -44,8 +54,8 @@ The stable plugin API exposes `chat.message` and
44
54
  `experimental.chat.system.transform`. Remembrance uses them together to run the
45
55
  fail-open query helper and inject bounded guidance before an eligible model
46
56
  turn. Empty or unavailable queries never block the task. The plugin also
47
- observes successful tool calls and prompts once for feedback after meaningful
48
- use.
57
+ observes successful tool calls and records pending feedback after meaningful
58
+ use for bounded recovery on the next prompt.
49
59
 
50
60
  ## Installation identity and preferences
51
61
 
@@ -64,10 +74,14 @@ working preferences follow each engineer across approved agents and steer
64
74
  relevant public and team skills without changing shared instructions or
65
75
  weakening organization policy. Profiles, observations, compatibility records,
66
76
  and feedback remain private to the organization. An explicit instruction
67
- governs the current task immediately. Known built-ins activate durably at once.
77
+ governs the current task immediately. Known built-ins activate durably at once for a
78
+ managed-encryption organization.
68
79
  Custom preferences remain pending until automatic normalization and validation
69
80
  approves them; unsafe, malformed, or uncertain custom behavior stays inactive
70
- and is never replayed to an agent. Classification runs
81
+ and is never replayed to an agent.
82
+ An organization using customer-held encryption is currently deferred: every substantive preference write and every compatibility-feedback call is refused before persistence with HTTP 422 and code `customer_held_preference_processing_deferred`. Existing records are unchanged, reads and the content-free withdrawal lane keep working, and responses withhold the compatibility-feedback offer there. Read `activated` and
83
+ `normalized_activation_state` in the response instead of assuming a setting is
84
+ live. Classification runs
71
85
  asynchronously against exact skill versions; query and invocation add no
72
86
  generative preference call or second embedding request. Material compatibility
73
87
  may reorder only already-relevant skills inside one match tier or apply a
@@ -132,12 +146,48 @@ link-local self-host also requires `REMEMBRANCE_ALLOW_PRIVATE_REGISTRY=true`.
132
146
  | `chat.message` | Runs the fail-open query helper for the current user turn. |
133
147
  | `experimental.chat.system.transform` | Adds bounded Remembrance guidance before model dispatch. |
134
148
  | `tool.execute.after` | Correlates query, invocation, detail, and contribution calls. |
135
- | `session.idle` | Shows a compact contribution nudge once per engagement. |
136
-
149
+ | `session.idle` | Records bounded pending contribution work for the next prompt. |
150
+
151
+ Host-generated text a trigger word matches (a Codex commit or pull-request
152
+ generation prompt, a subagent hand-back, pasted `git status` output, an explicit
153
+ local skill invocation) skips only the skill search: the organization policy,
154
+ required settings and preferences a search would carry still arrive; set
155
+ `REMEMBRANCE_AUTO_QUERY_HYGIENE=0` to search it anyway.
156
+ Besides prompts a keyword trigger matches, the hook searches any other
157
+ task-shaped prompt of at least 40 characters (trigger reason `task_shape`).
158
+ A task-shape search that finds nothing adds nothing of its own: no no-match
159
+ block, no completion recovery and no query-feedback request. Its organization
160
+ policy, required settings, preferences and capture lines still arrive, and the
161
+ server still records the missing-skill demand.
162
+ Set `REMEMBRANCE_AUTO_QUERY_TASK_SHAPE=0` to search keyword-triggered prompts
163
+ only.
164
+ Without an organization key, the server's relevance judge chooses what a search
165
+ is shown from the top 20 public candidates: Jev (TypeSafe, through Vercel AI
166
+ Gateway) rates each candidate first, and a language model decides the ones Jev
167
+ leaves undecided while there is time. It receives the search's task text and
168
+ public skill cards only. Every request asks for zero retention and no prompt
169
+ training; for Jev, zero retention rests on Remembrance's account-specific
170
+ agreement with TypeSafe through Vercel, which the Gateway does not confirm.
171
+ With an organization key, a search is judged only under the organization's
172
+ managed processing grant for semantic search, and Jev rates it first only when
173
+ that grant also approves TypeSafe, under the owner-attested zero-retention
174
+ agreement with TypeSafe (owner decisions 36 and 64), which covers this judge
175
+ stage and no other purpose: its task text and skill cards, the organization's
176
+ own included, go only to the hosts that grant approves, each asked for zero
177
+ retention and no prompt training. Without that grant nothing is sent and the
178
+ search keeps the rule's list.
179
+ A card shows a skill's use and exclusion conditions only where the
180
+ platform generated them or a person reviewed them. A use condition only
181
+ promotes a result, and only an exclusion its author stated or a person
182
+ reviewed makes it `unlikely`; `applicability.provenance` says who stated
183
+ them.
184
+ Set `REMEMBRANCE_AUTO_QUERY_STRIP_INJECTED_CONTEXT=1` to search only the text
185
+ after a leading `<compartment_examples_from_other_projects>` block (off by
186
+ default).
137
187
  Set `REMEMBRANCE_AUTO_QUERY=0` to disable the query, or
138
- `REMEMBRANCE_AUTO_CONTRIBUTE=0` to disable the completion nudge. OpenCode's
139
- current idle event exposes this compact instruction as a toast or structured
140
- log rather than forcing another agent turn.
188
+ `REMEMBRANCE_AUTO_CONTRIBUTE=0` to disable silent completion recovery. The
189
+ idle event does not emit routine contribution text as a toast or structured log;
190
+ the next real user prompt receives only the exact bounded recovery actions.
141
191
 
142
192
  ## Host policy
143
193
 
@@ -145,18 +195,29 @@ A tenant or privacy-policy denial in the host happens before Remembrance is
145
195
  called, so it is not a Remembrance misconfiguration — the same boundary as a
146
196
  Codex tenant/privacy-policy denial, which occurs before Remembrance is reached
147
197
  and must not be reported as a Remembrance setup failure. The plugin reports one
148
- local, content-free alert: **Remembrance was blocked by host policy before
149
- reaching Remembrance. Nothing was sent. Querying remains available.** It does
198
+ local, content-free alert: **The host blocked this Remembrance action. Do not
199
+ treat it as submitted. Earlier successful actions are unaffected; querying remains available.** It does
150
200
  not retry or automatically create a handoff.
151
201
 
152
- ### Private lesson autopilot
153
-
154
- The local `prepare_private_lesson_candidate` tool canonicalizes and redacts a
155
- routine organization lesson in memory and stores only an encrypted
156
- post-redaction draft. `submit_private_lesson_candidate` is the separate network
157
- action; it sends those exact bytes only to the authenticated organization's
158
- private verifier queue. This action always submits privately and never falls
159
- back to anonymous or public submission. After the private outcome is accepted,
202
+ ### Private learning autopilot
203
+
204
+ Current organization-capable local clients expose `capture_learning` as the
205
+ only routine reusable-learning entry point. Supply exactly one compact
206
+ `lesson`, complete `private_skill`, or structured durable `preference`. It
207
+ accepts no organization, destination, visibility, credential, or public
208
+ fallback. Lessons are canonicalized and redacted in memory; complete skills
209
+ retain bounded markdown, public citations, snippets, metadata, and actionable
210
+ instructions. Lesson and skill drafts are encrypted in separate outboxes; a
211
+ preference returns the exact existing `record_preference` action.
212
+ `capture_learning` is local and makes no network request.
213
+
214
+ Invoke only the returned `submit_private_lesson_candidate`,
215
+ `submit_private_skill_candidate`, or `record_preference` action.
216
+ These actions always submit privately. Lesson and skill actions carry only a local draft ID
217
+ through OpenCode to the authenticated organization's pipeline and never falls
218
+ back to anonymous or public submission. Accepted content reuses the existing verifier,
219
+ topology, review, versioning, audit, and admin-controlled propagation pipeline;
220
+ the submitting agent never chooses visibility. After the private outcome is accepted,
160
221
  a separate server-owned process may create a freshly redacted public candidate
161
222
  only when the organization admin enabled **Contribution propagation**; the
162
223
  submitting agent neither chooses nor sees that process, and the derivative must
@@ -165,20 +226,99 @@ or auto-delete. Verified submissions discard encrypted lesson content
165
226
  immediately; their content-free completion markers are automatically deleted
166
227
  after 14 days.
167
228
 
168
- The signed policy pins the corrected `private-lesson-redaction-v2` profile and
169
- its exact digest. Unsupported-profile drafts become terminal
170
- `superseded_redactor`, stay encrypted, and are never retried, re-redacted, or
171
- automatically deleted. When health reporting is enabled, a held draft may send
229
+ The lesson client offers a bounded `private-lesson-redaction-v2` supported
230
+ redactor-profile set and the
231
+ server signs policy for the newest mutual profile. Retained drafts require
232
+ their exact bound profile. Temporary incompatibility remains encrypted as
233
+ `awaiting_compatible_profile` with precise update, restart, or server-rollout
234
+ guidance; it is not mislabeled as safety or host denial. A profile becomes
235
+ terminal only when the installed client no longer supports it; that state is
236
+ `superseded_redactor`. Terminal drafts stay encrypted and are never retried, re-redacted, or automatically deleted.
237
+ When health reporting is enabled, a held draft may send
172
238
  only content-free category/version counts and signed protocol digests through
173
239
  the same approved action; that telemetry cannot enter verification, review,
174
240
  topology, or skill materialization. Disable it with
175
241
  `REMEMBRANCE_HEALTH_REPORTING=0`.
176
242
 
177
- OpenCode permission keys match MCP tool names. Set only
178
- `remembrance_submit_private_lesson_candidate` to `allow`; broader Remembrance
179
- write tools can remain `ask` or `deny`. Local preparation, inspection, retry,
180
- and confirmed deletion remain local-only. A host denial is held and is never
181
- retried through another transport.
243
+ OpenCode permission keys match MCP tool names. Allow the submit and retry tools
244
+ for the `private_lesson` and `private_skill` lanes; broader Remembrance write
245
+ tools can remain `ask` or `deny`. Remembrance maps an existing submit approval
246
+ to its lane. Local capture, inspection, and confirmed deletion remain
247
+ zero-network; a fresh-draft retry stops before network access. Broad `submit_remembrance` and
248
+ `propose_private_skill` are legacy/hosted/raw compatibility routes and are
249
+ unavailable by default locally. `submit_query_feedback` remains independently
250
+ available for one complete good/partial/poor query-fit verdict set;
251
+ `submit_feedback` is categorical post-use feedback. A host denial is held and
252
+ is never retried through another transport.
253
+
254
+ ### Routing metadata on complete skill proposals
255
+
256
+ In 0.2.0 a complete skill sent with `propose_skill_idea` or
257
+ `propose_private_skill` (or their REST routes) carries routing metadata
258
+ (contract v1, published at `/schemas/skill-routing-metadata.v1.json`): when
259
+ an agent should pick the skill (`applicability`) and what it does and needs
260
+ (`routing`). Ask for a platform draft first, show it to the author, and send
261
+ the confirmed or edited fields back with the draft's token. The draft call
262
+ stores nothing:
263
+
264
+ ```json
265
+ {
266
+ "title": "Deploy a prepared app to Azure",
267
+ "description": "Deploy an already-prepared app to Azure with azd.",
268
+ "proposed_skill_md": "# Deploy a prepared app to Azure\n\nRun azd up ...",
269
+ "prefill_routing_metadata": true
270
+ }
271
+ ```
272
+
273
+ Its answer holds `example.proposed_metadata` and `routing_draft.token`. The
274
+ submission then sends the confirmed fields:
275
+
276
+ ```json
277
+ {
278
+ "title": "Deploy a prepared app to Azure",
279
+ "description": "Deploy an already-prepared app to Azure with azd.",
280
+ "proposed_skill_md": "# Deploy a prepared app to Azure\n\nRun azd up ...",
281
+ "proposed_metadata": {
282
+ "applicability": {
283
+ "scope": "specialized",
284
+ "use_when": [
285
+ "The user wants to deploy an already-prepared app to Azure",
286
+ "A deployment plan and infrastructure files already exist",
287
+ "The user asks to run azd up or azd deploy"
288
+ ],
289
+ "avoid_when": [
290
+ "The app has no Azure deployment files yet (prepare it first)",
291
+ "The user only wants a readiness check without deploying"
292
+ ],
293
+ "task_examples": [
294
+ "Deploy this prepared app to Azure Container Apps",
295
+ "Run azd up for my project",
296
+ "Push the validated Bicep deployment to production"
297
+ ],
298
+ "not_for": []
299
+ },
300
+ "routing": {
301
+ "capabilities": ["deploy-to-cloud"],
302
+ "domains": ["deployments-cicd", "cloud-platforms"],
303
+ "runtime_requirements": ["repo-checkout", "shell", "cloud-account", "provider-azure"],
304
+ "risk": "cloud-changes"
305
+ }
306
+ },
307
+ "routing_draft_token": "rd1.<token from the draft>"
308
+ }
309
+ ```
310
+
311
+ A complete skill without these fields is refused with 422
312
+ `skill_routing_metadata_incomplete` (`delivery: not_delivered`; nothing is
313
+ stored). The refusal lists every issue and carries the same draft, so fix
314
+ the listed fields and send the request again. Fields taken unchanged are
315
+ recorded as author-confirmed draft values; edits that widen the draft (a
316
+ broader scope, added `use_when` items or capabilities, a lower risk) are
317
+ flagged for review. An organization's text is drafted only through its
318
+ approved semantic-enrichment processing grant; without one the draft is held
319
+ and nothing is sent. A private capture (`capture_learning` with
320
+ `private_skill`) may ask for automatic completion: the skill becomes
321
+ available after acceptance once its routing metadata is complete.
182
322
 
183
323
  ## Generated files
184
324
 
package/opencode.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "command": [
7
7
  "npx",
8
8
  "-y",
9
- "@remembrance-ai/mcp-server@0.1.77"
9
+ "@remembrance-ai/mcp-server@0.2.0"
10
10
  ],
11
11
  "enabled": true,
12
12
  "environment": {
@@ -16,6 +16,6 @@
16
16
  }
17
17
  },
18
18
  "plugin": [
19
- "@remembrance-ai/opencode-plugin@0.1.77"
19
+ "@remembrance-ai/opencode-plugin@0.2.0"
20
20
  ]
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remembrance-ai/opencode-plugin",
3
- "version": "0.1.77",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "opencode plugin for Remembrance session hooks, tool observation, and contribution nudges.",
6
6
  "type": "module",
@@ -0,0 +1,376 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ fstatSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ opendirSync,
12
+ readSync,
13
+ renameSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir, tmpdir, userInfo } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ const FEEDBACK_DENIAL_VERSION = "feedback-denial-acknowledgement-v1";
21
+ const FEEDBACK_DENIAL_SUBDIRECTORY = "feedback-denials";
22
+ const FEEDBACK_DENIAL_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
23
+ const MAX_FEEDBACK_IDENTIFIER_LENGTH = 512;
24
+ const MAX_FEEDBACK_DENIAL_MARKER_BYTES = 1024;
25
+ const MAX_FEEDBACK_DENIAL_MARKERS = 512;
26
+ const MARKER_NAME_PATTERN = /^[a-f0-9]{64}\.json$/u;
27
+
28
+ class FeedbackDenialStorageError extends Error {
29
+ constructor() {
30
+ super("The host-policy denial acknowledgement could not be persisted.");
31
+ this.name = "FeedbackDenialStorageError";
32
+ this.code = "feedback_denial_acknowledgement_failed";
33
+ this.status = "acknowledgement_failed";
34
+ this.acknowledged = false;
35
+ this.submitted = false;
36
+ this.local_only = true;
37
+ }
38
+ }
39
+
40
+ function normalizeInput(input) {
41
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
42
+ const keys = Object.keys(input).sort();
43
+ const expectedKeys =
44
+ input.tool === "submit_feedback"
45
+ ? ["query_id", "reason", "result_id", "tool"]
46
+ : ["query_id", "reason", "tool"];
47
+ if (
48
+ keys.length !== expectedKeys.length ||
49
+ keys.some((key, index) => key !== expectedKeys[index]) ||
50
+ !["submit_query_feedback", "submit_feedback"].includes(input.tool) ||
51
+ input.reason !== "host_policy_denied" ||
52
+ !validIdentifier(input.query_id)
53
+ ) {
54
+ return null;
55
+ }
56
+ if (input.tool === "submit_feedback") {
57
+ if (!validIdentifier(input.result_id)) return null;
58
+ return {
59
+ tool: input.tool,
60
+ query_id: input.query_id,
61
+ result_id: input.result_id,
62
+ reason: input.reason,
63
+ };
64
+ }
65
+ if ("result_id" in input) return null;
66
+ return {
67
+ tool: input.tool,
68
+ query_id: input.query_id,
69
+ reason: input.reason,
70
+ };
71
+ }
72
+
73
+ function validIdentifier(value) {
74
+ return (
75
+ typeof value === "string" &&
76
+ value.length > 0 &&
77
+ value.length <= MAX_FEEDBACK_IDENTIFIER_LENGTH
78
+ );
79
+ }
80
+
81
+ function exactEventDigest(input) {
82
+ const fields = [input.tool, input.query_id, input.result_id ?? ""];
83
+ const hash = createHash("sha256");
84
+ for (const field of fields) {
85
+ const bytes = Buffer.from(field, "utf8");
86
+ hash.update(String(bytes.length), "utf8");
87
+ hash.update(":", "utf8");
88
+ hash.update(bytes);
89
+ hash.update(";", "utf8");
90
+ }
91
+ return hash.digest("hex");
92
+ }
93
+
94
+ function localUsageOwnerId() {
95
+ const uid = currentUserId();
96
+ if (uid !== null) return `uid-${uid}`;
97
+ return `home-${createHash("sha256")
98
+ .update(homedir())
99
+ .digest("hex")
100
+ .slice(0, 16)}`;
101
+ }
102
+
103
+ function currentUserId() {
104
+ try {
105
+ const uid = userInfo().uid;
106
+ if (Number.isSafeInteger(uid) && uid >= 0) return uid;
107
+ } catch {
108
+ // Non-POSIX runtimes fall back to the same home hash as hook-core.
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function usageDirectory(env) {
114
+ return env?.REMEMBRANCE_USAGE_DIR
115
+ ? String(env.REMEMBRANCE_USAGE_DIR)
116
+ : join(tmpdir(), `remembrance-usage-${localUsageOwnerId()}`);
117
+ }
118
+
119
+ function markerDirectory(env) {
120
+ return join(usageDirectory(env), FEEDBACK_DENIAL_SUBDIRECTORY);
121
+ }
122
+
123
+ function markerPath(input, env) {
124
+ return join(markerDirectory(env), `${exactEventDigest(input)}.json`);
125
+ }
126
+
127
+ function isPosix() {
128
+ return currentUserId() !== null;
129
+ }
130
+
131
+ function assertCurrentOwner(uid) {
132
+ const currentUid = currentUserId();
133
+ if (currentUid !== null && uid !== currentUid) {
134
+ throw new Error("Local state is not owned by the current user.");
135
+ }
136
+ }
137
+
138
+ function ensurePrivateDirectory(path) {
139
+ mkdirSync(path, { recursive: true, mode: 0o700 });
140
+ const metadata = lstatSync(path);
141
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
142
+ throw new Error("Local state is not a regular directory.");
143
+ }
144
+ assertCurrentOwner(metadata.uid);
145
+ if (isPosix() && (metadata.mode & 0o077) !== 0) {
146
+ chmodSync(path, 0o700);
147
+ const repaired = lstatSync(path);
148
+ assertCurrentOwner(repaired.uid);
149
+ if ((repaired.mode & 0o077) !== 0) {
150
+ throw new Error("Local state directory permissions are not private.");
151
+ }
152
+ }
153
+ }
154
+
155
+ function assertPrivateDirectory(path) {
156
+ const metadata = lstatSync(path);
157
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
158
+ throw new Error("Local state is not a regular directory.");
159
+ }
160
+ assertCurrentOwner(metadata.uid);
161
+ if (isPosix() && (metadata.mode & 0o022) !== 0) {
162
+ throw new Error("Local state directory is writable by another user.");
163
+ }
164
+ }
165
+
166
+ function assertPrivateMarker(metadata) {
167
+ if (
168
+ metadata.isSymbolicLink() ||
169
+ !metadata.isFile() ||
170
+ metadata.size > MAX_FEEDBACK_DENIAL_MARKER_BYTES
171
+ ) {
172
+ throw new Error("Local state marker is unsafe.");
173
+ }
174
+ assertCurrentOwner(metadata.uid);
175
+ if (isPosix() && (metadata.mode & 0o077) !== 0) {
176
+ throw new Error("Local state marker permissions are not private.");
177
+ }
178
+ }
179
+
180
+ function readBoundedDescriptor(descriptor) {
181
+ const chunks = [];
182
+ let total = 0;
183
+ while (total <= MAX_FEEDBACK_DENIAL_MARKER_BYTES) {
184
+ const chunk = Buffer.allocUnsafe(
185
+ Math.min(1024, MAX_FEEDBACK_DENIAL_MARKER_BYTES + 1 - total),
186
+ );
187
+ const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
188
+ if (bytesRead === 0) break;
189
+ chunks.push(chunk.subarray(0, bytesRead));
190
+ total += bytesRead;
191
+ }
192
+ if (total > MAX_FEEDBACK_DENIAL_MARKER_BYTES) {
193
+ throw new Error("Local state marker exceeds its size limit.");
194
+ }
195
+ return Buffer.concat(chunks, total).toString("utf8");
196
+ }
197
+
198
+ function readMarker(path, expectedDigest) {
199
+ assertPrivateDirectory(dirname(dirname(path)));
200
+ assertPrivateDirectory(dirname(path));
201
+ const before = lstatSync(path);
202
+ assertPrivateMarker(before);
203
+ const descriptor = openSync(
204
+ path,
205
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
206
+ );
207
+ try {
208
+ const opened = fstatSync(descriptor);
209
+ assertPrivateMarker(opened);
210
+ if (before.dev !== opened.dev || before.ino !== opened.ino) {
211
+ throw new Error("Local state marker changed while opening.");
212
+ }
213
+ const parsed = JSON.parse(readBoundedDescriptor(descriptor));
214
+ if (
215
+ !parsed ||
216
+ typeof parsed !== "object" ||
217
+ Array.isArray(parsed) ||
218
+ parsed.version !== FEEDBACK_DENIAL_VERSION ||
219
+ parsed.event_digest !== `sha256:${expectedDigest}` ||
220
+ typeof parsed.acknowledged_at !== "string" ||
221
+ typeof parsed.expires_at !== "string" ||
222
+ !Number.isFinite(Date.parse(parsed.acknowledged_at)) ||
223
+ !Number.isFinite(Date.parse(parsed.expires_at))
224
+ ) {
225
+ throw new Error("Local state marker is malformed.");
226
+ }
227
+ return parsed;
228
+ } finally {
229
+ closeSync(descriptor);
230
+ }
231
+ }
232
+
233
+ function removeExpiredMarker(path, marker, now) {
234
+ if (Date.parse(marker.expires_at) > now) return false;
235
+ rmSync(path, { force: true });
236
+ return true;
237
+ }
238
+
239
+ function cleanupExpiredMarkers(directory, now) {
240
+ const handle = opendirSync(directory);
241
+ let markerCount = 0;
242
+ let visited = 0;
243
+ try {
244
+ while (visited <= MAX_FEEDBACK_DENIAL_MARKERS) {
245
+ const entry = handle.readSync();
246
+ if (!entry) break;
247
+ visited += 1;
248
+ if (!entry.isFile() || !MARKER_NAME_PATTERN.test(entry.name)) continue;
249
+ markerCount += 1;
250
+ const digest = entry.name.slice(0, -".json".length);
251
+ const path = join(directory, entry.name);
252
+ try {
253
+ if (removeExpiredMarker(path, readMarker(path, digest), now)) {
254
+ markerCount -= 1;
255
+ }
256
+ } catch {
257
+ // One corrupt marker cannot acknowledge or remove another event.
258
+ }
259
+ }
260
+ if (visited > MAX_FEEDBACK_DENIAL_MARKERS) {
261
+ throw new Error("Local acknowledgement storage exceeds its entry limit.");
262
+ }
263
+ } finally {
264
+ handle.closeSync();
265
+ }
266
+ if (markerCount >= MAX_FEEDBACK_DENIAL_MARKERS) {
267
+ throw new Error("Local acknowledgement storage is full.");
268
+ }
269
+ }
270
+
271
+ function writeMarkerAtomically(path, marker) {
272
+ const temporaryPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
273
+ let descriptor;
274
+ try {
275
+ descriptor = openSync(
276
+ temporaryPath,
277
+ constants.O_WRONLY |
278
+ constants.O_CREAT |
279
+ constants.O_EXCL |
280
+ (constants.O_NOFOLLOW ?? 0),
281
+ 0o600,
282
+ );
283
+ writeFileSync(descriptor, `${JSON.stringify(marker)}\n`, {
284
+ encoding: "utf8",
285
+ });
286
+ fsyncSync(descriptor);
287
+ closeSync(descriptor);
288
+ descriptor = undefined;
289
+ renameSync(temporaryPath, path);
290
+ } catch (error) {
291
+ if (descriptor !== undefined) {
292
+ try {
293
+ closeSync(descriptor);
294
+ } catch {
295
+ // Preserve the original persistence failure.
296
+ }
297
+ }
298
+ try {
299
+ rmSync(temporaryPath, { force: true });
300
+ } catch {
301
+ // Preserve the original persistence failure.
302
+ }
303
+ throw error;
304
+ }
305
+ }
306
+
307
+ export function acknowledgeFeedbackDenial(input, env = process.env) {
308
+ const normalized = normalizeInput(input);
309
+ if (!normalized) {
310
+ throw new TypeError("Invalid feedback denial acknowledgement input.");
311
+ }
312
+ const digest = exactEventDigest(normalized);
313
+ const directory = markerDirectory(env);
314
+ const path = markerPath(normalized, env);
315
+ const now = Date.now();
316
+ try {
317
+ ensurePrivateDirectory(usageDirectory(env));
318
+ ensurePrivateDirectory(directory);
319
+ cleanupExpiredMarkers(directory, now);
320
+ try {
321
+ const existing = readMarker(path, digest);
322
+ if (Date.parse(existing.expires_at) > now) {
323
+ return {
324
+ status: "acknowledged_host_denial",
325
+ acknowledged: true,
326
+ submitted: false,
327
+ local_only: true,
328
+ already_acknowledged: true,
329
+ expires_at: existing.expires_at,
330
+ };
331
+ }
332
+ rmSync(path, { force: true });
333
+ } catch (error) {
334
+ if (error?.code !== "ENOENT") throw error;
335
+ }
336
+ const acknowledgedAt = new Date(now).toISOString();
337
+ const expiresAt = new Date(
338
+ now + FEEDBACK_DENIAL_RETENTION_MS,
339
+ ).toISOString();
340
+ writeMarkerAtomically(path, {
341
+ version: FEEDBACK_DENIAL_VERSION,
342
+ event_digest: `sha256:${digest}`,
343
+ acknowledged_at: acknowledgedAt,
344
+ expires_at: expiresAt,
345
+ });
346
+ const persisted = readMarker(path, digest);
347
+ if (Date.parse(persisted.expires_at) <= now) {
348
+ throw new Error("Local acknowledgement expired before persistence.");
349
+ }
350
+ return {
351
+ status: "acknowledged_host_denial",
352
+ acknowledged: true,
353
+ submitted: false,
354
+ local_only: true,
355
+ already_acknowledged: false,
356
+ expires_at: persisted.expires_at,
357
+ };
358
+ } catch (error) {
359
+ throw new FeedbackDenialStorageError();
360
+ }
361
+ }
362
+
363
+ export function feedbackDenialAcknowledged(input, env = process.env) {
364
+ const normalized = normalizeInput(input);
365
+ if (!normalized) return false;
366
+ const digest = exactEventDigest(normalized);
367
+ const path = markerPath(normalized, env);
368
+ try {
369
+ const marker = readMarker(path, digest);
370
+ const now = Date.now();
371
+ if (removeExpiredMarker(path, marker, now)) return false;
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }