@remembrance-ai/opencode-plugin 0.1.64 → 0.1.71

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.
@@ -54,13 +54,15 @@ const MAX_SUMMARY_CHARS = 1200;
54
54
  const MAX_CONTEXT_CHARS = 4000;
55
55
  const MAX_CONTEXT_FIELD_CHARS = 280;
56
56
  const MAX_DIRECTIVE_CHARS = 900;
57
+ const MAX_PREFERENCE_CONTEXT_CHARS = 520;
58
+ const MAX_MANDATORY_PREFERENCE_CONTEXT_CHARS = 1200;
57
59
  const VALUE_EPISODE_MARKER_LIMIT = 20;
58
60
  const VALUE_EPISODE_MARKER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
59
61
  const DIRECT_SELECTION_MARKER_LIMIT = 20;
60
62
  const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
61
63
  const PREFERENCE_EVIDENCE_SKILL_LIMIT = 2;
62
64
  const PREFERENCE_EVIDENCE_SETTING_LIMIT = 4;
63
- const PREFERENCE_EVIDENCE_CONTEXT_CHARS = 760;
65
+ const PREFERENCE_EVIDENCE_CONTEXT_CHARS = 520;
64
66
  const EFFECTIVE_PREFERENCE_SOURCES = new Set([
65
67
  "mandatory_org",
66
68
  "explicit_task",
@@ -89,6 +91,7 @@ const MAX_LOCAL_CONFIG_BYTES = 64 * 1024;
89
91
  const MAX_LOCAL_HEALTH_MARKER_BYTES = 16 * 1024;
90
92
  const MAX_LOCAL_PLUGIN_ALERT_BYTES = 16 * 1024;
91
93
  const MAX_LOCAL_CLIENT_UPDATE_BYTES = 16 * 1024;
94
+ const MAX_PRIVATE_LESSON_ENVELOPE_BYTES = 64 * 1024;
92
95
  const PLUGIN_ALERT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
93
96
  const PLUGIN_ALERT_LIMIT = 32;
94
97
  const CLIENT_UPDATE_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -98,6 +101,10 @@ const MAX_LOCAL_PRINCIPAL_SESSION_BYTES = 16 * 1024;
98
101
  const principalSessionWarmups = new Map();
99
102
  export const HOST_POLICY_ALERT_TEXT =
100
103
  "Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.";
104
+ export const PRIVATE_LESSON_HOST_POLICY_ALERT_TEXT =
105
+ "Remembrance was blocked by host policy before reaching Remembrance. No lesson or candidate content was sent. The private draft remains on this device and will be submitted after authorization. Querying remains available.";
106
+ export const PRIVATE_LESSON_AUTHORIZATION_REQUEST_TEXT =
107
+ "Allow Remembrance to submit this retained private lesson to your organization?";
101
108
  const PLUGIN_HEALTH_COMPONENTS = new Set([
102
109
  "session_start",
103
110
  "prompt_hook",
@@ -869,14 +876,10 @@ export function formatContext(
869
876
  "Organization policy: use organization skills only. Do not use public skills or bundled public skill references, including as an offline fallback.",
870
877
  );
871
878
  }
872
- const effectivePreferences = sanitizeEffectivePreferences(
879
+ const effectivePreferenceLines = effectivePreferencesContextLines(
873
880
  body?.effective_preferences,
874
881
  );
875
- if (effectivePreferences.length > 0) {
876
- lines.push(
877
- `Apply these persisted working preferences silently: ${effectivePreferences.join("; ")}. Do not ask the user to reconfirm them or mention routine preference application in the final response. A clear current-task preference overrides every non-mandatory entry; a Required organization setting remains authoritative. Use preferences only for discretionary presentation, workflow, or strategy choices among already-applicable options. Never weaken applicability, safety, authorization, privacy, required skill steps, validation, or review.`,
878
- );
879
- }
882
+ lines.push(...effectivePreferenceLines);
880
883
  const queryId = body?.query_feedback?.query_id ?? body?.query_id;
881
884
  const highMatch = highMatchFromResponse(body);
882
885
  if (queryId) {
@@ -1381,6 +1384,13 @@ export function emptyQueryContext(reason = "trigger_match") {
1381
1384
  // the current credential is allowed to use.
1382
1385
  export async function runPromptHook(prompt, options = {}) {
1383
1386
  const env = options.env ?? process.env;
1387
+ // This is the master switch for prompt-hook automation. Check it before
1388
+ // preference detection, session warming, or any network-capable work.
1389
+ if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
1390
+ debugLog(env, "disabled", {}, options);
1391
+ return null;
1392
+ }
1393
+ const privateLessonRecovery = privateLessonLifecycleRecoveryInstruction(env);
1384
1394
  const includeSharedConfigCredentialNotice =
1385
1395
  options.includeSharedConfigCredentialNotice !== false;
1386
1396
  const redacted = redactPrompt(String(prompt ?? ""));
@@ -1395,17 +1405,6 @@ export async function runPromptHook(prompt, options = {}) {
1395
1405
  userAgent: options.userAgent,
1396
1406
  projectPath: options.projectPath,
1397
1407
  }).catch(() => 0);
1398
- if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
1399
- debugLog(env, "disabled", {}, options);
1400
- return preferenceCapture
1401
- ? {
1402
- consumed: false,
1403
- context: preferenceCapture,
1404
- eligible: false,
1405
- reason: "preference_capture",
1406
- }
1407
- : null;
1408
- }
1409
1408
  const decision = shouldQueryPrompt(redacted);
1410
1409
  if (!decision.likely_match) {
1411
1410
  if (isContextualContinuationPrompt(redacted)) {
@@ -1420,7 +1419,10 @@ export async function runPromptHook(prompt, options = {}) {
1420
1419
  return {
1421
1420
  consumed: false,
1422
1421
  context: appendPreferenceCapture(
1423
- continuationQueryContext(directive),
1422
+ appendPrivateLessonLifecycleRecovery(
1423
+ continuationQueryContext(directive),
1424
+ privateLessonRecovery,
1425
+ ),
1424
1426
  preferenceCapture,
1425
1427
  ),
1426
1428
  directive,
@@ -1431,11 +1433,22 @@ export async function runPromptHook(prompt, options = {}) {
1431
1433
  if (preferenceCapture) {
1432
1434
  return {
1433
1435
  consumed: false,
1434
- context: preferenceCapture,
1436
+ context: appendPrivateLessonLifecycleRecovery(
1437
+ preferenceCapture,
1438
+ privateLessonRecovery,
1439
+ ),
1435
1440
  eligible: false,
1436
1441
  reason: "preference_capture",
1437
1442
  };
1438
1443
  }
1444
+ if (privateLessonRecovery) {
1445
+ return {
1446
+ consumed: false,
1447
+ context: privateLessonRecovery,
1448
+ eligible: false,
1449
+ reason: "private_lesson_lifecycle_recovery",
1450
+ };
1451
+ }
1439
1452
  debugLog(env, "skip", { reason: decision.reason }, options);
1440
1453
  return null;
1441
1454
  }
@@ -1460,9 +1473,15 @@ export async function runPromptHook(prompt, options = {}) {
1460
1473
  return {
1461
1474
  consumed: false,
1462
1475
  context: appendPreferenceCapture(
1463
- includeSharedConfigCredentialNotice
1464
- ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
1465
- : unavailableQueryContext(env),
1476
+ appendPrivateLessonLifecycleRecovery(
1477
+ includeSharedConfigCredentialNotice
1478
+ ? withSharedConfigCredentialNotice(
1479
+ unavailableQueryContext(env),
1480
+ env,
1481
+ )
1482
+ : unavailableQueryContext(env),
1483
+ privateLessonRecovery,
1484
+ ),
1466
1485
  preferenceCapture,
1467
1486
  ),
1468
1487
  eligible: true,
@@ -1473,14 +1492,17 @@ export async function runPromptHook(prompt, options = {}) {
1473
1492
  consumed: true,
1474
1493
  matched: queryResponseHasMatches(response),
1475
1494
  context: appendPreferenceCapture(
1476
- includeSharedConfigCredentialNotice
1477
- ? withSharedConfigCredentialNotice(
1478
- formatContext(response, decision.reason, limitFromEnv(env)) ??
1479
- emptyQueryContext(decision.reason),
1480
- env,
1481
- )
1482
- : (formatContext(response, decision.reason, limitFromEnv(env)) ??
1483
- emptyQueryContext(decision.reason)),
1495
+ appendPrivateLessonLifecycleRecovery(
1496
+ includeSharedConfigCredentialNotice
1497
+ ? withSharedConfigCredentialNotice(
1498
+ formatContext(response, decision.reason, limitFromEnv(env)) ??
1499
+ emptyQueryContext(decision.reason),
1500
+ env,
1501
+ )
1502
+ : (formatContext(response, decision.reason, limitFromEnv(env)) ??
1503
+ emptyQueryContext(decision.reason)),
1504
+ privateLessonRecovery,
1505
+ ),
1484
1506
  preferenceCapture,
1485
1507
  ),
1486
1508
  eligible: true,
@@ -1494,6 +1516,10 @@ function appendPreferenceCapture(context, directive) {
1494
1516
  return directive ? `${context}\n\n${directive}` : context;
1495
1517
  }
1496
1518
 
1519
+ function appendPrivateLessonLifecycleRecovery(context, instruction) {
1520
+ return instruction ? `${context}\n\n${instruction}` : context;
1521
+ }
1522
+
1497
1523
  export function queryResponseHasMatches(response) {
1498
1524
  const body = responseBodyFromToolResponse(response);
1499
1525
  const skills = Array.isArray(body?.skills)
@@ -1529,6 +1555,7 @@ const TASK_ELIGIBILITY_MARKERS =
1529
1555
 
1530
1556
  const CONTRIBUTION_SUBMITTED_PATTERNS = [
1531
1557
  /\brpub_[a-f0-9]{16,}\b/i,
1558
+ /\baccepted_private_candidate\b/i,
1532
1559
  /\bsubmitted (?:it|this|the lesson|the failure|the remembrance) (?:to|as) Remembrance\b/i,
1533
1560
  /\bsubmit_remembrance\b/i,
1534
1561
  /\bREMEMBRANCE_SUBMISSION_PAYLOAD\b/,
@@ -1575,6 +1602,7 @@ export function contributionReason(
1575
1602
  signal = null,
1576
1603
  highMatch = null,
1577
1604
  directSelection = null,
1605
+ options = {},
1578
1606
  ) {
1579
1607
  const signalLine = signal
1580
1608
  ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
@@ -1589,6 +1617,12 @@ export function contributionReason(
1589
1617
  ? [directSelection]
1590
1618
  : []
1591
1619
  ).slice(0, DIRECT_SELECTION_MARKER_LIMIT);
1620
+ const privateLessonGuidance = options.privateLessonAuthorized
1621
+ ? privateLessonCompletionGuidance({
1622
+ includeLifecycleRecovery: options.includeLifecycleRecovery !== false,
1623
+ env: options.env,
1624
+ })
1625
+ : null;
1592
1626
  if (directSelections.length > 0) {
1593
1627
  const preferenceEvidenceLine =
1594
1628
  directPreferenceCompatibilityInstruction(directSelections);
@@ -1609,6 +1643,7 @@ export function contributionReason(
1609
1643
  "Call submit_feedback once for each listed skill with useful true or false and a concise post-use lesson.",
1610
1644
  preferenceEvidenceLine,
1611
1645
  "If submit_feedback returns next_step.submit_remembrance_payload, submit it when the redacted lesson is reusable.",
1646
+ privateLessonGuidance,
1612
1647
  routineContributionVisibilityInstruction(),
1613
1648
  ]
1614
1649
  .filter(Boolean)
@@ -1618,7 +1653,10 @@ export function contributionReason(
1618
1653
  "Before finishing, silently close the pending Remembrance loop using this session's query/result IDs.",
1619
1654
  signalLine,
1620
1655
  highMatchLine,
1621
- "Call submit_query_feedback for pending query fit, submit_feedback only for used results, and submit_remembrance only for a reusable redacted lesson. If no skill fit and you built a reusable workflow, default to propose_private_skill; use propose_skill_idea only when a public candidate is explicitly appropriate. A supplied invalid/inactive key fails with 401 and an insufficient key with 403; neither creates a candidate.",
1656
+ options.privateLessonAuthorized
1657
+ ? "Call submit_query_feedback for pending query fit and submit_feedback only for used results. Honor an exact next_step.submit_remembrance_payload returned by submit_feedback; route a compact organization evidence lesson through the private lesson lane below. If no skill fit and you built a complete reusable skill or playbook, default to propose_private_skill; use propose_skill_idea only when a public candidate is explicitly appropriate."
1658
+ : "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 complete reusable skill or playbook, 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.",
1659
+ privateLessonGuidance,
1622
1660
  routineContributionVisibilityInstruction(),
1623
1661
  ]
1624
1662
  .filter(Boolean)
@@ -1651,10 +1689,30 @@ function directPreferenceCompatibilityInstruction(selections) {
1651
1689
  }
1652
1690
 
1653
1691
  function routineContributionVisibilityInstruction() {
1654
- return `Use Remembrance MCP tools when available; otherwise use this session's REST fallback, or emit REMEMBRANCE_SUBMISSION_PAYLOAD only if the API is unavailable. Never remove or bypass an organization key to force a public candidate. Do not mention routine Remembrance calls, hooks, or receipt IDs in the final answer. Surface only failures, host blocks, or required action. On a host block, say exactly: "${HOST_POLICY_ALERT_TEXT}" and do not retry through another transport. Redact secrets and proprietary content.`;
1692
+ 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. For a host block unrelated to a private-lesson submit/retry action, say exactly: "${HOST_POLICY_ALERT_TEXT}" and do not retry through another transport. Redact secrets and proprietary content.`;
1693
+ }
1694
+
1695
+ export function privateLessonCompletionGuidance({
1696
+ includeLifecycleRecovery = true,
1697
+ env = process.env,
1698
+ } = {}) {
1699
+ const recovery = includeLifecycleRecovery
1700
+ ? privateLessonLifecycleRecoveryInstruction(env)
1701
+ : null;
1702
+ return [
1703
+ "For a new compact failure, correction, or evidence lesson that is not already a complete skill, silently generalize it without raw repository content, then call prepare_private_lesson_candidate. If it returns next_action, immediately call submit_private_lesson_candidate with exactly its draft_id; do not substitute submit_remembrance, REST, or another transport.",
1704
+ "If the work produced a complete reusable procedure, playbook, or actionable instructions, follow the separate skill-proposal guidance instead; do not compress a finished skill into the private lesson contract.",
1705
+ "Let the host's exact submit_private_lesson_candidate approval be the only authorization prompt. If the host cannot present it, ask once: \"" +
1706
+ PRIVATE_LESSON_AUTHORIZATION_REQUEST_TEXT +
1707
+ '\"',
1708
+ `If the host denies the action, say exactly: "${PRIVATE_LESSON_HOST_POLICY_ALERT_TEXT}" Do not retry that draft through another transport. Never auto-delete a draft.`,
1709
+ recovery,
1710
+ ]
1711
+ .filter(Boolean)
1712
+ .join("\n");
1655
1713
  }
1656
1714
 
1657
- export function taskClosureReason(signal = null) {
1715
+ export function taskClosureReason(signal = null, options = {}) {
1658
1716
  const signalLine = signal
1659
1717
  ? `High-value lesson detected: ${signal}. Capture it unless it was already submitted.`
1660
1718
  : null;
@@ -1662,6 +1720,9 @@ export function taskClosureReason(signal = null) {
1662
1720
  "Before finishing, silently recover the pending Remembrance query for this reusable task using the full conversation; no completed Remembrance query/use was recorded.",
1663
1721
  signalLine,
1664
1722
  "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.",
1723
+ options.privateLessonAuthorized
1724
+ ? privateLessonCompletionGuidance({ env: options.env })
1725
+ : null,
1665
1726
  routineContributionVisibilityInstruction(),
1666
1727
  ]
1667
1728
  .filter(Boolean)
@@ -1702,7 +1763,10 @@ export function hostPolicyAlertWasReported(input) {
1702
1763
  input?.message ??
1703
1764
  "",
1704
1765
  );
1705
- return value.includes(HOST_POLICY_ALERT_TEXT);
1766
+ return (
1767
+ value.includes(HOST_POLICY_ALERT_TEXT) ||
1768
+ value.includes(PRIVATE_LESSON_HOST_POLICY_ALERT_TEXT)
1769
+ );
1706
1770
  }
1707
1771
 
1708
1772
  // Pure decision function (unit-tested): compare completed registry use and task
@@ -1745,19 +1809,36 @@ export function decideStop(input, options = {}) {
1745
1809
  const hasUnclosedEligibility =
1746
1810
  eligibilityCount > useCount && newDirectSelections.length === 0;
1747
1811
  const highValueSignal = detectHighValueLessonSignal(input);
1748
- if (newDirectSelections.length > 0 && feedbackDirectSelections.length === 0) {
1812
+ const privateLessonAuthorized = organizationPrivateLessonAuthorized(env);
1813
+ const privateLessonRecovery = privateLessonAuthorized
1814
+ ? privateLessonLifecycleRecoveryInstruction(env)
1815
+ : null;
1816
+ if (
1817
+ newDirectSelections.length > 0 &&
1818
+ feedbackDirectSelections.length === 0 &&
1819
+ !privateLessonRecovery
1820
+ ) {
1749
1821
  return { allow: true, why: "direct_feedback_unavailable" };
1750
1822
  }
1751
- if (engagementCount === 0 && !highValueSignal) {
1823
+ if (engagementCount === 0 && !highValueSignal && !privateLessonRecovery) {
1752
1824
  return { allow: true, why: "registry_not_used" };
1753
1825
  }
1754
1826
  if (
1827
+ privateLessonRecovery &&
1828
+ promptedCount > 0 &&
1755
1829
  engagementCount <= promptedCount &&
1756
- newDirectSelections.length === 0 &&
1757
1830
  !highValueSignal
1758
1831
  ) {
1759
1832
  return { allow: true, why: "no_new_usage" };
1760
1833
  }
1834
+ if (
1835
+ engagementCount <= promptedCount &&
1836
+ newDirectSelections.length === 0 &&
1837
+ !highValueSignal &&
1838
+ !privateLessonRecovery
1839
+ ) {
1840
+ return { allow: true, why: "no_new_usage" };
1841
+ }
1761
1842
  if (
1762
1843
  highValueSignal &&
1763
1844
  promptedCount > 0 &&
@@ -1771,17 +1852,36 @@ export function decideStop(input, options = {}) {
1771
1852
  ? "prompt_high_value_lesson_contribution"
1772
1853
  : hasUnclosedEligibility
1773
1854
  ? "prompt_task_closure"
1774
- : "prompt_contribution",
1775
- reason: hasUnclosedEligibility
1776
- ? taskClosureReason(highValueSignal)
1777
- : contributionReason(
1778
- highValueSignal,
1779
- highMatch,
1780
- feedbackDirectSelections.length > 0 ? feedbackDirectSelections : null,
1781
- ),
1782
- useCount: highValueSignal
1783
- ? Math.max(engagementCount, promptedCount + 1, 1)
1784
- : engagementCount,
1855
+ : privateLessonRecovery && engagementCount === 0
1856
+ ? "prompt_private_lesson_retry"
1857
+ : "prompt_contribution",
1858
+ reason:
1859
+ privateLessonRecovery && engagementCount === 0 && !highValueSignal
1860
+ ? [
1861
+ privateLessonRecovery,
1862
+ routineContributionVisibilityInstruction(),
1863
+ ].join("\n")
1864
+ : hasUnclosedEligibility
1865
+ ? taskClosureReason(highValueSignal, {
1866
+ env,
1867
+ privateLessonAuthorized,
1868
+ })
1869
+ : contributionReason(
1870
+ highValueSignal,
1871
+ highMatch,
1872
+ feedbackDirectSelections.length > 0
1873
+ ? feedbackDirectSelections
1874
+ : null,
1875
+ {
1876
+ env,
1877
+ privateLessonAuthorized,
1878
+ includeLifecycleRecovery: true,
1879
+ },
1880
+ ),
1881
+ useCount:
1882
+ highValueSignal || privateLessonRecovery
1883
+ ? Math.max(engagementCount, promptedCount + 1, 1)
1884
+ : engagementCount,
1785
1885
  };
1786
1886
  }
1787
1887
 
@@ -2134,6 +2234,8 @@ export function toolResponseIndicatesFailure(value, depth = 0) {
2134
2234
  value.ok === false ||
2135
2235
  value.isError === true ||
2136
2236
  value.is_error === true ||
2237
+ value.status === "not_submitted" ||
2238
+ value.status === "held_safety" ||
2137
2239
  (typeof value.status === "number" && value.status >= 400)
2138
2240
  ) {
2139
2241
  return true;
@@ -2486,6 +2588,16 @@ export function clearHighMatchSurfaceIfOpened(
2486
2588
  const normalizedTool = String(toolName ?? "")
2487
2589
  .trim()
2488
2590
  .toLowerCase();
2591
+ if (
2592
+ normalizedTool.endsWith("submit_private_lesson_candidate") ||
2593
+ normalizedTool.endsWith("retry_private_lesson_candidate")
2594
+ ) {
2595
+ // The PostToolUse adapter calls this function only after a successful tool
2596
+ // response. Clear the content-free denial suppression so future retained
2597
+ // drafts can resume normal lifecycle recovery after exact-action approval.
2598
+ clearPrivateLessonHostPolicyDenials(env);
2599
+ return false;
2600
+ }
2489
2601
  const targetType = normalizedTool.endsWith("get_resource")
2490
2602
  ? "resource"
2491
2603
  : normalizedTool.endsWith("get_skill")
@@ -2903,6 +3015,125 @@ export function contributeDisabled(value) {
2903
3015
  return /^(0|false|no)$/i.test(String(value ?? "").trim());
2904
3016
  }
2905
3017
 
3018
+ function organizationPrivateLessonAuthorized(env = process.env) {
3019
+ if (contributeDisabled(env?.REMEMBRANCE_AUTO_CONTRIBUTE)) return false;
3020
+ const credential = resolveApiCredential(env);
3021
+ return Boolean(
3022
+ credential.apiKey && !isUnusableConfigurationSource(credential.source),
3023
+ );
3024
+ }
3025
+
3026
+ function privateLessonOutboxDirectoryForHook(env = process.env) {
3027
+ const stateRoot = String(env?.XDG_STATE_HOME ?? "").trim();
3028
+ return join(
3029
+ stateRoot || join(homedir(), ".local", "state"),
3030
+ "remembrance",
3031
+ "private-lessons",
3032
+ );
3033
+ }
3034
+
3035
+ export function readPrivateLessonLifecycleState(env = process.env) {
3036
+ if (!organizationPrivateLessonAuthorized(env)) return null;
3037
+ const directory = privateLessonOutboxDirectoryForHook(env);
3038
+ try {
3039
+ const directoryMetadata = lstatSync(directory);
3040
+ if (
3041
+ directoryMetadata.isSymbolicLink() ||
3042
+ !directoryMetadata.isDirectory() ||
3043
+ (isPosixHookRuntime() && (directoryMetadata.mode & 0o077) !== 0)
3044
+ ) {
3045
+ return null;
3046
+ }
3047
+ assertCurrentHookUser(directoryMetadata.uid);
3048
+ const entries = readdirSync(directory, { withFileTypes: true });
3049
+ const counts = {
3050
+ ready: 0,
3051
+ retry_scheduled: 0,
3052
+ hold_telemetry_pending: 0,
3053
+ hold_telemetry_retry_scheduled: 0,
3054
+ };
3055
+ for (const entry of entries) {
3056
+ if (!entry.name.endsWith(".lesson.json")) continue;
3057
+ if (entry.isSymbolicLink() || !entry.isFile()) return null;
3058
+ const expectedDraftId = entry.name.slice(0, -".lesson.json".length);
3059
+ if (!/^pld_[A-Za-z0-9_-]{8,96}$/.test(expectedDraftId)) return null;
3060
+ const parsed = JSON.parse(
3061
+ readSecureHookFile(
3062
+ join(directory, entry.name),
3063
+ MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
3064
+ ),
3065
+ );
3066
+ if (
3067
+ parsed?.version !== "private-lesson-outbox-envelope-v1" ||
3068
+ parsed?.draft_id !== expectedDraftId ||
3069
+ ![
3070
+ "ready",
3071
+ "awaiting_authorization",
3072
+ "retry_scheduled",
3073
+ "held_safety",
3074
+ "submitted",
3075
+ "superseded_redactor",
3076
+ ].includes(parsed?.state) ||
3077
+ ![
3078
+ "not_required",
3079
+ "pending",
3080
+ "awaiting_authorization",
3081
+ "retry_scheduled",
3082
+ "submitted",
3083
+ "disabled",
3084
+ ].includes(parsed?.hold_telemetry_status)
3085
+ ) {
3086
+ return null;
3087
+ }
3088
+ if (parsed.state === "ready" || parsed.state === "retry_scheduled") {
3089
+ counts[parsed.state] += 1;
3090
+ }
3091
+ if (
3092
+ privateLessonHealthReportingEnabledForHook(env) &&
3093
+ parsed.state === "held_safety" &&
3094
+ parsed.hold_telemetry_status === "pending"
3095
+ ) {
3096
+ counts.hold_telemetry_pending += 1;
3097
+ }
3098
+ if (
3099
+ privateLessonHealthReportingEnabledForHook(env) &&
3100
+ parsed.state === "held_safety" &&
3101
+ parsed.hold_telemetry_status === "retry_scheduled"
3102
+ ) {
3103
+ counts.hold_telemetry_retry_scheduled += 1;
3104
+ }
3105
+ }
3106
+ if (Object.values(counts).every((count) => count === 0)) return null;
3107
+ return {
3108
+ ...counts,
3109
+ host_denial_active: recentPrivateLessonHostDenial(env),
3110
+ };
3111
+ } catch {
3112
+ return null;
3113
+ }
3114
+ }
3115
+
3116
+ export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
3117
+ const state = readPrivateLessonLifecycleState(env);
3118
+ if (!state || state.host_denial_active) return null;
3119
+ const pending =
3120
+ state.ready +
3121
+ state.retry_scheduled +
3122
+ state.hold_telemetry_pending +
3123
+ state.hold_telemetry_retry_scheduled;
3124
+ return [
3125
+ `Private lesson lifecycle recovery: ${pending} retained local draft${pending === 1 ? " needs" : "s need"} attention. Silently call inspect_private_lesson_outbox.`,
3126
+ "Call submit_private_lesson_candidate for each ready candidate or safety-held draft with pending content-free telemetry. Call retry_private_lesson_candidate only when the candidate or telemetry retry is due.",
3127
+ "Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete a draft automatically. Hide routine results.",
3128
+ ].join("\n");
3129
+ }
3130
+
3131
+ function privateLessonHealthReportingEnabledForHook(env = process.env) {
3132
+ return !/^(0|false|no)$/i.test(
3133
+ String(env?.REMEMBRANCE_HEALTH_REPORTING ?? "").trim(),
3134
+ );
3135
+ }
3136
+
2906
3137
  function pluginHealthDir(env = process.env) {
2907
3138
  if (env?.REMEMBRANCE_PLUGIN_HEALTH_DIR) {
2908
3139
  return String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR);
@@ -3078,6 +3309,7 @@ async function registerHookIdentity(identity, fetchImpl, access, signal) {
3078
3309
  // Registration remains fail-open; an existing same-scope key can still use
3079
3310
  // the legacy proof while new organization claims require the binding.
3080
3311
  }
3312
+ if (signal.aborted) return false;
3081
3313
  const signedAt = new Date().toISOString();
3082
3314
  const publicKeyHash = `sha256:${createHash("sha256")
3083
3315
  .update(identity.public_key)
@@ -3118,7 +3350,7 @@ async function registerHookIdentity(identity, fetchImpl, access, signal) {
3118
3350
  signal,
3119
3351
  },
3120
3352
  );
3121
- return response?.ok === true;
3353
+ return !signal.aborted && response?.ok === true;
3122
3354
  }
3123
3355
 
3124
3356
  function principalSessionCachePath(
@@ -3287,90 +3519,101 @@ async function warmPrincipalSessionUncached(
3287
3519
  env,
3288
3520
  ) {
3289
3521
  const controller = new AbortController();
3290
- const timeout = setTimeout(
3291
- () => controller.abort(),
3292
- PRINCIPAL_SESSION_TIMEOUT_MS,
3293
- );
3294
- try {
3295
- if (
3296
- !(await registerHookIdentity(
3297
- identity,
3522
+ const operation = (async () => {
3523
+ try {
3524
+ if (
3525
+ !(await registerHookIdentity(
3526
+ identity,
3527
+ fetchImpl,
3528
+ apiAccess,
3529
+ controller.signal,
3530
+ ))
3531
+ ) {
3532
+ return null;
3533
+ }
3534
+ if (controller.signal.aborted) return null;
3535
+ const memberLinkToken = apiAccess.memberLinkToken;
3536
+ const profileKey = createHash("sha256")
3537
+ .update(
3538
+ [
3539
+ identity.key_id,
3540
+ normalizedRuntime,
3541
+ normalizedHostSurface,
3542
+ "plugin_hook",
3543
+ ].join(":"),
3544
+ "utf8",
3545
+ )
3546
+ .digest("base64url");
3547
+ const challengePayload = {
3548
+ action: "challenge",
3549
+ provider: identity.provider,
3550
+ key_id: identity.key_id,
3551
+ runtime_profile: {
3552
+ runtime: normalizedRuntime,
3553
+ surface: "plugin_hook",
3554
+ host_surface: normalizedHostSurface,
3555
+ client_name: runtimeDisplayName(normalizedRuntime),
3556
+ client_version: safeText(clientVersion ?? "unknown", 64),
3557
+ runtime_version: safeText(hostVersion ?? "", 64) || null,
3558
+ profile_key: `rpf_${profileKey}`,
3559
+ },
3560
+ ...(memberLinkToken ? { member_link_token: memberLinkToken } : {}),
3561
+ };
3562
+ let challenge = await postPrincipalSession(
3563
+ challengePayload,
3298
3564
  fetchImpl,
3299
3565
  apiAccess,
3300
3566
  controller.signal,
3301
- ))
3302
- ) {
3303
- return null;
3304
- }
3305
- const memberLinkToken = apiAccess.memberLinkToken;
3306
- const profileKey = createHash("sha256")
3307
- .update(
3308
- [
3309
- identity.key_id,
3310
- normalizedRuntime,
3311
- normalizedHostSurface,
3312
- "plugin_hook",
3313
- ].join(":"),
3314
- "utf8",
3315
- )
3316
- .digest("base64url");
3317
- const challengePayload = {
3318
- action: "challenge",
3319
- provider: identity.provider,
3320
- key_id: identity.key_id,
3321
- runtime_profile: {
3322
- runtime: normalizedRuntime,
3323
- surface: "plugin_hook",
3324
- host_surface: normalizedHostSurface,
3325
- client_name: runtimeDisplayName(normalizedRuntime),
3326
- client_version: safeText(clientVersion ?? "unknown", 64),
3327
- runtime_version: safeText(hostVersion ?? "", 64) || null,
3328
- profile_key: `rpf_${profileKey}`,
3329
- },
3330
- ...(memberLinkToken ? { member_link_token: memberLinkToken } : {}),
3331
- };
3332
- let challenge = await postPrincipalSession(
3333
- challengePayload,
3334
- fetchImpl,
3335
- apiAccess,
3336
- controller.signal,
3337
- );
3338
- if (!challenge && memberLinkToken) {
3339
- challenge = await postPrincipalSession(
3340
- { ...challengePayload, member_link_token: undefined },
3567
+ );
3568
+ if (!challenge && memberLinkToken) {
3569
+ challenge = await postPrincipalSession(
3570
+ { ...challengePayload, member_link_token: undefined },
3571
+ fetchImpl,
3572
+ apiAccess,
3573
+ controller.signal,
3574
+ );
3575
+ }
3576
+ if (controller.signal.aborted) return null;
3577
+ if (!challenge?.challenge_id || !challenge?.signing_payload) return null;
3578
+ const signature = signPayload(
3579
+ null,
3580
+ Buffer.from(challenge.signing_payload),
3581
+ createPrivateKey(identity.private_key),
3582
+ ).toString("base64url");
3583
+ const session = await postPrincipalSession(
3584
+ {
3585
+ action: "exchange",
3586
+ provider: identity.provider,
3587
+ key_id: identity.key_id,
3588
+ challenge_id: challenge.challenge_id,
3589
+ signature,
3590
+ },
3341
3591
  fetchImpl,
3342
3592
  apiAccess,
3343
3593
  controller.signal,
3344
3594
  );
3595
+ if (controller.signal.aborted) return null;
3596
+ if (!session?.session_token || !session?.expires_at) return null;
3597
+ const stored = {
3598
+ token: session.session_token,
3599
+ expires_at: session.expires_at,
3600
+ member_linked: session.member_linked === true,
3601
+ };
3602
+ writeHookPrincipalSession(warmupKey, stored);
3603
+ return stored;
3604
+ } catch {
3605
+ return null;
3345
3606
  }
3346
- if (!challenge?.challenge_id || !challenge?.signing_payload) return null;
3347
- const signature = signPayload(
3348
- null,
3349
- Buffer.from(challenge.signing_payload),
3350
- createPrivateKey(identity.private_key),
3351
- ).toString("base64url");
3352
- const session = await postPrincipalSession(
3353
- {
3354
- action: "exchange",
3355
- provider: identity.provider,
3356
- key_id: identity.key_id,
3357
- challenge_id: challenge.challenge_id,
3358
- signature,
3359
- },
3360
- fetchImpl,
3361
- apiAccess,
3362
- controller.signal,
3363
- );
3364
- if (!session?.session_token || !session?.expires_at) return null;
3365
- const stored = {
3366
- token: session.session_token,
3367
- expires_at: session.expires_at,
3368
- member_linked: session.member_linked === true,
3369
- };
3370
- writeHookPrincipalSession(warmupKey, stored);
3371
- return stored;
3372
- } catch {
3373
- return null;
3607
+ })();
3608
+ let timeout;
3609
+ const deadline = new Promise((resolve) => {
3610
+ timeout = setTimeout(() => {
3611
+ controller.abort();
3612
+ resolve(null);
3613
+ }, PRINCIPAL_SESSION_TIMEOUT_MS);
3614
+ });
3615
+ try {
3616
+ return await Promise.race([operation, deadline]);
3374
3617
  } finally {
3375
3618
  clearTimeout(timeout);
3376
3619
  }
@@ -3435,6 +3678,10 @@ function runtimeDisplayName(runtime) {
3435
3678
  }
3436
3679
 
3437
3680
  function sanitizeEffectivePreferences(value) {
3681
+ return sanitizeEffectivePreferenceEntries(value).map((item) => item.text);
3682
+ }
3683
+
3684
+ function sanitizeEffectivePreferenceEntries(value) {
3438
3685
  if (!Array.isArray(value)) return [];
3439
3686
  return value
3440
3687
  .filter(
@@ -3459,10 +3706,85 @@ function sanitizeEffectivePreferences(value) {
3459
3706
  const direction = item.strength === "avoid" ? "avoid" : "prefer";
3460
3707
  const authority =
3461
3708
  item.source === "mandatory_org" ? "required organization" : item.source;
3462
- return `${label} [${safeText(item.effect ?? "presentation", 24)}, ${safeText(authority ?? "preference", 32)}]: ${direction} ${behavior}`;
3709
+ return {
3710
+ mandatory: item.source === "mandatory_org",
3711
+ key: safeText(item.key, 48),
3712
+ value: safeText(item.value, 48),
3713
+ behavior,
3714
+ text: `${label} [${safeText(item.effect ?? "presentation", 24)}, ${safeText(authority ?? "preference", 32)}]: ${direction} ${behavior}`,
3715
+ };
3463
3716
  });
3464
3717
  }
3465
3718
 
3719
+ function effectivePreferencesContextLines(value) {
3720
+ const entries = sanitizeEffectivePreferenceEntries(value).sort(
3721
+ (left, right) => Number(right.mandatory) - Number(left.mandatory),
3722
+ );
3723
+ if (entries.length === 0) return [];
3724
+ const mandatory = entries.filter((entry) => entry.mandatory);
3725
+ const discretionary = entries.filter((entry) => !entry.mandatory);
3726
+ const mandatoryLine = mandatoryPreferenceContextLine(mandatory);
3727
+ const discretionaryLine = discretionaryPreferenceContextLine(discretionary);
3728
+ return [mandatoryLine, discretionaryLine].filter(Boolean);
3729
+ }
3730
+
3731
+ function mandatoryPreferenceContextLine(entries) {
3732
+ if (entries.length === 0) return null;
3733
+ const prefix = "Required organization settings (apply all): ";
3734
+ const suffix =
3735
+ ". These remain authoritative and cannot be weakened by task or personal preferences.";
3736
+ const separators = Math.max(0, entries.length - 1) * 2;
3737
+ const perEntryBudget = Math.max(
3738
+ 48,
3739
+ Math.floor(
3740
+ (MAX_MANDATORY_PREFERENCE_CONTEXT_CHARS -
3741
+ prefix.length -
3742
+ suffix.length -
3743
+ separators) /
3744
+ entries.length,
3745
+ ),
3746
+ );
3747
+ const body = entries
3748
+ .map((entry) =>
3749
+ safeText(
3750
+ `${entry.key}=${entry.value}: ${entry.behavior}`,
3751
+ perEntryBudget,
3752
+ ),
3753
+ )
3754
+ .join("; ");
3755
+ return `${prefix}${body}${suffix}`;
3756
+ }
3757
+
3758
+ function discretionaryPreferenceContextLine(entries) {
3759
+ if (entries.length === 0) return null;
3760
+ const prefix = "Apply these persisted discretionary preferences silently: ";
3761
+ const suffix =
3762
+ ". Do not ask the user to reconfirm them. Current-task instructions may override these preferences. Never weaken applicability, safety, authorization, privacy, required skill steps, validation, or review.";
3763
+ const budget = Math.max(
3764
+ 0,
3765
+ MAX_PREFERENCE_CONTEXT_CHARS - prefix.length - suffix.length,
3766
+ );
3767
+ const selected = [];
3768
+ let used = 0;
3769
+ for (const entry of entries) {
3770
+ const separator = selected.length > 0 ? "; " : "";
3771
+ if (used + separator.length + entry.text.length > budget) break;
3772
+ selected.push(entry);
3773
+ used += separator.length + entry.text.length;
3774
+ }
3775
+ const omitted = entries.slice(selected.length);
3776
+ let body = selected.map((entry) => entry.text).join("; ");
3777
+ if (omitted.length > 0) {
3778
+ const note = `; +${omitted.length} discretionary preference${omitted.length === 1 ? "" : "s"} omitted. Call get_effective_preferences when the omitted choices could affect the task`;
3779
+ while (selected.length > 0 && body.length + note.length > budget) {
3780
+ selected.pop();
3781
+ body = selected.map((entry) => entry.text).join("; ");
3782
+ }
3783
+ body = `${body || "Preferences available"}${note}`;
3784
+ }
3785
+ return safeText(`${prefix}${body}${suffix}`, MAX_PREFERENCE_CONTEXT_CHARS);
3786
+ }
3787
+
3466
3788
  function clientUpdatePath(surface, env = process.env) {
3467
3789
  const normalized = normalizedPluginHealthSurface(surface);
3468
3790
  return normalized ? join(clientUpdateDir(env), `${normalized}.json`) : null;
@@ -3519,9 +3841,10 @@ export async function checkForClientUpdate(
3519
3841
  clearTimeout(timer);
3520
3842
  }
3521
3843
  }
3844
+ const release = clientSurfaceRelease(manifest, normalizedSurface);
3522
3845
  if (
3523
- !manifest.surfaces.includes(normalizedSurface) ||
3524
- compareStableClientVersions(manifest.latest_version, currentVersion) <= 0
3846
+ !release ||
3847
+ compareStableClientVersions(release.version, currentVersion) <= 0
3525
3848
  ) {
3526
3849
  return null;
3527
3850
  }
@@ -3532,10 +3855,10 @@ export async function checkForClientUpdate(
3532
3855
  : "";
3533
3856
  return {
3534
3857
  current_version: currentVersion,
3535
- latest_version: manifest.latest_version,
3858
+ latest_version: release.version,
3536
3859
  surface: normalizedSurface,
3537
3860
  notice: [
3538
- `Remembrance update available: installed ${currentVersion}, published ${manifest.latest_version}.`,
3861
+ `Remembrance update available: installed ${currentVersion}, published ${release.version}.`,
3539
3862
  guidance.update,
3540
3863
  command,
3541
3864
  guidance.restart,
@@ -3609,14 +3932,81 @@ function parseClientReleaseManifest(value) {
3609
3932
  ) {
3610
3933
  return null;
3611
3934
  }
3935
+ const surfaceReleases = parseClientSurfaceReleases(value.surface_releases);
3936
+ if (value.surface_releases !== undefined && !surfaceReleases) return null;
3937
+ if (surfaceReleases) {
3938
+ const entries = Object.entries(surfaceReleases);
3939
+ if (entries.length === 0) return null;
3940
+ const newestVersion = entries.reduce(
3941
+ (latest, [, release]) =>
3942
+ compareStableClientVersions(release.version, latest) > 0
3943
+ ? release.version
3944
+ : latest,
3945
+ entries[0][1].version,
3946
+ );
3947
+ const newestSurfaces = entries
3948
+ .filter(([, release]) => release.version === newestVersion)
3949
+ .map(([surface]) => surface);
3950
+ if (
3951
+ newestVersion !== value.latest_version ||
3952
+ newestSurfaces.length !== surfaces.length ||
3953
+ newestSurfaces.some((surface) => !surfaces.includes(surface))
3954
+ ) {
3955
+ return null;
3956
+ }
3957
+ }
3612
3958
  return {
3613
3959
  schema_version: "1",
3614
3960
  latest_version: value.latest_version,
3615
3961
  published_at: new Date(value.published_at).toISOString(),
3616
3962
  surfaces,
3963
+ ...(surfaceReleases ? { surface_releases: surfaceReleases } : {}),
3617
3964
  };
3618
3965
  }
3619
3966
 
3967
+ function parseClientSurfaceReleases(value) {
3968
+ if (value === undefined) return undefined;
3969
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3970
+ const entries = Object.entries(value);
3971
+ if (
3972
+ entries.some(
3973
+ ([surface, release]) =>
3974
+ !CLIENT_RELEASE_MANIFEST_SURFACES.has(surface) ||
3975
+ !release ||
3976
+ typeof release !== "object" ||
3977
+ Array.isArray(release) ||
3978
+ Object.keys(release).some(
3979
+ (key) => key !== "version" && key !== "published_at",
3980
+ ) ||
3981
+ !parseStableClientVersion(release.version) ||
3982
+ typeof release.published_at !== "string" ||
3983
+ !isCanonicalClientReleaseTimestamp(release.published_at),
3984
+ )
3985
+ ) {
3986
+ return null;
3987
+ }
3988
+ return Object.fromEntries(
3989
+ entries.map(([surface, release]) => [
3990
+ surface,
3991
+ {
3992
+ version: release.version,
3993
+ published_at: new Date(release.published_at).toISOString(),
3994
+ },
3995
+ ]),
3996
+ );
3997
+ }
3998
+
3999
+ function clientSurfaceRelease(manifest, surface) {
4000
+ const exact = manifest.surface_releases?.[surface];
4001
+ if (exact) return exact;
4002
+ return manifest.surfaces.includes(surface)
4003
+ ? {
4004
+ version: manifest.latest_version,
4005
+ published_at: manifest.published_at,
4006
+ }
4007
+ : null;
4008
+ }
4009
+
3620
4010
  function isCanonicalClientReleaseTimestamp(value) {
3621
4011
  const timestamp = Date.parse(value);
3622
4012
  return (
@@ -3660,6 +4050,12 @@ function pluginAlertSessionPath(surface, sessionId, env = process.env) {
3660
4050
 
3661
4051
  function hostPolicyOperationClass(toolName) {
3662
4052
  const normalized = String(toolName ?? "").toLowerCase();
4053
+ if (
4054
+ normalized.endsWith("submit_private_lesson_candidate") ||
4055
+ normalized.endsWith("retry_private_lesson_candidate")
4056
+ ) {
4057
+ return "private_lesson";
4058
+ }
3663
4059
  if (
3664
4060
  normalized.endsWith("query_skills") ||
3665
4061
  normalized.endsWith("list_skills") ||
@@ -3667,12 +4063,16 @@ function hostPolicyOperationClass(toolName) {
3667
4063
  normalized.endsWith("get_resource") ||
3668
4064
  normalized.endsWith("invoke_skill") ||
3669
4065
  normalized.endsWith("get_effective_preferences") ||
4066
+ normalized.endsWith("get_private_lesson_policy") ||
4067
+ normalized.endsWith("inspect_private_lesson_outbox") ||
3670
4068
  normalized.endsWith("run_connection_doctor") ||
3671
4069
  normalized.endsWith("get_connection_status")
3672
4070
  ) {
3673
4071
  return "query";
3674
4072
  }
3675
4073
  if (
4074
+ normalized.endsWith("prepare_private_lesson_candidate") ||
4075
+ normalized.endsWith("delete_private_lesson_candidate") ||
3676
4076
  normalized.endsWith("propose_private_skill") ||
3677
4077
  normalized.endsWith("queue_private_skill_import")
3678
4078
  ) {
@@ -3713,6 +4113,12 @@ function isRemembranceToolName(toolName) {
3713
4113
  "invoke_skill",
3714
4114
  "get_value_proof",
3715
4115
  "get_effective_preferences",
4116
+ "get_private_lesson_policy",
4117
+ "prepare_private_lesson_candidate",
4118
+ "inspect_private_lesson_outbox",
4119
+ "submit_private_lesson_candidate",
4120
+ "retry_private_lesson_candidate",
4121
+ "delete_private_lesson_candidate",
3716
4122
  "record_preference",
3717
4123
  "submit_preference_compatibility_feedback",
3718
4124
  "link_current_installation",
@@ -3872,6 +4278,95 @@ function prunePluginAlertFiles(keepPath, env) {
3872
4278
  }
3873
4279
  }
3874
4280
 
4281
+ function recentPrivateLessonHostDenial(env = process.env) {
4282
+ const directory = pluginAlertDir(env);
4283
+ if (!existsSync(directory)) return false;
4284
+ try {
4285
+ const entries = readdirSync(directory, { withFileTypes: true });
4286
+ if (entries.length > PLUGIN_ALERT_LIMIT) return true;
4287
+ const cutoff = Date.now() - PLUGIN_ALERT_TTL_MS;
4288
+ for (const entry of entries) {
4289
+ if (
4290
+ !entry.isFile() ||
4291
+ entry.isSymbolicLink() ||
4292
+ !/^[a-z_]+\.[a-f0-9]{24}\.json$/.test(entry.name)
4293
+ ) {
4294
+ continue;
4295
+ }
4296
+ const parsed = JSON.parse(
4297
+ readSecureHookFile(
4298
+ join(directory, entry.name),
4299
+ MAX_LOCAL_PLUGIN_ALERT_BYTES,
4300
+ ),
4301
+ );
4302
+ if (
4303
+ Array.isArray(parsed?.observations) &&
4304
+ parsed.observations.some(
4305
+ (item) =>
4306
+ item?.operation_class === "private_lesson" &&
4307
+ Number.isFinite(Date.parse(item?.last_seen_at ?? "")) &&
4308
+ Date.parse(item.last_seen_at) >= cutoff,
4309
+ )
4310
+ ) {
4311
+ return true;
4312
+ }
4313
+ }
4314
+ } catch {
4315
+ // An unreadable or malformed alert store cannot prove that retrying is
4316
+ // authorized. Keep retained private lessons local until the state is
4317
+ // repaired or the exact action is invoked explicitly.
4318
+ return true;
4319
+ }
4320
+ return false;
4321
+ }
4322
+
4323
+ export function clearPrivateLessonHostPolicyDenials(env = process.env) {
4324
+ const directory = pluginAlertDir(env);
4325
+ let changed = false;
4326
+ try {
4327
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
4328
+ if (
4329
+ !entry.isFile() ||
4330
+ entry.isSymbolicLink() ||
4331
+ !/^[a-z_]+\.[a-f0-9]{24}\.json$/.test(entry.name)
4332
+ ) {
4333
+ continue;
4334
+ }
4335
+ const path = join(directory, entry.name);
4336
+ const parsed = JSON.parse(
4337
+ readSecureHookFile(path, MAX_LOCAL_PLUGIN_ALERT_BYTES),
4338
+ );
4339
+ if (!Array.isArray(parsed?.observations)) continue;
4340
+ const observations = parsed.observations.filter(
4341
+ (item) => item?.operation_class !== "private_lesson",
4342
+ );
4343
+ if (observations.length === parsed.observations.length) continue;
4344
+ changed = true;
4345
+ if (observations.length === 0) {
4346
+ rmSync(path, { force: true });
4347
+ } else {
4348
+ writePrivateUsageMarker(
4349
+ path,
4350
+ `${JSON.stringify({
4351
+ ...parsed,
4352
+ observations,
4353
+ updated_at: new Date().toISOString(),
4354
+ })}\n`,
4355
+ );
4356
+ }
4357
+ }
4358
+ } catch {
4359
+ return false;
4360
+ }
4361
+ return changed;
4362
+ }
4363
+
4364
+ export function hostPolicyAlertTextForOperation(operationClass) {
4365
+ return operationClass === "private_lesson"
4366
+ ? PRIVATE_LESSON_HOST_POLICY_ALERT_TEXT
4367
+ : HOST_POLICY_ALERT_TEXT;
4368
+ }
4369
+
3875
4370
  export function recordHostPolicyDenial(
3876
4371
  {
3877
4372
  surface,
@@ -4390,9 +4885,12 @@ export function resolveApiAccessSnapshot(env = process.env) {
4390
4885
  }
4391
4886
  }
4392
4887
  }
4393
- const rawMemberLinkToken = String(
4394
- env.REMEMBRANCE_MEMBER_LINK_TOKEN ?? shared.config.memberLinkToken ?? "",
4888
+ const environmentMemberLinkToken = String(
4889
+ env.REMEMBRANCE_MEMBER_LINK_TOKEN ?? "",
4395
4890
  ).trim();
4891
+ const rawMemberLinkToken =
4892
+ environmentMemberLinkToken ||
4893
+ String(shared.config.memberLinkToken ?? "").trim();
4396
4894
  return {
4397
4895
  apiConfiguration,
4398
4896
  credential,
@@ -4474,9 +4972,49 @@ export function explicitPreferenceSettingsFromPrompt(prompt) {
4474
4972
  }
4475
4973
 
4476
4974
  export function promptRequestsDurablePreference(prompt) {
4477
- 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(
4478
- String(prompt ?? ""),
4479
- );
4975
+ const text = String(prompt ?? "");
4976
+ if (
4977
+ /\b(?:from now on|going forward|for future tasks?|remember (?:that )?i (?:prefer|want)|my (?:lasting )?preference is|make this my default|(?:i|we)\s+always\s+(?:prefer|want|avoid))\b/i.test(
4978
+ text,
4979
+ )
4980
+ ) {
4981
+ return true;
4982
+ }
4983
+ // Terminators must be followed by whitespace or end of input, or a sentence
4984
+ // splits inside an identifier ("console.log", "v1.2") and separates a
4985
+ // durable instruction from the recurring qualifier that licenses it.
4986
+ return text.split(/[.!?]+(?=\s|$)|\n+/).some((sentence) => {
4987
+ const clause = sentence.trim();
4988
+ if (!clause || /^(?:nevermind|never\s+(?:mind|gonna))\b/i.test(clause)) {
4989
+ return false;
4990
+ }
4991
+ // Deliberately broad. Whether "must never return undefined" describes the
4992
+ // agent's working habits or the program's runtime behavior is a semantic
4993
+ // judgment no regex can make, and dropping the ambiguous half would
4994
+ // silently discard real user instructions. This gate only decides whether
4995
+ // to ASK; genericPreferenceCaptureDirective leaves the decision to the model
4996
+ // reading the full conversation, and server-side validation rejects what
4997
+ // slips through.
4998
+ if (/\b(?:should|must)\s+(?:always|never|only)\b/i.test(clause)) {
4999
+ return true;
5000
+ }
5001
+ const recurringContext =
5002
+ /\b(?:by default|as (?:a )?(?:default|rule)|for future tasks?|in future|whenever|every time|each time|(?:for|on) (?:all|each|every)\b|(?:before|after) (?:a|an|the|any|each|every|i|we|you|commits?|releases?|deployments?|reviews?|tasks?)(?:\s|$))/i.test(
5003
+ clause,
5004
+ );
5005
+ if (!recurringContext) return false;
5006
+ // A durable instruction may trail its qualifier ("Whenever you touch a
5007
+ // route, always add a test"), so each comma-separated segment gets its own
5008
+ // start-anchored test. The anchor still blocks descriptive tails such as
5009
+ // "..., and it always returns null".
5010
+ return clause
5011
+ .split(",")
5012
+ .some((segment) =>
5013
+ /^(?:please\s+)?(?:by default,?\s+)?(?:always|never)\s+(?!(?:mind|gonna|going|a|an|the|this|that|there|it)\b)[a-z][a-z-]*\b/i.test(
5014
+ segment.trim(),
5015
+ ),
5016
+ );
5017
+ });
4480
5018
  }
4481
5019
 
4482
5020
  export function promptProvidesPreferenceCorrection(prompt) {
@@ -4525,7 +5063,12 @@ export function genericPreferenceCaptureDirective(prompt, options = {}) {
4525
5063
  : '"scope":"auto"';
4526
5064
  return [
4527
5065
  "Remembrance durable-preference capture:",
4528
- "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.",
5066
+ // The lexical gate that produced this notice cannot tell an instruction
5067
+ // about your working habits from a statement about what the code should
5068
+ // do, so it deliberately over-triggers rather than dropping real user
5069
+ // instructions. Make that judgment here, where the whole conversation is
5070
+ // available, and decline silently when it does not apply.
5071
+ "The user's message may state a lasting or corrective working preference that is not one of the built-in controls. Record it only if it constrains how YOU work: a presentation, workflow, or strategy choice you would otherwise make at your own discretion. Do not record a statement about what the code should do; an expected value, an invariant, an API contract, a test expectation, or a bug report is not a working preference even when it is phrased with always, never, must, or should. If it qualifies, interpret only that preference and call record_preference once, and do not ask the user to classify it. Otherwise take no action and do not mention this notice.",
4529
5072
  '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.',
4530
5073
  `Use ${scopeFields}, "source_category":"explicit_user", "evidence_hash":"${evidenceHash}", "task_hash":"${taskHash}", and "confidence":1.`,
4531
5074
  "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.",