@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.
- package/README.md +23 -2
- package/opencode.json +4 -9
- package/package.json +1 -1
- package/scripts/hook-core.mjs +1110 -115
- package/servers/remembrance-mcp.mjs +898 -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 +221 -0
- package/skills/remembrancer/references/remembrance-payloads.md +13 -8
- package/skills/remembrancer/references/remembrance-setup.md +15 -0
- package/src/index.mjs +19 -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,
|
|
@@ -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
|
-
...(
|
|
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
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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:
|
|
1061
|
-
|
|
1062
|
-
|
|
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:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
|
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
|
-
?
|
|
1191
|
-
: `Before
|
|
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
|
|
1197
|
-
|
|
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
|
|
1413
|
+
"Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
|
|
1207
1414
|
signalLine,
|
|
1208
1415
|
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.",
|
|
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
|
|
1429
|
+
? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
|
|
1227
1430
|
: null;
|
|
1228
1431
|
return [
|
|
1229
|
-
"Before
|
|
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
|
-
"
|
|
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.",
|
|
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
|
|
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
|
|
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
|
-
|
|
3218
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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
|
-
|
|
3336
|
-
if (!existsSync(path)) {
|
|
4339
|
+
if (!shared.present) {
|
|
3337
4340
|
return { apiUrl: DEFAULT_API_URL, source: "default" };
|
|
3338
4341
|
}
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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) {
|