@remembrance-ai/opencode-plugin 0.1.55 → 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,
@@ -59,6 +66,9 @@ const PLUGIN_ALERT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
66
  const PLUGIN_ALERT_LIMIT = 32;
60
67
  const CLIENT_UPDATE_CACHE_TTL_MS = 5 * 60 * 1000;
61
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();
62
72
  export const HOST_POLICY_ALERT_TEXT =
63
73
  "Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.";
64
74
  const PLUGIN_HEALTH_COMPONENTS = new Set([
@@ -321,12 +331,22 @@ export function buildQueryPayload(
321
331
  env = process.env,
322
332
  identity = DEFAULT_AGENT_IDENTITY,
323
333
  clientContext = null,
334
+ projectPath = null,
324
335
  ) {
325
336
  const redacted = redactPrompt(prompt).trim();
326
337
  const summary =
327
338
  redacted.length <= MAX_SUMMARY_CHARS
328
339
  ? redacted
329
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;
330
350
  return {
331
351
  agent: {
332
352
  provider: identity.provider,
@@ -337,12 +357,27 @@ export function buildQueryPayload(
337
357
  summary,
338
358
  constraints: inferConstraints(summary),
339
359
  },
340
- ...(clientContext ? { client_context: clientContext } : {}),
360
+ ...(resolvedClientContext ? { client_context: resolvedClientContext } : {}),
341
361
  economics_context: buildEconomicsContext(summary, env, identity),
342
362
  limit: limitFromEnv(env),
343
363
  };
344
364
  }
345
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
+
346
381
  export function buildEconomicsContext(summary, env, identity) {
347
382
  const observedModel = stringOrNull(
348
383
  env.REMEMBRANCE_OBSERVED_MODEL_REVISION ?? env.REMEMBRANCE_MODEL_REVISION,
@@ -489,7 +524,8 @@ export async function queryRemembrance(payload, options = {}) {
489
524
  const controller = new AbortController();
490
525
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
491
526
  try {
492
- const credential = resolveApiCredential(env);
527
+ const access = resolveApiAccessSnapshot(env);
528
+ const credential = access.credential;
493
529
  if (isUnusableConfigurationSource(credential.source)) {
494
530
  debugLog(env, "shared_config_unusable", {}, options);
495
531
  return null;
@@ -502,17 +538,56 @@ export async function queryRemembrance(payload, options = {}) {
502
538
  if (apiKey) {
503
539
  headers["x-remembrance-api-key"] = apiKey;
504
540
  }
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
- });
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
+ }
511
586
  if (!response.ok) {
512
587
  debugLog(
513
588
  env,
514
589
  "http_error",
515
- { status: response.status, api_url: apiUrl(env) },
590
+ { status: response.status, api_url: access.apiConfiguration.apiUrl },
516
591
  options,
517
592
  );
518
593
  return null;
@@ -575,7 +650,8 @@ export async function reportDirectiveEvent(event, options = {}) {
575
650
  directiveEventTimeoutMs(env),
576
651
  );
577
652
  try {
578
- const credential = resolveApiCredential(env);
653
+ const access = resolveApiAccessSnapshot(env);
654
+ const credential = access.credential;
579
655
  if (isUnusableConfigurationSource(credential.source)) return false;
580
656
  const headers = {
581
657
  "content-type": "application/json",
@@ -583,8 +659,16 @@ export async function reportDirectiveEvent(event, options = {}) {
583
659
  };
584
660
  const apiKey = credential.apiKey;
585
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
+ }
586
670
  const response = await fetchImpl(
587
- `${apiUrl(env)}/api/v1/agent/directive-events`,
671
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/directive-events`,
588
672
  {
589
673
  method: "POST",
590
674
  headers,
@@ -638,6 +722,35 @@ function normalizeRuntime(value) {
638
722
  return "unknown";
639
723
  }
640
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
+
641
754
  // --- Context formatting ------------------------------------------------------
642
755
 
643
756
  function publicSkillCandidateAllowed(body, candidate) {
@@ -716,6 +829,14 @@ export function formatContext(
716
829
  "Organization policy: use organization skills only. Do not use public skills or bundled public skill references, including as an offline fallback.",
717
830
  );
718
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
+ }
719
840
  const queryId = body?.query_feedback?.query_id ?? body?.query_id;
720
841
  const highMatch = highMatchFromResponse(body);
721
842
  if (queryId) {
@@ -901,12 +1022,56 @@ function candidateDecisionLabel(candidate) {
901
1022
  );
902
1023
  }
903
1024
  }
904
- 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
+ ];
905
1034
  return details.length > 0
906
1035
  ? ` Decision: ${safeText(details.join("; "), 640)}.`
907
1036
  : "";
908
1037
  }
909
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
+
910
1075
  function sanitizeWhyMatched(value) {
911
1076
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
912
1077
  return {
@@ -1016,11 +1181,29 @@ export async function runPromptHook(prompt, options = {}) {
1016
1181
  const env = options.env ?? process.env;
1017
1182
  const includeSharedConfigCredentialNotice =
1018
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);
1019
1196
  if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
1020
1197
  debugLog(env, "disabled", {}, options);
1021
- return null;
1198
+ return preferenceCapture
1199
+ ? {
1200
+ consumed: false,
1201
+ context: preferenceCapture,
1202
+ eligible: false,
1203
+ reason: "preference_capture",
1204
+ }
1205
+ : null;
1022
1206
  }
1023
- const redacted = redactPrompt(String(prompt ?? ""));
1024
1207
  const decision = shouldQueryPrompt(redacted);
1025
1208
  if (!decision.likely_match) {
1026
1209
  if (isContextualContinuationPrompt(redacted)) {
@@ -1034,20 +1217,37 @@ export async function runPromptHook(prompt, options = {}) {
1034
1217
  });
1035
1218
  return {
1036
1219
  consumed: false,
1037
- context: continuationQueryContext(directive),
1220
+ context: appendPreferenceCapture(
1221
+ continuationQueryContext(directive),
1222
+ preferenceCapture,
1223
+ ),
1038
1224
  directive,
1039
1225
  eligible: true,
1040
1226
  reason: "contextual_continuation",
1041
1227
  };
1042
1228
  }
1229
+ if (preferenceCapture) {
1230
+ return {
1231
+ consumed: false,
1232
+ context: preferenceCapture,
1233
+ eligible: false,
1234
+ reason: "preference_capture",
1235
+ };
1236
+ }
1043
1237
  debugLog(env, "skip", { reason: decision.reason }, options);
1044
1238
  return null;
1045
1239
  }
1046
- const payload = buildQueryPayload(redacted, env, options.identity, {
1047
- surface: "plugin_hook",
1048
- runtime: runtimeFromIdentity(options.identity),
1049
- trigger_reason: decision.reason,
1050
- });
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
+ );
1051
1251
  const response = await queryRemembrance(payload, {
1052
1252
  env,
1053
1253
  fetchImpl: options.fetchImpl ?? fetch,
@@ -1057,9 +1257,12 @@ export async function runPromptHook(prompt, options = {}) {
1057
1257
  if (!response) {
1058
1258
  return {
1059
1259
  consumed: false,
1060
- context: includeSharedConfigCredentialNotice
1061
- ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1062
- : unavailableQueryContext(env),
1260
+ context: appendPreferenceCapture(
1261
+ includeSharedConfigCredentialNotice
1262
+ ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1263
+ : unavailableQueryContext(env),
1264
+ preferenceCapture,
1265
+ ),
1063
1266
  eligible: true,
1064
1267
  reason: "query_unavailable",
1065
1268
  };
@@ -1067,14 +1270,17 @@ export async function runPromptHook(prompt, options = {}) {
1067
1270
  return {
1068
1271
  consumed: true,
1069
1272
  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)),
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
+ ),
1078
1284
  eligible: true,
1079
1285
  highMatch: highMatchFromResponse(response),
1080
1286
  valueEpisode: valueEpisodeFromResponse(response),
@@ -1082,6 +1288,10 @@ export async function runPromptHook(prompt, options = {}) {
1082
1288
  };
1083
1289
  }
1084
1290
 
1291
+ function appendPreferenceCapture(context, directive) {
1292
+ return directive ? `${context}\n\n${directive}` : context;
1293
+ }
1294
+
1085
1295
  export function queryResponseHasMatches(response) {
1086
1296
  const body = responseBodyFromToolResponse(response);
1087
1297
  const skills = Array.isArray(body?.skills)
@@ -1165,7 +1375,7 @@ export function contributionReason(
1165
1375
  directSelection = null,
1166
1376
  ) {
1167
1377
  const signalLine = signal
1168
- ? `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.`
1169
1379
  : null;
1170
1380
  const highMatchLine = highMatch
1171
1381
  ? highMatchClosureInstruction(highMatch)
@@ -1187,52 +1397,42 @@ export function contributionReason(
1187
1397
  });
1188
1398
  return [
1189
1399
  directSelections.length === 1
1190
- ? `Before you finish: you explicitly used this Remembrance skill:`
1191
- : `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:`,
1192
1402
  ...selectionLines,
1193
1403
  signalLine,
1194
1404
  "Do not submit query-fit feedback for these selections; they were chosen directly rather than ranked for a query.",
1195
1405
  "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.",
1406
+ "If submit_feedback returns next_step.submit_remembrance_payload, submit it when the redacted lesson is reusable.",
1407
+ routineContributionVisibilityInstruction(),
1201
1408
  ]
1202
1409
  .filter(Boolean)
1203
1410
  .join("\n");
1204
1411
  }
1205
1412
  return [
1206
- "Before you finish: you used Remembrance this session.",
1413
+ "Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
1207
1414
  signalLine,
1208
1415
  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.",
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(),
1219
1418
  ]
1220
1419
  .filter(Boolean)
1221
1420
  .join("\n");
1222
1421
  }
1223
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
+
1224
1427
  export function taskClosureReason(signal = null) {
1225
1428
  const signalLine = signal
1226
- ? `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.`
1227
1430
  : null;
1228
1431
  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.",
1432
+ "Before finishing, silently recover the pending Remembrance query for this reusable task using the full conversation; no completed Remembrance query/use was recorded.",
1230
1433
  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.",
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(),
1236
1436
  ]
1237
1437
  .filter(Boolean)
1238
1438
  .join("\n");
@@ -2251,7 +2451,8 @@ async function postTaskOutcome(payload, options = {}) {
2251
2451
  const controller = new AbortController();
2252
2452
  const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
2253
2453
  try {
2254
- const credential = resolveApiCredential(env);
2454
+ const access = resolveApiAccessSnapshot(env);
2455
+ const credential = access.credential;
2255
2456
  if (isUnusableConfigurationSource(credential.source)) return false;
2256
2457
  const headers = {
2257
2458
  "content-type": "application/json",
@@ -2259,8 +2460,16 @@ async function postTaskOutcome(payload, options = {}) {
2259
2460
  };
2260
2461
  const apiKey = credential.apiKey;
2261
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
+ }
2262
2471
  const response = await (options.fetchImpl ?? fetch)(
2263
- `${apiUrl(env)}/api/v1/agent/task-outcomes`,
2472
+ `${access.apiConfiguration.apiUrl}/api/v1/agent/task-outcomes`,
2264
2473
  {
2265
2474
  method: "POST",
2266
2475
  headers,
@@ -2441,6 +2650,519 @@ function clientUpdateDir(env = process.env) {
2441
2650
  return join(homedir(), ".cache", "remembrance", "client-updates");
2442
2651
  }
2443
2652
 
2653
+ function principalSessionDir(env = process.env) {
2654
+ if (env?.REMEMBRANCE_PRINCIPAL_SESSION_DIR) {
2655
+ return String(env.REMEMBRANCE_PRINCIPAL_SESSION_DIR);
2656
+ }
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
+ )
2837
+ .digest("hex")
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`);
2852
+ }
2853
+
2854
+ export function readHookPrincipalSession(
2855
+ runtime,
2856
+ env = process.env,
2857
+ access = resolveApiAccessSnapshot(env),
2858
+ ) {
2859
+ const identity = readHookIdentity(env);
2860
+ if (!identity) return null;
2861
+ try {
2862
+ const parsed = JSON.parse(
2863
+ readSecureHookFile(
2864
+ principalSessionCachePath(runtime, identity.key_id, env, access),
2865
+ MAX_LOCAL_PRINCIPAL_SESSION_BYTES,
2866
+ ),
2867
+ );
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
+ };
2881
+ } catch {
2882
+ return null;
2883
+ }
2884
+ }
2885
+
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(
2904
+ {
2905
+ runtime,
2906
+ hostSurface = null,
2907
+ clientVersion = null,
2908
+ hostVersion = null,
2909
+ fetchImpl = fetch,
2910
+ apiAccess = null,
2911
+ },
2912
+ env = process.env,
2913
+ ) {
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;
2929
+ if (
2930
+ isUnusableConfigurationSource(access.apiConfiguration.source) ||
2931
+ isUnusableConfigurationSource(credential.source)
2932
+ ) {
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
+
2444
3166
  function clientUpdatePath(surface, env = process.env) {
2445
3167
  const normalized = normalizedPluginHealthSurface(surface);
2446
3168
  return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
@@ -3214,18 +3936,39 @@ export function readRemembranceConfig(env = process.env) {
3214
3936
  }
3215
3937
  }
3216
3938
 
3217
- export function resolveApiCredential(env = process.env) {
3218
- 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);
3219
3957
  if (isUnusableConfigurationSource(apiConfiguration.source)) {
3220
- return { apiKey: "", source: apiConfiguration.source };
3958
+ return {
3959
+ apiConfiguration,
3960
+ credential: { apiKey: "", source: apiConfiguration.source },
3961
+ memberLinkToken: "",
3962
+ };
3221
3963
  }
3222
3964
  const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
3965
+ let credential;
3223
3966
  if (environmentKey) {
3224
3967
  const explicitBinding = String(env.REMEMBRANCE_API_KEY_ORIGIN ?? "").trim();
3225
3968
  const binding = explicitBinding
3226
3969
  ? normalizeApiUrl(explicitBinding, env)
3227
3970
  : { apiUrl: DEFAULT_API_URL, issue: null };
3228
- return credentialForApiDestination(
3971
+ credential = credentialForApiDestination(
3229
3972
  binding.apiUrl && !binding.issue
3230
3973
  ? {
3231
3974
  apiKey: environmentKey,
@@ -3235,42 +3978,52 @@ export function resolveApiCredential(env = process.env) {
3235
3978
  : unusableDestinationCredential(),
3236
3979
  apiConfiguration,
3237
3980
  );
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
- }
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;
3248
3987
  if (
3249
3988
  Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
3250
3989
  (typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
3251
3990
  ) {
3252
- return unusableSharedConfigCredential();
3253
- }
3254
- const fromFile = parsed.apiKey;
3255
- if (typeof fromFile !== "string" || !fromFile.trim()) {
3256
- 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
+ }
3257
4011
  }
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
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;
3274
4027
  }
3275
4028
 
3276
4029
  // The org API key: an explicit env var wins, then the config file. Returns ""
@@ -3279,6 +4032,253 @@ export function resolveApiKey(env = process.env) {
3279
4032
  return resolveApiCredential(env).apiKey;
3280
4033
  }
3281
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
+
3282
4282
  export function sharedConfigCredentialNotice(env = process.env) {
3283
4283
  const source = resolveApiCredential(env).source;
3284
4284
  if (source === "unusable_environment") {
@@ -3321,6 +4321,10 @@ export function sharedConfigCredentialNotice(env = process.env) {
3321
4321
  }
3322
4322
 
3323
4323
  export function resolveApiConfiguration(env = process.env) {
4324
+ return resolveApiAccessSnapshot(env).apiConfiguration;
4325
+ }
4326
+
4327
+ function resolveApiConfigurationFromSnapshot(env, shared) {
3324
4328
  const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
3325
4329
  if (environmentUrl) {
3326
4330
  const normalized = normalizeApiUrl(environmentUrl, env);
@@ -3332,32 +4336,23 @@ export function resolveApiConfiguration(env = process.env) {
3332
4336
  issue: normalized.issue ?? "invalid_url",
3333
4337
  };
3334
4338
  }
3335
- const path = remembranceConfigPath(env);
3336
- if (!existsSync(path)) {
4339
+ if (!shared.present) {
3337
4340
  return { apiUrl: DEFAULT_API_URL, source: "default" };
3338
4341
  }
3339
- try {
3340
- const parsed = JSON.parse(readSecureHookFile(path, MAX_LOCAL_CONFIG_BYTES));
3341
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3342
- 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
+ : {
3343
4352
  apiUrl: DEFAULT_API_URL,
3344
4353
  source: "unusable_shared_config",
4354
+ issue: normalized.issue ?? "invalid_url",
3345
4355
  };
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
4356
  }
3362
4357
 
3363
4358
  function apiUrl(env) {