@remembrance-ai/opencode-plugin 0.1.53 → 0.1.55

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.
@@ -53,6 +53,14 @@ const DIRECT_SELECTION_MARKER_LIMIT = 20;
53
53
  const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
54
54
  const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
55
55
  const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
56
+ const MAX_LOCAL_PLUGIN_ALERT_BYTES = 16 * 1024;
57
+ const MAX_LOCAL_CLIENT_UPDATE_BYTES = 16 * 1024;
58
+ const PLUGIN_ALERT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
+ const PLUGIN_ALERT_LIMIT = 32;
60
+ const CLIENT_UPDATE_CACHE_TTL_MS = 5 * 60 * 1000;
61
+ const CLIENT_UPDATE_TIMEOUT_MS = 1200;
62
+ export const HOST_POLICY_ALERT_TEXT =
63
+ "Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.";
56
64
  const PLUGIN_HEALTH_COMPONENTS = new Set([
57
65
  "session_start",
58
66
  "prompt_hook",
@@ -67,6 +75,63 @@ const PLUGIN_HEALTH_SURFACES = new Set([
67
75
  "vs_code",
68
76
  "opencode",
69
77
  ]);
78
+ const CLIENT_RELEASE_MANIFEST_SURFACES = new Set([
79
+ ...PLUGIN_HEALTH_SURFACES,
80
+ "mcp",
81
+ ]);
82
+
83
+ const CLIENT_UPDATE_GUIDANCE = {
84
+ codex: {
85
+ command: [
86
+ 'CODEX_CLI="${CODEX_CLI:-$(command -v codex || true)}"',
87
+ '[ -x "$CODEX_CLI" ] || CODEX_CLI="/Applications/ChatGPT.app/Contents/Resources/codex"',
88
+ '[ -x "$CODEX_CLI" ] || CODEX_CLI="/Applications/Codex.app/Contents/Resources/codex"',
89
+ '[ -x "$CODEX_CLI" ] || { printf \'%s\\n\' "Codex CLI not found. Install the Codex CLI, or update the ChatGPT desktop app, then try again." >&2; exit 1; }',
90
+ '"$CODEX_CLI" plugin marketplace upgrade remembrance',
91
+ '"$CODEX_CLI" plugin add remembrance@remembrance --json',
92
+ ].join("\n"),
93
+ update:
94
+ "Ask the user for permission to update Remembrance. If approved, run the bundled Codex update command exactly as shown.",
95
+ restart:
96
+ "After it succeeds, tell the user to fully quit and reopen Codex. The current process remains on the installed version until restart.",
97
+ },
98
+ claude_code: {
99
+ command:
100
+ "claude plugin marketplace update remembrance\nclaude plugin update remembrance@remembrance",
101
+ update:
102
+ "Ask the user for permission to update Remembrance. If approved, run the bundled Claude Code update command exactly as shown.",
103
+ restart:
104
+ "After it succeeds, tell the user to run /reload-plugins or fully quit and reopen Claude Code. The current plugin process remains on the installed version until reload or restart.",
105
+ },
106
+ cursor: {
107
+ command: null,
108
+ update:
109
+ "Tell the user to open Cursor settings, refresh the marketplace that provides Remembrance, and choose Update for the Remembrance plugin.",
110
+ restart:
111
+ "After Cursor reports completion, tell the user to fully quit and reopen Cursor. The current process remains on the installed version until restart.",
112
+ },
113
+ openclaw: {
114
+ command: "openclaw plugins update remembrance\nopenclaw remembrance setup",
115
+ update:
116
+ "Ask the user for permission to update Remembrance. If approved, run the bundled OpenClaw update and setup commands exactly as shown.",
117
+ restart:
118
+ "After they succeed, tell the user to restart the OpenClaw Gateway unless its managed reload already restarted it, then begin a new agent session.",
119
+ },
120
+ vs_code: {
121
+ command: null,
122
+ update:
123
+ "Tell the user to refresh the marketplace or managed source that provides Remembrance and update the Remembrance plugin there.",
124
+ restart:
125
+ "After completion, tell the user to reload the VS Code window or fully quit and reopen VS Code. The current extension host remains on the installed version until reload or restart.",
126
+ },
127
+ opencode: {
128
+ command: "npx -y @remembrance-ai/opencode-plugin@latest setup",
129
+ update:
130
+ "Ask the user for permission to update Remembrance. If approved, run the bundled opencode setup command exactly as shown.",
131
+ restart:
132
+ "After it succeeds, tell the user to fully quit and reopen opencode. The current process remains on the installed version until restart.",
133
+ },
134
+ };
70
135
 
71
136
  const SERVICE_PATTERNS = [
72
137
  /\b(vercel|heroku|netlify|cloudflare|aws|gcp|azure)\b/i,
@@ -217,6 +282,39 @@ export function redactPrompt(prompt) {
217
282
  // options; the Claude adapter builds its own payload with its own identity.
218
283
  export const DEFAULT_AGENT_IDENTITY = { provider: "codex", model: "codex" };
219
284
  export const DEFAULT_USER_AGENT = "@remembrance/codex-plugin";
285
+ const VERSIONED_CLIENT_USER_AGENTS = new Set([
286
+ "@remembrance/codex-plugin",
287
+ "@remembrance/claude-code-plugin",
288
+ "@remembrance/cursor-plugin",
289
+ "@remembrance/openclaw-plugin",
290
+ "@remembrance/vscode-plugin",
291
+ "@remembrance-ai/opencode-plugin",
292
+ ]);
293
+ let cachedInstalledClientVersion;
294
+
295
+ export function installedClientVersion() {
296
+ if (cachedInstalledClientVersion !== undefined) {
297
+ return cachedInstalledClientVersion;
298
+ }
299
+ try {
300
+ const value = JSON.parse(
301
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
302
+ )?.version;
303
+ cachedInstalledClientVersion = /^\d+\.\d+\.\d+$/.test(String(value ?? ""))
304
+ ? String(value)
305
+ : null;
306
+ } catch {
307
+ cachedInstalledClientVersion = null;
308
+ }
309
+ return cachedInstalledClientVersion;
310
+ }
311
+
312
+ export function clientUserAgent(base = DEFAULT_USER_AGENT) {
313
+ const normalized = String(base || DEFAULT_USER_AGENT).trim();
314
+ if (!VERSIONED_CLIENT_USER_AGENTS.has(normalized)) return normalized;
315
+ const version = installedClientVersion();
316
+ return version ? `${normalized}/${version}` : normalized;
317
+ }
220
318
 
221
319
  export function buildQueryPayload(
222
320
  prompt,
@@ -362,10 +460,7 @@ export function inferConstraints(prompt) {
362
460
  for (const [pattern, value] of [
363
461
  [/\b(mcp|model context protocol|tool server)\b/i, "mcp"],
364
462
  [/\b(install|setup|set up|configure)\b/i, "setup"],
365
- [
366
- /\b(api key|enterprise key|organization key|credential)\b/i,
367
- "api-key",
368
- ],
463
+ [/\b(api key|enterprise key|organization key|credential)\b/i, "api-key"],
369
464
  [/\b(troubleshoot|troubleshooting)\b/i, "troubleshooting"],
370
465
  [/\b(ci|github actions?|circleci)\b/i, "ci"],
371
466
  [/\b(deploy|deployment|vercel|heroku)\b/i, "deployment"],
@@ -401,7 +496,7 @@ export async function queryRemembrance(payload, options = {}) {
401
496
  }
402
497
  const headers = {
403
498
  "content-type": "application/json",
404
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
499
+ "user-agent": clientUserAgent(options.userAgent),
405
500
  };
406
501
  const apiKey = credential.apiKey;
407
502
  if (apiKey) {
@@ -484,7 +579,7 @@ export async function reportDirectiveEvent(event, options = {}) {
484
579
  if (isUnusableConfigurationSource(credential.source)) return false;
485
580
  const headers = {
486
581
  "content-type": "application/json",
487
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
582
+ "user-agent": clientUserAgent(options.userAgent),
488
583
  };
489
584
  const apiKey = credential.apiKey;
490
585
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
@@ -1100,7 +1195,7 @@ export function contributionReason(
1100
1195
  "Call submit_feedback once for each listed skill with useful true or false and a concise post-use lesson.",
1101
1196
  "If submit_feedback returns next_step.submit_remembrance_payload, submit that reviewed evidence when the lesson is reusable.",
1102
1197
  "Report failed attempts, corrections, security findings, and reusable workflow improvements instead of silently changing the skill.",
1103
- "If host privacy policy blocks private repository-derived instructions, do not retry through another transport: queue organization skills locally with queue_private_skill_import (or the bundled handoff script) and wait for an admin import receipt.",
1198
+ `If host policy blocks the request, tell the user exactly: "${HOST_POLICY_ALERT_TEXT}" Do not retry the blocked content through another transport.`,
1104
1199
  "Redact secrets, private URLs, credentials, prompts, outputs, source paths, and proprietary content.",
1105
1200
  "If nothing is genuinely worth capturing, say so in one line; you will not be asked again for this use.",
1106
1201
  ]
@@ -1117,7 +1212,7 @@ export function contributionReason(
1117
1212
  "• submit_remembrance — post the ready next_step.submit_remembrance_payload returned by submit_feedback when the lesson is reusable, adding redacted evidence when available.",
1118
1213
  "• propose_private_skill — the default proposal path. Use it for anything repository-derived or organization-specific, after the host and organization approve Remembrance as the destination. It cannot create a public candidate under any credential state, so it stays private even if your key stops resolving.",
1119
1214
  "• propose_skill_idea — only if no existing skill fit, you built a reusable workflow, AND a public candidate is an acceptable outcome. An active organization key keeps it private; intentionally omitting a key makes the call PUBLIC. A supplied invalid/inactive key fails with 401 and an insufficient key fails with 403, creating no candidate. Read `visibility` in a successful response (organization_private or public_candidate) and report where it landed. Never remove or bypass the key to force a public candidate.",
1120
- "Use the Remembrance MCP tools directly when they are visible. If this runtime has no MCP tools exposed, use the REST endpoints from the query response. A host privacy-policy denial is not API unavailability: never retry private repository content through another transport; queue organization skills locally with queue_private_skill_import (or the bundled handoff script) and wait for an admin import receipt. If the API itself is unavailable, emit REMEMBRANCE_SUBMISSION_PAYLOAD with the redacted JSON you would submit.",
1215
+ `Use the Remembrance MCP tools directly when they are visible. If this runtime has no MCP tools exposed, use the REST endpoints from the query response. If host policy blocks the request, tell the user exactly: "${HOST_POLICY_ALERT_TEXT}" Do not retry the blocked content through another transport. If the API itself is unavailable, emit REMEMBRANCE_SUBMISSION_PAYLOAD with the redacted JSON you would submit.`,
1121
1216
  "Redact secrets, private URLs, and proprietary content; submit redacted summaries and hashes, not raw traces.",
1122
1217
  "Attach evidence: reproduction detail in outcome.failure_modes, artifact hashes of redacted logs/diffs, or an attestation. Evidence-less public reports wait in unverified intake until corroborated; evidence-backed ones verify faster and rank higher.",
1123
1218
  "If nothing is genuinely worth capturing, just say so in one line — you will not be asked again this session.",
@@ -1169,6 +1264,17 @@ export function detectHighValueLessonSignalInText(text) {
1169
1264
  return null;
1170
1265
  }
1171
1266
 
1267
+ export function hostPolicyAlertWasReported(input) {
1268
+ const value = String(
1269
+ input?.last_assistant_message ??
1270
+ input?.lastAssistantMessage ??
1271
+ input?.assistant_message ??
1272
+ input?.message ??
1273
+ "",
1274
+ );
1275
+ return value.includes(HOST_POLICY_ALERT_TEXT);
1276
+ }
1277
+
1172
1278
  // Pure decision function (unit-tested): compare completed registry use and task
1173
1279
  // eligibility with the last-prompted count. A completed use prompts for a
1174
1280
  // contribution; eligible work with no completed query prompts for full-context
@@ -1182,6 +1288,9 @@ export function decideStop(input, options = {}) {
1182
1288
  if (input?.stop_hook_active) {
1183
1289
  return { allow: true, why: "stop_hook_active" };
1184
1290
  }
1291
+ if (hostPolicyAlertWasReported(input)) {
1292
+ return { allow: true, why: "host_policy_alert_reported" };
1293
+ }
1185
1294
  const sessionId = sessionIdFor(input);
1186
1295
  const readUse = options.readUseCount ?? readRegistryUseCount;
1187
1296
  const useCount = readUse(sessionId, env);
@@ -2146,7 +2255,7 @@ async function postTaskOutcome(payload, options = {}) {
2146
2255
  if (isUnusableConfigurationSource(credential.source)) return false;
2147
2256
  const headers = {
2148
2257
  "content-type": "application/json",
2149
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
2258
+ "user-agent": clientUserAgent(options.userAgent),
2150
2259
  };
2151
2260
  const apiKey = credential.apiKey;
2152
2261
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
@@ -2304,6 +2413,549 @@ function pluginHealthDir(env = process.env) {
2304
2413
  return join(homedir(), ".cache", "remembrance", "plugin-health");
2305
2414
  }
2306
2415
 
2416
+ function pluginAlertDir(env = process.env) {
2417
+ if (env?.REMEMBRANCE_PLUGIN_ALERT_DIR) {
2418
+ return String(env.REMEMBRANCE_PLUGIN_ALERT_DIR);
2419
+ }
2420
+ if (process.env.VITEST) {
2421
+ return join(
2422
+ tmpdir(),
2423
+ "remembrance-plugin-alert-tests",
2424
+ String(process.pid),
2425
+ );
2426
+ }
2427
+ return join(homedir(), ".cache", "remembrance", "plugin-alerts");
2428
+ }
2429
+
2430
+ function clientUpdateDir(env = process.env) {
2431
+ if (env?.REMEMBRANCE_CLIENT_UPDATE_DIR) {
2432
+ return String(env.REMEMBRANCE_CLIENT_UPDATE_DIR);
2433
+ }
2434
+ if (process.env.VITEST) {
2435
+ return join(
2436
+ tmpdir(),
2437
+ "remembrance-client-update-tests",
2438
+ String(process.pid),
2439
+ );
2440
+ }
2441
+ return join(homedir(), ".cache", "remembrance", "client-updates");
2442
+ }
2443
+
2444
+ function clientUpdatePath(surface, env = process.env) {
2445
+ const normalized = normalizedPluginHealthSurface(surface);
2446
+ return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
2447
+ }
2448
+
2449
+ export async function checkForClientUpdate(
2450
+ {
2451
+ surface,
2452
+ currentVersion,
2453
+ fetchImpl = fetch,
2454
+ now = Date.now(),
2455
+ timeoutMs = CLIENT_UPDATE_TIMEOUT_MS,
2456
+ },
2457
+ env = process.env,
2458
+ ) {
2459
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
2460
+ if (
2461
+ !normalizedSurface ||
2462
+ disabled(env.REMEMBRANCE_CLIENT_UPDATE_CHECK) ||
2463
+ !parseStableClientVersion(currentVersion)
2464
+ ) {
2465
+ return null;
2466
+ }
2467
+ const configuration = resolveApiConfiguration(env);
2468
+ if (isUnusableConfigurationSource(configuration.source)) return null;
2469
+ const path = clientUpdatePath(normalizedSurface, env);
2470
+ let manifest = readCachedClientRelease(path, now);
2471
+ if (!manifest) {
2472
+ const controller = new AbortController();
2473
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2474
+ try {
2475
+ const response = await fetchImpl(
2476
+ `${configuration.apiUrl.replace(/\/+$/, "")}/.well-known/remembrance-client-release.json`,
2477
+ {
2478
+ headers: { accept: "application/json" },
2479
+ signal: controller.signal,
2480
+ },
2481
+ );
2482
+ if (!response?.ok) return null;
2483
+ const text = await response.text();
2484
+ if (Buffer.byteLength(text, "utf8") > MAX_LOCAL_CLIENT_UPDATE_BYTES) {
2485
+ return null;
2486
+ }
2487
+ try {
2488
+ manifest = parseClientReleaseManifest(JSON.parse(text));
2489
+ } catch {
2490
+ return null;
2491
+ }
2492
+ if (!manifest) return null;
2493
+ writeCachedClientRelease(path, manifest, now);
2494
+ } catch {
2495
+ return null;
2496
+ } finally {
2497
+ clearTimeout(timer);
2498
+ }
2499
+ }
2500
+ if (
2501
+ !manifest.surfaces.includes(normalizedSurface) ||
2502
+ compareStableClientVersions(manifest.latest_version, currentVersion) <= 0
2503
+ ) {
2504
+ return null;
2505
+ }
2506
+ const guidance = CLIENT_UPDATE_GUIDANCE[normalizedSurface];
2507
+ if (!guidance) return null;
2508
+ const command = guidance.command
2509
+ ? `\nTrusted update command bundled with this installed client:\n${guidance.command}`
2510
+ : "";
2511
+ return {
2512
+ current_version: currentVersion,
2513
+ latest_version: manifest.latest_version,
2514
+ surface: normalizedSurface,
2515
+ notice: [
2516
+ `Remembrance update available: installed ${currentVersion}, published ${manifest.latest_version}.`,
2517
+ guidance.update,
2518
+ command,
2519
+ guidance.restart,
2520
+ "Do not claim the new version is active until a fresh process reports it.",
2521
+ ]
2522
+ .filter(Boolean)
2523
+ .join("\n"),
2524
+ };
2525
+ }
2526
+
2527
+ function readCachedClientRelease(path, now) {
2528
+ if (!path) return null;
2529
+ try {
2530
+ const parsed = JSON.parse(
2531
+ readSecureHookFile(path, MAX_LOCAL_CLIENT_UPDATE_BYTES),
2532
+ );
2533
+ if (
2534
+ parsed?.schema_version !== 1 ||
2535
+ !Number.isFinite(parsed?.checked_at) ||
2536
+ parsed.checked_at > now + 5 * 60 * 1000 ||
2537
+ parsed.checked_at + CLIENT_UPDATE_CACHE_TTL_MS <= now
2538
+ ) {
2539
+ return null;
2540
+ }
2541
+ return parseClientReleaseManifest(parsed.manifest);
2542
+ } catch {
2543
+ return null;
2544
+ }
2545
+ }
2546
+
2547
+ function writeCachedClientRelease(path, manifest, now) {
2548
+ if (!path) return false;
2549
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
2550
+ try {
2551
+ ensureSecureHookDirectory(dirname(path));
2552
+ writeFileSync(
2553
+ temporaryPath,
2554
+ `${JSON.stringify({ schema_version: 1, checked_at: now, manifest })}\n`,
2555
+ { mode: 0o600, flag: "wx" },
2556
+ );
2557
+ renameSync(temporaryPath, path);
2558
+ return true;
2559
+ } catch {
2560
+ try {
2561
+ rmSync(temporaryPath, { force: true });
2562
+ } catch {
2563
+ // Update checks are advisory and never break the host lifecycle.
2564
+ }
2565
+ return false;
2566
+ }
2567
+ }
2568
+
2569
+ function parseClientReleaseManifest(value) {
2570
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2571
+ if (value.schema_version !== "1") return null;
2572
+ if (!parseStableClientVersion(value.latest_version)) return null;
2573
+ if (
2574
+ typeof value.published_at !== "string" ||
2575
+ !isCanonicalClientReleaseTimestamp(value.published_at) ||
2576
+ !Array.isArray(value.surfaces)
2577
+ ) {
2578
+ return null;
2579
+ }
2580
+ const surfaces = value.surfaces.filter(
2581
+ (item) =>
2582
+ typeof item === "string" && CLIENT_RELEASE_MANIFEST_SURFACES.has(item),
2583
+ );
2584
+ if (
2585
+ surfaces.length !== value.surfaces.length ||
2586
+ new Set(surfaces).size !== surfaces.length
2587
+ ) {
2588
+ return null;
2589
+ }
2590
+ return {
2591
+ schema_version: "1",
2592
+ latest_version: value.latest_version,
2593
+ published_at: new Date(value.published_at).toISOString(),
2594
+ surfaces,
2595
+ };
2596
+ }
2597
+
2598
+ function isCanonicalClientReleaseTimestamp(value) {
2599
+ const timestamp = Date.parse(value);
2600
+ return (
2601
+ Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value
2602
+ );
2603
+ }
2604
+
2605
+ function parseStableClientVersion(value) {
2606
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(
2607
+ String(value ?? ""),
2608
+ );
2609
+ if (!match) return null;
2610
+ const parts = match.slice(1).map(Number);
2611
+ return parts.every(
2612
+ (part) => Number.isSafeInteger(part) && part >= 0 && part <= 9_999,
2613
+ )
2614
+ ? parts
2615
+ : null;
2616
+ }
2617
+
2618
+ function compareStableClientVersions(left, right) {
2619
+ const leftParts = parseStableClientVersion(left);
2620
+ const rightParts = parseStableClientVersion(right);
2621
+ if (!leftParts || !rightParts) return 0;
2622
+ for (let index = 0; index < 3; index += 1) {
2623
+ const difference = leftParts[index] - rightParts[index];
2624
+ if (difference !== 0) return Math.sign(difference);
2625
+ }
2626
+ return 0;
2627
+ }
2628
+
2629
+ function pluginAlertSessionPath(surface, sessionId, env = process.env) {
2630
+ const normalized = normalizedPluginHealthSurface(surface);
2631
+ if (!normalized) return null;
2632
+ const hash = createHash("sha256")
2633
+ .update(String(sessionId ?? "unknown"), "utf8")
2634
+ .digest("hex")
2635
+ .slice(0, 24);
2636
+ return join(pluginAlertDir(env), `${normalized}.${hash}.json`);
2637
+ }
2638
+
2639
+ function hostPolicyOperationClass(toolName) {
2640
+ const normalized = String(toolName ?? "").toLowerCase();
2641
+ if (
2642
+ normalized.endsWith("query_skills") ||
2643
+ normalized.endsWith("list_skills") ||
2644
+ normalized.endsWith("get_skill") ||
2645
+ normalized.endsWith("get_resource") ||
2646
+ normalized.endsWith("invoke_skill") ||
2647
+ normalized.endsWith("run_connection_doctor") ||
2648
+ normalized.endsWith("get_connection_status")
2649
+ ) {
2650
+ return "query";
2651
+ }
2652
+ if (
2653
+ normalized.endsWith("propose_private_skill") ||
2654
+ normalized.endsWith("queue_private_skill_import")
2655
+ ) {
2656
+ return "private_contribution";
2657
+ }
2658
+ if (
2659
+ normalized.endsWith("propose_skill_idea") ||
2660
+ normalized.endsWith("submit_remembrance") ||
2661
+ normalized.endsWith("submit_suggestion") ||
2662
+ normalized.endsWith("submit_resource") ||
2663
+ normalized.endsWith("submit_resource_review")
2664
+ ) {
2665
+ return "contribution";
2666
+ }
2667
+ if (
2668
+ normalized.endsWith("submit_query_feedback") ||
2669
+ normalized.endsWith("submit_feedback") ||
2670
+ normalized.endsWith("report_task_outcome")
2671
+ ) {
2672
+ return "feedback";
2673
+ }
2674
+ return "other";
2675
+ }
2676
+
2677
+ function isRemembranceToolName(toolName) {
2678
+ const normalized = String(toolName ?? "").toLowerCase();
2679
+ return (
2680
+ normalized.includes("remembrance") ||
2681
+ [
2682
+ "run_connection_doctor",
2683
+ "get_connection_status",
2684
+ "query_skills",
2685
+ "list_skills",
2686
+ "get_skill",
2687
+ "get_resource",
2688
+ "invoke_skill",
2689
+ "get_value_proof",
2690
+ "report_task_outcome",
2691
+ "submit_query_feedback",
2692
+ "submit_feedback",
2693
+ "submit_remembrance",
2694
+ "propose_skill_idea",
2695
+ "propose_private_skill",
2696
+ "queue_private_skill_import",
2697
+ "submit_suggestion",
2698
+ "submit_resource",
2699
+ "submit_resource_review",
2700
+ ].some((candidate) => normalized.endsWith(candidate))
2701
+ );
2702
+ }
2703
+
2704
+ function boundedHostPolicyText(value, depth = 0) {
2705
+ if (depth > 3 || value == null) return "";
2706
+ if (typeof value === "string") return value.slice(0, 2_000);
2707
+ if (typeof value === "number" || typeof value === "boolean") {
2708
+ return String(value);
2709
+ }
2710
+ if (Array.isArray(value)) {
2711
+ return value
2712
+ .slice(0, 8)
2713
+ .map((item) => boundedHostPolicyText(item, depth + 1))
2714
+ .join(" ")
2715
+ .slice(0, 4_000);
2716
+ }
2717
+ if (typeof value !== "object") return "";
2718
+ return [
2719
+ value.message,
2720
+ value.reason,
2721
+ value.permission_decision_reason,
2722
+ value.permissionDecisionReason,
2723
+ value.decision,
2724
+ value.response,
2725
+ value.error,
2726
+ value.detail,
2727
+ value.details,
2728
+ value.output,
2729
+ value.result,
2730
+ value.tool_response,
2731
+ value.toolResponse,
2732
+ value.properties?.error,
2733
+ value.properties?.message,
2734
+ value.properties?.reason,
2735
+ ]
2736
+ .map((item) => boundedHostPolicyText(item, depth + 1))
2737
+ .filter(Boolean)
2738
+ .join(" ")
2739
+ .slice(0, 4_000);
2740
+ }
2741
+
2742
+ export function classifyHostPolicyDenial({
2743
+ eventType = "",
2744
+ toolName = "",
2745
+ value = null,
2746
+ } = {}) {
2747
+ if (!isRemembranceToolName(toolName)) return null;
2748
+ const text = boundedHostPolicyText(value);
2749
+ const denied =
2750
+ /\b(?:block(?:ed|ing)?|den(?:y|ied|ial)|disallow(?:ed)?|prohibit(?:ed)?|not permitted|permission refused|cannot (?:send|share|export)|may not (?:send|share|export))\b/i.test(
2751
+ text,
2752
+ );
2753
+ const policyContext =
2754
+ /\b(?:host|tenant|workspace|organization|administrator|admin|privacy|security|policy|data[- ](?:export|egress|loss prevention)|dlp|external service|trusted service)\b/i.test(
2755
+ text,
2756
+ );
2757
+ const normalizedEvent = String(eventType ?? "").toLowerCase();
2758
+ const permissionEvent = normalizedEvent.includes("permission");
2759
+ const explicitPreTransportContext =
2760
+ /\b(?:host(?:ed)?|tenant|workspace)\s+(?:(?:privacy|security|execution|tool|mcp|data[- ](?:export|egress))\s+)?policy\b|\b(?:privacy|security|data[- ](?:export|egress|loss prevention)|dlp)\s+(?:guard|policy|rule|restriction)\b|\bbefore\s+(?:contacting|reaching|calling|sending\s+(?:it\s+)?to)\s+(?:remembrance|the\s+(?:mcp|external service)|an?\s+external service)\b|\b(?:external|trusted)\s+service\b/i.test(
2761
+ text,
2762
+ );
2763
+ // A host permission event is itself pre-transport evidence. Generic failed
2764
+ // tool events need an explicit host/export signal so a Remembrance API 403
2765
+ // such as an organization-policy refusal is never mislabeled as host policy.
2766
+ if (
2767
+ !denied ||
2768
+ !policyContext ||
2769
+ (!permissionEvent && !explicitPreTransportContext)
2770
+ ) {
2771
+ return null;
2772
+ }
2773
+ return {
2774
+ denial_class: permissionEvent
2775
+ ? "host_permission_policy"
2776
+ : "host_execution_policy",
2777
+ operation_class: hostPolicyOperationClass(toolName),
2778
+ before_mcp: permissionEvent ? "yes" : "unknown",
2779
+ };
2780
+ }
2781
+
2782
+ function readPluginAlertState(surface, sessionId, env = process.env) {
2783
+ const path = pluginAlertSessionPath(surface, sessionId, env);
2784
+ if (!path) return null;
2785
+ try {
2786
+ const parsed = JSON.parse(
2787
+ readSecureHookFile(path, MAX_LOCAL_PLUGIN_ALERT_BYTES),
2788
+ );
2789
+ if (parsed?.schema_version !== 1 || !Array.isArray(parsed?.observations)) {
2790
+ return null;
2791
+ }
2792
+ return parsed;
2793
+ } catch {
2794
+ return null;
2795
+ }
2796
+ }
2797
+
2798
+ function writePluginAlertState(surface, sessionId, state, env = process.env) {
2799
+ const path = pluginAlertSessionPath(surface, sessionId, env);
2800
+ if (!path) return false;
2801
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
2802
+ try {
2803
+ ensureSecureHookDirectory(dirname(path));
2804
+ writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, {
2805
+ mode: 0o600,
2806
+ flag: "wx",
2807
+ });
2808
+ renameSync(temporaryPath, path);
2809
+ prunePluginAlertFiles(path, env);
2810
+ return true;
2811
+ } catch {
2812
+ try {
2813
+ rmSync(temporaryPath, { force: true });
2814
+ } catch {
2815
+ // Local policy observations are advisory and must never break the host.
2816
+ }
2817
+ return false;
2818
+ }
2819
+ }
2820
+
2821
+ function prunePluginAlertFiles(keepPath, env) {
2822
+ const directory = pluginAlertDir(env);
2823
+ const cutoff = Date.now() - PLUGIN_ALERT_TTL_MS;
2824
+ try {
2825
+ const candidates = readdirSync(directory)
2826
+ .filter((name) => /^[a-z_]+\.[a-f0-9]{24}\.json$/.test(name))
2827
+ .map((name) => {
2828
+ const path = join(directory, name);
2829
+ return { path, modified: statSync(path).mtimeMs };
2830
+ })
2831
+ .sort((left, right) => right.modified - left.modified);
2832
+ for (const candidate of candidates) {
2833
+ if (
2834
+ candidate.path !== keepPath &&
2835
+ (candidate.modified < cutoff ||
2836
+ candidates.indexOf(candidate) >= PLUGIN_ALERT_LIMIT)
2837
+ ) {
2838
+ rmSync(candidate.path, { force: true });
2839
+ }
2840
+ }
2841
+ } catch {
2842
+ // Pruning is advisory.
2843
+ }
2844
+ }
2845
+
2846
+ export function recordHostPolicyDenial(
2847
+ {
2848
+ surface,
2849
+ sessionId,
2850
+ eventType = "",
2851
+ toolName = "",
2852
+ value = null,
2853
+ pluginVersion = null,
2854
+ hostVersion = null,
2855
+ },
2856
+ env = process.env,
2857
+ ) {
2858
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
2859
+ const classification = classifyHostPolicyDenial({
2860
+ eventType,
2861
+ toolName,
2862
+ value,
2863
+ });
2864
+ if (!normalizedSurface || !classification) return null;
2865
+ const now = new Date().toISOString();
2866
+ const sessionHash = createHash("sha256")
2867
+ .update(String(sessionId ?? "unknown"), "utf8")
2868
+ .digest("hex")
2869
+ .slice(0, 24);
2870
+ const id = createHash("sha256")
2871
+ .update(
2872
+ [
2873
+ normalizedSurface,
2874
+ sessionHash,
2875
+ classification.operation_class,
2876
+ classification.denial_class,
2877
+ ].join(":"),
2878
+ "utf8",
2879
+ )
2880
+ .digest("hex")
2881
+ .slice(0, 24);
2882
+ const existing = readPluginAlertState(normalizedSurface, sessionId, env);
2883
+ const current = existing?.observations?.find((item) => item?.id === id);
2884
+ const observation = {
2885
+ id,
2886
+ denial_class: classification.denial_class,
2887
+ operation_class: classification.operation_class,
2888
+ before_mcp: classification.before_mcp,
2889
+ plugin_version: safeText(pluginVersion ?? "", 64),
2890
+ host_version: safeText(hostVersion ?? "", 64),
2891
+ first_seen_at: current?.first_seen_at ?? now,
2892
+ last_seen_at: now,
2893
+ count: Math.min(999, Math.max(0, Number(current?.count) || 0) + 1),
2894
+ alerted_at: current?.alerted_at ?? null,
2895
+ };
2896
+ const observations = [
2897
+ observation,
2898
+ ...(existing?.observations ?? []).filter((item) => item?.id !== id),
2899
+ ]
2900
+ .filter((item) => {
2901
+ const seenAt = Date.parse(item?.last_seen_at ?? "");
2902
+ return (
2903
+ Number.isFinite(seenAt) && Date.now() - seenAt <= PLUGIN_ALERT_TTL_MS
2904
+ );
2905
+ })
2906
+ .slice(0, 12);
2907
+ const state = {
2908
+ schema_version: 1,
2909
+ surface: normalizedSurface,
2910
+ session_hash: sessionHash,
2911
+ observations,
2912
+ updated_at: now,
2913
+ };
2914
+ return writePluginAlertState(normalizedSurface, sessionId, state, env)
2915
+ ? observation
2916
+ : null;
2917
+ }
2918
+
2919
+ export function readPendingHostPolicyAlert(
2920
+ surface,
2921
+ sessionId,
2922
+ env = process.env,
2923
+ ) {
2924
+ const state = readPluginAlertState(surface, sessionId, env);
2925
+ return (
2926
+ state?.observations?.find(
2927
+ (item) =>
2928
+ !item?.alerted_at &&
2929
+ Date.now() - Date.parse(item?.last_seen_at ?? "") <=
2930
+ PLUGIN_ALERT_TTL_MS,
2931
+ ) ?? null
2932
+ );
2933
+ }
2934
+
2935
+ export function markHostPolicyAlertDelivered(
2936
+ surface,
2937
+ sessionId,
2938
+ observationId,
2939
+ env = process.env,
2940
+ ) {
2941
+ const state = readPluginAlertState(surface, sessionId, env);
2942
+ if (!state) return false;
2943
+ const now = new Date().toISOString();
2944
+ let changed = false;
2945
+ const observations = state.observations.map((item) => {
2946
+ if (item?.id !== observationId || item?.alerted_at) return item;
2947
+ changed = true;
2948
+ return { ...item, alerted_at: now };
2949
+ });
2950
+ if (!changed) return false;
2951
+ return writePluginAlertState(
2952
+ surface,
2953
+ sessionId,
2954
+ { ...state, observations, updated_at: now },
2955
+ env,
2956
+ );
2957
+ }
2958
+
2307
2959
  function normalizedPluginHealthSurface(value) {
2308
2960
  const surface = String(value ?? "")
2309
2961
  .trim()
@@ -2439,8 +3091,24 @@ export function recordPluginLifecycleHealth(
2439
3091
  .update(apiConfiguration.apiUrl.replace(/\/+$/, ""), "utf8")
2440
3092
  .digest("hex")
2441
3093
  .slice(0, 16);
3094
+ const configuredEvidenceOrigin = String(
3095
+ env?.REMEMBRANCE_PLUGIN_HEALTH_EVIDENCE_ORIGIN ?? "host_runtime",
3096
+ )
3097
+ .trim()
3098
+ .toLowerCase();
3099
+ const evidenceOrigin = [
3100
+ "host_runtime",
3101
+ "host_process",
3102
+ "direct_adapter",
3103
+ ].includes(configuredEvidenceOrigin)
3104
+ ? configuredEvidenceOrigin
3105
+ : "host_runtime";
3106
+ const releaseRunId =
3107
+ evidenceOrigin === "host_process"
3108
+ ? safeText(env?.REMEMBRANCE_RELEASE_RUN_ID ?? "", 96)
3109
+ : "";
2442
3110
  const payload = {
2443
- schema_version: 1,
3111
+ schema_version: 2,
2444
3112
  surface: normalizedSurface,
2445
3113
  session_hash:
2446
3114
  sessionId && sessionId !== "unknown"
@@ -2465,6 +3133,8 @@ export function recordPluginLifecycleHealth(
2465
3133
  : "none",
2466
3134
  api_destination_source: apiConfiguration.source,
2467
3135
  api_destination_fingerprint: apiDestinationFingerprint,
3136
+ evidence_origin: evidenceOrigin,
3137
+ release_run_id: releaseRunId || null,
2468
3138
  components: {
2469
3139
  ...currentComponents,
2470
3140
  [normalizedComponent]: now,
@@ -2551,9 +3221,7 @@ export function resolveApiCredential(env = process.env) {
2551
3221
  }
2552
3222
  const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
2553
3223
  if (environmentKey) {
2554
- const explicitBinding = String(
2555
- env.REMEMBRANCE_API_KEY_ORIGIN ?? "",
2556
- ).trim();
3224
+ const explicitBinding = String(env.REMEMBRANCE_API_KEY_ORIGIN ?? "").trim();
2557
3225
  const binding = explicitBinding
2558
3226
  ? normalizeApiUrl(explicitBinding, env)
2559
3227
  : { apiUrl: DEFAULT_API_URL, issue: null };
@@ -2573,9 +3241,7 @@ export function resolveApiCredential(env = process.env) {
2573
3241
  return { apiKey: "", source: "none" };
2574
3242
  }
2575
3243
  try {
2576
- const parsed = JSON.parse(
2577
- readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2578
- );
3244
+ const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
2579
3245
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2580
3246
  return unusableSharedConfigCredential();
2581
3247
  }
@@ -2671,9 +3337,7 @@ export function resolveApiConfiguration(env = process.env) {
2671
3337
  return { apiUrl: DEFAULT_API_URL, source: "default" };
2672
3338
  }
2673
3339
  try {
2674
- const parsed = JSON.parse(
2675
- readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2676
- );
3340
+ const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
2677
3341
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2678
3342
  return {
2679
3343
  apiUrl: DEFAULT_API_URL,
@@ -2738,7 +3402,10 @@ function normalizeApiUrl(value, env = process.env) {
2738
3402
  }
2739
3403
 
2740
3404
  function credentialForApiDestination(credential, configuration) {
2741
- if (!credential.apiKey || isUnusableConfigurationSource(configuration.source)) {
3405
+ if (
3406
+ !credential.apiKey ||
3407
+ isUnusableConfigurationSource(configuration.source)
3408
+ ) {
2742
3409
  return credential.apiKey
2743
3410
  ? unusableDestinationCredential()
2744
3411
  : { apiKey: "", source: credential.source };
@@ -2811,7 +3478,9 @@ function isPrivateDestinationHostname(hostname) {
2811
3478
  }
2812
3479
 
2813
3480
  function normalizeHostname(hostname) {
2814
- return String(hostname).replace(/^\[|\]$/g, "").toLowerCase();
3481
+ return String(hostname)
3482
+ .replace(/^\[|\]$/g, "")
3483
+ .toLowerCase();
2815
3484
  }
2816
3485
 
2817
3486
  function readSecureHookFile(path, maxBytes) {