@remembrance-ai/opencode-plugin 0.1.53 → 0.1.60

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.
@@ -15,7 +15,14 @@
15
15
  // last-prompted sentinel — the same count-sentinel pattern the Claude hook uses,
16
16
  // but driven by markers instead of transcript scans.
17
17
 
18
- import { createHash, randomBytes } from "node:crypto";
18
+ import {
19
+ createHash,
20
+ createHmac,
21
+ createPrivateKey,
22
+ generateKeyPairSync,
23
+ randomBytes,
24
+ sign as signPayload,
25
+ } from "node:crypto";
19
26
  import {
20
27
  chmodSync,
21
28
  closeSync,
@@ -53,6 +60,17 @@ const DIRECT_SELECTION_MARKER_LIMIT = 20;
53
60
  const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
54
61
  const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
55
62
  const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
63
+ const MAX_LOCAL_PLUGIN_ALERT_BYTES = 16 * 1024;
64
+ const MAX_LOCAL_CLIENT_UPDATE_BYTES = 16 * 1024;
65
+ const PLUGIN_ALERT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
66
+ const PLUGIN_ALERT_LIMIT = 32;
67
+ const CLIENT_UPDATE_CACHE_TTL_MS = 5 * 60 * 1000;
68
+ const CLIENT_UPDATE_TIMEOUT_MS = 1200;
69
+ const PRINCIPAL_SESSION_TIMEOUT_MS = 1200;
70
+ const MAX_LOCAL_PRINCIPAL_SESSION_BYTES = 16 * 1024;
71
+ const principalSessionWarmups = new Map();
72
+ export const HOST_POLICY_ALERT_TEXT =
73
+ "Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.";
56
74
  const PLUGIN_HEALTH_COMPONENTS = new Set([
57
75
  "session_start",
58
76
  "prompt_hook",
@@ -67,6 +85,63 @@ const PLUGIN_HEALTH_SURFACES = new Set([
67
85
  "vs_code",
68
86
  "opencode",
69
87
  ]);
88
+ const CLIENT_RELEASE_MANIFEST_SURFACES = new Set([
89
+ ...PLUGIN_HEALTH_SURFACES,
90
+ "mcp",
91
+ ]);
92
+
93
+ const CLIENT_UPDATE_GUIDANCE = {
94
+ codex: {
95
+ command: [
96
+ 'CODEX_CLI="${CODEX_CLI:-$(command -v codex || true)}"',
97
+ '[ -x "$CODEX_CLI" ] || CODEX_CLI="/Applications/ChatGPT.app/Contents/Resources/codex"',
98
+ '[ -x "$CODEX_CLI" ] || CODEX_CLI="/Applications/Codex.app/Contents/Resources/codex"',
99
+ '[ -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; }',
100
+ '"$CODEX_CLI" plugin marketplace upgrade remembrance',
101
+ '"$CODEX_CLI" plugin add remembrance@remembrance --json',
102
+ ].join("\n"),
103
+ update:
104
+ "Ask the user for permission to update Remembrance. If approved, run the bundled Codex update command exactly as shown.",
105
+ restart:
106
+ "After it succeeds, tell the user to fully quit and reopen Codex. The current process remains on the installed version until restart.",
107
+ },
108
+ claude_code: {
109
+ command:
110
+ "claude plugin marketplace update remembrance\nclaude plugin update remembrance@remembrance",
111
+ update:
112
+ "Ask the user for permission to update Remembrance. If approved, run the bundled Claude Code update command exactly as shown.",
113
+ restart:
114
+ "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.",
115
+ },
116
+ cursor: {
117
+ command: null,
118
+ update:
119
+ "Tell the user to open Cursor settings, refresh the marketplace that provides Remembrance, and choose Update for the Remembrance plugin.",
120
+ restart:
121
+ "After Cursor reports completion, tell the user to fully quit and reopen Cursor. The current process remains on the installed version until restart.",
122
+ },
123
+ openclaw: {
124
+ command: "openclaw plugins update remembrance\nopenclaw remembrance setup",
125
+ update:
126
+ "Ask the user for permission to update Remembrance. If approved, run the bundled OpenClaw update and setup commands exactly as shown.",
127
+ restart:
128
+ "After they succeed, tell the user to restart the OpenClaw Gateway unless its managed reload already restarted it, then begin a new agent session.",
129
+ },
130
+ vs_code: {
131
+ command: null,
132
+ update:
133
+ "Tell the user to refresh the marketplace or managed source that provides Remembrance and update the Remembrance plugin there.",
134
+ restart:
135
+ "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.",
136
+ },
137
+ opencode: {
138
+ command: "npx -y @remembrance-ai/opencode-plugin@latest setup",
139
+ update:
140
+ "Ask the user for permission to update Remembrance. If approved, run the bundled opencode setup command exactly as shown.",
141
+ restart:
142
+ "After it succeeds, tell the user to fully quit and reopen opencode. The current process remains on the installed version until restart.",
143
+ },
144
+ };
70
145
 
71
146
  const SERVICE_PATTERNS = [
72
147
  /\b(vercel|heroku|netlify|cloudflare|aws|gcp|azure)\b/i,
@@ -217,18 +292,61 @@ export function redactPrompt(prompt) {
217
292
  // options; the Claude adapter builds its own payload with its own identity.
218
293
  export const DEFAULT_AGENT_IDENTITY = { provider: "codex", model: "codex" };
219
294
  export const DEFAULT_USER_AGENT = "@remembrance/codex-plugin";
295
+ const VERSIONED_CLIENT_USER_AGENTS = new Set([
296
+ "@remembrance/codex-plugin",
297
+ "@remembrance/claude-code-plugin",
298
+ "@remembrance/cursor-plugin",
299
+ "@remembrance/openclaw-plugin",
300
+ "@remembrance/vscode-plugin",
301
+ "@remembrance-ai/opencode-plugin",
302
+ ]);
303
+ let cachedInstalledClientVersion;
304
+
305
+ export function installedClientVersion() {
306
+ if (cachedInstalledClientVersion !== undefined) {
307
+ return cachedInstalledClientVersion;
308
+ }
309
+ try {
310
+ const value = JSON.parse(
311
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
312
+ )?.version;
313
+ cachedInstalledClientVersion = /^\d+\.\d+\.\d+$/.test(String(value ?? ""))
314
+ ? String(value)
315
+ : null;
316
+ } catch {
317
+ cachedInstalledClientVersion = null;
318
+ }
319
+ return cachedInstalledClientVersion;
320
+ }
321
+
322
+ export function clientUserAgent(base = DEFAULT_USER_AGENT) {
323
+ const normalized = String(base || DEFAULT_USER_AGENT).trim();
324
+ if (!VERSIONED_CLIENT_USER_AGENTS.has(normalized)) return normalized;
325
+ const version = installedClientVersion();
326
+ return version ? `${normalized}/${version}` : normalized;
327
+ }
220
328
 
221
329
  export function buildQueryPayload(
222
330
  prompt,
223
331
  env = process.env,
224
332
  identity = DEFAULT_AGENT_IDENTITY,
225
333
  clientContext = null,
334
+ projectPath = null,
226
335
  ) {
227
336
  const redacted = redactPrompt(prompt).trim();
228
337
  const summary =
229
338
  redacted.length <= MAX_SUMMARY_CHARS
230
339
  ? redacted
231
340
  : `${redacted.slice(0, MAX_SUMMARY_CHARS - 3).trim()}...`;
341
+ const projectKey = projectKeyForHook(env, projectPath);
342
+ const resolvedClientContext = clientContext
343
+ ? {
344
+ ...clientContext,
345
+ ...(clientContext.project_key || !projectKey
346
+ ? {}
347
+ : { project_key: projectKey }),
348
+ }
349
+ : null;
232
350
  return {
233
351
  agent: {
234
352
  provider: identity.provider,
@@ -239,12 +357,27 @@ export function buildQueryPayload(
239
357
  summary,
240
358
  constraints: inferConstraints(summary),
241
359
  },
242
- ...(clientContext ? { client_context: clientContext } : {}),
360
+ ...(resolvedClientContext ? { client_context: resolvedClientContext } : {}),
243
361
  economics_context: buildEconomicsContext(summary, env, identity),
244
362
  limit: limitFromEnv(env),
245
363
  };
246
364
  }
247
365
 
366
+ export function projectKeyForHook(env = process.env, projectPath = null) {
367
+ const configured = String(env.REMEMBRANCE_PROJECT_KEY ?? "").trim();
368
+ if (/^prj_[A-Za-z0-9_-]{12,120}$/.test(configured)) return configured;
369
+ const localPath = String(
370
+ projectPath ?? env.REMEMBRANCE_PROJECT_PATH ?? env.PWD ?? process.cwd(),
371
+ ).trim();
372
+ if (!localPath) return null;
373
+ const identity = readHookIdentity(env);
374
+ if (!identity?.private_key) return null;
375
+ return `prj_${createHmac("sha256", identity.private_key)
376
+ .update(`remembrance-project-v1:${localPath}`, "utf8")
377
+ .digest("base64url")
378
+ .slice(0, 32)}`;
379
+ }
380
+
248
381
  export function buildEconomicsContext(summary, env, identity) {
249
382
  const observedModel = stringOrNull(
250
383
  env.REMEMBRANCE_OBSERVED_MODEL_REVISION ?? env.REMEMBRANCE_MODEL_REVISION,
@@ -362,10 +495,7 @@ export function inferConstraints(prompt) {
362
495
  for (const [pattern, value] of [
363
496
  [/\b(mcp|model context protocol|tool server)\b/i, "mcp"],
364
497
  [/\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
- ],
498
+ [/\b(api key|enterprise key|organization key|credential)\b/i, "api-key"],
369
499
  [/\b(troubleshoot|troubleshooting)\b/i, "troubleshooting"],
370
500
  [/\b(ci|github actions?|circleci)\b/i, "ci"],
371
501
  [/\b(deploy|deployment|vercel|heroku)\b/i, "deployment"],
@@ -394,30 +524,70 @@ export async function queryRemembrance(payload, options = {}) {
394
524
  const controller = new AbortController();
395
525
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
396
526
  try {
397
- const credential = resolveApiCredential(env);
527
+ const access = resolveApiAccessSnapshot(env);
528
+ const credential = access.credential;
398
529
  if (isUnusableConfigurationSource(credential.source)) {
399
530
  debugLog(env, "shared_config_unusable", {}, options);
400
531
  return null;
401
532
  }
402
533
  const headers = {
403
534
  "content-type": "application/json",
404
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
535
+ "user-agent": clientUserAgent(options.userAgent),
405
536
  };
406
537
  const apiKey = credential.apiKey;
407
538
  if (apiKey) {
408
539
  headers["x-remembrance-api-key"] = apiKey;
409
540
  }
410
- const response = await fetchImpl(`${apiUrl(env)}/api/v1/agent/query`, {
411
- method: "POST",
412
- headers,
413
- body: JSON.stringify(payload),
414
- signal: controller.signal,
415
- });
541
+ const principalSession = readHookPrincipalSession(
542
+ normalizeRuntime(payload?.client_context?.runtime),
543
+ env,
544
+ access,
545
+ );
546
+ if (principalSession?.token) {
547
+ headers["x-remembrance-principal-session"] = principalSession.token;
548
+ }
549
+ const response = await fetchImpl(
550
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/query`,
551
+ {
552
+ method: "POST",
553
+ headers,
554
+ body: JSON.stringify(payload),
555
+ signal: controller.signal,
556
+ },
557
+ );
558
+ const runtime = normalizeRuntime(payload?.client_context?.runtime);
559
+ const sessionStatus = response.headers?.get?.(
560
+ "x-remembrance-principal-session-status",
561
+ );
562
+ if (sessionStatus === "refresh_required") {
563
+ clearHookPrincipalSession(runtime, env, access);
564
+ void warmPrincipalSession(
565
+ {
566
+ runtime,
567
+ hostSurface: runtimeHostSurface(runtime, env),
568
+ fetchImpl,
569
+ apiAccess: access,
570
+ },
571
+ env,
572
+ ).catch(() => null);
573
+ } else if (!principalSession?.token) {
574
+ // SessionStart is not guaranteed on every host. Retry registration and
575
+ // exchange opportunistically without delaying or failing this query.
576
+ void warmPrincipalSession(
577
+ {
578
+ runtime,
579
+ hostSurface: runtimeHostSurface(runtime, env),
580
+ fetchImpl,
581
+ apiAccess: access,
582
+ },
583
+ env,
584
+ ).catch(() => null);
585
+ }
416
586
  if (!response.ok) {
417
587
  debugLog(
418
588
  env,
419
589
  "http_error",
420
- { status: response.status, api_url: apiUrl(env) },
590
+ { status: response.status, api_url: access.apiConfiguration.apiUrl },
421
591
  options,
422
592
  );
423
593
  return null;
@@ -480,16 +650,25 @@ export async function reportDirectiveEvent(event, options = {}) {
480
650
  directiveEventTimeoutMs(env),
481
651
  );
482
652
  try {
483
- const credential = resolveApiCredential(env);
653
+ const access = resolveApiAccessSnapshot(env);
654
+ const credential = access.credential;
484
655
  if (isUnusableConfigurationSource(credential.source)) return false;
485
656
  const headers = {
486
657
  "content-type": "application/json",
487
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
658
+ "user-agent": clientUserAgent(options.userAgent),
488
659
  };
489
660
  const apiKey = credential.apiKey;
490
661
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
662
+ const principalSession = readHookPrincipalSession(
663
+ normalizeRuntime(event.runtime),
664
+ env,
665
+ access,
666
+ );
667
+ if (principalSession?.token) {
668
+ headers["x-remembrance-principal-session"] = principalSession.token;
669
+ }
491
670
  const response = await fetchImpl(
492
- `${apiUrl(env)}/api/v1/agent/directive-events`,
671
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/directive-events`,
493
672
  {
494
673
  method: "POST",
495
674
  headers,
@@ -543,6 +722,35 @@ function normalizeRuntime(value) {
543
722
  return "unknown";
544
723
  }
545
724
 
725
+ export function runtimeHostSurface(runtime, env = process.env, hint = null) {
726
+ const configured = String(env?.REMEMBRANCE_HOST_SURFACE ?? "")
727
+ .trim()
728
+ .toLowerCase();
729
+ if (
730
+ ["desktop", "cli", "extension", "gateway", "unknown"].includes(configured)
731
+ ) {
732
+ return configured;
733
+ }
734
+ const normalizedHint = String(hint ?? "")
735
+ .trim()
736
+ .toLowerCase();
737
+ if (["desktop", "cli", "extension", "gateway"].includes(normalizedHint)) {
738
+ return normalizedHint;
739
+ }
740
+ switch (normalizeRuntime(runtime)) {
741
+ case "claude_code":
742
+ case "opencode":
743
+ return "cli";
744
+ case "cursor":
745
+ case "vs_code":
746
+ return "extension";
747
+ case "openclaw":
748
+ return "gateway";
749
+ default:
750
+ return "unknown";
751
+ }
752
+ }
753
+
546
754
  // --- Context formatting ------------------------------------------------------
547
755
 
548
756
  function publicSkillCandidateAllowed(body, candidate) {
@@ -621,6 +829,14 @@ export function formatContext(
621
829
  "Organization policy: use organization skills only. Do not use public skills or bundled public skill references, including as an offline fallback.",
622
830
  );
623
831
  }
832
+ const effectivePreferences = sanitizeEffectivePreferences(
833
+ body?.effective_preferences,
834
+ );
835
+ if (effectivePreferences.length > 0) {
836
+ lines.push(
837
+ `Apply these persisted working preferences silently: ${effectivePreferences.join("; ")}. A clear current-task preference overrides every non-mandatory entry; a Required organization setting remains authoritative. Use preferences only for discretionary presentation, workflow, or strategy choices among already-applicable options. Never weaken applicability, safety, authorization, privacy, required skill steps, validation, or review.`,
838
+ );
839
+ }
624
840
  const queryId = body?.query_feedback?.query_id ?? body?.query_id;
625
841
  const highMatch = highMatchFromResponse(body);
626
842
  if (queryId) {
@@ -806,12 +1022,56 @@ function candidateDecisionLabel(candidate) {
806
1022
  );
807
1023
  }
808
1024
  }
809
- const details = [...evidence, ...applicabilityParts];
1025
+ const preferenceApplication = sanitizePreferenceApplication(
1026
+ candidate?.preference_application,
1027
+ candidate?.effective_preferences,
1028
+ );
1029
+ const details = [
1030
+ ...evidence,
1031
+ ...applicabilityParts,
1032
+ ...(preferenceApplication ? [preferenceApplication] : []),
1033
+ ];
810
1034
  return details.length > 0
811
1035
  ? ` Decision: ${safeText(details.join("; "), 640)}.`
812
1036
  : "";
813
1037
  }
814
1038
 
1039
+ function sanitizePreferenceApplication(value, effectivePreferences) {
1040
+ if (
1041
+ !value ||
1042
+ typeof value !== "object" ||
1043
+ !["skill_defaults", "surgical_overlay"].includes(value.mode)
1044
+ ) {
1045
+ return null;
1046
+ }
1047
+ const settings = sanitizeEffectivePreferences(effectivePreferences);
1048
+ if (settings.length === 0) return null;
1049
+ const overridden = Array.isArray(value.overridden_skill_defaults)
1050
+ ? value.overridden_skill_defaults
1051
+ .filter(
1052
+ (item) =>
1053
+ item && typeof item === "object" && typeof item.key === "string",
1054
+ )
1055
+ .slice(0, 8)
1056
+ .map((item) => safeText(item.key, 96))
1057
+ : [];
1058
+ const blocked = Array.isArray(value.blocked_preferences)
1059
+ ? value.blocked_preferences
1060
+ .filter(
1061
+ (item) =>
1062
+ item && typeof item === "object" && typeof item.key === "string",
1063
+ )
1064
+ .slice(0, 8)
1065
+ .map(
1066
+ (item) =>
1067
+ `${safeText(item.key, 96)} (${safeText(item.reason ?? "skill requirement wins", 160)})`,
1068
+ )
1069
+ : [];
1070
+ return value.mode === "surgical_overlay"
1071
+ ? `preference sidecar ${settings.join("; ")}${overridden.length > 0 ? `; replaces discretionary skill defaults for ${overridden.join(", ")}` : ""}${blocked.length > 0 ? `; blocked by skill requirements: ${blocked.join(", ")}` : ""}; preserve every hard constraint`
1072
+ : `skill preference defaults ${settings.join("; ")}`;
1073
+ }
1074
+
815
1075
  function sanitizeWhyMatched(value) {
816
1076
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
817
1077
  return {
@@ -921,11 +1181,29 @@ export async function runPromptHook(prompt, options = {}) {
921
1181
  const env = options.env ?? process.env;
922
1182
  const includeSharedConfigCredentialNotice =
923
1183
  options.includeSharedConfigCredentialNotice !== false;
1184
+ const redacted = redactPrompt(String(prompt ?? ""));
1185
+ const preferenceCapture = genericPreferenceCaptureDirective(redacted, {
1186
+ env,
1187
+ projectPath: options.projectPath,
1188
+ });
1189
+ void recordExplicitPreferenceObservations(redacted, {
1190
+ env,
1191
+ fetchImpl: options.fetchImpl ?? fetch,
1192
+ runtime: runtimeFromIdentity(options.identity),
1193
+ userAgent: options.userAgent,
1194
+ projectPath: options.projectPath,
1195
+ }).catch(() => 0);
924
1196
  if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
925
1197
  debugLog(env, "disabled", {}, options);
926
- return null;
1198
+ return preferenceCapture
1199
+ ? {
1200
+ consumed: false,
1201
+ context: preferenceCapture,
1202
+ eligible: false,
1203
+ reason: "preference_capture",
1204
+ }
1205
+ : null;
927
1206
  }
928
- const redacted = redactPrompt(String(prompt ?? ""));
929
1207
  const decision = shouldQueryPrompt(redacted);
930
1208
  if (!decision.likely_match) {
931
1209
  if (isContextualContinuationPrompt(redacted)) {
@@ -939,20 +1217,37 @@ export async function runPromptHook(prompt, options = {}) {
939
1217
  });
940
1218
  return {
941
1219
  consumed: false,
942
- context: continuationQueryContext(directive),
1220
+ context: appendPreferenceCapture(
1221
+ continuationQueryContext(directive),
1222
+ preferenceCapture,
1223
+ ),
943
1224
  directive,
944
1225
  eligible: true,
945
1226
  reason: "contextual_continuation",
946
1227
  };
947
1228
  }
1229
+ if (preferenceCapture) {
1230
+ return {
1231
+ consumed: false,
1232
+ context: preferenceCapture,
1233
+ eligible: false,
1234
+ reason: "preference_capture",
1235
+ };
1236
+ }
948
1237
  debugLog(env, "skip", { reason: decision.reason }, options);
949
1238
  return null;
950
1239
  }
951
- const payload = buildQueryPayload(redacted, env, options.identity, {
952
- surface: "plugin_hook",
953
- runtime: runtimeFromIdentity(options.identity),
954
- trigger_reason: decision.reason,
955
- });
1240
+ const payload = buildQueryPayload(
1241
+ redacted,
1242
+ env,
1243
+ options.identity,
1244
+ {
1245
+ surface: "plugin_hook",
1246
+ runtime: runtimeFromIdentity(options.identity),
1247
+ trigger_reason: decision.reason,
1248
+ },
1249
+ options.projectPath,
1250
+ );
956
1251
  const response = await queryRemembrance(payload, {
957
1252
  env,
958
1253
  fetchImpl: options.fetchImpl ?? fetch,
@@ -962,9 +1257,12 @@ export async function runPromptHook(prompt, options = {}) {
962
1257
  if (!response) {
963
1258
  return {
964
1259
  consumed: false,
965
- context: includeSharedConfigCredentialNotice
966
- ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
967
- : unavailableQueryContext(env),
1260
+ context: appendPreferenceCapture(
1261
+ includeSharedConfigCredentialNotice
1262
+ ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1263
+ : unavailableQueryContext(env),
1264
+ preferenceCapture,
1265
+ ),
968
1266
  eligible: true,
969
1267
  reason: "query_unavailable",
970
1268
  };
@@ -972,14 +1270,17 @@ export async function runPromptHook(prompt, options = {}) {
972
1270
  return {
973
1271
  consumed: true,
974
1272
  matched: queryResponseHasMatches(response),
975
- context: includeSharedConfigCredentialNotice
976
- ? withSharedConfigCredentialNotice(
977
- formatContext(response, decision.reason, limitFromEnv(env)) ??
978
- emptyQueryContext(decision.reason),
979
- env,
980
- )
981
- : (formatContext(response, decision.reason, limitFromEnv(env)) ??
982
- emptyQueryContext(decision.reason)),
1273
+ context: appendPreferenceCapture(
1274
+ includeSharedConfigCredentialNotice
1275
+ ? withSharedConfigCredentialNotice(
1276
+ formatContext(response, decision.reason, limitFromEnv(env)) ??
1277
+ emptyQueryContext(decision.reason),
1278
+ env,
1279
+ )
1280
+ : (formatContext(response, decision.reason, limitFromEnv(env)) ??
1281
+ emptyQueryContext(decision.reason)),
1282
+ preferenceCapture,
1283
+ ),
983
1284
  eligible: true,
984
1285
  highMatch: highMatchFromResponse(response),
985
1286
  valueEpisode: valueEpisodeFromResponse(response),
@@ -987,6 +1288,10 @@ export async function runPromptHook(prompt, options = {}) {
987
1288
  };
988
1289
  }
989
1290
 
1291
+ function appendPreferenceCapture(context, directive) {
1292
+ return directive ? `${context}\n\n${directive}` : context;
1293
+ }
1294
+
990
1295
  export function queryResponseHasMatches(response) {
991
1296
  const body = responseBodyFromToolResponse(response);
992
1297
  const skills = Array.isArray(body?.skills)
@@ -1070,7 +1375,7 @@ export function contributionReason(
1070
1375
  directSelection = null,
1071
1376
  ) {
1072
1377
  const signalLine = signal
1073
- ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1378
+ ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
1074
1379
  : null;
1075
1380
  const highMatchLine = highMatch
1076
1381
  ? highMatchClosureInstruction(highMatch)
@@ -1092,52 +1397,42 @@ export function contributionReason(
1092
1397
  });
1093
1398
  return [
1094
1399
  directSelections.length === 1
1095
- ? `Before you finish: you explicitly used this Remembrance skill:`
1096
- : `Before you finish: you explicitly used ${directSelections.length} Remembrance skills:`,
1400
+ ? "Before finishing, silently close post-use Remembrance feedback for this explicitly selected Remembrance skill:"
1401
+ : `Before finishing, silently close post-use Remembrance feedback for these ${directSelections.length} Remembrance skills selected explicitly:`,
1097
1402
  ...selectionLines,
1098
1403
  signalLine,
1099
1404
  "Do not submit query-fit feedback for these selections; they were chosen directly rather than ranked for a query.",
1100
1405
  "Call submit_feedback once for each listed skill with useful true or false and a concise post-use lesson.",
1101
- "If submit_feedback returns next_step.submit_remembrance_payload, submit that reviewed evidence when the lesson is reusable.",
1102
- "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.",
1104
- "Redact secrets, private URLs, credentials, prompts, outputs, source paths, and proprietary content.",
1105
- "If nothing is genuinely worth capturing, say so in one line; you will not be asked again for this use.",
1406
+ "If submit_feedback returns next_step.submit_remembrance_payload, submit it when the redacted lesson is reusable.",
1407
+ routineContributionVisibilityInstruction(),
1106
1408
  ]
1107
1409
  .filter(Boolean)
1108
1410
  .join("\n");
1109
1411
  }
1110
1412
  return [
1111
- "Before you finish: you used Remembrance this session.",
1413
+ "Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
1112
1414
  signalLine,
1113
1415
  highMatchLine,
1114
- "If you learned something reusable — a lesson, a gotcha, a fix, a skill that helped, or a skill that was missing — contribute it now so the next agent inherits it instead of rediscovering it:",
1115
- "• submit_query_feedback — send one complete good / partial / poor verdict set using query_id and returned result_id values; leave uncertain results unrated. A poor query match is not the same as a globally bad skill.",
1116
- "• submit_feedback — a quick useful / not-useful signal plus a one-line lesson.",
1117
- "• 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
- "• 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
- "• 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.",
1121
- "Redact secrets, private URLs, and proprietary content; submit redacted summaries and hashes, not raw traces.",
1122
- "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
- "If nothing is genuinely worth capturing, just say so in one line — you will not be asked again this session.",
1416
+ "Call submit_query_feedback for pending query fit, submit_feedback only for used results, and submit_remembrance only for a reusable redacted lesson. If no skill fit and you built a reusable workflow, default to propose_private_skill; use propose_skill_idea only when a public candidate is explicitly appropriate. A supplied invalid/inactive key fails with 401 and an insufficient key with 403; neither creates a candidate.",
1417
+ routineContributionVisibilityInstruction(),
1124
1418
  ]
1125
1419
  .filter(Boolean)
1126
1420
  .join("\n");
1127
1421
  }
1128
1422
 
1423
+ function routineContributionVisibilityInstruction() {
1424
+ return `Use Remembrance MCP tools when available; otherwise use this session's REST fallback, or emit REMEMBRANCE_SUBMISSION_PAYLOAD only if the API is unavailable. Never remove or bypass an organization key to force a public candidate. Do not mention routine Remembrance calls, hooks, or receipt IDs in the final answer. Surface only failures, host blocks, or required action. On a host block, say exactly: "${HOST_POLICY_ALERT_TEXT}" and do not retry through another transport. Redact secrets and proprietary content.`;
1425
+ }
1426
+
1129
1427
  export function taskClosureReason(signal = null) {
1130
1428
  const signalLine = signal
1131
- ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1429
+ ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
1132
1430
  : null;
1133
1431
  return [
1134
- "Before you finish: this session involved a reusable service, API, tool, workflow, UI, review, or engineering task, but no completed Remembrance query/use was recorded.",
1432
+ "Before finishing, silently recover the pending Remembrance query for this reusable task using the full conversation; no completed Remembrance query/use was recorded.",
1135
1433
  signalLine,
1136
- "Infer the concrete task from the full conversation and call query_skills now when the MCP tool is visible. If MCP is unavailable, use POST /api/v1/agent/query with a redacted summary.",
1137
- "Evaluate returned result IDs explicitly: call submit_query_feedback once with the complete good/partial/poor verdict set, then use submit_feedback only after actually using a skill. If nothing fits, report overall_fit none and propose a skill idea only when you actually built a reusable workflow.",
1138
- "Submit a failure_report for a reusable self-correction, user-caught mistake, CI/deploy failure, security issue, or release/versioning miss even when no skill was used.",
1139
- "Redact secrets, private URLs, credentials, raw logs, and proprietary content; attach bounded reproduction detail or artifact hashes when available.",
1140
- "If the task was truly trivial and produced no reusable lesson, say so in one line; you will not be asked again for this task.",
1434
+ "Call query_skills with a redacted full-context summary, evaluate the returned matches, then close any warranted submit_query_feedback, submit_feedback, or submit_remembrance action before responding.",
1435
+ routineContributionVisibilityInstruction(),
1141
1436
  ]
1142
1437
  .filter(Boolean)
1143
1438
  .join("\n");
@@ -1169,6 +1464,17 @@ export function detectHighValueLessonSignalInText(text) {
1169
1464
  return null;
1170
1465
  }
1171
1466
 
1467
+ export function hostPolicyAlertWasReported(input) {
1468
+ const value = String(
1469
+ input?.last_assistant_message ??
1470
+ input?.lastAssistantMessage ??
1471
+ input?.assistant_message ??
1472
+ input?.message ??
1473
+ "",
1474
+ );
1475
+ return value.includes(HOST_POLICY_ALERT_TEXT);
1476
+ }
1477
+
1172
1478
  // Pure decision function (unit-tested): compare completed registry use and task
1173
1479
  // eligibility with the last-prompted count. A completed use prompts for a
1174
1480
  // contribution; eligible work with no completed query prompts for full-context
@@ -1182,6 +1488,9 @@ export function decideStop(input, options = {}) {
1182
1488
  if (input?.stop_hook_active) {
1183
1489
  return { allow: true, why: "stop_hook_active" };
1184
1490
  }
1491
+ if (hostPolicyAlertWasReported(input)) {
1492
+ return { allow: true, why: "host_policy_alert_reported" };
1493
+ }
1185
1494
  const sessionId = sessionIdFor(input);
1186
1495
  const readUse = options.readUseCount ?? readRegistryUseCount;
1187
1496
  const useCount = readUse(sessionId, env);
@@ -2142,16 +2451,25 @@ async function postTaskOutcome(payload, options = {}) {
2142
2451
  const controller = new AbortController();
2143
2452
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
2144
2453
  try {
2145
- const credential = resolveApiCredential(env);
2454
+ const access = resolveApiAccessSnapshot(env);
2455
+ const credential = access.credential;
2146
2456
  if (isUnusableConfigurationSource(credential.source)) return false;
2147
2457
  const headers = {
2148
2458
  "content-type": "application/json",
2149
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
2459
+ "user-agent": clientUserAgent(options.userAgent),
2150
2460
  };
2151
2461
  const apiKey = credential.apiKey;
2152
2462
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
2463
+ const principalSession = readHookPrincipalSession(
2464
+ normalizeRuntime(env.REMEMBRANCE_PLUGIN_HOST),
2465
+ env,
2466
+ access,
2467
+ );
2468
+ if (principalSession?.token) {
2469
+ headers["x-remembrance-principal-session"] = principalSession.token;
2470
+ }
2153
2471
  const response = await (options.fetchImpl ?? fetch)(
2154
- `${apiUrl(env)}/api/v1/agent/task-outcomes`,
2472
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/task-outcomes`,
2155
2473
  {
2156
2474
  method: "POST",
2157
2475
  headers,
@@ -2304,74 +2622,1130 @@ function pluginHealthDir(env = process.env) {
2304
2622
  return join(homedir(), ".cache", "remembrance", "plugin-health");
2305
2623
  }
2306
2624
 
2307
- function normalizedPluginHealthSurface(value) {
2308
- const surface = String(value ?? "")
2309
- .trim()
2310
- .toLowerCase();
2311
- return PLUGIN_HEALTH_SURFACES.has(surface) ? surface : null;
2625
+ function pluginAlertDir(env = process.env) {
2626
+ if (env?.REMEMBRANCE_PLUGIN_ALERT_DIR) {
2627
+ return String(env.REMEMBRANCE_PLUGIN_ALERT_DIR);
2628
+ }
2629
+ if (process.env.VITEST) {
2630
+ return join(
2631
+ tmpdir(),
2632
+ "remembrance-plugin-alert-tests",
2633
+ String(process.pid),
2634
+ );
2635
+ }
2636
+ return join(homedir(), ".cache", "remembrance", "plugin-alerts");
2312
2637
  }
2313
2638
 
2314
- export function pluginHealthPath(surface, env = process.env) {
2315
- return pluginHealthSessionPath(surface, null, env);
2639
+ function clientUpdateDir(env = process.env) {
2640
+ if (env?.REMEMBRANCE_CLIENT_UPDATE_DIR) {
2641
+ return String(env.REMEMBRANCE_CLIENT_UPDATE_DIR);
2642
+ }
2643
+ if (process.env.VITEST) {
2644
+ return join(
2645
+ tmpdir(),
2646
+ "remembrance-client-update-tests",
2647
+ String(process.pid),
2648
+ );
2649
+ }
2650
+ return join(homedir(), ".cache", "remembrance", "client-updates");
2316
2651
  }
2317
2652
 
2318
- function pluginHealthSessionPath(surface, sessionId, env = process.env) {
2319
- const normalized = normalizedPluginHealthSurface(surface);
2320
- if (!normalized) return null;
2321
- const normalizedSession = safeText(sessionId ?? "", 256);
2322
- if (!normalizedSession || normalizedSession === "unknown") {
2323
- return join(pluginHealthDir(env), `${normalized}.json`);
2653
+ function principalSessionDir(env = process.env) {
2654
+ if (env?.REMEMBRANCE_PRINCIPAL_SESSION_DIR) {
2655
+ return String(env.REMEMBRANCE_PRINCIPAL_SESSION_DIR);
2324
2656
  }
2325
- const sessionHash = createHash("sha256")
2326
- .update(normalizedSession, "utf8")
2657
+ if (process.env.VITEST) {
2658
+ return join(
2659
+ tmpdir(),
2660
+ "remembrance-principal-session-tests",
2661
+ String(process.pid),
2662
+ );
2663
+ }
2664
+ return join(homedir(), ".cache", "remembrance", "principal-sessions");
2665
+ }
2666
+
2667
+ function agentIdentityPath(env = process.env) {
2668
+ return (
2669
+ String(env.REMEMBRANCE_AGENT_KEY_PATH ?? "").trim() ||
2670
+ join(
2671
+ env.XDG_CONFIG_HOME || join(homedir(), ".config"),
2672
+ "remembrance",
2673
+ "agent-key.json",
2674
+ )
2675
+ );
2676
+ }
2677
+
2678
+ function readHookIdentity(env = process.env) {
2679
+ try {
2680
+ const parsed = JSON.parse(
2681
+ readSecureHookFile(agentIdentityPath(env), MAX_LOCAL_CONFIG_BYTES),
2682
+ );
2683
+ if (
2684
+ !parsed ||
2685
+ typeof parsed !== "object" ||
2686
+ Array.isArray(parsed) ||
2687
+ !["other", "codex", "cursor", "claude_code"].includes(parsed.provider) ||
2688
+ typeof parsed.key_id !== "string" ||
2689
+ !parsed.key_id ||
2690
+ typeof parsed.subject !== "string" ||
2691
+ !parsed.subject ||
2692
+ typeof parsed.public_key !== "string" ||
2693
+ !parsed.public_key ||
2694
+ typeof parsed.private_key !== "string" ||
2695
+ !parsed.private_key
2696
+ ) {
2697
+ return null;
2698
+ }
2699
+ createPrivateKey(parsed.private_key);
2700
+ return parsed;
2701
+ } catch {
2702
+ return null;
2703
+ }
2704
+ }
2705
+
2706
+ function localIdentityProvider(runtime) {
2707
+ const normalized = normalizeRuntime(runtime);
2708
+ return ["codex", "cursor", "claude_code"].includes(normalized)
2709
+ ? normalized
2710
+ : "other";
2711
+ }
2712
+
2713
+ function createHookIdentity(runtime, env = process.env) {
2714
+ const path = agentIdentityPath(env);
2715
+ const existing = readHookIdentity(env);
2716
+ if (existing) return existing;
2717
+ try {
2718
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
2719
+ const publicKeyPem = String(
2720
+ publicKey.export({ type: "spki", format: "pem" }),
2721
+ );
2722
+ const keyId = `tofu_${createHash("sha256")
2723
+ .update(publicKeyPem)
2724
+ .digest("hex")
2725
+ .slice(0, 24)}`;
2726
+ const identity = {
2727
+ provider: localIdentityProvider(runtime),
2728
+ subject: `local:${keyId}`,
2729
+ key_id: keyId,
2730
+ public_key: publicKeyPem,
2731
+ private_key: String(privateKey.export({ type: "pkcs8", format: "pem" })),
2732
+ created_at: new Date().toISOString(),
2733
+ };
2734
+ ensureSecureHookDirectory(dirname(path));
2735
+ writeFileSync(path, `${JSON.stringify(identity, null, 2)}\n`, {
2736
+ mode: 0o600,
2737
+ flag: "wx",
2738
+ });
2739
+ return identity;
2740
+ } catch {
2741
+ return readHookIdentity(env);
2742
+ }
2743
+ }
2744
+
2745
+ function canonicalHookJson(value) {
2746
+ const sort = (candidate) => {
2747
+ if (Array.isArray(candidate)) return candidate.map(sort);
2748
+ if (candidate && typeof candidate === "object") {
2749
+ return Object.keys(candidate)
2750
+ .sort()
2751
+ .reduce((result, key) => {
2752
+ result[key] = sort(candidate[key]);
2753
+ return result;
2754
+ }, {});
2755
+ }
2756
+ return candidate;
2757
+ };
2758
+ return JSON.stringify(sort(value));
2759
+ }
2760
+
2761
+ async function registerHookIdentity(identity, fetchImpl, access, signal) {
2762
+ let ownerBinding = null;
2763
+ try {
2764
+ const bindingResponse = await fetchImpl(
2765
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/keys/register`,
2766
+ {
2767
+ method: "GET",
2768
+ headers: principalRequestHeaders(access),
2769
+ signal,
2770
+ },
2771
+ );
2772
+ if (bindingResponse?.ok) {
2773
+ const bindingPayload = await bindingResponse.json();
2774
+ const candidate = String(bindingPayload?.owner_binding ?? "").trim();
2775
+ if (/^areg_[A-Za-z0-9_-]{24,120}$/.test(candidate)) {
2776
+ ownerBinding = candidate;
2777
+ }
2778
+ }
2779
+ } catch {
2780
+ // Registration remains fail-open; an existing same-scope key can still use
2781
+ // the legacy proof while new organization claims require the binding.
2782
+ }
2783
+ const signedAt = new Date().toISOString();
2784
+ const publicKeyHash = `sha256:${createHash("sha256")
2785
+ .update(identity.public_key)
2786
+ .digest("hex")}`;
2787
+ const signingPayload = canonicalHookJson({
2788
+ version: ownerBinding ? "v2" : "v1",
2789
+ purpose: "remembrance-agent-key-registration",
2790
+ provider: identity.provider,
2791
+ key_id: identity.key_id,
2792
+ ...(ownerBinding ? { owner_binding: ownerBinding } : {}),
2793
+ public_key_hash: publicKeyHash,
2794
+ subject: identity.subject,
2795
+ signed_at: signedAt,
2796
+ });
2797
+ const signature = signPayload(
2798
+ null,
2799
+ Buffer.from(signingPayload),
2800
+ createPrivateKey(identity.private_key),
2801
+ ).toString("base64url");
2802
+ const response = await fetchImpl(
2803
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/keys/register`,
2804
+ {
2805
+ method: "POST",
2806
+ headers: principalRequestHeaders(access),
2807
+ body: JSON.stringify({
2808
+ provider: identity.provider,
2809
+ key_id: identity.key_id,
2810
+ public_key: identity.public_key,
2811
+ subject: identity.subject,
2812
+ proof: {
2813
+ algorithm: "ed25519",
2814
+ ...(ownerBinding ? { owner_binding: ownerBinding } : {}),
2815
+ signed_at: signedAt,
2816
+ signature,
2817
+ },
2818
+ metadata: { registered_by: "remembrance-plugin-hook" },
2819
+ }),
2820
+ signal,
2821
+ },
2822
+ );
2823
+ return response?.ok === true;
2824
+ }
2825
+
2826
+ function principalSessionCachePath(
2827
+ runtime,
2828
+ keyId,
2829
+ env = process.env,
2830
+ access = resolveApiAccessSnapshot(env),
2831
+ ) {
2832
+ const credentialFingerprint = createHash("sha256")
2833
+ .update(
2834
+ [access.credential.source, access.credential.apiKey].join(":"),
2835
+ "utf8",
2836
+ )
2327
2837
  .digest("hex")
2328
- .slice(0, 24);
2329
- return join(pluginHealthDir(env), `${normalized}.${sessionHash}.json`);
2838
+ .slice(0, 16);
2839
+ const digest = createHash("sha256")
2840
+ .update(
2841
+ [
2842
+ access.apiConfiguration.apiUrl,
2843
+ credentialFingerprint,
2844
+ normalizeRuntime(runtime),
2845
+ String(keyId),
2846
+ ].join(":"),
2847
+ "utf8",
2848
+ )
2849
+ .digest("hex")
2850
+ .slice(0, 32);
2851
+ return join(principalSessionDir(env), `${digest}.json`);
2330
2852
  }
2331
2853
 
2332
- export function readPluginLifecycleHealth(
2333
- surface,
2854
+ export function readHookPrincipalSession(
2855
+ runtime,
2334
2856
  env = process.env,
2335
- sessionId = null,
2857
+ access = resolveApiAccessSnapshot(env),
2336
2858
  ) {
2337
- const path = pluginHealthSessionPath(surface, sessionId, env);
2338
- if (!path) return null;
2859
+ const identity = readHookIdentity(env);
2860
+ if (!identity) return null;
2339
2861
  try {
2340
2862
  const parsed = JSON.parse(
2341
- readSecureHookFile(path, MAX_LOCAL_HEALTH_MARKER_BYTES),
2863
+ readSecureHookFile(
2864
+ principalSessionCachePath(runtime, identity.key_id, env, access),
2865
+ MAX_LOCAL_PRINCIPAL_SESSION_BYTES,
2866
+ ),
2342
2867
  );
2343
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2344
- ? parsed
2345
- : null;
2868
+ if (
2869
+ typeof parsed?.token !== "string" ||
2870
+ !/^psess_[A-Za-z0-9_-]{24,160}$/.test(parsed.token) ||
2871
+ !Number.isFinite(Date.parse(parsed.expires_at ?? "")) ||
2872
+ Date.parse(parsed.expires_at) <= Date.now() + 60_000
2873
+ ) {
2874
+ return null;
2875
+ }
2876
+ return {
2877
+ token: parsed.token,
2878
+ expires_at: parsed.expires_at,
2879
+ member_linked: parsed.member_linked === true,
2880
+ };
2346
2881
  } catch {
2347
2882
  return null;
2348
2883
  }
2349
2884
  }
2350
2885
 
2351
- // Record that a native lifecycle component actually executed. The marker is
2352
- // intentionally local and content-free: it contains only surface/version,
2353
- // component timestamps, and the credential source category. Local MCP reads
2354
- // it through run_connection_doctor so partial activation cannot look healthy.
2355
- export function recordPluginLifecycleHealth(
2886
+ export function clearHookPrincipalSession(
2887
+ runtime,
2888
+ env = process.env,
2889
+ access = resolveApiAccessSnapshot(env),
2890
+ ) {
2891
+ const identity = readHookIdentity(env);
2892
+ if (!identity) return false;
2893
+ try {
2894
+ rmSync(principalSessionCachePath(runtime, identity.key_id, env, access), {
2895
+ force: true,
2896
+ });
2897
+ return true;
2898
+ } catch {
2899
+ return false;
2900
+ }
2901
+ }
2902
+
2903
+ export async function warmPrincipalSession(
2356
2904
  {
2357
- surface,
2358
- component,
2359
- pluginVersion = null,
2905
+ runtime,
2906
+ hostSurface = null,
2907
+ clientVersion = null,
2360
2908
  hostVersion = null,
2361
- credentialSource = null,
2362
- sessionId = null,
2909
+ fetchImpl = fetch,
2910
+ apiAccess = null,
2363
2911
  },
2364
2912
  env = process.env,
2365
2913
  ) {
2366
- const normalizedSurface = normalizedPluginHealthSurface(surface);
2367
- const normalizedComponent = String(component ?? "")
2368
- .trim()
2369
- .toLowerCase();
2914
+ // Same pause-don't-downgrade rule the query, directive-event, and task-outcome
2915
+ // paths enforce. It has to live HERE, not in the callers: every host adapter
2916
+ // warms the principal session at session start, so a per-caller guard is one
2917
+ // that six adapters can each forget.
2918
+ //
2919
+ // principalRequestHeaders() already withholds the API key when the source is
2920
+ // unusable, which is only half a guard: the request would still go out, and
2921
+ // apiUrl() hands back DEFAULT_API_URL while merely flagging the source, so an
2922
+ // unreadable config would register the agent key anonymously against the
2923
+ // default registry instead of the destination that config was naming. The
2924
+ // principal-session challenge also carries a member link token, so suppressing
2925
+ // the request — not just the header — is what keeps a secret from reaching a
2926
+ // destination the user never chose.
2927
+ const access = apiAccess ?? resolveApiAccessSnapshot(env);
2928
+ const credential = access.credential;
2370
2929
  if (
2371
- !normalizedSurface ||
2372
- !PLUGIN_HEALTH_COMPONENTS.has(normalizedComponent)
2930
+ isUnusableConfigurationSource(access.apiConfiguration.source) ||
2931
+ isUnusableConfigurationSource(credential.source)
2373
2932
  ) {
2374
- return false;
2933
+ debugLog(env, "shared_config_unusable", {});
2934
+ return null;
2935
+ }
2936
+ const normalizedRuntime = normalizeRuntime(runtime);
2937
+ const normalizedHostSurface = runtimeHostSurface(
2938
+ normalizedRuntime,
2939
+ env,
2940
+ hostSurface,
2941
+ );
2942
+ const identity =
2943
+ readHookIdentity(env) ?? createHookIdentity(normalizedRuntime, env);
2944
+ if (!identity) return null;
2945
+ const cached = readHookPrincipalSession(normalizedRuntime, env, access);
2946
+ if (cached) return cached;
2947
+ const warmupKey = principalSessionCachePath(
2948
+ normalizedRuntime,
2949
+ identity.key_id,
2950
+ env,
2951
+ access,
2952
+ );
2953
+ const activeWarmup = principalSessionWarmups.get(warmupKey);
2954
+ if (activeWarmup) return activeWarmup;
2955
+ const warmup = warmPrincipalSessionUncached(
2956
+ {
2957
+ normalizedRuntime,
2958
+ normalizedHostSurface,
2959
+ clientVersion,
2960
+ hostVersion,
2961
+ fetchImpl,
2962
+ identity,
2963
+ warmupKey,
2964
+ apiAccess: access,
2965
+ },
2966
+ env,
2967
+ );
2968
+ principalSessionWarmups.set(warmupKey, warmup);
2969
+ try {
2970
+ return await warmup;
2971
+ } finally {
2972
+ if (principalSessionWarmups.get(warmupKey) === warmup) {
2973
+ principalSessionWarmups.delete(warmupKey);
2974
+ }
2975
+ }
2976
+ }
2977
+
2978
+ async function warmPrincipalSessionUncached(
2979
+ {
2980
+ normalizedRuntime,
2981
+ normalizedHostSurface,
2982
+ clientVersion,
2983
+ hostVersion,
2984
+ fetchImpl,
2985
+ identity,
2986
+ warmupKey,
2987
+ apiAccess,
2988
+ },
2989
+ env,
2990
+ ) {
2991
+ const controller = new AbortController();
2992
+ const timeout = setTimeout(
2993
+ () => controller.abort(),
2994
+ PRINCIPAL_SESSION_TIMEOUT_MS,
2995
+ );
2996
+ try {
2997
+ if (
2998
+ !(await registerHookIdentity(
2999
+ identity,
3000
+ fetchImpl,
3001
+ apiAccess,
3002
+ controller.signal,
3003
+ ))
3004
+ ) {
3005
+ return null;
3006
+ }
3007
+ const memberLinkToken = apiAccess.memberLinkToken;
3008
+ const profileKey = createHash("sha256")
3009
+ .update(
3010
+ [
3011
+ identity.key_id,
3012
+ normalizedRuntime,
3013
+ normalizedHostSurface,
3014
+ "plugin_hook",
3015
+ ].join(":"),
3016
+ "utf8",
3017
+ )
3018
+ .digest("base64url");
3019
+ const challengePayload = {
3020
+ action: "challenge",
3021
+ provider: identity.provider,
3022
+ key_id: identity.key_id,
3023
+ runtime_profile: {
3024
+ runtime: normalizedRuntime,
3025
+ surface: "plugin_hook",
3026
+ host_surface: normalizedHostSurface,
3027
+ client_name: runtimeDisplayName(normalizedRuntime),
3028
+ client_version: safeText(clientVersion ?? "unknown", 64),
3029
+ runtime_version: safeText(hostVersion ?? "", 64) || null,
3030
+ profile_key: `rpf_${profileKey}`,
3031
+ },
3032
+ ...(memberLinkToken ? { member_link_token: memberLinkToken } : {}),
3033
+ };
3034
+ let challenge = await postPrincipalSession(
3035
+ challengePayload,
3036
+ fetchImpl,
3037
+ apiAccess,
3038
+ controller.signal,
3039
+ );
3040
+ if (!challenge && memberLinkToken) {
3041
+ challenge = await postPrincipalSession(
3042
+ { ...challengePayload, member_link_token: undefined },
3043
+ fetchImpl,
3044
+ apiAccess,
3045
+ controller.signal,
3046
+ );
3047
+ }
3048
+ if (!challenge?.challenge_id || !challenge?.signing_payload) return null;
3049
+ const signature = signPayload(
3050
+ null,
3051
+ Buffer.from(challenge.signing_payload),
3052
+ createPrivateKey(identity.private_key),
3053
+ ).toString("base64url");
3054
+ const session = await postPrincipalSession(
3055
+ {
3056
+ action: "exchange",
3057
+ provider: identity.provider,
3058
+ key_id: identity.key_id,
3059
+ challenge_id: challenge.challenge_id,
3060
+ signature,
3061
+ },
3062
+ fetchImpl,
3063
+ apiAccess,
3064
+ controller.signal,
3065
+ );
3066
+ if (!session?.session_token || !session?.expires_at) return null;
3067
+ const stored = {
3068
+ token: session.session_token,
3069
+ expires_at: session.expires_at,
3070
+ member_linked: session.member_linked === true,
3071
+ };
3072
+ writeHookPrincipalSession(warmupKey, stored);
3073
+ return stored;
3074
+ } catch {
3075
+ return null;
3076
+ } finally {
3077
+ clearTimeout(timeout);
3078
+ }
3079
+ }
3080
+
3081
+ async function postPrincipalSession(payload, fetchImpl, access, signal) {
3082
+ const response = await fetchImpl(
3083
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/principal-sessions`,
3084
+ {
3085
+ method: "POST",
3086
+ headers: principalRequestHeaders(access),
3087
+ body: JSON.stringify(payload),
3088
+ signal,
3089
+ },
3090
+ );
3091
+ return response?.ok ? response.json() : null;
3092
+ }
3093
+
3094
+ function principalRequestHeaders(access) {
3095
+ const headers = {
3096
+ "content-type": "application/json",
3097
+ "user-agent": clientUserAgent(),
3098
+ };
3099
+ const credential = access.credential;
3100
+ if (credential.apiKey && !isUnusableConfigurationSource(credential.source)) {
3101
+ headers["x-remembrance-api-key"] = credential.apiKey;
3102
+ }
3103
+ return headers;
3104
+ }
3105
+
3106
+ function writeHookPrincipalSession(path, value) {
3107
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3108
+ try {
3109
+ ensureSecureHookDirectory(dirname(path));
3110
+ writeFileSync(temporaryPath, `${JSON.stringify(value)}\n`, {
3111
+ mode: 0o600,
3112
+ flag: "wx",
3113
+ });
3114
+ renameSync(temporaryPath, path);
3115
+ return true;
3116
+ } catch {
3117
+ try {
3118
+ rmSync(temporaryPath, { force: true });
3119
+ } catch {
3120
+ // Principal sessions are optional and always fail open for retrieval.
3121
+ }
3122
+ return false;
3123
+ }
3124
+ }
3125
+
3126
+ function runtimeDisplayName(runtime) {
3127
+ return (
3128
+ {
3129
+ codex: "Codex",
3130
+ claude_code: "Claude Code",
3131
+ cursor: "Cursor",
3132
+ openclaw: "OpenClaw",
3133
+ vs_code: "VS Code",
3134
+ opencode: "opencode",
3135
+ }[runtime] ?? "Agent"
3136
+ );
3137
+ }
3138
+
3139
+ function sanitizeEffectivePreferences(value) {
3140
+ if (!Array.isArray(value)) return [];
3141
+ return value
3142
+ .filter(
3143
+ (item) =>
3144
+ item &&
3145
+ typeof item === "object" &&
3146
+ !Array.isArray(item) &&
3147
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(item.key) &&
3148
+ typeof item.value === "string" &&
3149
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(item.value) &&
3150
+ ["presentation", "workflow", "strategy_selection"].includes(
3151
+ item.effect ?? "presentation",
3152
+ ) &&
3153
+ ["prefer", "avoid"].includes(item.strength ?? "prefer"),
3154
+ )
3155
+ .slice(0, 32)
3156
+ .map((item) => {
3157
+ const label = safeText(item.label ?? item.key, 96);
3158
+ const behavior = safeText(item.behavior ?? item.value, 320);
3159
+ const direction = item.strength === "avoid" ? "avoid" : "prefer";
3160
+ const authority =
3161
+ item.source === "mandatory_org" ? "required organization" : item.source;
3162
+ return `${label} [${safeText(item.effect ?? "presentation", 24)}, ${safeText(authority ?? "preference", 32)}]: ${direction} ${behavior}`;
3163
+ });
3164
+ }
3165
+
3166
+ function clientUpdatePath(surface, env = process.env) {
3167
+ const normalized = normalizedPluginHealthSurface(surface);
3168
+ return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
3169
+ }
3170
+
3171
+ export async function checkForClientUpdate(
3172
+ {
3173
+ surface,
3174
+ currentVersion,
3175
+ fetchImpl = fetch,
3176
+ now = Date.now(),
3177
+ timeoutMs = CLIENT_UPDATE_TIMEOUT_MS,
3178
+ },
3179
+ env = process.env,
3180
+ ) {
3181
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
3182
+ if (
3183
+ !normalizedSurface ||
3184
+ disabled(env.REMEMBRANCE_CLIENT_UPDATE_CHECK) ||
3185
+ !parseStableClientVersion(currentVersion)
3186
+ ) {
3187
+ return null;
3188
+ }
3189
+ const configuration = resolveApiConfiguration(env);
3190
+ if (isUnusableConfigurationSource(configuration.source)) return null;
3191
+ const path = clientUpdatePath(normalizedSurface, env);
3192
+ let manifest = readCachedClientRelease(path, now);
3193
+ if (!manifest) {
3194
+ const controller = new AbortController();
3195
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3196
+ try {
3197
+ const response = await fetchImpl(
3198
+ `${configuration.apiUrl.replace(/\/+$/, "")}/.well-known/remembrance-client-release.json`,
3199
+ {
3200
+ headers: { accept: "application/json" },
3201
+ signal: controller.signal,
3202
+ },
3203
+ );
3204
+ if (!response?.ok) return null;
3205
+ const text = await response.text();
3206
+ if (Buffer.byteLength(text, "utf8") > MAX_LOCAL_CLIENT_UPDATE_BYTES) {
3207
+ return null;
3208
+ }
3209
+ try {
3210
+ manifest = parseClientReleaseManifest(JSON.parse(text));
3211
+ } catch {
3212
+ return null;
3213
+ }
3214
+ if (!manifest) return null;
3215
+ writeCachedClientRelease(path, manifest, now);
3216
+ } catch {
3217
+ return null;
3218
+ } finally {
3219
+ clearTimeout(timer);
3220
+ }
3221
+ }
3222
+ if (
3223
+ !manifest.surfaces.includes(normalizedSurface) ||
3224
+ compareStableClientVersions(manifest.latest_version, currentVersion) <= 0
3225
+ ) {
3226
+ return null;
3227
+ }
3228
+ const guidance = CLIENT_UPDATE_GUIDANCE[normalizedSurface];
3229
+ if (!guidance) return null;
3230
+ const command = guidance.command
3231
+ ? `\nTrusted update command bundled with this installed client:\n${guidance.command}`
3232
+ : "";
3233
+ return {
3234
+ current_version: currentVersion,
3235
+ latest_version: manifest.latest_version,
3236
+ surface: normalizedSurface,
3237
+ notice: [
3238
+ `Remembrance update available: installed ${currentVersion}, published ${manifest.latest_version}.`,
3239
+ guidance.update,
3240
+ command,
3241
+ guidance.restart,
3242
+ "Do not claim the new version is active until a fresh process reports it.",
3243
+ ]
3244
+ .filter(Boolean)
3245
+ .join("\n"),
3246
+ };
3247
+ }
3248
+
3249
+ function readCachedClientRelease(path, now) {
3250
+ if (!path) return null;
3251
+ try {
3252
+ const parsed = JSON.parse(
3253
+ readSecureHookFile(path, MAX_LOCAL_CLIENT_UPDATE_BYTES),
3254
+ );
3255
+ if (
3256
+ parsed?.schema_version !== 1 ||
3257
+ !Number.isFinite(parsed?.checked_at) ||
3258
+ parsed.checked_at > now + 5 * 60 * 1000 ||
3259
+ parsed.checked_at + CLIENT_UPDATE_CACHE_TTL_MS <= now
3260
+ ) {
3261
+ return null;
3262
+ }
3263
+ return parseClientReleaseManifest(parsed.manifest);
3264
+ } catch {
3265
+ return null;
3266
+ }
3267
+ }
3268
+
3269
+ function writeCachedClientRelease(path, manifest, now) {
3270
+ if (!path) return false;
3271
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3272
+ try {
3273
+ ensureSecureHookDirectory(dirname(path));
3274
+ writeFileSync(
3275
+ temporaryPath,
3276
+ `${JSON.stringify({ schema_version: 1, checked_at: now, manifest })}\n`,
3277
+ { mode: 0o600, flag: "wx" },
3278
+ );
3279
+ renameSync(temporaryPath, path);
3280
+ return true;
3281
+ } catch {
3282
+ try {
3283
+ rmSync(temporaryPath, { force: true });
3284
+ } catch {
3285
+ // Update checks are advisory and never break the host lifecycle.
3286
+ }
3287
+ return false;
3288
+ }
3289
+ }
3290
+
3291
+ function parseClientReleaseManifest(value) {
3292
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3293
+ if (value.schema_version !== "1") return null;
3294
+ if (!parseStableClientVersion(value.latest_version)) return null;
3295
+ if (
3296
+ typeof value.published_at !== "string" ||
3297
+ !isCanonicalClientReleaseTimestamp(value.published_at) ||
3298
+ !Array.isArray(value.surfaces)
3299
+ ) {
3300
+ return null;
3301
+ }
3302
+ const surfaces = value.surfaces.filter(
3303
+ (item) =>
3304
+ typeof item === "string" && CLIENT_RELEASE_MANIFEST_SURFACES.has(item),
3305
+ );
3306
+ if (
3307
+ surfaces.length !== value.surfaces.length ||
3308
+ new Set(surfaces).size !== surfaces.length
3309
+ ) {
3310
+ return null;
3311
+ }
3312
+ return {
3313
+ schema_version: "1",
3314
+ latest_version: value.latest_version,
3315
+ published_at: new Date(value.published_at).toISOString(),
3316
+ surfaces,
3317
+ };
3318
+ }
3319
+
3320
+ function isCanonicalClientReleaseTimestamp(value) {
3321
+ const timestamp = Date.parse(value);
3322
+ return (
3323
+ Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value
3324
+ );
3325
+ }
3326
+
3327
+ function parseStableClientVersion(value) {
3328
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(
3329
+ String(value ?? ""),
3330
+ );
3331
+ if (!match) return null;
3332
+ const parts = match.slice(1).map(Number);
3333
+ return parts.every(
3334
+ (part) => Number.isSafeInteger(part) && part >= 0 && part <= 9_999,
3335
+ )
3336
+ ? parts
3337
+ : null;
3338
+ }
3339
+
3340
+ function compareStableClientVersions(left, right) {
3341
+ const leftParts = parseStableClientVersion(left);
3342
+ const rightParts = parseStableClientVersion(right);
3343
+ if (!leftParts || !rightParts) return 0;
3344
+ for (let index = 0; index < 3; index += 1) {
3345
+ const difference = leftParts[index] - rightParts[index];
3346
+ if (difference !== 0) return Math.sign(difference);
3347
+ }
3348
+ return 0;
3349
+ }
3350
+
3351
+ function pluginAlertSessionPath(surface, sessionId, env = process.env) {
3352
+ const normalized = normalizedPluginHealthSurface(surface);
3353
+ if (!normalized) return null;
3354
+ const hash = createHash("sha256")
3355
+ .update(String(sessionId ?? "unknown"), "utf8")
3356
+ .digest("hex")
3357
+ .slice(0, 24);
3358
+ return join(pluginAlertDir(env), `${normalized}.${hash}.json`);
3359
+ }
3360
+
3361
+ function hostPolicyOperationClass(toolName) {
3362
+ const normalized = String(toolName ?? "").toLowerCase();
3363
+ if (
3364
+ normalized.endsWith("query_skills") ||
3365
+ normalized.endsWith("list_skills") ||
3366
+ normalized.endsWith("get_skill") ||
3367
+ normalized.endsWith("get_resource") ||
3368
+ normalized.endsWith("invoke_skill") ||
3369
+ normalized.endsWith("run_connection_doctor") ||
3370
+ normalized.endsWith("get_connection_status")
3371
+ ) {
3372
+ return "query";
3373
+ }
3374
+ if (
3375
+ normalized.endsWith("propose_private_skill") ||
3376
+ normalized.endsWith("queue_private_skill_import")
3377
+ ) {
3378
+ return "private_contribution";
3379
+ }
3380
+ if (
3381
+ normalized.endsWith("propose_skill_idea") ||
3382
+ normalized.endsWith("submit_remembrance") ||
3383
+ normalized.endsWith("submit_suggestion") ||
3384
+ normalized.endsWith("submit_resource") ||
3385
+ normalized.endsWith("submit_resource_review")
3386
+ ) {
3387
+ return "contribution";
3388
+ }
3389
+ if (
3390
+ normalized.endsWith("submit_query_feedback") ||
3391
+ normalized.endsWith("submit_feedback") ||
3392
+ normalized.endsWith("report_task_outcome")
3393
+ ) {
3394
+ return "feedback";
3395
+ }
3396
+ return "other";
3397
+ }
3398
+
3399
+ function isRemembranceToolName(toolName) {
3400
+ const normalized = String(toolName ?? "").toLowerCase();
3401
+ return (
3402
+ normalized.includes("remembrance") ||
3403
+ [
3404
+ "run_connection_doctor",
3405
+ "get_connection_status",
3406
+ "query_skills",
3407
+ "list_skills",
3408
+ "get_skill",
3409
+ "get_resource",
3410
+ "invoke_skill",
3411
+ "get_value_proof",
3412
+ "report_task_outcome",
3413
+ "submit_query_feedback",
3414
+ "submit_feedback",
3415
+ "submit_remembrance",
3416
+ "propose_skill_idea",
3417
+ "propose_private_skill",
3418
+ "queue_private_skill_import",
3419
+ "submit_suggestion",
3420
+ "submit_resource",
3421
+ "submit_resource_review",
3422
+ ].some((candidate) => normalized.endsWith(candidate))
3423
+ );
3424
+ }
3425
+
3426
+ function boundedHostPolicyText(value, depth = 0) {
3427
+ if (depth > 3 || value == null) return "";
3428
+ if (typeof value === "string") return value.slice(0, 2_000);
3429
+ if (typeof value === "number" || typeof value === "boolean") {
3430
+ return String(value);
3431
+ }
3432
+ if (Array.isArray(value)) {
3433
+ return value
3434
+ .slice(0, 8)
3435
+ .map((item) => boundedHostPolicyText(item, depth + 1))
3436
+ .join(" ")
3437
+ .slice(0, 4_000);
3438
+ }
3439
+ if (typeof value !== "object") return "";
3440
+ return [
3441
+ value.message,
3442
+ value.reason,
3443
+ value.permission_decision_reason,
3444
+ value.permissionDecisionReason,
3445
+ value.decision,
3446
+ value.response,
3447
+ value.error,
3448
+ value.detail,
3449
+ value.details,
3450
+ value.output,
3451
+ value.result,
3452
+ value.tool_response,
3453
+ value.toolResponse,
3454
+ value.properties?.error,
3455
+ value.properties?.message,
3456
+ value.properties?.reason,
3457
+ ]
3458
+ .map((item) => boundedHostPolicyText(item, depth + 1))
3459
+ .filter(Boolean)
3460
+ .join(" ")
3461
+ .slice(0, 4_000);
3462
+ }
3463
+
3464
+ export function classifyHostPolicyDenial({
3465
+ eventType = "",
3466
+ toolName = "",
3467
+ value = null,
3468
+ } = {}) {
3469
+ if (!isRemembranceToolName(toolName)) return null;
3470
+ const text = boundedHostPolicyText(value);
3471
+ const denied =
3472
+ /\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(
3473
+ text,
3474
+ );
3475
+ const policyContext =
3476
+ /\b(?:host|tenant|workspace|organization|administrator|admin|privacy|security|policy|data[- ](?:export|egress|loss prevention)|dlp|external service|trusted service)\b/i.test(
3477
+ text,
3478
+ );
3479
+ const normalizedEvent = String(eventType ?? "").toLowerCase();
3480
+ const permissionEvent = normalizedEvent.includes("permission");
3481
+ const explicitPreTransportContext =
3482
+ /\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(
3483
+ text,
3484
+ );
3485
+ // A host permission event is itself pre-transport evidence. Generic failed
3486
+ // tool events need an explicit host/export signal so a Remembrance API 403
3487
+ // such as an organization-policy refusal is never mislabeled as host policy.
3488
+ if (
3489
+ !denied ||
3490
+ !policyContext ||
3491
+ (!permissionEvent && !explicitPreTransportContext)
3492
+ ) {
3493
+ return null;
3494
+ }
3495
+ return {
3496
+ denial_class: permissionEvent
3497
+ ? "host_permission_policy"
3498
+ : "host_execution_policy",
3499
+ operation_class: hostPolicyOperationClass(toolName),
3500
+ before_mcp: permissionEvent ? "yes" : "unknown",
3501
+ };
3502
+ }
3503
+
3504
+ function readPluginAlertState(surface, sessionId, env = process.env) {
3505
+ const path = pluginAlertSessionPath(surface, sessionId, env);
3506
+ if (!path) return null;
3507
+ try {
3508
+ const parsed = JSON.parse(
3509
+ readSecureHookFile(path, MAX_LOCAL_PLUGIN_ALERT_BYTES),
3510
+ );
3511
+ if (parsed?.schema_version !== 1 || !Array.isArray(parsed?.observations)) {
3512
+ return null;
3513
+ }
3514
+ return parsed;
3515
+ } catch {
3516
+ return null;
3517
+ }
3518
+ }
3519
+
3520
+ function writePluginAlertState(surface, sessionId, state, env = process.env) {
3521
+ const path = pluginAlertSessionPath(surface, sessionId, env);
3522
+ if (!path) return false;
3523
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3524
+ try {
3525
+ ensureSecureHookDirectory(dirname(path));
3526
+ writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, {
3527
+ mode: 0o600,
3528
+ flag: "wx",
3529
+ });
3530
+ renameSync(temporaryPath, path);
3531
+ prunePluginAlertFiles(path, env);
3532
+ return true;
3533
+ } catch {
3534
+ try {
3535
+ rmSync(temporaryPath, { force: true });
3536
+ } catch {
3537
+ // Local policy observations are advisory and must never break the host.
3538
+ }
3539
+ return false;
3540
+ }
3541
+ }
3542
+
3543
+ function prunePluginAlertFiles(keepPath, env) {
3544
+ const directory = pluginAlertDir(env);
3545
+ const cutoff = Date.now() - PLUGIN_ALERT_TTL_MS;
3546
+ try {
3547
+ const candidates = readdirSync(directory)
3548
+ .filter((name) => /^[a-z_]+\.[a-f0-9]{24}\.json$/.test(name))
3549
+ .map((name) => {
3550
+ const path = join(directory, name);
3551
+ return { path, modified: statSync(path).mtimeMs };
3552
+ })
3553
+ .sort((left, right) => right.modified - left.modified);
3554
+ for (const candidate of candidates) {
3555
+ if (
3556
+ candidate.path !== keepPath &&
3557
+ (candidate.modified < cutoff ||
3558
+ candidates.indexOf(candidate) >= PLUGIN_ALERT_LIMIT)
3559
+ ) {
3560
+ rmSync(candidate.path, { force: true });
3561
+ }
3562
+ }
3563
+ } catch {
3564
+ // Pruning is advisory.
3565
+ }
3566
+ }
3567
+
3568
+ export function recordHostPolicyDenial(
3569
+ {
3570
+ surface,
3571
+ sessionId,
3572
+ eventType = "",
3573
+ toolName = "",
3574
+ value = null,
3575
+ pluginVersion = null,
3576
+ hostVersion = null,
3577
+ },
3578
+ env = process.env,
3579
+ ) {
3580
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
3581
+ const classification = classifyHostPolicyDenial({
3582
+ eventType,
3583
+ toolName,
3584
+ value,
3585
+ });
3586
+ if (!normalizedSurface || !classification) return null;
3587
+ const now = new Date().toISOString();
3588
+ const sessionHash = createHash("sha256")
3589
+ .update(String(sessionId ?? "unknown"), "utf8")
3590
+ .digest("hex")
3591
+ .slice(0, 24);
3592
+ const id = createHash("sha256")
3593
+ .update(
3594
+ [
3595
+ normalizedSurface,
3596
+ sessionHash,
3597
+ classification.operation_class,
3598
+ classification.denial_class,
3599
+ ].join(":"),
3600
+ "utf8",
3601
+ )
3602
+ .digest("hex")
3603
+ .slice(0, 24);
3604
+ const existing = readPluginAlertState(normalizedSurface, sessionId, env);
3605
+ const current = existing?.observations?.find((item) => item?.id === id);
3606
+ const observation = {
3607
+ id,
3608
+ denial_class: classification.denial_class,
3609
+ operation_class: classification.operation_class,
3610
+ before_mcp: classification.before_mcp,
3611
+ plugin_version: safeText(pluginVersion ?? "", 64),
3612
+ host_version: safeText(hostVersion ?? "", 64),
3613
+ first_seen_at: current?.first_seen_at ?? now,
3614
+ last_seen_at: now,
3615
+ count: Math.min(999, Math.max(0, Number(current?.count) || 0) + 1),
3616
+ alerted_at: current?.alerted_at ?? null,
3617
+ };
3618
+ const observations = [
3619
+ observation,
3620
+ ...(existing?.observations ?? []).filter((item) => item?.id !== id),
3621
+ ]
3622
+ .filter((item) => {
3623
+ const seenAt = Date.parse(item?.last_seen_at ?? "");
3624
+ return (
3625
+ Number.isFinite(seenAt) && Date.now() - seenAt <= PLUGIN_ALERT_TTL_MS
3626
+ );
3627
+ })
3628
+ .slice(0, 12);
3629
+ const state = {
3630
+ schema_version: 1,
3631
+ surface: normalizedSurface,
3632
+ session_hash: sessionHash,
3633
+ observations,
3634
+ updated_at: now,
3635
+ };
3636
+ return writePluginAlertState(normalizedSurface, sessionId, state, env)
3637
+ ? observation
3638
+ : null;
3639
+ }
3640
+
3641
+ export function readPendingHostPolicyAlert(
3642
+ surface,
3643
+ sessionId,
3644
+ env = process.env,
3645
+ ) {
3646
+ const state = readPluginAlertState(surface, sessionId, env);
3647
+ return (
3648
+ state?.observations?.find(
3649
+ (item) =>
3650
+ !item?.alerted_at &&
3651
+ Date.now() - Date.parse(item?.last_seen_at ?? "") <=
3652
+ PLUGIN_ALERT_TTL_MS,
3653
+ ) ?? null
3654
+ );
3655
+ }
3656
+
3657
+ export function markHostPolicyAlertDelivered(
3658
+ surface,
3659
+ sessionId,
3660
+ observationId,
3661
+ env = process.env,
3662
+ ) {
3663
+ const state = readPluginAlertState(surface, sessionId, env);
3664
+ if (!state) return false;
3665
+ const now = new Date().toISOString();
3666
+ let changed = false;
3667
+ const observations = state.observations.map((item) => {
3668
+ if (item?.id !== observationId || item?.alerted_at) return item;
3669
+ changed = true;
3670
+ return { ...item, alerted_at: now };
3671
+ });
3672
+ if (!changed) return false;
3673
+ return writePluginAlertState(
3674
+ surface,
3675
+ sessionId,
3676
+ { ...state, observations, updated_at: now },
3677
+ env,
3678
+ );
3679
+ }
3680
+
3681
+ function normalizedPluginHealthSurface(value) {
3682
+ const surface = String(value ?? "")
3683
+ .trim()
3684
+ .toLowerCase();
3685
+ return PLUGIN_HEALTH_SURFACES.has(surface) ? surface : null;
3686
+ }
3687
+
3688
+ export function pluginHealthPath(surface, env = process.env) {
3689
+ return pluginHealthSessionPath(surface, null, env);
3690
+ }
3691
+
3692
+ function pluginHealthSessionPath(surface, sessionId, env = process.env) {
3693
+ const normalized = normalizedPluginHealthSurface(surface);
3694
+ if (!normalized) return null;
3695
+ const normalizedSession = safeText(sessionId ?? "", 256);
3696
+ if (!normalizedSession || normalizedSession === "unknown") {
3697
+ return join(pluginHealthDir(env), `${normalized}.json`);
3698
+ }
3699
+ const sessionHash = createHash("sha256")
3700
+ .update(normalizedSession, "utf8")
3701
+ .digest("hex")
3702
+ .slice(0, 24);
3703
+ return join(pluginHealthDir(env), `${normalized}.${sessionHash}.json`);
3704
+ }
3705
+
3706
+ export function readPluginLifecycleHealth(
3707
+ surface,
3708
+ env = process.env,
3709
+ sessionId = null,
3710
+ ) {
3711
+ const path = pluginHealthSessionPath(surface, sessionId, env);
3712
+ if (!path) return null;
3713
+ try {
3714
+ const parsed = JSON.parse(
3715
+ readSecureHookFile(path, MAX_LOCAL_HEALTH_MARKER_BYTES),
3716
+ );
3717
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
3718
+ ? parsed
3719
+ : null;
3720
+ } catch {
3721
+ return null;
3722
+ }
3723
+ }
3724
+
3725
+ // Record that a native lifecycle component actually executed. The marker is
3726
+ // intentionally local and content-free: it contains only surface/version,
3727
+ // component timestamps, and the credential source category. Local MCP reads
3728
+ // it through run_connection_doctor so partial activation cannot look healthy.
3729
+ export function recordPluginLifecycleHealth(
3730
+ {
3731
+ surface,
3732
+ component,
3733
+ pluginVersion = null,
3734
+ hostVersion = null,
3735
+ credentialSource = null,
3736
+ sessionId = null,
3737
+ },
3738
+ env = process.env,
3739
+ ) {
3740
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
3741
+ const normalizedComponent = String(component ?? "")
3742
+ .trim()
3743
+ .toLowerCase();
3744
+ if (
3745
+ !normalizedSurface ||
3746
+ !PLUGIN_HEALTH_COMPONENTS.has(normalizedComponent)
3747
+ ) {
3748
+ return false;
2375
3749
  }
2376
3750
  // Codex currently gives SessionStart a thread identifier while prompt/tool/
2377
3751
  // completion hooks use turn identifiers. Preserve the specific startup
@@ -2439,8 +3813,24 @@ export function recordPluginLifecycleHealth(
2439
3813
  .update(apiConfiguration.apiUrl.replace(/\/+$/, ""), "utf8")
2440
3814
  .digest("hex")
2441
3815
  .slice(0, 16);
3816
+ const configuredEvidenceOrigin = String(
3817
+ env?.REMEMBRANCE_PLUGIN_HEALTH_EVIDENCE_ORIGIN ?? "host_runtime",
3818
+ )
3819
+ .trim()
3820
+ .toLowerCase();
3821
+ const evidenceOrigin = [
3822
+ "host_runtime",
3823
+ "host_process",
3824
+ "direct_adapter",
3825
+ ].includes(configuredEvidenceOrigin)
3826
+ ? configuredEvidenceOrigin
3827
+ : "host_runtime";
3828
+ const releaseRunId =
3829
+ evidenceOrigin === "host_process"
3830
+ ? safeText(env?.REMEMBRANCE_RELEASE_RUN_ID ?? "", 96)
3831
+ : "";
2442
3832
  const payload = {
2443
- schema_version: 1,
3833
+ schema_version: 2,
2444
3834
  surface: normalizedSurface,
2445
3835
  session_hash:
2446
3836
  sessionId && sessionId !== "unknown"
@@ -2465,6 +3855,8 @@ export function recordPluginLifecycleHealth(
2465
3855
  : "none",
2466
3856
  api_destination_source: apiConfiguration.source,
2467
3857
  api_destination_fingerprint: apiDestinationFingerprint,
3858
+ evidence_origin: evidenceOrigin,
3859
+ release_run_id: releaseRunId || null,
2468
3860
  components: {
2469
3861
  ...currentComponents,
2470
3862
  [normalizedComponent]: now,
@@ -2544,20 +3936,39 @@ export function readRemembranceConfig(env = process.env) {
2544
3936
  }
2545
3937
  }
2546
3938
 
2547
- export function resolveApiCredential(env = process.env) {
2548
- const apiConfiguration = resolveApiConfiguration(env);
3939
+ function readSharedConfigSnapshot(env = process.env) {
3940
+ const path = remembranceConfigPath(env);
3941
+ if (!existsSync(path)) {
3942
+ return { present: false, parsed: null, config: {} };
3943
+ }
3944
+ try {
3945
+ const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
3946
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
3947
+ ? { present: true, parsed, config: parsed }
3948
+ : { present: true, parsed: null, config: {} };
3949
+ } catch {
3950
+ return { present: true, parsed: null, config: {} };
3951
+ }
3952
+ }
3953
+
3954
+ export function resolveApiAccessSnapshot(env = process.env) {
3955
+ const shared = readSharedConfigSnapshot(env);
3956
+ const apiConfiguration = resolveApiConfigurationFromSnapshot(env, shared);
2549
3957
  if (isUnusableConfigurationSource(apiConfiguration.source)) {
2550
- return { apiKey: "", source: apiConfiguration.source };
3958
+ return {
3959
+ apiConfiguration,
3960
+ credential: { apiKey: "", source: apiConfiguration.source },
3961
+ memberLinkToken: "",
3962
+ };
2551
3963
  }
2552
3964
  const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
3965
+ let credential;
2553
3966
  if (environmentKey) {
2554
- const explicitBinding = String(
2555
- env.REMEMBRANCE_API_KEY_ORIGIN ?? "",
2556
- ).trim();
3967
+ const explicitBinding = String(env.REMEMBRANCE_API_KEY_ORIGIN ?? "").trim();
2557
3968
  const binding = explicitBinding
2558
3969
  ? normalizeApiUrl(explicitBinding, env)
2559
3970
  : { apiUrl: DEFAULT_API_URL, issue: null };
2560
- return credentialForApiDestination(
3971
+ credential = credentialForApiDestination(
2561
3972
  binding.apiUrl && !binding.issue
2562
3973
  ? {
2563
3974
  apiKey: environmentKey,
@@ -2567,44 +3978,52 @@ export function resolveApiCredential(env = process.env) {
2567
3978
  : unusableDestinationCredential(),
2568
3979
  apiConfiguration,
2569
3980
  );
2570
- }
2571
- const path = remembranceConfigPath(env);
2572
- if (!existsSync(path)) {
2573
- return { apiKey: "", source: "none" };
2574
- }
2575
- try {
2576
- const parsed = JSON.parse(
2577
- readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2578
- );
2579
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2580
- return unusableSharedConfigCredential();
2581
- }
3981
+ } else if (!shared.present) {
3982
+ credential = { apiKey: "", source: "none" };
3983
+ } else if (!shared.parsed) {
3984
+ credential = unusableSharedConfigCredential();
3985
+ } else {
3986
+ const parsed = shared.parsed;
2582
3987
  if (
2583
3988
  Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
2584
3989
  (typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
2585
3990
  ) {
2586
- return unusableSharedConfigCredential();
2587
- }
2588
- const fromFile = parsed.apiKey;
2589
- if (typeof fromFile !== "string" || !fromFile.trim()) {
2590
- return { apiKey: "", source: "none" };
3991
+ credential = unusableSharedConfigCredential();
3992
+ } else {
3993
+ const fromFile = parsed.apiKey;
3994
+ if (typeof fromFile !== "string" || !fromFile.trim()) {
3995
+ credential = { apiKey: "", source: "none" };
3996
+ } else {
3997
+ const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
3998
+ ? normalizeApiUrl(parsed.apiUrl, env)
3999
+ : { apiUrl: DEFAULT_API_URL, issue: null };
4000
+ credential = credentialForApiDestination(
4001
+ binding.apiUrl && !binding.issue
4002
+ ? {
4003
+ apiKey: fromFile.trim(),
4004
+ source: "shared_config",
4005
+ boundApiUrl: binding.apiUrl,
4006
+ }
4007
+ : unusableSharedConfigCredential(),
4008
+ apiConfiguration,
4009
+ );
4010
+ }
2591
4011
  }
2592
- const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
2593
- ? normalizeApiUrl(parsed.apiUrl, env)
2594
- : { apiUrl: DEFAULT_API_URL, issue: null };
2595
- return credentialForApiDestination(
2596
- binding.apiUrl && !binding.issue
2597
- ? {
2598
- apiKey: fromFile.trim(),
2599
- source: "shared_config",
2600
- boundApiUrl: binding.apiUrl,
2601
- }
2602
- : unusableSharedConfigCredential(),
2603
- apiConfiguration,
2604
- );
2605
- } catch {
2606
- return unusableSharedConfigCredential();
2607
4012
  }
4013
+ const rawMemberLinkToken = String(
4014
+ env.REMEMBRANCE_MEMBER_LINK_TOKEN ?? shared.config.memberLinkToken ?? "",
4015
+ ).trim();
4016
+ return {
4017
+ apiConfiguration,
4018
+ credential,
4019
+ memberLinkToken: /^mlink_[A-Za-z0-9_-]{24,160}$/.test(rawMemberLinkToken)
4020
+ ? rawMemberLinkToken
4021
+ : "",
4022
+ };
4023
+ }
4024
+
4025
+ export function resolveApiCredential(env = process.env) {
4026
+ return resolveApiAccessSnapshot(env).credential;
2608
4027
  }
2609
4028
 
2610
4029
  // The org API key: an explicit env var wins, then the config file. Returns ""
@@ -2613,6 +4032,253 @@ export function resolveApiKey(env = process.env) {
2613
4032
  return resolveApiCredential(env).apiKey;
2614
4033
  }
2615
4034
 
4035
+ export function explicitPreferenceSettingsFromPrompt(prompt) {
4036
+ const text = String(prompt ?? "").toLowerCase();
4037
+ const settings = new Map();
4038
+ const set = (key, value) => settings.set(key, { key, value });
4039
+ if (
4040
+ /\b(?:too many|fewer|less|minimal|sparse)\s+(?:code\s+)?comments?\b/.test(
4041
+ text,
4042
+ ) ||
4043
+ /\bcomments?\s+(?:only\s+)?(?:when|for)\s+(?:the\s+)?(?:logic is )?(?:tricky|non-obvious)\b/.test(
4044
+ text,
4045
+ )
4046
+ ) {
4047
+ set("comment_density", "sparse");
4048
+ } else if (
4049
+ /\b(?:more|detailed|comprehensive)\s+(?:code\s+)?comments?\b/.test(text)
4050
+ ) {
4051
+ set("comment_density", "detailed");
4052
+ }
4053
+ if (/\bcomments?\s+(?:only\s+)?(?:on|for)\s+api\s+contracts?\b/.test(text)) {
4054
+ set("comment_focus", "api_contracts");
4055
+ } else if (
4056
+ /\bcomments?\s+(?:only\s+)?(?:when|for)\s+(?:the\s+)?(?:logic is )?(?:tricky|non-obvious)\b/.test(
4057
+ text,
4058
+ )
4059
+ ) {
4060
+ set("comment_focus", "tricky_logic");
4061
+ } else if (/\bcomments?\s+(?:only\s+)?(?:on|for)\s+intent\b/.test(text)) {
4062
+ set("comment_focus", "intent_only");
4063
+ }
4064
+ if (
4065
+ /\b(?:be|keep (?:it|(?:your |the )?(?:answers?|responses?|explanations?))|prefer)\s+(?:more\s+)?concise\b/.test(
4066
+ text,
4067
+ ) ||
4068
+ /\b(?:too|overly)\s+verbose\b/.test(text)
4069
+ ) {
4070
+ set("explanation_depth", "concise");
4071
+ } else if (
4072
+ /\b(?:prefer|give|provide|use)\s+(?:more\s+)?detailed\s+(?:answers?|explanations?)\b/.test(
4073
+ text,
4074
+ )
4075
+ ) {
4076
+ set("explanation_depth", "detailed");
4077
+ }
4078
+ if (/\b(?:use|prefer)\s+step[- ]by[- ]step\b/.test(text)) {
4079
+ set("output_organization", "step_by_step");
4080
+ } else if (
4081
+ /\b(?:use|prefer)\s+(?:a\s+)?structured\s+(?:answer|format|output)\b/.test(
4082
+ text,
4083
+ )
4084
+ ) {
4085
+ set("output_organization", "structured");
4086
+ } else if (
4087
+ /\b(?:use|prefer|keep)\s+(?:a\s+)?compact\s+(?:answer|format|output)\b/.test(
4088
+ text,
4089
+ )
4090
+ ) {
4091
+ set("output_organization", "compact");
4092
+ }
4093
+ return [...settings.values()];
4094
+ }
4095
+
4096
+ export function promptRequestsDurablePreference(prompt) {
4097
+ return /\b(?:always|from now on|going forward|for future tasks?|remember (?:that )?i (?:prefer|want)|my (?:lasting )?preference is|make this my default|(?:should|must)\s+(?:always|never|only))\b/i.test(
4098
+ String(prompt ?? ""),
4099
+ );
4100
+ }
4101
+
4102
+ export function promptProvidesPreferenceCorrection(prompt) {
4103
+ return /\b(?:too many comments?|fewer comments?|less verbose|too verbose|overly verbose|stop (?:using|adding|writing)|do not (?:use|add|write)|don't (?:use|add|write)|i (?:do not|don't) (?:want|like)|instead,? (?:use|keep|make))\b/i.test(
4104
+ String(prompt ?? ""),
4105
+ );
4106
+ }
4107
+
4108
+ function promptDisclaimsDurablePreference(prompt) {
4109
+ return /\b(?:(?:contrived|hypothetical|illustrative)\s+(?:example|scenario)|(?:example|scenario)\s+only|not\s+(?:a\s+)?(?:lasting\s+)?(?:preference|rule|default)|(?:do not|don't)\s+(?:save|remember|record|learn)\s+(?:this|that|it))\b/i.test(
4110
+ String(prompt ?? ""),
4111
+ );
4112
+ }
4113
+
4114
+ export function promptScopesPreferenceToProject(prompt) {
4115
+ return /\b(?:in|for)\s+(?:this|the current)\s+(?:project|repository|repo|workspace|codebase)\b/i.test(
4116
+ String(prompt ?? ""),
4117
+ );
4118
+ }
4119
+
4120
+ export function genericPreferenceCaptureDirective(prompt, options = {}) {
4121
+ const text = String(prompt ?? "");
4122
+ if (
4123
+ promptDisclaimsDurablePreference(text) ||
4124
+ (!promptRequestsDurablePreference(text) &&
4125
+ !promptProvidesPreferenceCorrection(text)) ||
4126
+ explicitPreferenceSettingsFromPrompt(text).length > 0
4127
+ ) {
4128
+ return null;
4129
+ }
4130
+ const taskHash = createHash("sha256").update(text, "utf8").digest("hex");
4131
+ const evidenceHash = createHash("sha256")
4132
+ .update(
4133
+ JSON.stringify({
4134
+ purpose: "generic-working-preference",
4135
+ task_hash: taskHash,
4136
+ }),
4137
+ "utf8",
4138
+ )
4139
+ .digest("hex");
4140
+ const projectKey = promptScopesPreferenceToProject(text)
4141
+ ? projectKeyForHook(options.env ?? process.env, options.projectPath)
4142
+ : null;
4143
+ const scopeFields = projectKey
4144
+ ? `"scope":"project","project_key":"${projectKey}"`
4145
+ : '"scope":"auto"';
4146
+ return [
4147
+ "Remembrance durable-preference capture:",
4148
+ "The user stated a lasting or corrective working preference that is not one of the built-in controls. Interpret only that preference and call record_preference once. Do not ask the user to classify it.",
4149
+ 'Use setting {"key":"<presentation|workflow|strategy_selection>.<stable_concept>","value":"<stable_behavior_id>","label":"<short name>","behavior":"<concise normalized behavior>","effect":"presentation|workflow|strategy_selection","strength":"prefer|avoid","definition_version":1}. This vocabulary is extensible; do not force the preference into a built-in category.',
4150
+ `Use ${scopeFields}, "source_category":"explicit_user", "evidence_hash":"${evidenceHash}", "task_hash":"${taskHash}", and "confidence":1.`,
4151
+ "Never send the raw prompt or private task details. Never encode a request to weaken safety, authorization, privacy, required skill steps, validation, or review; those constraints remain authoritative.",
4152
+ ].join("\n");
4153
+ }
4154
+
4155
+ export async function recordExplicitPreferenceObservations(
4156
+ prompt,
4157
+ options = {},
4158
+ ) {
4159
+ if (promptDisclaimsDurablePreference(prompt)) return 0;
4160
+ const durable = promptRequestsDurablePreference(prompt);
4161
+ const corrective = promptProvidesPreferenceCorrection(prompt);
4162
+ if (!durable && !corrective) return 0;
4163
+ const settings = explicitPreferenceSettingsFromPrompt(prompt);
4164
+ if (settings.length === 0) return 0;
4165
+ const env = options.env ?? process.env;
4166
+ const runtime = normalizeRuntime(options.runtime);
4167
+ const access = resolveApiAccessSnapshot(env);
4168
+ const credential = access.credential;
4169
+ if (isUnusableConfigurationSource(credential.source) || !credential.apiKey) {
4170
+ return 0;
4171
+ }
4172
+ let principalSession = readHookPrincipalSession(runtime, env, access);
4173
+ if (!principalSession?.token) {
4174
+ principalSession = await warmPrincipalSession(
4175
+ {
4176
+ runtime,
4177
+ hostSurface: runtimeHostSurface(runtime, env),
4178
+ fetchImpl: options.fetchImpl ?? fetch,
4179
+ apiAccess: access,
4180
+ },
4181
+ env,
4182
+ );
4183
+ }
4184
+ if (!principalSession?.token) return 0;
4185
+ // A direct correction is explicit evidence even when the user did not say
4186
+ // "remember this". Persist it immediately so one correction supersedes a
4187
+ // learned profile; genuinely inferred behavior still enters through the
4188
+ // topology/observation path and must satisfy the multi-task confidence gate.
4189
+ const sourceCategory = "explicit_user";
4190
+ const projectKey = promptScopesPreferenceToProject(prompt)
4191
+ ? projectKeyForHook(env, options.projectPath)
4192
+ : null;
4193
+ const taskHash = createHash("sha256")
4194
+ .update(String(prompt), "utf8")
4195
+ .digest("hex");
4196
+ let refreshPromise = null;
4197
+ const refreshSession = async () => {
4198
+ refreshPromise ??= (async () => {
4199
+ clearHookPrincipalSession(runtime, env, access);
4200
+ return warmPrincipalSession(
4201
+ {
4202
+ runtime,
4203
+ hostSurface: runtimeHostSurface(runtime, env),
4204
+ fetchImpl: options.fetchImpl ?? fetch,
4205
+ apiAccess: access,
4206
+ },
4207
+ env,
4208
+ );
4209
+ })();
4210
+ return refreshPromise;
4211
+ };
4212
+ const results = await Promise.all(
4213
+ settings.map(async (setting) => {
4214
+ const controller = new AbortController();
4215
+ const timeout = setTimeout(
4216
+ () => controller.abort(),
4217
+ DEFAULT_DIRECTIVE_EVENT_TIMEOUT_MS,
4218
+ );
4219
+ timeout.unref?.();
4220
+ try {
4221
+ const evidenceHash = createHash("sha256")
4222
+ .update(
4223
+ JSON.stringify({
4224
+ setting,
4225
+ task_hash: taskHash,
4226
+ source: "hook",
4227
+ source_category: sourceCategory,
4228
+ project_key: projectKey,
4229
+ }),
4230
+ "utf8",
4231
+ )
4232
+ .digest("hex");
4233
+ const submit = (token) =>
4234
+ (options.fetchImpl ?? fetch)(
4235
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/preferences`,
4236
+ {
4237
+ method: "POST",
4238
+ headers: {
4239
+ "content-type": "application/json",
4240
+ "user-agent": clientUserAgent(options.userAgent),
4241
+ "x-remembrance-api-key": credential.apiKey,
4242
+ "x-remembrance-principal-session": token,
4243
+ },
4244
+ body: JSON.stringify({
4245
+ setting,
4246
+ scope: projectKey
4247
+ ? "project"
4248
+ : principalSession.member_linked
4249
+ ? durable
4250
+ ? "member"
4251
+ : "member_runtime"
4252
+ : "installation",
4253
+ source_category: sourceCategory,
4254
+ evidence_hash: evidenceHash,
4255
+ task_hash: taskHash,
4256
+ ...(projectKey ? { project_key: projectKey } : {}),
4257
+ confidence: 1,
4258
+ }),
4259
+ signal: controller.signal,
4260
+ },
4261
+ );
4262
+ let response = await submit(principalSession.token);
4263
+ if (response?.status === 401 || response?.status === 403) {
4264
+ const refreshed = await refreshSession();
4265
+ if (refreshed?.token) {
4266
+ principalSession = refreshed;
4267
+ response = await submit(refreshed.token);
4268
+ }
4269
+ }
4270
+ return response?.ok === true;
4271
+ } catch {
4272
+ // Preference capture is advisory and cannot block the prompt hook.
4273
+ return false;
4274
+ } finally {
4275
+ clearTimeout(timeout);
4276
+ }
4277
+ }),
4278
+ );
4279
+ return results.filter(Boolean).length;
4280
+ }
4281
+
2616
4282
  export function sharedConfigCredentialNotice(env = process.env) {
2617
4283
  const source = resolveApiCredential(env).source;
2618
4284
  if (source === "unusable_environment") {
@@ -2655,6 +4321,10 @@ export function sharedConfigCredentialNotice(env = process.env) {
2655
4321
  }
2656
4322
 
2657
4323
  export function resolveApiConfiguration(env = process.env) {
4324
+ return resolveApiAccessSnapshot(env).apiConfiguration;
4325
+ }
4326
+
4327
+ function resolveApiConfigurationFromSnapshot(env, shared) {
2658
4328
  const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
2659
4329
  if (environmentUrl) {
2660
4330
  const normalized = normalizeApiUrl(environmentUrl, env);
@@ -2666,34 +4336,23 @@ export function resolveApiConfiguration(env = process.env) {
2666
4336
  issue: normalized.issue ?? "invalid_url",
2667
4337
  };
2668
4338
  }
2669
- const path = remembranceConfigPath(env);
2670
- if (!existsSync(path)) {
4339
+ if (!shared.present) {
2671
4340
  return { apiUrl: DEFAULT_API_URL, source: "default" };
2672
4341
  }
2673
- try {
2674
- const parsed = JSON.parse(
2675
- readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES),
2676
- );
2677
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2678
- return {
4342
+ if (!shared.parsed) {
4343
+ return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
4344
+ }
4345
+ if (!Object.prototype.hasOwnProperty.call(shared.parsed, "apiUrl")) {
4346
+ return { apiUrl: DEFAULT_API_URL, source: "default" };
4347
+ }
4348
+ const normalized = normalizeApiUrl(shared.parsed.apiUrl, env);
4349
+ return normalized.apiUrl && !normalized.issue
4350
+ ? { apiUrl: normalized.apiUrl, source: "shared_config" }
4351
+ : {
2679
4352
  apiUrl: DEFAULT_API_URL,
2680
4353
  source: "unusable_shared_config",
4354
+ issue: normalized.issue ?? "invalid_url",
2681
4355
  };
2682
- }
2683
- if (!Object.prototype.hasOwnProperty.call(parsed, "apiUrl")) {
2684
- return { apiUrl: DEFAULT_API_URL, source: "default" };
2685
- }
2686
- const normalized = normalizeApiUrl(parsed.apiUrl, env);
2687
- return normalized.apiUrl && !normalized.issue
2688
- ? { apiUrl: normalized.apiUrl, source: "shared_config" }
2689
- : {
2690
- apiUrl: DEFAULT_API_URL,
2691
- source: "unusable_shared_config",
2692
- issue: normalized.issue ?? "invalid_url",
2693
- };
2694
- } catch {
2695
- return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
2696
- }
2697
4356
  }
2698
4357
 
2699
4358
  function apiUrl(env) {
@@ -2738,7 +4397,10 @@ function normalizeApiUrl(value, env = process.env) {
2738
4397
  }
2739
4398
 
2740
4399
  function credentialForApiDestination(credential, configuration) {
2741
- if (!credential.apiKey || isUnusableConfigurationSource(configuration.source)) {
4400
+ if (
4401
+ !credential.apiKey ||
4402
+ isUnusableConfigurationSource(configuration.source)
4403
+ ) {
2742
4404
  return credential.apiKey
2743
4405
  ? unusableDestinationCredential()
2744
4406
  : { apiKey: "", source: credential.source };
@@ -2811,7 +4473,9 @@ function isPrivateDestinationHostname(hostname) {
2811
4473
  }
2812
4474
 
2813
4475
  function normalizeHostname(hostname) {
2814
- return String(hostname).replace(/^\[|\]$/g, "").toLowerCase();
4476
+ return String(hostname)
4477
+ .replace(/^\[|\]$/g, "")
4478
+ .toLowerCase();
2815
4479
  }
2816
4480
 
2817
4481
  function readSecureHookFile(path, maxBytes) {