@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.
- package/README.md +37 -2
- package/opencode.json +4 -3
- package/package.json +1 -1
- package/scripts/hook-core.mjs +1477 -175
- package/servers/remembrance-mcp.mjs +984 -231
- package/skills/remembrancer/SKILL.md +64 -39
- package/skills/remembrancer/references/attestation-rest.md +43 -5
- package/skills/remembrancer/references/identity-preferences-topology.md +233 -0
- package/skills/remembrancer/references/remembrance-payloads.md +13 -8
- package/skills/remembrancer/references/remembrance-setup.md +20 -0
- package/src/index.mjs +29 -9
package/scripts/hook-core.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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:
|
|
1061
|
-
|
|
1062
|
-
|
|
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:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
|
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
|
-
?
|
|
1191
|
-
: `Before
|
|
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
|
-
|
|
1197
|
-
"
|
|
1198
|
-
|
|
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
|
|
1618
|
+
"Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
|
|
1207
1619
|
signalLine,
|
|
1208
1620
|
highMatchLine,
|
|
1209
|
-
"
|
|
1210
|
-
|
|
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
|
|
1659
|
+
? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
|
|
1227
1660
|
: null;
|
|
1228
1661
|
return [
|
|
1229
|
-
"Before
|
|
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
|
-
"
|
|
1232
|
-
|
|
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
|
-
//
|
|
1371
|
-
// os.tmpdir()/remembrance-usage
|
|
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
|
|
1874
|
+
function writePrivateUsageMarker(path, value) {
|
|
1875
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1435
1876
|
try {
|
|
1436
|
-
|
|
1437
|
-
writeFileSync(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
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
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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
|
-
|
|
3218
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3240
|
-
if (!
|
|
3241
|
-
|
|
3242
|
-
}
|
|
3243
|
-
|
|
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
|
-
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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
|
-
|
|
3336
|
-
if (!existsSync(path)) {
|
|
4646
|
+
if (!shared.present) {
|
|
3337
4647
|
return { apiUrl: DEFAULT_API_URL, source: "default" };
|
|
3338
4648
|
}
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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) {
|