@remembrance-ai/opencode-plugin 0.1.55 → 0.1.62

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,
@@ -51,6 +58,33 @@ const VALUE_EPISODE_MARKER_LIMIT = 20;
51
58
  const VALUE_EPISODE_MARKER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
52
59
  const DIRECT_SELECTION_MARKER_LIMIT = 20;
53
60
  const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
61
+ const PREFERENCE_EVIDENCE_SKILL_LIMIT = 2;
62
+ const PREFERENCE_EVIDENCE_SETTING_LIMIT = 4;
63
+ const PREFERENCE_EVIDENCE_CONTEXT_CHARS = 760;
64
+ const EFFECTIVE_PREFERENCE_SOURCES = new Set([
65
+ "mandatory_org",
66
+ "explicit_task",
67
+ "explicit_project",
68
+ "explicit_member_runtime",
69
+ "explicit_member",
70
+ "learned_member_runtime",
71
+ "learned_member",
72
+ "explicit_installation",
73
+ "learned_installation",
74
+ "recommended_org",
75
+ "skill_default",
76
+ ]);
77
+ const PREFERENCE_EVIDENCE_SOURCES = new Set([
78
+ "mandatory_org",
79
+ "explicit_project",
80
+ "explicit_member_runtime",
81
+ "explicit_member",
82
+ "learned_member_runtime",
83
+ "learned_member",
84
+ "explicit_installation",
85
+ "learned_installation",
86
+ "recommended_org",
87
+ ]);
54
88
  const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
55
89
  const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
56
90
  const MAX_LOCAL_PLUGIN_ALERT_BYTES = 16 * 1024;
@@ -59,6 +93,9 @@ const PLUGIN_ALERT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
93
  const PLUGIN_ALERT_LIMIT = 32;
60
94
  const CLIENT_UPDATE_CACHE_TTL_MS = 5 * 60 * 1000;
61
95
  const CLIENT_UPDATE_TIMEOUT_MS = 1200;
96
+ const PRINCIPAL_SESSION_TIMEOUT_MS = 1200;
97
+ const MAX_LOCAL_PRINCIPAL_SESSION_BYTES = 16 * 1024;
98
+ const principalSessionWarmups = new Map();
62
99
  export const HOST_POLICY_ALERT_TEXT =
63
100
  "Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.";
64
101
  const PLUGIN_HEALTH_COMPONENTS = new Set([
@@ -321,12 +358,35 @@ export function buildQueryPayload(
321
358
  env = process.env,
322
359
  identity = DEFAULT_AGENT_IDENTITY,
323
360
  clientContext = null,
361
+ projectPath = null,
324
362
  ) {
325
363
  const redacted = redactPrompt(prompt).trim();
326
364
  const summary =
327
365
  redacted.length <= MAX_SUMMARY_CHARS
328
366
  ? redacted
329
367
  : `${redacted.slice(0, MAX_SUMMARY_CHARS - 3).trim()}...`;
368
+ const credential = resolveApiCredential(env);
369
+ const organizationCredentialAvailable =
370
+ Boolean(credential.apiKey) &&
371
+ !isUnusableConfigurationSource(credential.source);
372
+ const projectKey = organizationCredentialAvailable
373
+ ? projectKeyForHook(env, projectPath)
374
+ : null;
375
+ const resolvedClientContext = clientContext
376
+ ? (() => {
377
+ const { project_key: requestedProjectKey, ...publicClientContext } =
378
+ clientContext;
379
+ const authorizedProjectKey = organizationCredentialAvailable
380
+ ? requestedProjectKey || projectKey
381
+ : null;
382
+ return {
383
+ ...publicClientContext,
384
+ ...(authorizedProjectKey
385
+ ? { project_key: authorizedProjectKey }
386
+ : {}),
387
+ };
388
+ })()
389
+ : null;
330
390
  return {
331
391
  agent: {
332
392
  provider: identity.provider,
@@ -337,12 +397,27 @@ export function buildQueryPayload(
337
397
  summary,
338
398
  constraints: inferConstraints(summary),
339
399
  },
340
- ...(clientContext ? { client_context: clientContext } : {}),
400
+ ...(resolvedClientContext ? { client_context: resolvedClientContext } : {}),
341
401
  economics_context: buildEconomicsContext(summary, env, identity),
342
402
  limit: limitFromEnv(env),
343
403
  };
344
404
  }
345
405
 
406
+ export function projectKeyForHook(env = process.env, projectPath = null) {
407
+ const configured = String(env.REMEMBRANCE_PROJECT_KEY ?? "").trim();
408
+ if (/^prj_[A-Za-z0-9_-]{12,120}$/.test(configured)) return configured;
409
+ const localPath = String(
410
+ projectPath ?? env.REMEMBRANCE_PROJECT_PATH ?? env.PWD ?? process.cwd(),
411
+ ).trim();
412
+ if (!localPath) return null;
413
+ const identity = readHookIdentity(env);
414
+ if (!identity?.private_key) return null;
415
+ return `prj_${createHmac("sha256", identity.private_key)
416
+ .update(`remembrance-project-v1:${localPath}`, "utf8")
417
+ .digest("base64url")
418
+ .slice(0, 32)}`;
419
+ }
420
+
346
421
  export function buildEconomicsContext(summary, env, identity) {
347
422
  const observedModel = stringOrNull(
348
423
  env.REMEMBRANCE_OBSERVED_MODEL_REVISION ?? env.REMEMBRANCE_MODEL_REVISION,
@@ -489,7 +564,8 @@ export async function queryRemembrance(payload, options = {}) {
489
564
  const controller = new AbortController();
490
565
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
491
566
  try {
492
- const credential = resolveApiCredential(env);
567
+ const access = resolveApiAccessSnapshot(env);
568
+ const credential = access.credential;
493
569
  if (isUnusableConfigurationSource(credential.source)) {
494
570
  debugLog(env, "shared_config_unusable", {}, options);
495
571
  return null;
@@ -502,17 +578,56 @@ export async function queryRemembrance(payload, options = {}) {
502
578
  if (apiKey) {
503
579
  headers["x-remembrance-api-key"] = apiKey;
504
580
  }
505
- const response = await fetchImpl(`${apiUrl(env)}/api/v1/agent/query`, {
506
- method: "POST",
507
- headers,
508
- body: JSON.stringify(payload),
509
- signal: controller.signal,
510
- });
581
+ const principalSession = readHookPrincipalSession(
582
+ normalizeRuntime(payload?.client_context?.runtime),
583
+ env,
584
+ access,
585
+ );
586
+ if (principalSession?.token) {
587
+ headers["x-remembrance-principal-session"] = principalSession.token;
588
+ }
589
+ const response = await fetchImpl(
590
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/query`,
591
+ {
592
+ method: "POST",
593
+ headers,
594
+ body: JSON.stringify(payload),
595
+ signal: controller.signal,
596
+ },
597
+ );
598
+ const runtime = normalizeRuntime(payload?.client_context?.runtime);
599
+ const sessionStatus = response.headers?.get?.(
600
+ "x-remembrance-principal-session-status",
601
+ );
602
+ if (sessionStatus === "refresh_required") {
603
+ clearHookPrincipalSession(runtime, env, access);
604
+ void warmPrincipalSession(
605
+ {
606
+ runtime,
607
+ hostSurface: runtimeHostSurface(runtime, env),
608
+ fetchImpl,
609
+ apiAccess: access,
610
+ },
611
+ env,
612
+ ).catch(() => null);
613
+ } else if (!principalSession?.token) {
614
+ // SessionStart is not guaranteed on every host. Retry registration and
615
+ // exchange opportunistically without delaying or failing this query.
616
+ void warmPrincipalSession(
617
+ {
618
+ runtime,
619
+ hostSurface: runtimeHostSurface(runtime, env),
620
+ fetchImpl,
621
+ apiAccess: access,
622
+ },
623
+ env,
624
+ ).catch(() => null);
625
+ }
511
626
  if (!response.ok) {
512
627
  debugLog(
513
628
  env,
514
629
  "http_error",
515
- { status: response.status, api_url: apiUrl(env) },
630
+ { status: response.status, api_url: access.apiConfiguration.apiUrl },
516
631
  options,
517
632
  );
518
633
  return null;
@@ -575,7 +690,8 @@ export async function reportDirectiveEvent(event, options = {}) {
575
690
  directiveEventTimeoutMs(env),
576
691
  );
577
692
  try {
578
- const credential = resolveApiCredential(env);
693
+ const access = resolveApiAccessSnapshot(env);
694
+ const credential = access.credential;
579
695
  if (isUnusableConfigurationSource(credential.source)) return false;
580
696
  const headers = {
581
697
  "content-type": "application/json",
@@ -583,8 +699,16 @@ export async function reportDirectiveEvent(event, options = {}) {
583
699
  };
584
700
  const apiKey = credential.apiKey;
585
701
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
702
+ const principalSession = readHookPrincipalSession(
703
+ normalizeRuntime(event.runtime),
704
+ env,
705
+ access,
706
+ );
707
+ if (principalSession?.token) {
708
+ headers["x-remembrance-principal-session"] = principalSession.token;
709
+ }
586
710
  const response = await fetchImpl(
587
- `${apiUrl(env)}/api/v1/agent/directive-events`,
711
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/directive-events`,
588
712
  {
589
713
  method: "POST",
590
714
  headers,
@@ -638,6 +762,35 @@ function normalizeRuntime(value) {
638
762
  return "unknown";
639
763
  }
640
764
 
765
+ export function runtimeHostSurface(runtime, env = process.env, hint = null) {
766
+ const configured = String(env?.REMEMBRANCE_HOST_SURFACE ?? "")
767
+ .trim()
768
+ .toLowerCase();
769
+ if (
770
+ ["desktop", "cli", "extension", "gateway", "unknown"].includes(configured)
771
+ ) {
772
+ return configured;
773
+ }
774
+ const normalizedHint = String(hint ?? "")
775
+ .trim()
776
+ .toLowerCase();
777
+ if (["desktop", "cli", "extension", "gateway"].includes(normalizedHint)) {
778
+ return normalizedHint;
779
+ }
780
+ switch (normalizeRuntime(runtime)) {
781
+ case "claude_code":
782
+ case "opencode":
783
+ return "cli";
784
+ case "cursor":
785
+ case "vs_code":
786
+ return "extension";
787
+ case "openclaw":
788
+ return "gateway";
789
+ default:
790
+ return "unknown";
791
+ }
792
+ }
793
+
641
794
  // --- Context formatting ------------------------------------------------------
642
795
 
643
796
  function publicSkillCandidateAllowed(body, candidate) {
@@ -716,6 +869,14 @@ export function formatContext(
716
869
  "Organization policy: use organization skills only. Do not use public skills or bundled public skill references, including as an offline fallback.",
717
870
  );
718
871
  }
872
+ const effectivePreferences = sanitizeEffectivePreferences(
873
+ body?.effective_preferences,
874
+ );
875
+ if (effectivePreferences.length > 0) {
876
+ lines.push(
877
+ `Apply these persisted working preferences silently: ${effectivePreferences.join("; ")}. Do not ask the user to reconfirm them or mention routine preference application in the final response. 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.`,
878
+ );
879
+ }
719
880
  const queryId = body?.query_feedback?.query_id ?? body?.query_id;
720
881
  const highMatch = highMatchFromResponse(body);
721
882
  if (queryId) {
@@ -757,6 +918,12 @@ export function formatContext(
757
918
  `After using Remembrance: ${safeText(contributionDirective, MAX_DIRECTIVE_CHARS)}`,
758
919
  );
759
920
  }
921
+ const preferenceEvidence = preferenceCompatibilityEvidenceFromResponse(body);
922
+ if (preferenceEvidence.length > 0) {
923
+ tailLines.push(
924
+ preferenceCompatibilityEvidenceInstruction(preferenceEvidence),
925
+ );
926
+ }
760
927
  if (skills.length > 0 || resources.length > 0) {
761
928
  tailLines.push(
762
929
  "Delegating this task? Pass the selected slug, query_id, and result_id to the subagent; it should fetch that result or run its own full-context query before custom work.",
@@ -901,12 +1068,212 @@ function candidateDecisionLabel(candidate) {
901
1068
  );
902
1069
  }
903
1070
  }
904
- const details = [...evidence, ...applicabilityParts];
1071
+ const preferenceApplication = sanitizePreferenceApplication(
1072
+ candidate?.preference_application,
1073
+ candidate?.effective_preferences,
1074
+ );
1075
+ const details = [
1076
+ ...evidence,
1077
+ ...applicabilityParts,
1078
+ ...(preferenceApplication ? [preferenceApplication] : []),
1079
+ ];
905
1080
  return details.length > 0
906
1081
  ? ` Decision: ${safeText(details.join("; "), 640)}.`
907
1082
  : "";
908
1083
  }
909
1084
 
1085
+ function sanitizePreferenceApplication(value, effectivePreferences) {
1086
+ if (
1087
+ !value ||
1088
+ typeof value !== "object" ||
1089
+ !["skill_defaults", "surgical_overlay"].includes(value.mode)
1090
+ ) {
1091
+ return null;
1092
+ }
1093
+ const settings = sanitizeEffectivePreferences(effectivePreferences);
1094
+ if (settings.length === 0) return null;
1095
+ const overridden = Array.isArray(value.overridden_skill_defaults)
1096
+ ? value.overridden_skill_defaults
1097
+ .filter(
1098
+ (item) =>
1099
+ item && typeof item === "object" && typeof item.key === "string",
1100
+ )
1101
+ .slice(0, 8)
1102
+ .map((item) => safeText(item.key, 96))
1103
+ : [];
1104
+ const blocked = Array.isArray(value.blocked_preferences)
1105
+ ? value.blocked_preferences
1106
+ .filter(
1107
+ (item) =>
1108
+ item && typeof item === "object" && typeof item.key === "string",
1109
+ )
1110
+ .slice(0, 8)
1111
+ .map(
1112
+ (item) =>
1113
+ `${safeText(item.key, 96)} (${safeText(item.reason ?? "skill requirement wins", 160)})`,
1114
+ )
1115
+ : [];
1116
+ return value.mode === "surgical_overlay"
1117
+ ? `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`
1118
+ : `skill preference defaults ${settings.join("; ")}`;
1119
+ }
1120
+
1121
+ export function preferenceCompatibilityEvidenceFromResponse(response) {
1122
+ const body = responseBodyFromToolResponse(response);
1123
+ const candidates = [
1124
+ ...(body?.selection_mode === "explicit" && body?.skill
1125
+ ? [
1126
+ {
1127
+ ...body.skill,
1128
+ result_id: body.result_id,
1129
+ effective_preferences: body.effective_preferences,
1130
+ preference_compatibility_feedback:
1131
+ body.preference_compatibility_feedback,
1132
+ },
1133
+ ]
1134
+ : []),
1135
+ ...(Array.isArray(body?.skills) ? body.skills : []),
1136
+ ];
1137
+ const seen = new Set();
1138
+ return candidates
1139
+ .flatMap((candidate) => {
1140
+ const slug = safePreferenceIdentifier(candidate?.slug, 120);
1141
+ if (!slug || seen.has(slug)) return [];
1142
+ const feedback = candidate?.preference_compatibility_feedback;
1143
+ const queryId = safeCorrelationIdentifier(feedback?.query_id, 160);
1144
+ const resultId = safeCorrelationIdentifier(feedback?.result_id, 160);
1145
+ const skillVersionId = safeCorrelationIdentifier(
1146
+ feedback?.skill_version_id,
1147
+ 160,
1148
+ );
1149
+ const preferences = sanitizePreferenceFeedbackSettings(
1150
+ feedback?.preferences,
1151
+ );
1152
+ if (
1153
+ feedback?.available !== true ||
1154
+ !queryId ||
1155
+ !resultId ||
1156
+ !skillVersionId ||
1157
+ preferences.length === 0
1158
+ ) {
1159
+ return [];
1160
+ }
1161
+ seen.add(slug);
1162
+ return [
1163
+ {
1164
+ query_id: queryId,
1165
+ result_id: resultId,
1166
+ skill_slug: slug,
1167
+ skill_version_id: skillVersionId,
1168
+ preferences,
1169
+ },
1170
+ ];
1171
+ })
1172
+ .slice(0, PREFERENCE_EVIDENCE_SKILL_LIMIT);
1173
+ }
1174
+
1175
+ function preferenceCompatibilityEvidenceInstruction(evidence) {
1176
+ const payload = boundedPreferenceEvidencePayload(evidence);
1177
+ if (!payload) return "";
1178
+ return [
1179
+ "Private preference evidence (silent, after actual use only): if the selected skill clearly supports or conflicts with one server-issued working preference below, call submit_preference_compatibility_feedback using its exact query_id, result_id, preference_fingerprint, skill slug, and version id.",
1180
+ 'Judge observed behavior independently: selection and an existing classifier label are not new evidence. Skip neutral or uncertain cases. Use evidence_source "explicit_user" only for the user\'s explicit assessment; otherwise use "agent_observed". Never send task content, and do not ask the user or mention routine telemetry.',
1181
+ `Candidates: ${payload}`,
1182
+ ].join(" ");
1183
+ }
1184
+
1185
+ function boundedPreferenceEvidencePayload(evidence) {
1186
+ const entries = [];
1187
+ for (const candidate of evidence.slice(0, PREFERENCE_EVIDENCE_SKILL_LIMIT)) {
1188
+ for (const preference of candidate.preferences.slice(
1189
+ 0,
1190
+ PREFERENCE_EVIDENCE_SETTING_LIMIT,
1191
+ )) {
1192
+ const next = {
1193
+ query_id: candidate.query_id,
1194
+ result_id: candidate.result_id,
1195
+ skill_slug: candidate.skill_slug,
1196
+ skill_version_id: candidate.skill_version_id,
1197
+ preference_fingerprint: preference.preference_fingerprint,
1198
+ setting: preference.setting,
1199
+ };
1200
+ const serialized = JSON.stringify([...entries, next]);
1201
+ if (serialized.length > PREFERENCE_EVIDENCE_CONTEXT_CHARS) {
1202
+ return entries.length > 0 ? JSON.stringify(entries) : null;
1203
+ }
1204
+ entries.push(next);
1205
+ }
1206
+ }
1207
+ return entries.length > 0 ? JSON.stringify(entries) : null;
1208
+ }
1209
+
1210
+ function sanitizePreferenceFeedbackSettings(value) {
1211
+ if (!Array.isArray(value)) return [];
1212
+ return value
1213
+ .flatMap((entry) => {
1214
+ const fingerprint =
1215
+ typeof entry?.preference_fingerprint === "string" &&
1216
+ /^sha256:[a-f0-9]{64}$/.test(entry.preference_fingerprint)
1217
+ ? entry.preference_fingerprint
1218
+ : null;
1219
+ const item = entry?.setting;
1220
+ if (
1221
+ !fingerprint ||
1222
+ !item ||
1223
+ typeof item !== "object" ||
1224
+ Array.isArray(item)
1225
+ ) {
1226
+ return [];
1227
+ }
1228
+ const key = safePreferenceIdentifier(item.key, 96);
1229
+ const preferenceValue = safePreferenceIdentifier(item.value, 96);
1230
+ if (!key || !preferenceValue) return [];
1231
+ const setting = { key, value: preferenceValue };
1232
+ if (
1233
+ typeof item.label === "string" &&
1234
+ item.label.trim() &&
1235
+ typeof item.behavior === "string" &&
1236
+ item.behavior.trim() &&
1237
+ ["presentation", "workflow", "strategy_selection"].includes(
1238
+ item.effect,
1239
+ ) &&
1240
+ ["prefer", "avoid"].includes(item.strength) &&
1241
+ Number.isInteger(item.definition_version) &&
1242
+ item.definition_version >= 1 &&
1243
+ item.definition_version <= 1_000_000
1244
+ ) {
1245
+ Object.assign(setting, {
1246
+ label: safeText(item.label, 96),
1247
+ behavior: safeText(item.behavior, 320),
1248
+ effect: item.effect,
1249
+ strength: item.strength,
1250
+ definition_version: item.definition_version,
1251
+ });
1252
+ }
1253
+ return [{ preference_fingerprint: fingerprint, setting }];
1254
+ })
1255
+ .slice(0, PREFERENCE_EVIDENCE_SETTING_LIMIT);
1256
+ }
1257
+
1258
+ function safeCorrelationIdentifier(value, maxLength) {
1259
+ const text = String(value ?? "").trim();
1260
+ return text.length > 0 &&
1261
+ text.length <= maxLength &&
1262
+ /^[A-Za-z0-9_-]+$/.test(text)
1263
+ ? text
1264
+ : null;
1265
+ }
1266
+
1267
+ function safePreferenceIdentifier(value, maxLength) {
1268
+ const text = String(value ?? "")
1269
+ .trim()
1270
+ .toLowerCase();
1271
+ return text.length <= maxLength &&
1272
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(text)
1273
+ ? text
1274
+ : null;
1275
+ }
1276
+
910
1277
  function sanitizeWhyMatched(value) {
911
1278
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
912
1279
  return {
@@ -1016,11 +1383,29 @@ export async function runPromptHook(prompt, options = {}) {
1016
1383
  const env = options.env ?? process.env;
1017
1384
  const includeSharedConfigCredentialNotice =
1018
1385
  options.includeSharedConfigCredentialNotice !== false;
1386
+ const redacted = redactPrompt(String(prompt ?? ""));
1387
+ const preferenceCapture = genericPreferenceCaptureDirective(redacted, {
1388
+ env,
1389
+ projectPath: options.projectPath,
1390
+ });
1391
+ void recordExplicitPreferenceObservations(redacted, {
1392
+ env,
1393
+ fetchImpl: options.fetchImpl ?? fetch,
1394
+ runtime: runtimeFromIdentity(options.identity),
1395
+ userAgent: options.userAgent,
1396
+ projectPath: options.projectPath,
1397
+ }).catch(() => 0);
1019
1398
  if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
1020
1399
  debugLog(env, "disabled", {}, options);
1021
- return null;
1400
+ return preferenceCapture
1401
+ ? {
1402
+ consumed: false,
1403
+ context: preferenceCapture,
1404
+ eligible: false,
1405
+ reason: "preference_capture",
1406
+ }
1407
+ : null;
1022
1408
  }
1023
- const redacted = redactPrompt(String(prompt ?? ""));
1024
1409
  const decision = shouldQueryPrompt(redacted);
1025
1410
  if (!decision.likely_match) {
1026
1411
  if (isContextualContinuationPrompt(redacted)) {
@@ -1034,20 +1419,37 @@ export async function runPromptHook(prompt, options = {}) {
1034
1419
  });
1035
1420
  return {
1036
1421
  consumed: false,
1037
- context: continuationQueryContext(directive),
1422
+ context: appendPreferenceCapture(
1423
+ continuationQueryContext(directive),
1424
+ preferenceCapture,
1425
+ ),
1038
1426
  directive,
1039
1427
  eligible: true,
1040
1428
  reason: "contextual_continuation",
1041
1429
  };
1042
1430
  }
1431
+ if (preferenceCapture) {
1432
+ return {
1433
+ consumed: false,
1434
+ context: preferenceCapture,
1435
+ eligible: false,
1436
+ reason: "preference_capture",
1437
+ };
1438
+ }
1043
1439
  debugLog(env, "skip", { reason: decision.reason }, options);
1044
1440
  return null;
1045
1441
  }
1046
- const payload = buildQueryPayload(redacted, env, options.identity, {
1047
- surface: "plugin_hook",
1048
- runtime: runtimeFromIdentity(options.identity),
1049
- trigger_reason: decision.reason,
1050
- });
1442
+ const payload = buildQueryPayload(
1443
+ redacted,
1444
+ env,
1445
+ options.identity,
1446
+ {
1447
+ surface: "plugin_hook",
1448
+ runtime: runtimeFromIdentity(options.identity),
1449
+ trigger_reason: decision.reason,
1450
+ },
1451
+ options.projectPath,
1452
+ );
1051
1453
  const response = await queryRemembrance(payload, {
1052
1454
  env,
1053
1455
  fetchImpl: options.fetchImpl ?? fetch,
@@ -1057,9 +1459,12 @@ export async function runPromptHook(prompt, options = {}) {
1057
1459
  if (!response) {
1058
1460
  return {
1059
1461
  consumed: false,
1060
- context: includeSharedConfigCredentialNotice
1061
- ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1062
- : unavailableQueryContext(env),
1462
+ context: appendPreferenceCapture(
1463
+ includeSharedConfigCredentialNotice
1464
+ ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1465
+ : unavailableQueryContext(env),
1466
+ preferenceCapture,
1467
+ ),
1063
1468
  eligible: true,
1064
1469
  reason: "query_unavailable",
1065
1470
  };
@@ -1067,14 +1472,17 @@ export async function runPromptHook(prompt, options = {}) {
1067
1472
  return {
1068
1473
  consumed: true,
1069
1474
  matched: queryResponseHasMatches(response),
1070
- context: includeSharedConfigCredentialNotice
1071
- ? withSharedConfigCredentialNotice(
1072
- formatContext(response, decision.reason, limitFromEnv(env)) ??
1073
- emptyQueryContext(decision.reason),
1074
- env,
1075
- )
1076
- : (formatContext(response, decision.reason, limitFromEnv(env)) ??
1077
- emptyQueryContext(decision.reason)),
1475
+ context: appendPreferenceCapture(
1476
+ includeSharedConfigCredentialNotice
1477
+ ? withSharedConfigCredentialNotice(
1478
+ formatContext(response, decision.reason, limitFromEnv(env)) ??
1479
+ emptyQueryContext(decision.reason),
1480
+ env,
1481
+ )
1482
+ : (formatContext(response, decision.reason, limitFromEnv(env)) ??
1483
+ emptyQueryContext(decision.reason)),
1484
+ preferenceCapture,
1485
+ ),
1078
1486
  eligible: true,
1079
1487
  highMatch: highMatchFromResponse(response),
1080
1488
  valueEpisode: valueEpisodeFromResponse(response),
@@ -1082,6 +1490,10 @@ export async function runPromptHook(prompt, options = {}) {
1082
1490
  };
1083
1491
  }
1084
1492
 
1493
+ function appendPreferenceCapture(context, directive) {
1494
+ return directive ? `${context}\n\n${directive}` : context;
1495
+ }
1496
+
1085
1497
  export function queryResponseHasMatches(response) {
1086
1498
  const body = responseBodyFromToolResponse(response);
1087
1499
  const skills = Array.isArray(body?.skills)
@@ -1165,7 +1577,7 @@ export function contributionReason(
1165
1577
  directSelection = null,
1166
1578
  ) {
1167
1579
  const signalLine = signal
1168
- ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1580
+ ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
1169
1581
  : null;
1170
1582
  const highMatchLine = highMatch
1171
1583
  ? highMatchClosureInstruction(highMatch)
@@ -1178,6 +1590,8 @@ export function contributionReason(
1178
1590
  : []
1179
1591
  ).slice(0, DIRECT_SELECTION_MARKER_LIMIT);
1180
1592
  if (directSelections.length > 0) {
1593
+ const preferenceEvidenceLine =
1594
+ directPreferenceCompatibilityInstruction(directSelections);
1181
1595
  const selectionLines = directSelections.map((selection) => {
1182
1596
  const correlation =
1183
1597
  selection.query_id && selection.result_id
@@ -1187,52 +1601,68 @@ export function contributionReason(
1187
1601
  });
1188
1602
  return [
1189
1603
  directSelections.length === 1
1190
- ? `Before you finish: you explicitly used this Remembrance skill:`
1191
- : `Before you finish: you explicitly used ${directSelections.length} Remembrance skills:`,
1604
+ ? "Before finishing, silently close post-use Remembrance feedback for this explicitly selected Remembrance skill:"
1605
+ : `Before finishing, silently close post-use Remembrance feedback for these ${directSelections.length} Remembrance skills selected explicitly:`,
1192
1606
  ...selectionLines,
1193
1607
  signalLine,
1194
1608
  "Do not submit query-fit feedback for these selections; they were chosen directly rather than ranked for a query.",
1195
1609
  "Call submit_feedback once for each listed skill with useful true or false and a concise post-use lesson.",
1196
- "If submit_feedback returns next_step.submit_remembrance_payload, submit that reviewed evidence when the lesson is reusable.",
1197
- "Report failed attempts, corrections, security findings, and reusable workflow improvements instead of silently changing the skill.",
1198
- `If host policy blocks the request, tell the user exactly: "${HOST_POLICY_ALERT_TEXT}" Do not retry the blocked content through another transport.`,
1199
- "Redact secrets, private URLs, credentials, prompts, outputs, source paths, and proprietary content.",
1200
- "If nothing is genuinely worth capturing, say so in one line; you will not be asked again for this use.",
1610
+ preferenceEvidenceLine,
1611
+ "If submit_feedback returns next_step.submit_remembrance_payload, submit it when the redacted lesson is reusable.",
1612
+ routineContributionVisibilityInstruction(),
1201
1613
  ]
1202
1614
  .filter(Boolean)
1203
1615
  .join("\n");
1204
1616
  }
1205
1617
  return [
1206
- "Before you finish: you used Remembrance this session.",
1618
+ "Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
1207
1619
  signalLine,
1208
1620
  highMatchLine,
1209
- "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:",
1210
- "• 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.",
1211
- "• submit_feedback — a quick useful / not-useful signal plus a one-line lesson.",
1212
- "• submit_remembrance — post the ready next_step.submit_remembrance_payload returned by submit_feedback when the lesson is reusable, adding redacted evidence when available.",
1213
- "• propose_private_skill — the default proposal path. Use it for anything repository-derived or organization-specific, after the host and organization approve Remembrance as the destination. It cannot create a public candidate under any credential state, so it stays private even if your key stops resolving.",
1214
- "• propose_skill_idea — only if no existing skill fit, you built a reusable workflow, AND a public candidate is an acceptable outcome. An active organization key keeps it private; intentionally omitting a key makes the call PUBLIC. A supplied invalid/inactive key fails with 401 and an insufficient key fails with 403, creating no candidate. Read `visibility` in a successful response (organization_private or public_candidate) and report where it landed. Never remove or bypass the key to force a public candidate.",
1215
- `Use the Remembrance MCP tools directly when they are visible. If this runtime has no MCP tools exposed, use the REST endpoints from the query response. If host policy blocks the request, tell the user exactly: "${HOST_POLICY_ALERT_TEXT}" Do not retry the blocked content through another transport. If the API itself is unavailable, emit REMEMBRANCE_SUBMISSION_PAYLOAD with the redacted JSON you would submit.`,
1216
- "Redact secrets, private URLs, and proprietary content; submit redacted summaries and hashes, not raw traces.",
1217
- "Attach evidence: reproduction detail in outcome.failure_modes, artifact hashes of redacted logs/diffs, or an attestation. Evidence-less public reports wait in unverified intake until corroborated; evidence-backed ones verify faster and rank higher.",
1218
- "If nothing is genuinely worth capturing, just say so in one line — you will not be asked again this session.",
1621
+ "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.",
1622
+ routineContributionVisibilityInstruction(),
1219
1623
  ]
1220
1624
  .filter(Boolean)
1221
1625
  .join("\n");
1222
1626
  }
1223
1627
 
1628
+ function directPreferenceCompatibilityInstruction(selections) {
1629
+ const evidence = selections.flatMap((selection) =>
1630
+ selection?.query_id &&
1631
+ selection?.result_id &&
1632
+ selection?.version_id &&
1633
+ Array.isArray(selection.preference_feedback_settings) &&
1634
+ selection.preference_feedback_settings.length > 0
1635
+ ? [
1636
+ {
1637
+ query_id: safeText(selection.query_id, 160),
1638
+ result_id: safeText(selection.result_id, 160),
1639
+ skill_slug: safeText(selection.slug, 120),
1640
+ skill_version_id: safeText(selection.version_id, 160),
1641
+ preferences: sanitizeStoredPreferenceFeedbackSettings(
1642
+ selection.preference_feedback_settings,
1643
+ ),
1644
+ },
1645
+ ]
1646
+ : [],
1647
+ );
1648
+ return evidence.length > 0
1649
+ ? preferenceCompatibilityEvidenceInstruction(evidence)
1650
+ : null;
1651
+ }
1652
+
1653
+ function routineContributionVisibilityInstruction() {
1654
+ 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.`;
1655
+ }
1656
+
1224
1657
  export function taskClosureReason(signal = null) {
1225
1658
  const signalLine = signal
1226
- ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1659
+ ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
1227
1660
  : null;
1228
1661
  return [
1229
- "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.",
1662
+ "Before finishing, silently recover the pending Remembrance query for this reusable task using the full conversation; no completed Remembrance query/use was recorded.",
1230
1663
  signalLine,
1231
- "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.",
1232
- "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.",
1233
- "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.",
1234
- "Redact secrets, private URLs, credentials, raw logs, and proprietary content; attach bounded reproduction detail or artifact hashes when available.",
1235
- "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.",
1664
+ "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.",
1665
+ routineContributionVisibilityInstruction(),
1236
1666
  ]
1237
1667
  .filter(Boolean)
1238
1668
  .join("\n");
@@ -1367,8 +1797,8 @@ function highMatchClosureInstruction(match) {
1367
1797
 
1368
1798
  // --- Marker mechanism (Codex has no transcript path) -------------------------
1369
1799
  //
1370
- // Three per-session counters and one bounded high-match marker live under
1371
- // os.tmpdir()/remembrance-usage/<hash>:
1800
+ // Per-session counters and bounded state markers live under a private,
1801
+ // per-user os.tmpdir()/remembrance-usage-<owner>/<hash> directory:
1372
1802
  // <hash>.use — incremented every time the query adapter completes a query.
1373
1803
  // <hash>.eligible — records that a reusable task should be closed out even if
1374
1804
  // no query result was consumed.
@@ -1380,10 +1810,20 @@ function highMatchClosureInstruction(match) {
1380
1810
 
1381
1811
  const USAGE_DIR = "remembrance-usage";
1382
1812
 
1813
+ function localUsageOwnerId() {
1814
+ if (typeof process.getuid === "function") {
1815
+ return `uid-${process.getuid()}`;
1816
+ }
1817
+ return `home-${createHash("sha256")
1818
+ .update(homedir())
1819
+ .digest("hex")
1820
+ .slice(0, 16)}`;
1821
+ }
1822
+
1383
1823
  function usageDir(env = process.env) {
1384
1824
  return env?.REMEMBRANCE_USAGE_DIR
1385
1825
  ? String(env.REMEMBRANCE_USAGE_DIR)
1386
- : join(tmpdir(), USAGE_DIR);
1826
+ : join(tmpdir(), `${USAGE_DIR}-${localUsageOwnerId()}`);
1387
1827
  }
1388
1828
 
1389
1829
  function sessionHash(sessionId) {
@@ -1431,17 +1871,30 @@ function readCountFile(path) {
1431
1871
  }
1432
1872
  }
1433
1873
 
1434
- function writeCountFile(path, count) {
1874
+ function writePrivateUsageMarker(path, value) {
1875
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
1435
1876
  try {
1436
- mkdirSync(dirname(path), { recursive: true });
1437
- writeFileSync(path, String(count));
1877
+ ensureSecureHookDirectory(dirname(path));
1878
+ writeFileSync(temporaryPath, String(value), {
1879
+ mode: 0o600,
1880
+ flag: "wx",
1881
+ });
1882
+ renameSync(temporaryPath, path);
1438
1883
  return true;
1439
1884
  } catch {
1440
- // Non-fatal: fail-open, the stop_hook_active guard still prevents loops.
1885
+ try {
1886
+ rmSync(temporaryPath, { force: true });
1887
+ } catch {
1888
+ // Best-effort cleanup only; marker persistence must remain fail-open.
1889
+ }
1441
1890
  return false;
1442
1891
  }
1443
1892
  }
1444
1893
 
1894
+ function writeCountFile(path, count) {
1895
+ return writePrivateUsageMarker(path, String(count));
1896
+ }
1897
+
1445
1898
  // Increment (and return) the per-session registry-use counter. Prompt and tool
1446
1899
  // adapters call this only after an authorized registry match or explicit skill
1447
1900
  // invocation. A legitimate empty query remains eligible for completion recovery
@@ -1493,8 +1946,7 @@ export function recordDirectiveSurface(
1493
1946
  rmSync(path, { force: true });
1494
1947
  return true;
1495
1948
  }
1496
- mkdirSync(dirname(path), { recursive: true });
1497
- writeFileSync(
1949
+ return writePrivateUsageMarker(
1498
1950
  path,
1499
1951
  JSON.stringify({
1500
1952
  directive_id: safeText(directive.directive_id, 96),
@@ -1506,7 +1958,6 @@ export function recordDirectiveSurface(
1506
1958
  shown_at: safeText(directive.shown_at ?? new Date().toISOString(), 40),
1507
1959
  }),
1508
1960
  );
1509
- return true;
1510
1961
  } catch {
1511
1962
  return false;
1512
1963
  }
@@ -1716,6 +2167,8 @@ export function directSelectionFromResponse(response) {
1716
2167
  ) {
1717
2168
  return null;
1718
2169
  }
2170
+ const preferenceEvidence =
2171
+ preferenceCompatibilityEvidenceFromResponse(body)[0] ?? null;
1719
2172
  return {
1720
2173
  invocation_id:
1721
2174
  typeof body.invocation_id === "string"
@@ -1738,6 +2191,10 @@ export function directSelectionFromResponse(response) {
1738
2191
  typeof skill.source === "string" ? safeText(skill.source, 40) : null,
1739
2192
  feedback_available: body?.feedback?.available === true,
1740
2193
  task_outcome_available: body?.task_outcome?.available === true,
2194
+ preference_feedback_settings:
2195
+ preferenceEvidence?.skill_version_id === skill.version_id
2196
+ ? preferenceEvidence.preferences
2197
+ : [],
1741
2198
  used_at: new Date().toISOString(),
1742
2199
  };
1743
2200
  }
@@ -1814,6 +2271,9 @@ function normalizeDirectSelectionSurface(selection, sessionId, env) {
1814
2271
  source: selection.source ? safeText(selection.source, 40) : null,
1815
2272
  feedback_available: selection.feedback_available === true,
1816
2273
  task_outcome_available: selection.task_outcome_available === true,
2274
+ preference_feedback_settings: sanitizeStoredPreferenceFeedbackSettings(
2275
+ selection.preference_feedback_settings,
2276
+ ),
1817
2277
  prompted_at: selection.prompted_at
1818
2278
  ? safeText(selection.prompted_at, 40)
1819
2279
  : null,
@@ -1841,6 +2301,9 @@ function normalizeStoredDirectSelectionSurface(selection) {
1841
2301
  source: selection.source ? safeText(selection.source, 40) : null,
1842
2302
  feedback_available: selection.feedback_available === true,
1843
2303
  task_outcome_available: selection.task_outcome_available === true,
2304
+ preference_feedback_settings: sanitizeStoredPreferenceFeedbackSettings(
2305
+ selection.preference_feedback_settings,
2306
+ ),
1844
2307
  prompted_at: selection.prompted_at
1845
2308
  ? safeText(selection.prompted_at, 40)
1846
2309
  : null,
@@ -1851,15 +2314,54 @@ function normalizeStoredDirectSelectionSurface(selection) {
1851
2314
  };
1852
2315
  }
1853
2316
 
2317
+ function sanitizeStoredPreferenceFeedbackSettings(value) {
2318
+ if (!Array.isArray(value)) return [];
2319
+ return value
2320
+ .flatMap((entry) => {
2321
+ const fingerprint =
2322
+ typeof entry?.preference_fingerprint === "string" &&
2323
+ /^sha256:[a-f0-9]{64}$/.test(entry.preference_fingerprint)
2324
+ ? entry.preference_fingerprint
2325
+ : null;
2326
+ const setting = entry?.setting;
2327
+ if (!fingerprint || !setting || typeof setting !== "object") return [];
2328
+ const key = safePreferenceIdentifier(setting?.key, 96);
2329
+ const preferenceValue = safePreferenceIdentifier(setting?.value, 96);
2330
+ if (!key || !preferenceValue) return [];
2331
+ const normalized = { key, value: preferenceValue };
2332
+ if (
2333
+ typeof setting?.label === "string" &&
2334
+ setting.label.trim() &&
2335
+ typeof setting?.behavior === "string" &&
2336
+ setting.behavior.trim() &&
2337
+ ["presentation", "workflow", "strategy_selection"].includes(
2338
+ setting.effect,
2339
+ ) &&
2340
+ ["prefer", "avoid"].includes(setting.strength) &&
2341
+ Number.isInteger(setting.definition_version) &&
2342
+ setting.definition_version >= 1 &&
2343
+ setting.definition_version <= 1_000_000
2344
+ ) {
2345
+ Object.assign(normalized, {
2346
+ label: safeText(setting.label, 96),
2347
+ behavior: safeText(setting.behavior, 320),
2348
+ effect: setting.effect,
2349
+ strength: setting.strength,
2350
+ definition_version: setting.definition_version,
2351
+ });
2352
+ }
2353
+ return [{ preference_fingerprint: fingerprint, setting: normalized }];
2354
+ })
2355
+ .slice(0, PREFERENCE_EVIDENCE_SETTING_LIMIT);
2356
+ }
2357
+
1854
2358
  function writeDirectSelectionSurfaces(sessionId, records, env) {
1855
2359
  try {
1856
2360
  const path = directSelectionPath(sessionId, env);
1857
- mkdirSync(dirname(path), { recursive: true });
1858
- writeFileSync(
2361
+ return writePrivateUsageMarker(
1859
2362
  path,
1860
2363
  JSON.stringify(records.slice(-DIRECT_SELECTION_MARKER_LIMIT)),
1861
2364
  );
1862
- return true;
1863
2365
  } catch {
1864
2366
  return false;
1865
2367
  }
@@ -1909,8 +2411,7 @@ export function recordHighMatchSurface(sessionId, match, env = process.env) {
1909
2411
  rmSync(path, { force: true });
1910
2412
  return true;
1911
2413
  }
1912
- mkdirSync(dirname(path), { recursive: true });
1913
- writeFileSync(
2414
+ return writePrivateUsageMarker(
1914
2415
  path,
1915
2416
  JSON.stringify({
1916
2417
  query_id: match.query_id ? safeText(match.query_id, 80) : null,
@@ -1932,7 +2433,6 @@ export function recordHighMatchSurface(sessionId, match, env = process.env) {
1932
2433
  potential_savings: sanitizePotentialSavings(match.potential_savings),
1933
2434
  }),
1934
2435
  );
1935
- return true;
1936
2436
  } catch {
1937
2437
  return false;
1938
2438
  }
@@ -2251,7 +2751,8 @@ async function postTaskOutcome(payload, options = {}) {
2251
2751
  const controller = new AbortController();
2252
2752
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
2253
2753
  try {
2254
- const credential = resolveApiCredential(env);
2754
+ const access = resolveApiAccessSnapshot(env);
2755
+ const credential = access.credential;
2255
2756
  if (isUnusableConfigurationSource(credential.source)) return false;
2256
2757
  const headers = {
2257
2758
  "content-type": "application/json",
@@ -2259,8 +2760,16 @@ async function postTaskOutcome(payload, options = {}) {
2259
2760
  };
2260
2761
  const apiKey = credential.apiKey;
2261
2762
  if (apiKey) headers["x-remembrance-api-key"] = apiKey;
2763
+ const principalSession = readHookPrincipalSession(
2764
+ normalizeRuntime(env.REMEMBRANCE_PLUGIN_HOST),
2765
+ env,
2766
+ access,
2767
+ );
2768
+ if (principalSession?.token) {
2769
+ headers["x-remembrance-principal-session"] = principalSession.token;
2770
+ }
2262
2771
  const response = await (options.fetchImpl ?? fetch)(
2263
- `${apiUrl(env)}/api/v1/agent/task-outcomes`,
2772
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/task-outcomes`,
2264
2773
  {
2265
2774
  method: "POST",
2266
2775
  headers,
@@ -2279,12 +2788,10 @@ async function postTaskOutcome(payload, options = {}) {
2279
2788
  function writeValueEpisodeSurfaces(sessionId, records, env) {
2280
2789
  try {
2281
2790
  const path = valueEpisodePath(sessionId, env);
2282
- mkdirSync(dirname(path), { recursive: true });
2283
- writeFileSync(
2791
+ return writePrivateUsageMarker(
2284
2792
  path,
2285
2793
  JSON.stringify(records.slice(-VALUE_EPISODE_MARKER_LIMIT)),
2286
2794
  );
2287
- return true;
2288
2795
  } catch {
2289
2796
  return false;
2290
2797
  }
@@ -2441,51 +2948,566 @@ function clientUpdateDir(env = process.env) {
2441
2948
  return join(homedir(), ".cache", "remembrance", "client-updates");
2442
2949
  }
2443
2950
 
2444
- function clientUpdatePath(surface, env = process.env) {
2445
- const normalized = normalizedPluginHealthSurface(surface);
2446
- return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
2951
+ function principalSessionDir(env = process.env) {
2952
+ if (env?.REMEMBRANCE_PRINCIPAL_SESSION_DIR) {
2953
+ return String(env.REMEMBRANCE_PRINCIPAL_SESSION_DIR);
2954
+ }
2955
+ if (process.env.VITEST) {
2956
+ return join(
2957
+ tmpdir(),
2958
+ "remembrance-principal-session-tests",
2959
+ String(process.pid),
2960
+ );
2961
+ }
2962
+ return join(homedir(), ".cache", "remembrance", "principal-sessions");
2447
2963
  }
2448
2964
 
2449
- export async function checkForClientUpdate(
2450
- {
2451
- surface,
2452
- currentVersion,
2453
- fetchImpl = fetch,
2454
- now = Date.now(),
2455
- timeoutMs = CLIENT_UPDATE_TIMEOUT_MS,
2456
- },
2457
- env = process.env,
2458
- ) {
2459
- const normalizedSurface = normalizedPluginHealthSurface(surface);
2460
- if (
2461
- !normalizedSurface ||
2462
- disabled(env.REMEMBRANCE_CLIENT_UPDATE_CHECK) ||
2463
- !parseStableClientVersion(currentVersion)
2464
- ) {
2965
+ function agentIdentityPath(env = process.env) {
2966
+ return (
2967
+ String(env.REMEMBRANCE_AGENT_KEY_PATH ?? "").trim() ||
2968
+ join(
2969
+ env.XDG_CONFIG_HOME || join(homedir(), ".config"),
2970
+ "remembrance",
2971
+ "agent-key.json",
2972
+ )
2973
+ );
2974
+ }
2975
+
2976
+ function readHookIdentity(env = process.env) {
2977
+ try {
2978
+ const parsed = JSON.parse(
2979
+ readSecureHookFile(agentIdentityPath(env), MAX_LOCAL_CONFIG_BYTES),
2980
+ );
2981
+ if (
2982
+ !parsed ||
2983
+ typeof parsed !== "object" ||
2984
+ Array.isArray(parsed) ||
2985
+ !["other", "codex", "cursor", "claude_code"].includes(parsed.provider) ||
2986
+ typeof parsed.key_id !== "string" ||
2987
+ !parsed.key_id ||
2988
+ typeof parsed.subject !== "string" ||
2989
+ !parsed.subject ||
2990
+ typeof parsed.public_key !== "string" ||
2991
+ !parsed.public_key ||
2992
+ typeof parsed.private_key !== "string" ||
2993
+ !parsed.private_key
2994
+ ) {
2995
+ return null;
2996
+ }
2997
+ createPrivateKey(parsed.private_key);
2998
+ return parsed;
2999
+ } catch {
2465
3000
  return null;
2466
3001
  }
2467
- const configuration = resolveApiConfiguration(env);
2468
- if (isUnusableConfigurationSource(configuration.source)) return null;
2469
- const path = clientUpdatePath(normalizedSurface, env);
2470
- let manifest = readCachedClientRelease(path, now);
2471
- if (!manifest) {
2472
- const controller = new AbortController();
2473
- const timer = setTimeout(() => controller.abort(), timeoutMs);
2474
- try {
2475
- const response = await fetchImpl(
2476
- `${configuration.apiUrl.replace(/\/+$/, "")}/.well-known/remembrance-client-release.json`,
2477
- {
2478
- headers: { accept: "application/json" },
2479
- signal: controller.signal,
2480
- },
2481
- );
2482
- if (!response?.ok) return null;
2483
- const text = await response.text();
2484
- if (Buffer.byteLength(text, "utf8") > MAX_LOCAL_CLIENT_UPDATE_BYTES) {
2485
- return null;
2486
- }
2487
- try {
2488
- manifest = parseClientReleaseManifest(JSON.parse(text));
3002
+ }
3003
+
3004
+ function localIdentityProvider(runtime) {
3005
+ const normalized = normalizeRuntime(runtime);
3006
+ return ["codex", "cursor", "claude_code"].includes(normalized)
3007
+ ? normalized
3008
+ : "other";
3009
+ }
3010
+
3011
+ function createHookIdentity(runtime, env = process.env) {
3012
+ const path = agentIdentityPath(env);
3013
+ const existing = readHookIdentity(env);
3014
+ if (existing) return existing;
3015
+ try {
3016
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
3017
+ const publicKeyPem = String(
3018
+ publicKey.export({ type: "spki", format: "pem" }),
3019
+ );
3020
+ const keyId = `tofu_${createHash("sha256")
3021
+ .update(publicKeyPem)
3022
+ .digest("hex")
3023
+ .slice(0, 24)}`;
3024
+ const identity = {
3025
+ provider: localIdentityProvider(runtime),
3026
+ subject: `local:${keyId}`,
3027
+ key_id: keyId,
3028
+ public_key: publicKeyPem,
3029
+ private_key: String(privateKey.export({ type: "pkcs8", format: "pem" })),
3030
+ created_at: new Date().toISOString(),
3031
+ };
3032
+ ensureSecureHookDirectory(dirname(path));
3033
+ writeFileSync(path, `${JSON.stringify(identity, null, 2)}\n`, {
3034
+ mode: 0o600,
3035
+ flag: "wx",
3036
+ });
3037
+ return identity;
3038
+ } catch {
3039
+ return readHookIdentity(env);
3040
+ }
3041
+ }
3042
+
3043
+ function canonicalHookJson(value) {
3044
+ const sort = (candidate) => {
3045
+ if (Array.isArray(candidate)) return candidate.map(sort);
3046
+ if (candidate && typeof candidate === "object") {
3047
+ return Object.keys(candidate)
3048
+ .sort()
3049
+ .reduce((result, key) => {
3050
+ result[key] = sort(candidate[key]);
3051
+ return result;
3052
+ }, {});
3053
+ }
3054
+ return candidate;
3055
+ };
3056
+ return JSON.stringify(sort(value));
3057
+ }
3058
+
3059
+ async function registerHookIdentity(identity, fetchImpl, access, signal) {
3060
+ let ownerBinding = null;
3061
+ try {
3062
+ const bindingResponse = await fetchImpl(
3063
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/keys/register`,
3064
+ {
3065
+ method: "GET",
3066
+ headers: principalRequestHeaders(access),
3067
+ signal,
3068
+ },
3069
+ );
3070
+ if (bindingResponse?.ok) {
3071
+ const bindingPayload = await bindingResponse.json();
3072
+ const candidate = String(bindingPayload?.owner_binding ?? "").trim();
3073
+ if (/^areg_[A-Za-z0-9_-]{24,120}$/.test(candidate)) {
3074
+ ownerBinding = candidate;
3075
+ }
3076
+ }
3077
+ } catch {
3078
+ // Registration remains fail-open; an existing same-scope key can still use
3079
+ // the legacy proof while new organization claims require the binding.
3080
+ }
3081
+ const signedAt = new Date().toISOString();
3082
+ const publicKeyHash = `sha256:${createHash("sha256")
3083
+ .update(identity.public_key)
3084
+ .digest("hex")}`;
3085
+ const signingPayload = canonicalHookJson({
3086
+ version: ownerBinding ? "v2" : "v1",
3087
+ purpose: "remembrance-agent-key-registration",
3088
+ provider: identity.provider,
3089
+ key_id: identity.key_id,
3090
+ ...(ownerBinding ? { owner_binding: ownerBinding } : {}),
3091
+ public_key_hash: publicKeyHash,
3092
+ subject: identity.subject,
3093
+ signed_at: signedAt,
3094
+ });
3095
+ const signature = signPayload(
3096
+ null,
3097
+ Buffer.from(signingPayload),
3098
+ createPrivateKey(identity.private_key),
3099
+ ).toString("base64url");
3100
+ const response = await fetchImpl(
3101
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/keys/register`,
3102
+ {
3103
+ method: "POST",
3104
+ headers: principalRequestHeaders(access),
3105
+ body: JSON.stringify({
3106
+ provider: identity.provider,
3107
+ key_id: identity.key_id,
3108
+ public_key: identity.public_key,
3109
+ subject: identity.subject,
3110
+ proof: {
3111
+ algorithm: "ed25519",
3112
+ ...(ownerBinding ? { owner_binding: ownerBinding } : {}),
3113
+ signed_at: signedAt,
3114
+ signature,
3115
+ },
3116
+ metadata: { registered_by: "remembrance-plugin-hook" },
3117
+ }),
3118
+ signal,
3119
+ },
3120
+ );
3121
+ return response?.ok === true;
3122
+ }
3123
+
3124
+ function principalSessionCachePath(
3125
+ runtime,
3126
+ keyId,
3127
+ env = process.env,
3128
+ access = resolveApiAccessSnapshot(env),
3129
+ ) {
3130
+ const credentialFingerprint = createHash("sha256")
3131
+ .update(
3132
+ [access.credential.source, access.credential.apiKey].join(":"),
3133
+ "utf8",
3134
+ )
3135
+ .digest("hex")
3136
+ .slice(0, 16);
3137
+ const digest = createHash("sha256")
3138
+ .update(
3139
+ [
3140
+ access.apiConfiguration.apiUrl,
3141
+ credentialFingerprint,
3142
+ normalizeRuntime(runtime),
3143
+ String(keyId),
3144
+ ].join(":"),
3145
+ "utf8",
3146
+ )
3147
+ .digest("hex")
3148
+ .slice(0, 32);
3149
+ return join(principalSessionDir(env), `${digest}.json`);
3150
+ }
3151
+
3152
+ export function readHookPrincipalSession(
3153
+ runtime,
3154
+ env = process.env,
3155
+ access = resolveApiAccessSnapshot(env),
3156
+ ) {
3157
+ const identity = readHookIdentity(env);
3158
+ if (!identity) return null;
3159
+ try {
3160
+ const parsed = JSON.parse(
3161
+ readSecureHookFile(
3162
+ principalSessionCachePath(runtime, identity.key_id, env, access),
3163
+ MAX_LOCAL_PRINCIPAL_SESSION_BYTES,
3164
+ ),
3165
+ );
3166
+ if (
3167
+ typeof parsed?.token !== "string" ||
3168
+ !/^psess_[A-Za-z0-9_-]{24,160}$/.test(parsed.token) ||
3169
+ !Number.isFinite(Date.parse(parsed.expires_at ?? "")) ||
3170
+ Date.parse(parsed.expires_at) <= Date.now() + 60_000
3171
+ ) {
3172
+ return null;
3173
+ }
3174
+ return {
3175
+ token: parsed.token,
3176
+ expires_at: parsed.expires_at,
3177
+ member_linked: parsed.member_linked === true,
3178
+ };
3179
+ } catch {
3180
+ return null;
3181
+ }
3182
+ }
3183
+
3184
+ export function clearHookPrincipalSession(
3185
+ runtime,
3186
+ env = process.env,
3187
+ access = resolveApiAccessSnapshot(env),
3188
+ ) {
3189
+ const identity = readHookIdentity(env);
3190
+ if (!identity) return false;
3191
+ try {
3192
+ rmSync(principalSessionCachePath(runtime, identity.key_id, env, access), {
3193
+ force: true,
3194
+ });
3195
+ return true;
3196
+ } catch {
3197
+ return false;
3198
+ }
3199
+ }
3200
+
3201
+ export async function warmPrincipalSession(
3202
+ {
3203
+ runtime,
3204
+ hostSurface = null,
3205
+ clientVersion = null,
3206
+ hostVersion = null,
3207
+ fetchImpl = fetch,
3208
+ apiAccess = null,
3209
+ },
3210
+ env = process.env,
3211
+ ) {
3212
+ // Same pause-don't-downgrade rule the query, directive-event, and task-outcome
3213
+ // paths enforce. It has to live HERE, not in the callers: every host adapter
3214
+ // warms the principal session at session start, so a per-caller guard is one
3215
+ // that six adapters can each forget.
3216
+ //
3217
+ // principalRequestHeaders() already withholds the API key when the source is
3218
+ // unusable, which is only half a guard: the request would still go out, and
3219
+ // apiUrl() hands back DEFAULT_API_URL while merely flagging the source, so an
3220
+ // unreadable config would register the agent key anonymously against the
3221
+ // default registry instead of the destination that config was naming. The
3222
+ // principal-session challenge also carries a member link token, so suppressing
3223
+ // the request — not just the header — is what keeps a secret from reaching a
3224
+ // destination the user never chose.
3225
+ const access = apiAccess ?? resolveApiAccessSnapshot(env);
3226
+ const credential = access.credential;
3227
+ if (
3228
+ isUnusableConfigurationSource(access.apiConfiguration.source) ||
3229
+ isUnusableConfigurationSource(credential.source)
3230
+ ) {
3231
+ debugLog(env, "shared_config_unusable", {});
3232
+ return null;
3233
+ }
3234
+ const normalizedRuntime = normalizeRuntime(runtime);
3235
+ const normalizedHostSurface = runtimeHostSurface(
3236
+ normalizedRuntime,
3237
+ env,
3238
+ hostSurface,
3239
+ );
3240
+ const identity =
3241
+ readHookIdentity(env) ?? createHookIdentity(normalizedRuntime, env);
3242
+ if (!identity) return null;
3243
+ const cached = readHookPrincipalSession(normalizedRuntime, env, access);
3244
+ if (cached) return cached;
3245
+ const warmupKey = principalSessionCachePath(
3246
+ normalizedRuntime,
3247
+ identity.key_id,
3248
+ env,
3249
+ access,
3250
+ );
3251
+ const activeWarmup = principalSessionWarmups.get(warmupKey);
3252
+ if (activeWarmup) return activeWarmup;
3253
+ const warmup = warmPrincipalSessionUncached(
3254
+ {
3255
+ normalizedRuntime,
3256
+ normalizedHostSurface,
3257
+ clientVersion,
3258
+ hostVersion,
3259
+ fetchImpl,
3260
+ identity,
3261
+ warmupKey,
3262
+ apiAccess: access,
3263
+ },
3264
+ env,
3265
+ );
3266
+ principalSessionWarmups.set(warmupKey, warmup);
3267
+ try {
3268
+ return await warmup;
3269
+ } finally {
3270
+ if (principalSessionWarmups.get(warmupKey) === warmup) {
3271
+ principalSessionWarmups.delete(warmupKey);
3272
+ }
3273
+ }
3274
+ }
3275
+
3276
+ async function warmPrincipalSessionUncached(
3277
+ {
3278
+ normalizedRuntime,
3279
+ normalizedHostSurface,
3280
+ clientVersion,
3281
+ hostVersion,
3282
+ fetchImpl,
3283
+ identity,
3284
+ warmupKey,
3285
+ apiAccess,
3286
+ },
3287
+ env,
3288
+ ) {
3289
+ const controller = new AbortController();
3290
+ const timeout = setTimeout(
3291
+ () => controller.abort(),
3292
+ PRINCIPAL_SESSION_TIMEOUT_MS,
3293
+ );
3294
+ try {
3295
+ if (
3296
+ !(await registerHookIdentity(
3297
+ identity,
3298
+ fetchImpl,
3299
+ apiAccess,
3300
+ controller.signal,
3301
+ ))
3302
+ ) {
3303
+ return null;
3304
+ }
3305
+ const memberLinkToken = apiAccess.memberLinkToken;
3306
+ const profileKey = createHash("sha256")
3307
+ .update(
3308
+ [
3309
+ identity.key_id,
3310
+ normalizedRuntime,
3311
+ normalizedHostSurface,
3312
+ "plugin_hook",
3313
+ ].join(":"),
3314
+ "utf8",
3315
+ )
3316
+ .digest("base64url");
3317
+ const challengePayload = {
3318
+ action: "challenge",
3319
+ provider: identity.provider,
3320
+ key_id: identity.key_id,
3321
+ runtime_profile: {
3322
+ runtime: normalizedRuntime,
3323
+ surface: "plugin_hook",
3324
+ host_surface: normalizedHostSurface,
3325
+ client_name: runtimeDisplayName(normalizedRuntime),
3326
+ client_version: safeText(clientVersion ?? "unknown", 64),
3327
+ runtime_version: safeText(hostVersion ?? "", 64) || null,
3328
+ profile_key: `rpf_${profileKey}`,
3329
+ },
3330
+ ...(memberLinkToken ? { member_link_token: memberLinkToken } : {}),
3331
+ };
3332
+ let challenge = await postPrincipalSession(
3333
+ challengePayload,
3334
+ fetchImpl,
3335
+ apiAccess,
3336
+ controller.signal,
3337
+ );
3338
+ if (!challenge && memberLinkToken) {
3339
+ challenge = await postPrincipalSession(
3340
+ { ...challengePayload, member_link_token: undefined },
3341
+ fetchImpl,
3342
+ apiAccess,
3343
+ controller.signal,
3344
+ );
3345
+ }
3346
+ if (!challenge?.challenge_id || !challenge?.signing_payload) return null;
3347
+ const signature = signPayload(
3348
+ null,
3349
+ Buffer.from(challenge.signing_payload),
3350
+ createPrivateKey(identity.private_key),
3351
+ ).toString("base64url");
3352
+ const session = await postPrincipalSession(
3353
+ {
3354
+ action: "exchange",
3355
+ provider: identity.provider,
3356
+ key_id: identity.key_id,
3357
+ challenge_id: challenge.challenge_id,
3358
+ signature,
3359
+ },
3360
+ fetchImpl,
3361
+ apiAccess,
3362
+ controller.signal,
3363
+ );
3364
+ if (!session?.session_token || !session?.expires_at) return null;
3365
+ const stored = {
3366
+ token: session.session_token,
3367
+ expires_at: session.expires_at,
3368
+ member_linked: session.member_linked === true,
3369
+ };
3370
+ writeHookPrincipalSession(warmupKey, stored);
3371
+ return stored;
3372
+ } catch {
3373
+ return null;
3374
+ } finally {
3375
+ clearTimeout(timeout);
3376
+ }
3377
+ }
3378
+
3379
+ async function postPrincipalSession(payload, fetchImpl, access, signal) {
3380
+ const response = await fetchImpl(
3381
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/principal-sessions`,
3382
+ {
3383
+ method: "POST",
3384
+ headers: principalRequestHeaders(access),
3385
+ body: JSON.stringify(payload),
3386
+ signal,
3387
+ },
3388
+ );
3389
+ return response?.ok ? response.json() : null;
3390
+ }
3391
+
3392
+ function principalRequestHeaders(access) {
3393
+ const headers = {
3394
+ "content-type": "application/json",
3395
+ "user-agent": clientUserAgent(),
3396
+ };
3397
+ const credential = access.credential;
3398
+ if (credential.apiKey && !isUnusableConfigurationSource(credential.source)) {
3399
+ headers["x-remembrance-api-key"] = credential.apiKey;
3400
+ }
3401
+ return headers;
3402
+ }
3403
+
3404
+ function writeHookPrincipalSession(path, value) {
3405
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3406
+ try {
3407
+ ensureSecureHookDirectory(dirname(path));
3408
+ writeFileSync(temporaryPath, `${JSON.stringify(value)}\n`, {
3409
+ mode: 0o600,
3410
+ flag: "wx",
3411
+ });
3412
+ renameSync(temporaryPath, path);
3413
+ return true;
3414
+ } catch {
3415
+ try {
3416
+ rmSync(temporaryPath, { force: true });
3417
+ } catch {
3418
+ // Principal sessions are optional and always fail open for retrieval.
3419
+ }
3420
+ return false;
3421
+ }
3422
+ }
3423
+
3424
+ function runtimeDisplayName(runtime) {
3425
+ return (
3426
+ {
3427
+ codex: "Codex",
3428
+ claude_code: "Claude Code",
3429
+ cursor: "Cursor",
3430
+ openclaw: "OpenClaw",
3431
+ vs_code: "VS Code",
3432
+ opencode: "opencode",
3433
+ }[runtime] ?? "Agent"
3434
+ );
3435
+ }
3436
+
3437
+ function sanitizeEffectivePreferences(value) {
3438
+ if (!Array.isArray(value)) return [];
3439
+ return value
3440
+ .filter(
3441
+ (item) =>
3442
+ item &&
3443
+ typeof item === "object" &&
3444
+ !Array.isArray(item) &&
3445
+ typeof item.key === "string" &&
3446
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(item.key) &&
3447
+ typeof item.value === "string" &&
3448
+ /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(item.value) &&
3449
+ EFFECTIVE_PREFERENCE_SOURCES.has(item.source) &&
3450
+ ["presentation", "workflow", "strategy_selection"].includes(
3451
+ item.effect ?? "presentation",
3452
+ ) &&
3453
+ ["prefer", "avoid"].includes(item.strength ?? "prefer"),
3454
+ )
3455
+ .slice(0, 32)
3456
+ .map((item) => {
3457
+ const label = safeText(item.label ?? item.key, 96);
3458
+ const behavior = safeText(item.behavior ?? item.value, 320);
3459
+ const direction = item.strength === "avoid" ? "avoid" : "prefer";
3460
+ const authority =
3461
+ item.source === "mandatory_org" ? "required organization" : item.source;
3462
+ return `${label} [${safeText(item.effect ?? "presentation", 24)}, ${safeText(authority ?? "preference", 32)}]: ${direction} ${behavior}`;
3463
+ });
3464
+ }
3465
+
3466
+ function clientUpdatePath(surface, env = process.env) {
3467
+ const normalized = normalizedPluginHealthSurface(surface);
3468
+ return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
3469
+ }
3470
+
3471
+ export async function checkForClientUpdate(
3472
+ {
3473
+ surface,
3474
+ currentVersion,
3475
+ fetchImpl = fetch,
3476
+ now = Date.now(),
3477
+ timeoutMs = CLIENT_UPDATE_TIMEOUT_MS,
3478
+ },
3479
+ env = process.env,
3480
+ ) {
3481
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
3482
+ if (
3483
+ !normalizedSurface ||
3484
+ disabled(env.REMEMBRANCE_CLIENT_UPDATE_CHECK) ||
3485
+ !parseStableClientVersion(currentVersion)
3486
+ ) {
3487
+ return null;
3488
+ }
3489
+ const configuration = resolveApiConfiguration(env);
3490
+ if (isUnusableConfigurationSource(configuration.source)) return null;
3491
+ const path = clientUpdatePath(normalizedSurface, env);
3492
+ let manifest = readCachedClientRelease(path, now);
3493
+ if (!manifest) {
3494
+ const controller = new AbortController();
3495
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3496
+ try {
3497
+ const response = await fetchImpl(
3498
+ `${configuration.apiUrl.replace(/\/+$/, "")}/.well-known/remembrance-client-release.json`,
3499
+ {
3500
+ headers: { accept: "application/json" },
3501
+ signal: controller.signal,
3502
+ },
3503
+ );
3504
+ if (!response?.ok) return null;
3505
+ const text = await response.text();
3506
+ if (Buffer.byteLength(text, "utf8") > MAX_LOCAL_CLIENT_UPDATE_BYTES) {
3507
+ return null;
3508
+ }
3509
+ try {
3510
+ manifest = parseClientReleaseManifest(JSON.parse(text));
2489
3511
  } catch {
2490
3512
  return null;
2491
3513
  }
@@ -2644,6 +3666,7 @@ function hostPolicyOperationClass(toolName) {
2644
3666
  normalized.endsWith("get_skill") ||
2645
3667
  normalized.endsWith("get_resource") ||
2646
3668
  normalized.endsWith("invoke_skill") ||
3669
+ normalized.endsWith("get_effective_preferences") ||
2647
3670
  normalized.endsWith("run_connection_doctor") ||
2648
3671
  normalized.endsWith("get_connection_status")
2649
3672
  ) {
@@ -2667,6 +3690,8 @@ function hostPolicyOperationClass(toolName) {
2667
3690
  if (
2668
3691
  normalized.endsWith("submit_query_feedback") ||
2669
3692
  normalized.endsWith("submit_feedback") ||
3693
+ normalized.endsWith("submit_preference_compatibility_feedback") ||
3694
+ normalized.endsWith("record_preference") ||
2670
3695
  normalized.endsWith("report_task_outcome")
2671
3696
  ) {
2672
3697
  return "feedback";
@@ -2687,6 +3712,10 @@ function isRemembranceToolName(toolName) {
2687
3712
  "get_resource",
2688
3713
  "invoke_skill",
2689
3714
  "get_value_proof",
3715
+ "get_effective_preferences",
3716
+ "record_preference",
3717
+ "submit_preference_compatibility_feedback",
3718
+ "link_current_installation",
2690
3719
  "report_task_outcome",
2691
3720
  "submit_query_feedback",
2692
3721
  "submit_feedback",
@@ -3214,18 +4243,39 @@ export function readRemembranceConfig(env = process.env) {
3214
4243
  }
3215
4244
  }
3216
4245
 
3217
- export function resolveApiCredential(env = process.env) {
3218
- const apiConfiguration = resolveApiConfiguration(env);
4246
+ function readSharedConfigSnapshot(env = process.env) {
4247
+ const path = remembranceConfigPath(env);
4248
+ if (!existsSync(path)) {
4249
+ return { present: false, parsed: null, config: {} };
4250
+ }
4251
+ try {
4252
+ const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
4253
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
4254
+ ? { present: true, parsed, config: parsed }
4255
+ : { present: true, parsed: null, config: {} };
4256
+ } catch {
4257
+ return { present: true, parsed: null, config: {} };
4258
+ }
4259
+ }
4260
+
4261
+ export function resolveApiAccessSnapshot(env = process.env) {
4262
+ const shared = readSharedConfigSnapshot(env);
4263
+ const apiConfiguration = resolveApiConfigurationFromSnapshot(env, shared);
3219
4264
  if (isUnusableConfigurationSource(apiConfiguration.source)) {
3220
- return { apiKey: "", source: apiConfiguration.source };
4265
+ return {
4266
+ apiConfiguration,
4267
+ credential: { apiKey: "", source: apiConfiguration.source },
4268
+ memberLinkToken: "",
4269
+ };
3221
4270
  }
3222
4271
  const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
4272
+ let credential;
3223
4273
  if (environmentKey) {
3224
4274
  const explicitBinding = String(env.REMEMBRANCE_API_KEY_ORIGIN ?? "").trim();
3225
4275
  const binding = explicitBinding
3226
4276
  ? normalizeApiUrl(explicitBinding, env)
3227
4277
  : { apiUrl: DEFAULT_API_URL, issue: null };
3228
- return credentialForApiDestination(
4278
+ credential = credentialForApiDestination(
3229
4279
  binding.apiUrl && !binding.issue
3230
4280
  ? {
3231
4281
  apiKey: environmentKey,
@@ -3235,42 +4285,52 @@ export function resolveApiCredential(env = process.env) {
3235
4285
  : unusableDestinationCredential(),
3236
4286
  apiConfiguration,
3237
4287
  );
3238
- }
3239
- const path = remembranceConfigPath(env);
3240
- if (!existsSync(path)) {
3241
- return { apiKey: "", source: "none" };
3242
- }
3243
- try {
3244
- const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
3245
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3246
- return unusableSharedConfigCredential();
3247
- }
4288
+ } else if (!shared.present) {
4289
+ credential = { apiKey: "", source: "none" };
4290
+ } else if (!shared.parsed) {
4291
+ credential = unusableSharedConfigCredential();
4292
+ } else {
4293
+ const parsed = shared.parsed;
3248
4294
  if (
3249
4295
  Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
3250
4296
  (typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
3251
4297
  ) {
3252
- return unusableSharedConfigCredential();
3253
- }
3254
- const fromFile = parsed.apiKey;
3255
- if (typeof fromFile !== "string" || !fromFile.trim()) {
3256
- return { apiKey: "", source: "none" };
4298
+ credential = unusableSharedConfigCredential();
4299
+ } else {
4300
+ const fromFile = parsed.apiKey;
4301
+ if (typeof fromFile !== "string" || !fromFile.trim()) {
4302
+ credential = { apiKey: "", source: "none" };
4303
+ } else {
4304
+ const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
4305
+ ? normalizeApiUrl(parsed.apiUrl, env)
4306
+ : { apiUrl: DEFAULT_API_URL, issue: null };
4307
+ credential = credentialForApiDestination(
4308
+ binding.apiUrl && !binding.issue
4309
+ ? {
4310
+ apiKey: fromFile.trim(),
4311
+ source: "shared_config",
4312
+ boundApiUrl: binding.apiUrl,
4313
+ }
4314
+ : unusableSharedConfigCredential(),
4315
+ apiConfiguration,
4316
+ );
4317
+ }
3257
4318
  }
3258
- const binding = Object.prototype.hasOwnProperty.call(parsed, "apiUrl")
3259
- ? normalizeApiUrl(parsed.apiUrl, env)
3260
- : { apiUrl: DEFAULT_API_URL, issue: null };
3261
- return credentialForApiDestination(
3262
- binding.apiUrl && !binding.issue
3263
- ? {
3264
- apiKey: fromFile.trim(),
3265
- source: "shared_config",
3266
- boundApiUrl: binding.apiUrl,
3267
- }
3268
- : unusableSharedConfigCredential(),
3269
- apiConfiguration,
3270
- );
3271
- } catch {
3272
- return unusableSharedConfigCredential();
3273
4319
  }
4320
+ const rawMemberLinkToken = String(
4321
+ env.REMEMBRANCE_MEMBER_LINK_TOKEN ?? shared.config.memberLinkToken ?? "",
4322
+ ).trim();
4323
+ return {
4324
+ apiConfiguration,
4325
+ credential,
4326
+ memberLinkToken: /^mlink_[A-Za-z0-9_-]{24,160}$/.test(rawMemberLinkToken)
4327
+ ? rawMemberLinkToken
4328
+ : "",
4329
+ };
4330
+ }
4331
+
4332
+ export function resolveApiCredential(env = process.env) {
4333
+ return resolveApiAccessSnapshot(env).credential;
3274
4334
  }
3275
4335
 
3276
4336
  // The org API key: an explicit env var wins, then the config file. Returns ""
@@ -3279,6 +4339,253 @@ export function resolveApiKey(env = process.env) {
3279
4339
  return resolveApiCredential(env).apiKey;
3280
4340
  }
3281
4341
 
4342
+ export function explicitPreferenceSettingsFromPrompt(prompt) {
4343
+ const text = String(prompt ?? "").toLowerCase();
4344
+ const settings = new Map();
4345
+ const set = (key, value) => settings.set(key, { key, value });
4346
+ if (
4347
+ /\b(?:too many|fewer|less|minimal|sparse)\s+(?:code\s+)?comments?\b/.test(
4348
+ text,
4349
+ ) ||
4350
+ /\bcomments?\s+(?:only\s+)?(?:when|for)\s+(?:the\s+)?(?:logic is )?(?:tricky|non-obvious)\b/.test(
4351
+ text,
4352
+ )
4353
+ ) {
4354
+ set("comment_density", "sparse");
4355
+ } else if (
4356
+ /\b(?:more|detailed|comprehensive)\s+(?:code\s+)?comments?\b/.test(text)
4357
+ ) {
4358
+ set("comment_density", "detailed");
4359
+ }
4360
+ if (/\bcomments?\s+(?:only\s+)?(?:on|for)\s+api\s+contracts?\b/.test(text)) {
4361
+ set("comment_focus", "api_contracts");
4362
+ } else if (
4363
+ /\bcomments?\s+(?:only\s+)?(?:when|for)\s+(?:the\s+)?(?:logic is )?(?:tricky|non-obvious)\b/.test(
4364
+ text,
4365
+ )
4366
+ ) {
4367
+ set("comment_focus", "tricky_logic");
4368
+ } else if (/\bcomments?\s+(?:only\s+)?(?:on|for)\s+intent\b/.test(text)) {
4369
+ set("comment_focus", "intent_only");
4370
+ }
4371
+ if (
4372
+ /\b(?:be|keep (?:it|(?:your |the )?(?:answers?|responses?|explanations?))|prefer)\s+(?:more\s+)?concise\b/.test(
4373
+ text,
4374
+ ) ||
4375
+ /\b(?:too|overly)\s+verbose\b/.test(text)
4376
+ ) {
4377
+ set("explanation_depth", "concise");
4378
+ } else if (
4379
+ /\b(?:prefer|give|provide|use)\s+(?:more\s+)?detailed\s+(?:answers?|explanations?)\b/.test(
4380
+ text,
4381
+ )
4382
+ ) {
4383
+ set("explanation_depth", "detailed");
4384
+ }
4385
+ if (/\b(?:use|prefer)\s+step[- ]by[- ]step\b/.test(text)) {
4386
+ set("output_organization", "step_by_step");
4387
+ } else if (
4388
+ /\b(?:use|prefer)\s+(?:a\s+)?structured\s+(?:answer|format|output)\b/.test(
4389
+ text,
4390
+ )
4391
+ ) {
4392
+ set("output_organization", "structured");
4393
+ } else if (
4394
+ /\b(?:use|prefer|keep)\s+(?:a\s+)?compact\s+(?:answer|format|output)\b/.test(
4395
+ text,
4396
+ )
4397
+ ) {
4398
+ set("output_organization", "compact");
4399
+ }
4400
+ return [...settings.values()];
4401
+ }
4402
+
4403
+ export function promptRequestsDurablePreference(prompt) {
4404
+ 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(
4405
+ String(prompt ?? ""),
4406
+ );
4407
+ }
4408
+
4409
+ export function promptProvidesPreferenceCorrection(prompt) {
4410
+ 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(
4411
+ String(prompt ?? ""),
4412
+ );
4413
+ }
4414
+
4415
+ function promptDisclaimsDurablePreference(prompt) {
4416
+ 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(
4417
+ String(prompt ?? ""),
4418
+ );
4419
+ }
4420
+
4421
+ export function promptScopesPreferenceToProject(prompt) {
4422
+ return /\b(?:in|for)\s+(?:this|the current)\s+(?:project|repository|repo|workspace|codebase)\b/i.test(
4423
+ String(prompt ?? ""),
4424
+ );
4425
+ }
4426
+
4427
+ export function genericPreferenceCaptureDirective(prompt, options = {}) {
4428
+ const text = String(prompt ?? "");
4429
+ if (
4430
+ promptDisclaimsDurablePreference(text) ||
4431
+ (!promptRequestsDurablePreference(text) &&
4432
+ !promptProvidesPreferenceCorrection(text)) ||
4433
+ explicitPreferenceSettingsFromPrompt(text).length > 0
4434
+ ) {
4435
+ return null;
4436
+ }
4437
+ const taskHash = createHash("sha256").update(text, "utf8").digest("hex");
4438
+ const evidenceHash = createHash("sha256")
4439
+ .update(
4440
+ JSON.stringify({
4441
+ purpose: "generic-working-preference",
4442
+ task_hash: taskHash,
4443
+ }),
4444
+ "utf8",
4445
+ )
4446
+ .digest("hex");
4447
+ const projectKey = promptScopesPreferenceToProject(text)
4448
+ ? projectKeyForHook(options.env ?? process.env, options.projectPath)
4449
+ : null;
4450
+ const scopeFields = projectKey
4451
+ ? `"scope":"project","project_key":"${projectKey}"`
4452
+ : '"scope":"auto"';
4453
+ return [
4454
+ "Remembrance durable-preference capture:",
4455
+ "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.",
4456
+ '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.',
4457
+ `Use ${scopeFields}, "source_category":"explicit_user", "evidence_hash":"${evidenceHash}", "task_hash":"${taskHash}", and "confidence":1.`,
4458
+ "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.",
4459
+ ].join("\n");
4460
+ }
4461
+
4462
+ export async function recordExplicitPreferenceObservations(
4463
+ prompt,
4464
+ options = {},
4465
+ ) {
4466
+ if (promptDisclaimsDurablePreference(prompt)) return 0;
4467
+ const durable = promptRequestsDurablePreference(prompt);
4468
+ const corrective = promptProvidesPreferenceCorrection(prompt);
4469
+ if (!durable && !corrective) return 0;
4470
+ const settings = explicitPreferenceSettingsFromPrompt(prompt);
4471
+ if (settings.length === 0) return 0;
4472
+ const env = options.env ?? process.env;
4473
+ const runtime = normalizeRuntime(options.runtime);
4474
+ const access = resolveApiAccessSnapshot(env);
4475
+ const credential = access.credential;
4476
+ if (isUnusableConfigurationSource(credential.source) || !credential.apiKey) {
4477
+ return 0;
4478
+ }
4479
+ let principalSession = readHookPrincipalSession(runtime, env, access);
4480
+ if (!principalSession?.token) {
4481
+ principalSession = await warmPrincipalSession(
4482
+ {
4483
+ runtime,
4484
+ hostSurface: runtimeHostSurface(runtime, env),
4485
+ fetchImpl: options.fetchImpl ?? fetch,
4486
+ apiAccess: access,
4487
+ },
4488
+ env,
4489
+ );
4490
+ }
4491
+ if (!principalSession?.token) return 0;
4492
+ // A direct correction is explicit evidence even when the user did not say
4493
+ // "remember this". Persist it immediately so one correction supersedes a
4494
+ // learned profile; genuinely inferred behavior still enters through the
4495
+ // topology/observation path and must satisfy the multi-task confidence gate.
4496
+ const sourceCategory = "explicit_user";
4497
+ const projectKey = promptScopesPreferenceToProject(prompt)
4498
+ ? projectKeyForHook(env, options.projectPath)
4499
+ : null;
4500
+ const taskHash = createHash("sha256")
4501
+ .update(String(prompt), "utf8")
4502
+ .digest("hex");
4503
+ let refreshPromise = null;
4504
+ const refreshSession = async () => {
4505
+ refreshPromise ??= (async () => {
4506
+ clearHookPrincipalSession(runtime, env, access);
4507
+ return warmPrincipalSession(
4508
+ {
4509
+ runtime,
4510
+ hostSurface: runtimeHostSurface(runtime, env),
4511
+ fetchImpl: options.fetchImpl ?? fetch,
4512
+ apiAccess: access,
4513
+ },
4514
+ env,
4515
+ );
4516
+ })();
4517
+ return refreshPromise;
4518
+ };
4519
+ const results = await Promise.all(
4520
+ settings.map(async (setting) => {
4521
+ const controller = new AbortController();
4522
+ const timeout = setTimeout(
4523
+ () => controller.abort(),
4524
+ DEFAULT_DIRECTIVE_EVENT_TIMEOUT_MS,
4525
+ );
4526
+ timeout.unref?.();
4527
+ try {
4528
+ const evidenceHash = createHash("sha256")
4529
+ .update(
4530
+ JSON.stringify({
4531
+ setting,
4532
+ task_hash: taskHash,
4533
+ source: "hook",
4534
+ source_category: sourceCategory,
4535
+ project_key: projectKey,
4536
+ }),
4537
+ "utf8",
4538
+ )
4539
+ .digest("hex");
4540
+ const submit = (token) =>
4541
+ (options.fetchImpl ?? fetch)(
4542
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/preferences`,
4543
+ {
4544
+ method: "POST",
4545
+ headers: {
4546
+ "content-type": "application/json",
4547
+ "user-agent": clientUserAgent(options.userAgent),
4548
+ "x-remembrance-api-key": credential.apiKey,
4549
+ "x-remembrance-principal-session": token,
4550
+ },
4551
+ body: JSON.stringify({
4552
+ setting,
4553
+ scope: projectKey
4554
+ ? "project"
4555
+ : principalSession.member_linked
4556
+ ? durable
4557
+ ? "member"
4558
+ : "member_runtime"
4559
+ : "installation",
4560
+ source_category: sourceCategory,
4561
+ evidence_hash: evidenceHash,
4562
+ task_hash: taskHash,
4563
+ ...(projectKey ? { project_key: projectKey } : {}),
4564
+ confidence: 1,
4565
+ }),
4566
+ signal: controller.signal,
4567
+ },
4568
+ );
4569
+ let response = await submit(principalSession.token);
4570
+ if (response?.status === 401 || response?.status === 403) {
4571
+ const refreshed = await refreshSession();
4572
+ if (refreshed?.token) {
4573
+ principalSession = refreshed;
4574
+ response = await submit(refreshed.token);
4575
+ }
4576
+ }
4577
+ return response?.ok === true;
4578
+ } catch {
4579
+ // Preference capture is advisory and cannot block the prompt hook.
4580
+ return false;
4581
+ } finally {
4582
+ clearTimeout(timeout);
4583
+ }
4584
+ }),
4585
+ );
4586
+ return results.filter(Boolean).length;
4587
+ }
4588
+
3282
4589
  export function sharedConfigCredentialNotice(env = process.env) {
3283
4590
  const source = resolveApiCredential(env).source;
3284
4591
  if (source === "unusable_environment") {
@@ -3321,6 +4628,10 @@ export function sharedConfigCredentialNotice(env = process.env) {
3321
4628
  }
3322
4629
 
3323
4630
  export function resolveApiConfiguration(env = process.env) {
4631
+ return resolveApiAccessSnapshot(env).apiConfiguration;
4632
+ }
4633
+
4634
+ function resolveApiConfigurationFromSnapshot(env, shared) {
3324
4635
  const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
3325
4636
  if (environmentUrl) {
3326
4637
  const normalized = normalizeApiUrl(environmentUrl, env);
@@ -3332,32 +4643,23 @@ export function resolveApiConfiguration(env = process.env) {
3332
4643
  issue: normalized.issue ?? "invalid_url",
3333
4644
  };
3334
4645
  }
3335
- const path = remembranceConfigPath(env);
3336
- if (!existsSync(path)) {
4646
+ if (!shared.present) {
3337
4647
  return { apiUrl: DEFAULT_API_URL, source: "default" };
3338
4648
  }
3339
- try {
3340
- const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
3341
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3342
- return {
4649
+ if (!shared.parsed) {
4650
+ return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
4651
+ }
4652
+ if (!Object.prototype.hasOwnProperty.call(shared.parsed, "apiUrl")) {
4653
+ return { apiUrl: DEFAULT_API_URL, source: "default" };
4654
+ }
4655
+ const normalized = normalizeApiUrl(shared.parsed.apiUrl, env);
4656
+ return normalized.apiUrl && !normalized.issue
4657
+ ? { apiUrl: normalized.apiUrl, source: "shared_config" }
4658
+ : {
3343
4659
  apiUrl: DEFAULT_API_URL,
3344
4660
  source: "unusable_shared_config",
4661
+ issue: normalized.issue ?? "invalid_url",
3345
4662
  };
3346
- }
3347
- if (!Object.prototype.hasOwnProperty.call(parsed, "apiUrl")) {
3348
- return { apiUrl: DEFAULT_API_URL, source: "default" };
3349
- }
3350
- const normalized = normalizeApiUrl(parsed.apiUrl, env);
3351
- return normalized.apiUrl && !normalized.issue
3352
- ? { apiUrl: normalized.apiUrl, source: "shared_config" }
3353
- : {
3354
- apiUrl: DEFAULT_API_URL,
3355
- source: "unusable_shared_config",
3356
- issue: normalized.issue ?? "invalid_url",
3357
- };
3358
- } catch {
3359
- return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
3360
- }
3361
4663
  }
3362
4664
 
3363
4665
  function apiUrl(env) {