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