@remembrance-ai/opencode-plugin 0.2.2 → 0.2.3
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 +64 -6
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/hook-core.mjs +1405 -396
- package/servers/remembrance-mcp.mjs +667 -138
- package/skills/remembrancer/SKILL.md +13 -0
- package/skills/remembrancer/references/remembrance-setup.md +864 -150
- package/src/index.mjs +74 -29
package/scripts/hook-core.mjs
CHANGED
|
@@ -134,6 +134,18 @@ const COMPLETION_OBLIGATION_GROWN_FIELDS = Object.freeze({
|
|
|
134
134
|
}),
|
|
135
135
|
});
|
|
136
136
|
const PRIVATE_AUTHORIZATION_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
137
|
+
// A private draft whose session tree has had no user prompt for this long is
|
|
138
|
+
// offered to other sessions, and only as a user decision (owner decision,
|
|
139
|
+
// 0.2.3). The notice offering such drafts shows at most once per interval on
|
|
140
|
+
// a machine.
|
|
141
|
+
const PRIVATE_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
142
|
+
const PRIVATE_ORPHAN_NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
143
|
+
// A declined or host-denied draft returns in that notice after these waits,
|
|
144
|
+
// then not again until its lane is allowed, the user asks for it, or the
|
|
145
|
+
// client updates (owner decision, 0.2.3).
|
|
146
|
+
const PRIVATE_PARKED_NOTICE_DELAYS_MS = [1, 7, 30].map(
|
|
147
|
+
(days) => days * 24 * 60 * 60 * 1000,
|
|
148
|
+
);
|
|
137
149
|
// Inherited cleanup for a debt record without content (a marker), which a
|
|
138
150
|
// ledger read removes once it is this old. It never applies to a record
|
|
139
151
|
// that keeps an unresolved action's payload: only completing, declining or
|
|
@@ -253,6 +265,18 @@ const PENDING_ACTION_DECLINE_REASONS = [
|
|
|
253
265
|
];
|
|
254
266
|
const PENDING_ACTION_DECLINE_GUIDANCE =
|
|
255
267
|
"If you cannot complete an action, call local decline_pending_action with the action_id under it and one reason (not_observed_by_this_agent, agent_exited, not_reusable, or duplicate) instead of guessing.";
|
|
268
|
+
// Accepted for a private line or notice only; feedback denials keep
|
|
269
|
+
// acknowledge_feedback_denial.
|
|
270
|
+
const PRIVATE_DECLINE_REASON = "host_policy_denied";
|
|
271
|
+
const PRIVATE_DECLINE_GUIDANCE =
|
|
272
|
+
"If the host denies a private line's exact action, decline that line with reason host_policy_denied; a declined private draft stays retained, returns later only as a user decision, and is never deleted automatically.";
|
|
273
|
+
// The private notice for drafts awaiting the user is not a ledger record;
|
|
274
|
+
// it has one fixed action_id, which a decline names. The held-safety notice
|
|
275
|
+
// names none: its delivery marks the drafts it showed, and it needs no reply.
|
|
276
|
+
const PRIVATE_ORPHAN_NOTICE_ACTION_ID = createHash("sha256")
|
|
277
|
+
.update("private_draft_notice:awaiting_user", "utf8")
|
|
278
|
+
.digest("hex")
|
|
279
|
+
.slice(0, 16);
|
|
256
280
|
export const PRIVATE_LESSON_AUTHORIZATION_REQUEST_TEXT =
|
|
257
281
|
"Allow Remembrance to submit this retained private lesson or skill candidate to your organization?";
|
|
258
282
|
const PLUGIN_HEALTH_COMPONENTS = new Set([
|
|
@@ -2281,6 +2305,7 @@ export async function runPromptHook(prompt, options = {}) {
|
|
|
2281
2305
|
acknowledgePrivateAuthorizationReminders(
|
|
2282
2306
|
result?.context ?? "",
|
|
2283
2307
|
options.env ?? process.env,
|
|
2308
|
+
options.sessionId ?? null,
|
|
2284
2309
|
);
|
|
2285
2310
|
}
|
|
2286
2311
|
return result;
|
|
@@ -2312,8 +2337,14 @@ async function buildPromptHookResult(prompt, options = {}) {
|
|
|
2312
2337
|
// Rendered for each prompt context in withContext below.
|
|
2313
2338
|
const lifecycleRecovery =
|
|
2314
2339
|
!options.sessionId && privateLearningLifecycleRecoveryInstruction(env);
|
|
2340
|
+
// `subagentSession`: the host runs this session for a subagent, or cannot
|
|
2341
|
+
// show it is a main session, and its ledger names no agent (OpenClaw's
|
|
2342
|
+
// sessions_spawn; on OpenCode, every session but one created with no
|
|
2343
|
+
// parent session, isRecordedMainSession).
|
|
2315
2344
|
const recovery = options.sessionId
|
|
2316
|
-
? pendingCompletionRecovery(options.sessionId, env
|
|
2345
|
+
? pendingCompletionRecovery(options.sessionId, env, {
|
|
2346
|
+
notices: !options.subagentSession,
|
|
2347
|
+
})
|
|
2317
2348
|
: lifecycleRecovery && (() => lifecycleRecovery);
|
|
2318
2349
|
const recoveryAddition = recovery
|
|
2319
2350
|
? { id: "private_learning_recovery", kind: "completion" }
|
|
@@ -2672,6 +2703,7 @@ export function contributionReason(
|
|
|
2672
2703
|
? privateLessonCompletionGuidance({
|
|
2673
2704
|
includeLifecycleRecovery: options.includeLifecycleRecovery !== false,
|
|
2674
2705
|
env: options.env,
|
|
2706
|
+
lifecycleRecovery: options.lifecycleRecovery,
|
|
2675
2707
|
})
|
|
2676
2708
|
: null;
|
|
2677
2709
|
if (directSelections.length > 0) {
|
|
@@ -2759,13 +2791,18 @@ function routineContributionVisibilityInstruction(privateCapture = false) {
|
|
|
2759
2791
|
return `${visibility} ${FEEDBACK_DENIAL_GUIDANCE}`;
|
|
2760
2792
|
}
|
|
2761
2793
|
|
|
2794
|
+
// `lifecycleRecovery`: a caller's session-scoped recovery text, used in place
|
|
2795
|
+
// of the machine-wide one.
|
|
2762
2796
|
export function privateLessonCompletionGuidance({
|
|
2763
2797
|
includeLifecycleRecovery = true,
|
|
2764
2798
|
env = process.env,
|
|
2799
|
+
lifecycleRecovery,
|
|
2765
2800
|
} = {}) {
|
|
2766
|
-
const recovery = includeLifecycleRecovery
|
|
2767
|
-
?
|
|
2768
|
-
:
|
|
2801
|
+
const recovery = !includeLifecycleRecovery
|
|
2802
|
+
? null
|
|
2803
|
+
: lifecycleRecovery !== undefined
|
|
2804
|
+
? lifecycleRecovery
|
|
2805
|
+
: privateLearningLifecycleRecoveryInstruction(env);
|
|
2769
2806
|
return [
|
|
2770
2807
|
"Call capture_learning once per new item: lesson for a compact generalized failure/correction, private_skill with complete instructions for a reusable procedure, or preference for a durable working preference. Follow its exact next_action or bounded agent_recovery instructions; reformulation updates the retained draft, not a new item.",
|
|
2771
2808
|
"The router encrypts lesson and private-skill drafts locally before any network action. Do not substitute a legacy broad write, REST, another destination, or another visibility.",
|
|
@@ -2823,7 +2860,10 @@ export function taskClosureReason(signal = null, options = {}) {
|
|
|
2823
2860
|
? `Skills already selected for this session (${options.sessionSkills.join(", ")}) stay active; do not invoke or query them again.`
|
|
2824
2861
|
: null,
|
|
2825
2862
|
options.privateLessonAuthorized
|
|
2826
|
-
? privateLessonCompletionGuidance({
|
|
2863
|
+
? privateLessonCompletionGuidance({
|
|
2864
|
+
env: options.env,
|
|
2865
|
+
lifecycleRecovery: options.lifecycleRecovery,
|
|
2866
|
+
})
|
|
2827
2867
|
: null,
|
|
2828
2868
|
routineContributionVisibilityInstruction(options.privateLessonAuthorized),
|
|
2829
2869
|
]
|
|
@@ -2879,7 +2919,9 @@ export function hostPolicyAlertWasReported(input) {
|
|
|
2879
2919
|
// eligibility with the last-prompted count. A completed use prompts for a
|
|
2880
2920
|
// contribution; eligible work with no completed query prompts for full-context
|
|
2881
2921
|
// recovery. `useCount`/`promptedCount` are injectable so tests never touch the
|
|
2882
|
-
// filesystem.
|
|
2922
|
+
// filesystem. `subagentSession`: the host runs this session for a subagent,
|
|
2923
|
+
// or cannot show it is a main session (runPromptHook), so it gets no user
|
|
2924
|
+
// decisions.
|
|
2883
2925
|
export function decideStop(input, options = {}) {
|
|
2884
2926
|
const env = options.env ?? process.env;
|
|
2885
2927
|
if (contributeDisabled(env.REMEMBRANCE_AUTO_CONTRIBUTE)) {
|
|
@@ -2920,9 +2962,11 @@ export function decideStop(input, options = {}) {
|
|
|
2920
2962
|
const readObligations =
|
|
2921
2963
|
options.readCompletionObligations ?? readCompletionObligations;
|
|
2922
2964
|
const obligations = readObligations(sessionId, env);
|
|
2923
|
-
const
|
|
2924
|
-
|
|
2925
|
-
|
|
2965
|
+
const drafts = readPrivateDrafts(env);
|
|
2966
|
+
const unpromptedObligations = duePendingActions(
|
|
2967
|
+
obligations.filter((obligation) => !obligation.prompted_at),
|
|
2968
|
+
env,
|
|
2969
|
+
drafts,
|
|
2926
2970
|
);
|
|
2927
2971
|
const overflow = readCompletionObligationOverflow(sessionId, env);
|
|
2928
2972
|
const highValueSignal = detectHighValueLessonSignal(input);
|
|
@@ -2996,8 +3040,16 @@ export function decideStop(input, options = {}) {
|
|
|
2996
3040
|
eligibilityCount > readClosed(sessionId, env) &&
|
|
2997
3041
|
newDirectSelections.length === 0;
|
|
2998
3042
|
const privateLessonAuthorized = organizationPrivateLessonAuthorized(env);
|
|
3043
|
+
// Scoped to this ledger's session tree: another session's drafts appear
|
|
3044
|
+
// only as a user decision, never as silent recovery.
|
|
2999
3045
|
const privateLessonRecovery = privateLessonAuthorized
|
|
3000
|
-
?
|
|
3046
|
+
? privateLearningSessionRecovery(
|
|
3047
|
+
sessionId,
|
|
3048
|
+
privateHintsOnMachine(sessionId, env),
|
|
3049
|
+
drafts,
|
|
3050
|
+
env,
|
|
3051
|
+
{ notices: !options.subagentSession },
|
|
3052
|
+
)
|
|
3001
3053
|
: null;
|
|
3002
3054
|
if (
|
|
3003
3055
|
newDirectSelections.length > 0 &&
|
|
@@ -3069,6 +3121,7 @@ export function decideStop(input, options = {}) {
|
|
|
3069
3121
|
env,
|
|
3070
3122
|
privateLessonAuthorized,
|
|
3071
3123
|
sessionSkills,
|
|
3124
|
+
lifecycleRecovery: privateLessonRecovery,
|
|
3072
3125
|
})
|
|
3073
3126
|
: contributionReason(
|
|
3074
3127
|
highValueSignal,
|
|
@@ -3080,6 +3133,7 @@ export function decideStop(input, options = {}) {
|
|
|
3080
3133
|
env,
|
|
3081
3134
|
privateLessonAuthorized,
|
|
3082
3135
|
includeLifecycleRecovery: true,
|
|
3136
|
+
lifecycleRecovery: privateLessonRecovery,
|
|
3083
3137
|
},
|
|
3084
3138
|
),
|
|
3085
3139
|
]
|
|
@@ -3131,6 +3185,9 @@ function highMatchClosureInstruction(match) {
|
|
|
3131
3185
|
// record, since when, and of which kinds.
|
|
3132
3186
|
// <hash>.user-prompts — the user prompts the agent received (host text and
|
|
3133
3187
|
// a subagent's task are not counted).
|
|
3188
|
+
// <hash>.main-session — the host created this session with no parent.
|
|
3189
|
+
// <hash>.parent-session — the parent session id the host created this
|
|
3190
|
+
// child session with.
|
|
3134
3191
|
// <hash>.rated.json — results the ledger paid post-use feedback for, each
|
|
3135
3192
|
// with the user prompt (.user-prompts count) it
|
|
3136
3193
|
// was paid in.
|
|
@@ -3411,13 +3468,29 @@ function privateAuthorizationMarker(draftId, env) {
|
|
|
3411
3468
|
}
|
|
3412
3469
|
}
|
|
3413
3470
|
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3471
|
+
// A draft's content-free markers beside its hint: authorization backoff,
|
|
3472
|
+
// parking (a decline or host denial), and its one held-safety notice.
|
|
3473
|
+
const PRIVATE_DRAFT_MARKERS = ["authorization", "parked", "held-notice"];
|
|
3474
|
+
|
|
3475
|
+
function privateDraftMarkerPath(draftId, marker, env) {
|
|
3476
|
+
return /^(pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId ?? "")
|
|
3477
|
+
? join(privateLearningLifecycleDirectory(env), `${draftId}.${marker}.json`)
|
|
3478
|
+
: null;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
function clearPrivateDraftMarkers(
|
|
3482
|
+
draftId,
|
|
3483
|
+
env,
|
|
3484
|
+
markers = PRIVATE_DRAFT_MARKERS,
|
|
3485
|
+
) {
|
|
3486
|
+
for (const marker of markers) {
|
|
3487
|
+
const path = privateDraftMarkerPath(draftId, marker, env);
|
|
3488
|
+
if (!path) return;
|
|
3489
|
+
try {
|
|
3490
|
+
rmSync(path, { force: true });
|
|
3491
|
+
} catch {
|
|
3492
|
+
// Content-free scheduling state never controls draft acceptance/deletion.
|
|
3493
|
+
}
|
|
3421
3494
|
}
|
|
3422
3495
|
}
|
|
3423
3496
|
|
|
@@ -3481,18 +3554,19 @@ function clearKnownTerminalAuthorizationOpportunities(env) {
|
|
|
3481
3554
|
assertSecureHookDirectory(lstatSync(directory));
|
|
3482
3555
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
3483
3556
|
const match = entry.name.match(
|
|
3484
|
-
/^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.authorization\.json$/,
|
|
3557
|
+
/^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.(authorization|parked|held-notice)\.json$/,
|
|
3485
3558
|
);
|
|
3486
3559
|
if (
|
|
3487
3560
|
!match ||
|
|
3488
3561
|
entry.isSymbolicLink() ||
|
|
3489
3562
|
!entry.isFile() ||
|
|
3490
|
-
|
|
3563
|
+
(match[2] === "authorization" &&
|
|
3564
|
+
!privateAuthorizationMarker(match[1], env)) ||
|
|
3491
3565
|
!privateAuthorizationTerminalIsKnown(match[1], env)
|
|
3492
3566
|
) {
|
|
3493
3567
|
continue;
|
|
3494
3568
|
}
|
|
3495
|
-
|
|
3569
|
+
clearPrivateDraftMarkers(match[1], env, [match[2]]);
|
|
3496
3570
|
}
|
|
3497
3571
|
} catch {
|
|
3498
3572
|
// Terminal cleanup is best-effort and never controls retained draft state.
|
|
@@ -3541,17 +3615,23 @@ function privateAuthorizationRetryIsDue(draftId, lastFailureAt, env) {
|
|
|
3541
3615
|
);
|
|
3542
3616
|
}
|
|
3543
3617
|
|
|
3618
|
+
// `ledgerId`: the ledger the delivered context was composed for. Where
|
|
3619
|
+
// another session of the same process can compose between a composition and
|
|
3620
|
+
// its delivery (OpenCode's turns, OpenClaw's prompts through runPromptHook),
|
|
3621
|
+
// the host names it, so that session's notice in the same words is not
|
|
3622
|
+
// acknowledged with this delivery's; without it, the newest composition is
|
|
3623
|
+
// (acknowledgePrivateDraftNotices).
|
|
3544
3624
|
export function acknowledgePrivateAuthorizationReminders(
|
|
3545
3625
|
context,
|
|
3546
3626
|
env = process.env,
|
|
3627
|
+
ledgerId = null,
|
|
3547
3628
|
) {
|
|
3548
3629
|
clearKnownTerminalAuthorizationOpportunities(env);
|
|
3549
3630
|
retireKnownTerminalPrivateObligations(env);
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
)
|
|
3554
|
-
return;
|
|
3631
|
+
// Per-draft parking, not the lane-wide denial, now holds a denied draft's
|
|
3632
|
+
// line; an acknowledged retry still backs off.
|
|
3633
|
+
if (!organizationPrivateLessonAuthorized(env)) return;
|
|
3634
|
+
acknowledgePrivateDraftNotices(String(context ?? ""), env, ledgerId);
|
|
3555
3635
|
const references = String(context ?? "").matchAll(
|
|
3556
3636
|
/\bcall retry_private_(lesson|skill)_candidate with draft_id ((?:pld|psd)_[A-Za-z0-9_-]{8,96})(?![A-Za-z0-9_-])/g,
|
|
3557
3637
|
);
|
|
@@ -3634,7 +3714,9 @@ function feedbackObligationDenied(obligation, env) {
|
|
|
3634
3714
|
);
|
|
3635
3715
|
}
|
|
3636
3716
|
|
|
3637
|
-
|
|
3717
|
+
// `drafts` (readPrivateDrafts), when the caller has read the outboxes once
|
|
3718
|
+
// for this prompt; without it a private hint reads its own draft's state.
|
|
3719
|
+
function completionObligationIsDue(obligation, env, drafts = null) {
|
|
3638
3720
|
if (feedbackObligationDenied(obligation, env)) return false;
|
|
3639
3721
|
if (!["private_lesson", "private_skill"].includes(obligation.kind))
|
|
3640
3722
|
return true;
|
|
@@ -3642,12 +3724,37 @@ function completionObligationIsDue(obligation, env) {
|
|
|
3642
3724
|
// an older content-free obligation after best-effort retirement.
|
|
3643
3725
|
if (privateAuthorizationTerminalIsKnown(obligation.draft_id, env))
|
|
3644
3726
|
return false;
|
|
3645
|
-
if (
|
|
3727
|
+
if (!organizationPrivateLessonAuthorized(env)) return false;
|
|
3728
|
+
// A parked draft returns only as a user decision, never as a silent line.
|
|
3646
3729
|
if (
|
|
3647
|
-
|
|
3648
|
-
|
|
3730
|
+
drafts
|
|
3731
|
+
? drafts.parked.has(obligation.draft_id)
|
|
3732
|
+
: readPrivateParkedMarker(obligation.draft_id, env)
|
|
3649
3733
|
)
|
|
3650
3734
|
return false;
|
|
3735
|
+
// A lesson's own envelope says what is due now; the hint's recorded
|
|
3736
|
+
// fields decide only when its outbox cannot answer. Only the hint knows
|
|
3737
|
+
// which credential an authorization refusal named: a changed one is due.
|
|
3738
|
+
if (obligation.kind === "private_lesson") {
|
|
3739
|
+
const known = privateLessonHintAction(
|
|
3740
|
+
obligation.draft_id,
|
|
3741
|
+
env,
|
|
3742
|
+
drafts?.lessons,
|
|
3743
|
+
);
|
|
3744
|
+
if (
|
|
3745
|
+
known?.action?.count === "awaiting_authorization" &&
|
|
3746
|
+
!known.action.due &&
|
|
3747
|
+
obligation.failed_credential_digest &&
|
|
3748
|
+
!privateAuthorizationMarker(obligation.draft_id, env)
|
|
3749
|
+
) {
|
|
3750
|
+
return (
|
|
3751
|
+
obligation.failed_credential_digest !==
|
|
3752
|
+
privateObligationCredentialDigest(env)
|
|
3753
|
+
);
|
|
3754
|
+
}
|
|
3755
|
+
if (known) return Boolean(known.action?.due);
|
|
3756
|
+
}
|
|
3757
|
+
if (obligation.delivery_blocked) return false;
|
|
3651
3758
|
if (obligation.next_retry_at && !retryIsDue(obligation.next_retry_at))
|
|
3652
3759
|
return false;
|
|
3653
3760
|
if (!obligation.failed_credential_digest) return true;
|
|
@@ -3808,6 +3915,7 @@ function normalizeCompletionObligation(value) {
|
|
|
3808
3915
|
...(value.subagent_identity === "unavailable"
|
|
3809
3916
|
? { subagent_identity: "unavailable" }
|
|
3810
3917
|
: {}),
|
|
3918
|
+
...privateHintOwnerFields(value),
|
|
3811
3919
|
...normalizedAliasQueryIds(value),
|
|
3812
3920
|
// The subagent that left this debt unpaid when it exited.
|
|
3813
3921
|
transferred_from: normalizeNativeSessionId(value.transferred_from?.agent_id)
|
|
@@ -3850,6 +3958,82 @@ function normalizeCompletionObligation(value) {
|
|
|
3850
3958
|
};
|
|
3851
3959
|
}
|
|
3852
3960
|
|
|
3961
|
+
// Who a private hint belongs to: the session tree that captured or last
|
|
3962
|
+
// acted on its draft (owner_session, the main session's ledger), and the
|
|
3963
|
+
// ledger and agent that did (captured_by_*). A hint without them (written
|
|
3964
|
+
// by an earlier client) belongs to no session. All are content-free.
|
|
3965
|
+
function privateHintOwnerFields(value) {
|
|
3966
|
+
if (
|
|
3967
|
+
!["private_lesson", "private_skill"].includes(value.kind) ||
|
|
3968
|
+
!/^[a-f0-9]{16}$/.test(value.owner_session ?? "") ||
|
|
3969
|
+
!/^[a-f0-9]{16}$/.test(value.captured_by_ledger ?? "")
|
|
3970
|
+
) {
|
|
3971
|
+
return {};
|
|
3972
|
+
}
|
|
3973
|
+
const agent = normalizeNativeSessionId(value.captured_by_agent);
|
|
3974
|
+
return {
|
|
3975
|
+
owner_session: value.owner_session,
|
|
3976
|
+
captured_by_ledger: value.captured_by_ledger,
|
|
3977
|
+
...(agent ? { captured_by_agent: agent } : {}),
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
// The owner fields for a hint `sessionId` (a ledger id, agentLedgerIdFor's
|
|
3982
|
+
// form) records.
|
|
3983
|
+
function privateHintOwner(sessionId) {
|
|
3984
|
+
const ledger = String(sessionId ?? "unknown");
|
|
3985
|
+
const split = ledger.indexOf(":agent:");
|
|
3986
|
+
const agent =
|
|
3987
|
+
split >= 0 ? normalizeNativeSessionId(ledger.slice(split + 7)) : null;
|
|
3988
|
+
return {
|
|
3989
|
+
owner_session: sessionHash(split >= 0 ? ledger.slice(0, split) : ledger),
|
|
3990
|
+
captured_by_ledger: sessionHash(ledger),
|
|
3991
|
+
...(agent ? { captured_by_agent: agent } : {}),
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
// A main thread holds the private hints of its whole session tree, a
|
|
3996
|
+
// subagent only those it captured.
|
|
3997
|
+
function privateHintOwnedBy(hint, sessionId) {
|
|
3998
|
+
if (sessionId !== null && typeof sessionId === "object") return false;
|
|
3999
|
+
const ledger = String(sessionId ?? "unknown");
|
|
4000
|
+
return ledger.includes(":agent:")
|
|
4001
|
+
? hint.captured_by_ledger === sessionHash(ledger)
|
|
4002
|
+
: hint.owner_session === sessionHash(ledger);
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
function isPrivateHint(obligation) {
|
|
4006
|
+
return ["private_lesson", "private_skill"].includes(obligation?.kind);
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// Rewrites a private hint where it is held with `to` as its owner; nothing
|
|
4010
|
+
// else of it changes, and its draft is never touched. When the session
|
|
4011
|
+
// whose tree `to` is in has handed over itself (OpenClaw: a nested subagent
|
|
4012
|
+
// that ends after the one that spawned it), the hint goes on, as that
|
|
4013
|
+
// session's debts went, to the session its forwarding markers lead to
|
|
4014
|
+
// (resolveCompletionRecipient), which is the one that reads it.
|
|
4015
|
+
function adoptPrivateHint(hint, to, env) {
|
|
4016
|
+
if (typeof to !== "string" || !lifecycleDirectoryUsable(env)) return false;
|
|
4017
|
+
const owner = privateHintOwner(to);
|
|
4018
|
+
const { recipient } = resolveCompletionRecipient(
|
|
4019
|
+
{ ledger: owner.owner_session },
|
|
4020
|
+
to,
|
|
4021
|
+
env,
|
|
4022
|
+
);
|
|
4023
|
+
const adopted = normalizeCompletionObligation({
|
|
4024
|
+
...hint,
|
|
4025
|
+
...owner,
|
|
4026
|
+
owner_session: sessionHash(recipient),
|
|
4027
|
+
});
|
|
4028
|
+
return Boolean(
|
|
4029
|
+
adopted &&
|
|
4030
|
+
writePrivateUsageMarker(
|
|
4031
|
+
completionObligationPath(to, adopted.id, env),
|
|
4032
|
+
`${JSON.stringify(adopted)}\n`,
|
|
4033
|
+
),
|
|
4034
|
+
);
|
|
4035
|
+
}
|
|
4036
|
+
|
|
3853
4037
|
function normalizedAliasQueryIds(value) {
|
|
3854
4038
|
if (
|
|
3855
4039
|
value.kind !== "post_use_feedback" ||
|
|
@@ -3885,7 +4069,9 @@ function transferTranscriptPath(value) {
|
|
|
3885
4069
|
|
|
3886
4070
|
// `pointers: false` reads only the ledger's own records, for a decision that
|
|
3887
4071
|
// needs nothing else (is it held, is it full): no pointer entry is examined,
|
|
3888
|
-
// and such a read never counts the ledger as drained.
|
|
4072
|
+
// and such a read never counts the ledger as drained. `privateHints: "all"`
|
|
4073
|
+
// shows every private hint on this machine, not only the reader's
|
|
4074
|
+
// (privateHintOwnedBy): for a removal, retirement, or the orphan scan.
|
|
3889
4075
|
export function readCompletionObligations(
|
|
3890
4076
|
sessionId,
|
|
3891
4077
|
env = process.env,
|
|
@@ -3926,6 +4112,7 @@ function readCompletionLedger(
|
|
|
3926
4112
|
associations = "expand",
|
|
3927
4113
|
settle = false,
|
|
3928
4114
|
ask = true,
|
|
4115
|
+
privateHints = "own",
|
|
3929
4116
|
} = {},
|
|
3930
4117
|
) {
|
|
3931
4118
|
const obligations = new Map();
|
|
@@ -4073,6 +4260,7 @@ function readCompletionLedger(
|
|
|
4073
4260
|
// Non-expiring private hints must never occupy the session feedback budget.
|
|
4074
4261
|
// Records another ledger keeps for this one come after its own; they never
|
|
4075
4262
|
// count against its own bound.
|
|
4263
|
+
const hints = ordered.filter((item) => privateKinds.includes(item.kind));
|
|
4076
4264
|
return {
|
|
4077
4265
|
complete,
|
|
4078
4266
|
obligations: [
|
|
@@ -4080,9 +4268,11 @@ function readCompletionLedger(
|
|
|
4080
4268
|
.filter((item) => !privateKinds.includes(item.kind))
|
|
4081
4269
|
.slice(0, COMPLETION_OBLIGATION_LIMIT),
|
|
4082
4270
|
...pointed,
|
|
4083
|
-
...
|
|
4084
|
-
|
|
4085
|
-
|
|
4271
|
+
...(privateHints === "all"
|
|
4272
|
+
? hints
|
|
4273
|
+
: hints
|
|
4274
|
+
.filter((item) => privateHintOwnedBy(item, sessionId))
|
|
4275
|
+
.slice(0, COMPLETION_OBLIGATION_LIMIT)),
|
|
4086
4276
|
],
|
|
4087
4277
|
};
|
|
4088
4278
|
}
|
|
@@ -4686,6 +4876,28 @@ function completionLedgerIsFull(existing, obligation) {
|
|
|
4686
4876
|
);
|
|
4687
4877
|
}
|
|
4688
4878
|
|
|
4879
|
+
// A full machine-wide bound of private hints makes room for the writer's by
|
|
4880
|
+
// dropping the oldest hint whose draft is already the user's to decide
|
|
4881
|
+
// (privateDraftsAwaitingUser): a parked draft's, or one with no owner or an
|
|
4882
|
+
// owner outside the writer's tree that is idle (privateHintIdle). That
|
|
4883
|
+
// draft is offered as before; only its pointer goes. When every hint
|
|
4884
|
+
// belongs to an active session, none goes, and the new draft, with no hint,
|
|
4885
|
+
// reaches the user as one of those decisions.
|
|
4886
|
+
function evictUserGatedPrivateHint(sessionId, existing, env) {
|
|
4887
|
+
const writer = privateHintOwner(sessionId).owner_session;
|
|
4888
|
+
const gated = existing.find(
|
|
4889
|
+
(hint) =>
|
|
4890
|
+
isPrivateHint(hint) &&
|
|
4891
|
+
!hint.ledger &&
|
|
4892
|
+
(readPrivateParkedMarker(hint.draft_id, env) ||
|
|
4893
|
+
(hint.owner_session !== writer &&
|
|
4894
|
+
(!hint.owner_session ||
|
|
4895
|
+
privateHintIdle(hint, (ledger) => privateSessionIdle(ledger, env))))),
|
|
4896
|
+
);
|
|
4897
|
+
if (gated) dropPrivateHint(gated.draft_id, env);
|
|
4898
|
+
return Boolean(gated);
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4689
4901
|
// Debts a full ledger could not record are counted, never dropped silently:
|
|
4690
4902
|
// every demand for that ledger reports them until it has drained.
|
|
4691
4903
|
function readCompletionObligationOverflow(sessionId, env) {
|
|
@@ -4758,12 +4970,17 @@ function writeCompletionObligation(sessionId, obligation, env, { kept = false }
|
|
|
4758
4970
|
const ledger = /^[a-f0-9]{16}$/.test(obligation?.ledger ?? "")
|
|
4759
4971
|
? obligation.ledger
|
|
4760
4972
|
: own;
|
|
4761
|
-
|
|
4973
|
+
// Private hints are bounded per machine, whoever owns them.
|
|
4974
|
+
const existing =
|
|
4762
4975
|
ledger === own &&
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
)
|
|
4976
|
+
readCompletionObligations(sessionId, env, {
|
|
4977
|
+
pointers: false,
|
|
4978
|
+
privateHints: "all",
|
|
4979
|
+
});
|
|
4980
|
+
if (
|
|
4981
|
+
existing &&
|
|
4982
|
+
completionLedgerIsFull(existing, normalized) &&
|
|
4983
|
+
!(isPrivateHint(normalized) && evictUserGatedPrivateHint(sessionId, existing, env))
|
|
4767
4984
|
) {
|
|
4768
4985
|
// A private hint only points into the private-learning outbox, which
|
|
4769
4986
|
// stays authoritative; any other debt not kept elsewhere is counted.
|
|
@@ -4864,15 +5081,21 @@ function writeRetainedRecord(ledger, name, serialized, normalized, env) {
|
|
|
4864
5081
|
return true;
|
|
4865
5082
|
}
|
|
4866
5083
|
|
|
5084
|
+
// A private hint is (re)stamped with the recording ledger as its owner: the
|
|
5085
|
+
// session tree that captured its draft, or acted on it last (an observed
|
|
5086
|
+
// submit or retry adopts another session's draft).
|
|
4867
5087
|
function addCompletionObligation(sessionId, obligation, env = process.env) {
|
|
4868
|
-
const existing =
|
|
4869
|
-
|
|
4870
|
-
|
|
5088
|
+
const existing = readCompletionObligations(sessionId, env, {
|
|
5089
|
+
ask: false,
|
|
5090
|
+
privateHints: "all",
|
|
5091
|
+
}).find((item) => item.id === obligation.id);
|
|
5092
|
+
const owner = isPrivateHint(obligation) ? privateHintOwner(sessionId) : {};
|
|
4871
5093
|
return writeCompletionObligation(
|
|
4872
5094
|
sessionId,
|
|
4873
5095
|
{
|
|
4874
5096
|
version: COMPLETION_OBLIGATION_VERSION,
|
|
4875
5097
|
...obligation,
|
|
5098
|
+
...owner,
|
|
4876
5099
|
agent_keyed: true,
|
|
4877
5100
|
...(subagentIdentityUnavailable
|
|
4878
5101
|
? { subagent_identity: "unavailable" }
|
|
@@ -4882,7 +5105,8 @@ function addCompletionObligation(sessionId, obligation, env = process.env) {
|
|
|
4882
5105
|
existing?.action_tool === (obligation.action_tool ?? null) &&
|
|
4883
5106
|
existing?.next_retry_at === (obligation.next_retry_at ?? null) &&
|
|
4884
5107
|
existing?.failed_credential_digest ===
|
|
4885
|
-
(obligation.failed_credential_digest ?? null)
|
|
5108
|
+
(obligation.failed_credential_digest ?? null) &&
|
|
5109
|
+
existing?.captured_by_ledger === owner.captured_by_ledger
|
|
4886
5110
|
? (existing?.prompted_at ?? null)
|
|
4887
5111
|
: null,
|
|
4888
5112
|
},
|
|
@@ -4905,7 +5129,8 @@ function addCompletionObligation(sessionId, obligation, env = process.env) {
|
|
|
4905
5129
|
// points to. One stop hands over one page; the parent's reads reach the rest
|
|
4906
5130
|
// through one association (handOverLedger). A kept debt is never counted.
|
|
4907
5131
|
// Private-learning hints are held per machine, not per agent, and stay where
|
|
4908
|
-
// they are
|
|
5132
|
+
// they are; the subagent's session tree, its parent's, already owns them
|
|
5133
|
+
// (privateHintOwner).
|
|
4909
5134
|
export function transferSubagentObligations(
|
|
4910
5135
|
input,
|
|
4911
5136
|
env = process.env,
|
|
@@ -5014,7 +5239,11 @@ function handOverLedger(
|
|
|
5014
5239
|
let moved = 0;
|
|
5015
5240
|
let handed = complete;
|
|
5016
5241
|
// Records of `from`'s own that this page leaves in its homes: kept records
|
|
5017
|
-
// `to` now points to. (Private hints are held per machine, not there
|
|
5242
|
+
// `to` now points to. (Private hints are held per machine, not there, and
|
|
5243
|
+
// stay where they are: a subagent's already belong to `to`'s session tree,
|
|
5244
|
+
// an OpenClaw subagent session's went to the session that reads `to`'s
|
|
5245
|
+
// tree when its fold began (foldTurnLedgerIntoSession), and a Codex turn
|
|
5246
|
+
// records none.)
|
|
5018
5247
|
let left = 0;
|
|
5019
5248
|
for (const obligation of obligations) {
|
|
5020
5249
|
if (obligation.association) {
|
|
@@ -5041,7 +5270,7 @@ function handOverLedger(
|
|
|
5041
5270
|
}
|
|
5042
5271
|
continue;
|
|
5043
5272
|
}
|
|
5044
|
-
if (
|
|
5273
|
+
if (isPrivateHint(obligation)) continue;
|
|
5045
5274
|
const result = handOverCompletionObligation(
|
|
5046
5275
|
from,
|
|
5047
5276
|
to,
|
|
@@ -5465,12 +5694,22 @@ function removeCompletionObligationRecord(sessionId, obligation, env, durable) {
|
|
|
5465
5694
|
return ledger;
|
|
5466
5695
|
}
|
|
5467
5696
|
|
|
5468
|
-
|
|
5697
|
+
// A private hint any session holds can be removed (its draft was accepted,
|
|
5698
|
+
// deleted or is no longer actionable); a decline passes `privateHints:
|
|
5699
|
+
// "own"`, closing only what the caller was shown, and `keepAuthorization`,
|
|
5700
|
+
// since its draft is only parked.
|
|
5701
|
+
function removeCompletionObligations(
|
|
5702
|
+
sessionId,
|
|
5703
|
+
predicate,
|
|
5704
|
+
env,
|
|
5705
|
+
{ privateHints = "all", keepAuthorization = false } = {},
|
|
5706
|
+
) {
|
|
5469
5707
|
const removed = [];
|
|
5470
5708
|
const durable = retainedRootsUsable(env);
|
|
5471
5709
|
const emptied = new Set();
|
|
5472
5710
|
for (const obligation of readCompletionObligations(sessionId, env, {
|
|
5473
5711
|
settle: true,
|
|
5712
|
+
privateHints,
|
|
5474
5713
|
})) {
|
|
5475
5714
|
if (!predicate(obligation)) continue;
|
|
5476
5715
|
try {
|
|
@@ -5496,7 +5735,9 @@ function removeCompletionObligations(sessionId, predicate, env) {
|
|
|
5496
5735
|
join(legacyPrivateCompletionObligationDir(env), `${hash}.json`),
|
|
5497
5736
|
{ force: true },
|
|
5498
5737
|
);
|
|
5499
|
-
const marker =
|
|
5738
|
+
const marker =
|
|
5739
|
+
!keepAuthorization &&
|
|
5740
|
+
privateAuthorizationMarkerPath(obligation.draft_id, env);
|
|
5500
5741
|
if (marker) rmSync(marker, { force: true });
|
|
5501
5742
|
}
|
|
5502
5743
|
removed.push(obligation);
|
|
@@ -5726,7 +5967,14 @@ export function recordPrivateLessonObligation(
|
|
|
5726
5967
|
const body = responseBodyFromToolResponse(response);
|
|
5727
5968
|
const draftId = completionIdentifier(body?.draft_id);
|
|
5728
5969
|
if (!/^pld_[A-Za-z0-9_-]{8,96}$/.test(draftId)) return null;
|
|
5729
|
-
|
|
5970
|
+
// The draft's own envelope says which exact action is due; the response's
|
|
5971
|
+
// exact next_action stands in only while the envelope cannot be read.
|
|
5972
|
+
// Nothing an agent can do (null): no hint, and the caller retires one.
|
|
5973
|
+
const envelope = readPrivateLessonEnvelope(draftId, env);
|
|
5974
|
+
const actionTool = envelope
|
|
5975
|
+
? (privateLessonDraftAction(envelope, env)?.tool ?? null)
|
|
5976
|
+
: privateLessonSubmissionAction(body, draftId);
|
|
5977
|
+
if (!actionTool) return null;
|
|
5730
5978
|
if (
|
|
5731
5979
|
body?.state === "awaiting_authorization" &&
|
|
5732
5980
|
body?.status === "not_submitted"
|
|
@@ -5740,21 +5988,8 @@ export function recordPrivateLessonObligation(
|
|
|
5740
5988
|
kind: "private_lesson",
|
|
5741
5989
|
engagement_count: Math.max(1, engagementCount),
|
|
5742
5990
|
draft_id: draftId,
|
|
5743
|
-
action_tool:
|
|
5744
|
-
"retry_scheduled",
|
|
5745
|
-
"awaiting_authorization",
|
|
5746
|
-
"awaiting_compatible_profile",
|
|
5747
|
-
].includes(body?.state)
|
|
5748
|
-
? "retry_private_lesson_candidate"
|
|
5749
|
-
: "submit_private_lesson_candidate",
|
|
5991
|
+
action_tool: actionTool,
|
|
5750
5992
|
next_retry_at: body?.next_retry_at ?? null,
|
|
5751
|
-
delivery_blocked:
|
|
5752
|
-
body?.status === "not_submitted" &&
|
|
5753
|
-
![
|
|
5754
|
-
"retry_scheduled",
|
|
5755
|
-
"awaiting_authorization",
|
|
5756
|
-
"awaiting_compatible_profile",
|
|
5757
|
-
].includes(body?.state),
|
|
5758
5993
|
failed_credential_digest:
|
|
5759
5994
|
body?.state === "awaiting_authorization" &&
|
|
5760
5995
|
body?.status === "not_submitted"
|
|
@@ -5812,29 +6047,41 @@ export function recordPrivateSkillObligation(
|
|
|
5812
6047
|
);
|
|
5813
6048
|
}
|
|
5814
6049
|
|
|
5815
|
-
|
|
6050
|
+
// A response's exact next_action for one draft: the named lane's submit or
|
|
6051
|
+
// retry tool with exactly { draft_id }.
|
|
6052
|
+
function exactPrivateDraftAction(value, draftId, lane) {
|
|
5816
6053
|
const action = value?.next_action;
|
|
6054
|
+
return action &&
|
|
6055
|
+
typeof action === "object" &&
|
|
6056
|
+
!Array.isArray(action) &&
|
|
6057
|
+
[
|
|
6058
|
+
`submit_private_${lane}_candidate`,
|
|
6059
|
+
`retry_private_${lane}_candidate`,
|
|
6060
|
+
].includes(action.tool) &&
|
|
6061
|
+
action.arguments &&
|
|
6062
|
+
typeof action.arguments === "object" &&
|
|
6063
|
+
!Array.isArray(action.arguments) &&
|
|
6064
|
+
Object.keys(action.arguments).join(",") === "draft_id" &&
|
|
6065
|
+
action.arguments.draft_id === draftId
|
|
6066
|
+
? action.tool
|
|
6067
|
+
: null;
|
|
6068
|
+
}
|
|
6069
|
+
|
|
6070
|
+
function privateLessonSubmissionAction(value, draftId) {
|
|
6071
|
+
return exactPrivateDraftAction(value, draftId, "lesson");
|
|
6072
|
+
}
|
|
6073
|
+
|
|
6074
|
+
function privateSkillSubmissionAction(value, draftId) {
|
|
6075
|
+
const tool = exactPrivateDraftAction(value, draftId, "skill");
|
|
5817
6076
|
if (
|
|
5818
|
-
!
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
![
|
|
5822
|
-
"submit_private_skill_candidate",
|
|
5823
|
-
"retry_private_skill_candidate",
|
|
5824
|
-
].includes(action.tool) ||
|
|
5825
|
-
!action.arguments ||
|
|
5826
|
-
typeof action.arguments !== "object" ||
|
|
5827
|
-
Array.isArray(action.arguments) ||
|
|
5828
|
-
Object.keys(action.arguments).join(",") !== "draft_id" ||
|
|
5829
|
-
action.arguments.draft_id !== draftId ||
|
|
5830
|
-
(action.tool === "submit_private_skill_candidate" &&
|
|
5831
|
-
value?.state !== "ready") ||
|
|
5832
|
-
(action.tool === "retry_private_skill_candidate" &&
|
|
6077
|
+
!tool ||
|
|
6078
|
+
(tool === "submit_private_skill_candidate" && value?.state !== "ready") ||
|
|
6079
|
+
(tool === "retry_private_skill_candidate" &&
|
|
5833
6080
|
!["retry_scheduled", "awaiting_authorization"].includes(value?.state))
|
|
5834
6081
|
) {
|
|
5835
6082
|
return null;
|
|
5836
6083
|
}
|
|
5837
|
-
return
|
|
6084
|
+
return tool;
|
|
5838
6085
|
}
|
|
5839
6086
|
|
|
5840
6087
|
function recordCaptureLearningObligation(
|
|
@@ -6187,10 +6434,17 @@ function retainedPayloadOpener(env) {
|
|
|
6187
6434
|
};
|
|
6188
6435
|
}
|
|
6189
6436
|
|
|
6437
|
+
// A private hint counts as pending only while its action is due.
|
|
6438
|
+
function completionObligationPending(obligation, env) {
|
|
6439
|
+
return isPrivateHint(obligation)
|
|
6440
|
+
? completionObligationIsDue(obligation, env)
|
|
6441
|
+
: !feedbackObligationDenied(obligation, env);
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6190
6444
|
function markEngagementHandledWhenClear(sessionId, env) {
|
|
6191
6445
|
const count = currentEngagementCount(sessionId, env);
|
|
6192
6446
|
const hasPending = heldCompletionObligations(sessionId, env).some(
|
|
6193
|
-
(obligation) =>
|
|
6447
|
+
(obligation) => completionObligationPending(obligation, env),
|
|
6194
6448
|
);
|
|
6195
6449
|
if (!hasPending && count > 0) {
|
|
6196
6450
|
writePromptedCount(sessionId, count, env);
|
|
@@ -6304,7 +6558,7 @@ export function observeSuccessfulCompletionTool(
|
|
|
6304
6558
|
(body?.version === "private-lesson-outbox-deletion-v1" &&
|
|
6305
6559
|
body?.reason === "explicit_confirmed_deletion"))
|
|
6306
6560
|
) {
|
|
6307
|
-
|
|
6561
|
+
clearPrivateDraftMarkers(input.draft_id, env);
|
|
6308
6562
|
closed = removeCompletionObligations(
|
|
6309
6563
|
sessionId,
|
|
6310
6564
|
(obligation) =>
|
|
@@ -6347,17 +6601,34 @@ export function observeSuccessfulCompletionTool(
|
|
|
6347
6601
|
if (followup) opened.push(followup);
|
|
6348
6602
|
} else if (tool === "decline_pending_action") {
|
|
6349
6603
|
// Only the observed call closes an action, in the caller's own ledger;
|
|
6350
|
-
// nothing in a message can.
|
|
6604
|
+
// nothing in a message can. A declined private line parks its draft
|
|
6605
|
+
// (keeping its authorization backoff), a declined notice for drafts
|
|
6606
|
+
// awaiting the user the drafts it offered; host_policy_denied is for
|
|
6607
|
+
// private lines and notices only. The held-safety notice names no
|
|
6608
|
+
// action to decline (an earlier client's did, and its decline changes
|
|
6609
|
+
// nothing): its delivery marked the drafts it showed.
|
|
6351
6610
|
const actionId = String(input.action_id ?? "");
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6611
|
+
const reason = String(input.reason ?? "");
|
|
6612
|
+
const privateOnly = reason === PRIVATE_DECLINE_REASON;
|
|
6613
|
+
if (PENDING_ACTION_DECLINE_REASONS.includes(reason) || privateOnly) {
|
|
6614
|
+
closed = removeCompletionObligations(
|
|
6615
|
+
sessionId,
|
|
6616
|
+
(obligation) =>
|
|
6617
|
+
(!privateOnly || isPrivateHint(obligation)) &&
|
|
6618
|
+
completionActionId(obligation) === actionId,
|
|
6619
|
+
env,
|
|
6620
|
+
{ privateHints: "own", keepAuthorization: true },
|
|
6621
|
+
);
|
|
6622
|
+
for (const hint of closed.filter(isPrivateHint)) {
|
|
6623
|
+
parkPrivateDraft(hint.draft_id, reason, env);
|
|
6624
|
+
}
|
|
6625
|
+
if (
|
|
6626
|
+
actionId === PRIVATE_ORPHAN_NOTICE_ACTION_ID &&
|
|
6627
|
+
organizationPrivateLessonAuthorized(env)
|
|
6628
|
+
) {
|
|
6629
|
+
parkPrivateDraftsAwaitingUser(sessionId, reason, env);
|
|
6630
|
+
}
|
|
6631
|
+
}
|
|
6361
6632
|
} else if (tool === "submit_remembrance") {
|
|
6362
6633
|
const digest = completionPayloadDigest(input);
|
|
6363
6634
|
const queryId = completionIdentifier(input?.interaction?.query_id);
|
|
@@ -6396,6 +6667,10 @@ export function observeSuccessfulCompletionTool(
|
|
|
6396
6667
|
) {
|
|
6397
6668
|
const body = responseBodyFromToolResponse(response);
|
|
6398
6669
|
const draftId = completionIdentifier(input.draft_id ?? body?.draft_id);
|
|
6670
|
+
// The host let this exact call through, whatever the server answered:
|
|
6671
|
+
// the lane is allowed again, and this explicit request unparks the draft.
|
|
6672
|
+
allowPrivateLane("pld_", env);
|
|
6673
|
+
clearPrivateDraftMarkers(draftId, env, ["parked"]);
|
|
6399
6674
|
if (body?.retained_locally === true) {
|
|
6400
6675
|
const retained = recordPrivateLessonObligation(
|
|
6401
6676
|
sessionId,
|
|
@@ -6430,13 +6705,15 @@ export function observeSuccessfulCompletionTool(
|
|
|
6430
6705
|
obligation.draft_id === draftId,
|
|
6431
6706
|
env,
|
|
6432
6707
|
);
|
|
6433
|
-
|
|
6708
|
+
clearPrivateDraftMarkers(draftId, env);
|
|
6434
6709
|
} else if (
|
|
6435
6710
|
tool === "submit_private_skill_candidate" ||
|
|
6436
6711
|
tool === "retry_private_skill_candidate"
|
|
6437
6712
|
) {
|
|
6438
6713
|
const body = responseBodyFromToolResponse(response);
|
|
6439
6714
|
const draftId = completionIdentifier(input.draft_id ?? body?.draft_id);
|
|
6715
|
+
allowPrivateLane("psd_", env);
|
|
6716
|
+
clearPrivateDraftMarkers(draftId, env, ["parked"]);
|
|
6440
6717
|
if (body?.retained_locally === true) {
|
|
6441
6718
|
const retained = recordPrivateSkillObligation(
|
|
6442
6719
|
sessionId,
|
|
@@ -6469,7 +6746,7 @@ export function observeSuccessfulCompletionTool(
|
|
|
6469
6746
|
obligation.draft_id === draftId,
|
|
6470
6747
|
env,
|
|
6471
6748
|
);
|
|
6472
|
-
|
|
6749
|
+
clearPrivateDraftMarkers(draftId, env);
|
|
6473
6750
|
}
|
|
6474
6751
|
|
|
6475
6752
|
const handled = COMPLETION_CONTRIBUTION_TOOLS.has(tool)
|
|
@@ -6547,13 +6824,8 @@ function preferenceOfferPointer(obligation, directSelections) {
|
|
|
6547
6824
|
return `; offered ${label}${tail}`;
|
|
6548
6825
|
}
|
|
6549
6826
|
|
|
6550
|
-
// A reminder lists each
|
|
6551
|
-
//
|
|
6552
|
-
// private-learning outbox and are handled there, not declined.
|
|
6553
|
-
function completionActionDeclinable(obligation) {
|
|
6554
|
-
return !["private_lesson", "private_skill"].includes(obligation.kind);
|
|
6555
|
-
}
|
|
6556
|
-
|
|
6827
|
+
// A reminder lists each action with this id, which decline_pending_action
|
|
6828
|
+
// echoes back. A declined private line parks its draft (parkPrivateDraft).
|
|
6557
6829
|
function completionActionId(obligation) {
|
|
6558
6830
|
return createHash("sha256")
|
|
6559
6831
|
.update(obligation.id, "utf8")
|
|
@@ -6593,7 +6865,8 @@ function completionObligationInstruction(
|
|
|
6593
6865
|
const shown = obligations.slice(0, 12);
|
|
6594
6866
|
// The first line with each tag carries the tag's explanation, so the
|
|
6595
6867
|
// context allocator keeps or drops the two together. Private-learning
|
|
6596
|
-
// lines
|
|
6868
|
+
// lines always come after the others; one a subagent of this session
|
|
6869
|
+
// captured says which.
|
|
6597
6870
|
const explained = new Set(
|
|
6598
6871
|
[...PROVENANCE_TAG_EXPLANATIONS.keys()].map((tag) =>
|
|
6599
6872
|
shown.find((obligation) => provenanceLabel(obligation) === tag),
|
|
@@ -6633,17 +6906,22 @@ function completionObligationInstruction(
|
|
|
6633
6906
|
? `- Reusable evidence${ids}${from}: ${unretained}; call submit_remembrance with the payload submit_feedback returned only if you still hold it verbatim.`
|
|
6634
6907
|
: `- Reusable evidence${ids}${from}: submit the exact next_step.submit_remembrance_payload returned by the preceding feedback call.`;
|
|
6635
6908
|
}
|
|
6909
|
+
const by =
|
|
6910
|
+
obligation.captured_by_agent &&
|
|
6911
|
+
obligation.captured_by_ledger !== retained.ledger
|
|
6912
|
+
? ` (captured by subagent ${obligation.captured_by_agent})`
|
|
6913
|
+
: "";
|
|
6636
6914
|
if (obligation.kind === "private_lesson") {
|
|
6637
|
-
return `- Private lesson: call ${obligation.action_tool ?? "submit_private_lesson_candidate"} with draft_id ${obligation.draft_id}.`;
|
|
6915
|
+
return `- Private lesson${by}${from}: call ${obligation.action_tool ?? "submit_private_lesson_candidate"} with draft_id ${obligation.draft_id}.`;
|
|
6638
6916
|
}
|
|
6639
6917
|
if (obligation.kind === "private_skill") {
|
|
6640
6918
|
if (obligation.action_tool === "inspect_private_learning_outbox") {
|
|
6641
|
-
return `- Recoverable private skill: call inspect_private_learning_outbox with private_skill_draft_id ${obligation.draft_id}, then follow its bounded agent reformulation instructions. Do not submit the held original.`;
|
|
6919
|
+
return `- Recoverable private skill${by}${from}: call inspect_private_learning_outbox with private_skill_draft_id ${obligation.draft_id}, then follow its bounded agent reformulation instructions. Do not submit the held original.`;
|
|
6642
6920
|
}
|
|
6643
6921
|
if (obligation.action_tool === "retry_private_skill_candidate") {
|
|
6644
|
-
return `- Retry private skill: call retry_private_skill_candidate with draft_id ${obligation.draft_id}.`;
|
|
6922
|
+
return `- Retry private skill${by}${from}: call retry_private_skill_candidate with draft_id ${obligation.draft_id}.`;
|
|
6645
6923
|
}
|
|
6646
|
-
return `- Complete private skill: call submit_private_skill_candidate with draft_id ${obligation.draft_id}.`;
|
|
6924
|
+
return `- Complete private skill${by}${from}: call submit_private_skill_candidate with draft_id ${obligation.draft_id}.`;
|
|
6647
6925
|
}
|
|
6648
6926
|
if (obligation.local_resupply_envelope) {
|
|
6649
6927
|
return `- Durable preference${from}: arguments kept on this machine (${retainedPayloadBytes(obligation)} bytes, ${obligation.payload_digest}); call local get_pending_action_payload with action_id ${completionActionId(obligation)} and ledger ${obligation.ledger ?? retained.ledger}, then record_preference with exactly the arguments it returns.`;
|
|
@@ -6658,9 +6936,7 @@ function completionObligationInstruction(
|
|
|
6658
6936
|
: `- Durable preference${from}: call record_preference with the exact next_action returned by capture_learning.`;
|
|
6659
6937
|
});
|
|
6660
6938
|
shown.forEach((obligation, index) => {
|
|
6661
|
-
|
|
6662
|
-
lines[index] += `\n action_id ${completionActionId(obligation)}`;
|
|
6663
|
-
}
|
|
6939
|
+
lines[index] += `\n action_id ${completionActionId(obligation)}`;
|
|
6664
6940
|
});
|
|
6665
6941
|
if (obligations.length > lines.length) {
|
|
6666
6942
|
lines.push(
|
|
@@ -6696,9 +6972,8 @@ function completionObligationInstruction(
|
|
|
6696
6972
|
return [
|
|
6697
6973
|
"Before finishing, silently complete only these pending Remembrance actions:",
|
|
6698
6974
|
...lines,
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
: null,
|
|
6975
|
+
PENDING_ACTION_DECLINE_GUIDANCE,
|
|
6976
|
+
shown.some(isPrivateHint) ? PRIVATE_DECLINE_GUIDANCE : null,
|
|
6702
6977
|
FEEDBACK_DENIAL_GUIDANCE,
|
|
6703
6978
|
"Do not mention routine hook, tool, or receipt output. Surface only a failure or required user action.",
|
|
6704
6979
|
]
|
|
@@ -6762,11 +7037,44 @@ export function pendingCompletionRecoveryInstruction(
|
|
|
6762
7037
|
// of more bytes than the cap is not opened, since its line cannot fit. A
|
|
6763
7038
|
// durable preference's arguments are never opened here: only its handle is
|
|
6764
7039
|
// shown.
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
7040
|
+
// The outboxes are read once for the whole prompt (readPrivateDrafts); the
|
|
7041
|
+
// ledger once, with every private hint on the machine, of which this session
|
|
7042
|
+
// tree's own become lines and the rest only feed the user decisions, which
|
|
7043
|
+
// a session the host runs for a subagent does not get (`notices: false`).
|
|
7044
|
+
export function pendingCompletionRecovery(
|
|
7045
|
+
sessionId,
|
|
7046
|
+
env = process.env,
|
|
7047
|
+
{ notices = true } = {},
|
|
7048
|
+
) {
|
|
7049
|
+
const drafts = readPrivateDrafts(env);
|
|
7050
|
+
const held = readCompletionObligations(sessionId, env, {
|
|
7051
|
+
privateHints: "all",
|
|
7052
|
+
});
|
|
7053
|
+
const retired = retireUnactionablePrivateLessonHints(
|
|
7054
|
+
held.filter(isPrivateHint),
|
|
7055
|
+
drafts,
|
|
7056
|
+
env,
|
|
7057
|
+
);
|
|
7058
|
+
const hints = held.filter(
|
|
7059
|
+
(obligation) => isPrivateHint(obligation) && !retired.has(obligation.id),
|
|
7060
|
+
);
|
|
7061
|
+
const obligations = duePendingActions(
|
|
7062
|
+
[
|
|
7063
|
+
...held.filter((obligation) => !isPrivateHint(obligation)),
|
|
7064
|
+
...hints
|
|
7065
|
+
.filter((hint) => privateHintOwnedBy(hint, sessionId))
|
|
7066
|
+
.slice(0, COMPLETION_OBLIGATION_LIMIT),
|
|
7067
|
+
],
|
|
7068
|
+
env,
|
|
7069
|
+
drafts,
|
|
7070
|
+
);
|
|
7071
|
+
const lifecycle = privateLearningSessionRecovery(
|
|
7072
|
+
sessionId,
|
|
7073
|
+
hints,
|
|
7074
|
+
drafts,
|
|
7075
|
+
env,
|
|
7076
|
+
{ notices },
|
|
6768
7077
|
);
|
|
6769
|
-
const lifecycle = privateLearningLifecycleRecoveryInstruction(env);
|
|
6770
7078
|
if (obligations.length === 0 && !lifecycle) return null;
|
|
6771
7079
|
const directSelections = readDirectSelectionSurfaces(sessionId, env);
|
|
6772
7080
|
const overflow = readCompletionObligationOverflow(sessionId, env);
|
|
@@ -6868,8 +7176,8 @@ export function markCurrentEngagementHandled(sessionId, env = process.env) {
|
|
|
6868
7176
|
);
|
|
6869
7177
|
if (
|
|
6870
7178
|
count > 0 &&
|
|
6871
|
-
!heldCompletionObligations(sessionId, env).some(
|
|
6872
|
-
(obligation
|
|
7179
|
+
!heldCompletionObligations(sessionId, env).some((obligation) =>
|
|
7180
|
+
completionObligationPending(obligation, env),
|
|
6873
7181
|
)
|
|
6874
7182
|
) {
|
|
6875
7183
|
writePromptedCount(sessionId, count, env);
|
|
@@ -6909,6 +7217,79 @@ export function readUserPromptCount(sessionId, env = process.env) {
|
|
|
6909
7217
|
return readCountFile(userPromptCountPath(sessionId, env));
|
|
6910
7218
|
}
|
|
6911
7219
|
|
|
7220
|
+
// A session the host showed to be a main session, not one it runs for a
|
|
7221
|
+
// subagent: OpenCode's session.created names no parent session. Kept,
|
|
7222
|
+
// content-free, with the session's other usage markers, so the host's next
|
|
7223
|
+
// process still knows a session it resumes. On such a host only these get
|
|
7224
|
+
// the user decisions about retained drafts (`subagentSession`).
|
|
7225
|
+
function mainSessionPath(sessionId, env) {
|
|
7226
|
+
return join(usageDir(env), `${sessionHash(sessionId)}.main-session`);
|
|
7227
|
+
}
|
|
7228
|
+
|
|
7229
|
+
export function recordMainSession(sessionId, env = process.env) {
|
|
7230
|
+
return writePrivateUsageMarker(mainSessionPath(sessionId, env), "main\n");
|
|
7231
|
+
}
|
|
7232
|
+
|
|
7233
|
+
export function isRecordedMainSession(sessionId, env = process.env) {
|
|
7234
|
+
try {
|
|
7235
|
+
return readSecureHookFile(mainSessionPath(sessionId, env), 16) === "main\n";
|
|
7236
|
+
} catch {
|
|
7237
|
+
return false;
|
|
7238
|
+
}
|
|
7239
|
+
}
|
|
7240
|
+
|
|
7241
|
+
// A child session's parent, as the host named it when it created the child
|
|
7242
|
+
// (OpenCode's session.created parentID): the parent's session id, kept
|
|
7243
|
+
// beside the main-session marker, so every later hook of the child, in any
|
|
7244
|
+
// process, finds the root of its session tree (sessionTreeRoot).
|
|
7245
|
+
const SESSION_TREE_DEPTH_LIMIT = 8;
|
|
7246
|
+
|
|
7247
|
+
function sessionParentPath(sessionId, env) {
|
|
7248
|
+
return join(usageDir(env), `${sessionHash(sessionId)}.parent-session`);
|
|
7249
|
+
}
|
|
7250
|
+
|
|
7251
|
+
export function recordSessionParent(sessionId, parentId, env = process.env) {
|
|
7252
|
+
const parent = normalizeNativeSessionId(parentId);
|
|
7253
|
+
return Boolean(
|
|
7254
|
+
parent &&
|
|
7255
|
+
writePrivateUsageMarker(sessionParentPath(sessionId, env), `${parent}\n`),
|
|
7256
|
+
);
|
|
7257
|
+
}
|
|
7258
|
+
|
|
7259
|
+
// The ledger a session's usage, debts and drafts are kept under. A child
|
|
7260
|
+
// session's is its tree root's agent ledger (<root>:agent:<child>), as a
|
|
7261
|
+
// subagent's is, so its drafts belong to the root's session tree: listed
|
|
7262
|
+
// silently there and to the child itself, never as a user decision to the
|
|
7263
|
+
// child. Any other session's, a child whose root is not proven among them,
|
|
7264
|
+
// is its own id.
|
|
7265
|
+
export function sessionTreeLedgerId(sessionId, env = process.env) {
|
|
7266
|
+
const root = sessionTreeRoot(sessionId, env);
|
|
7267
|
+
return root && normalizeNativeSessionId(sessionId)
|
|
7268
|
+
? agentLedgerIdFor({ session_id: root, agent_id: sessionId })
|
|
7269
|
+
: sessionId;
|
|
7270
|
+
}
|
|
7271
|
+
|
|
7272
|
+
// The root of a child session's tree, its recorded parent links followed at
|
|
7273
|
+
// most SESSION_TREE_DEPTH_LIMIT times. Null for a session with no recorded
|
|
7274
|
+
// parent, and for a chain that does not end within the bound, which proves
|
|
7275
|
+
// nothing.
|
|
7276
|
+
function sessionTreeRoot(sessionId, env) {
|
|
7277
|
+
let current = sessionId;
|
|
7278
|
+
for (let depth = 0; depth <= SESSION_TREE_DEPTH_LIMIT; depth += 1) {
|
|
7279
|
+
let parent = null;
|
|
7280
|
+
try {
|
|
7281
|
+
parent = normalizeNativeSessionId(
|
|
7282
|
+
readSecureHookFile(sessionParentPath(current, env), 512).trim(),
|
|
7283
|
+
);
|
|
7284
|
+
} catch {
|
|
7285
|
+
// No recorded parent: `current` is the root.
|
|
7286
|
+
}
|
|
7287
|
+
if (!parent) return depth === 0 ? null : current;
|
|
7288
|
+
current = parent;
|
|
7289
|
+
}
|
|
7290
|
+
return null;
|
|
7291
|
+
}
|
|
7292
|
+
|
|
6912
7293
|
export function recordDirectiveSurface(
|
|
6913
7294
|
sessionId,
|
|
6914
7295
|
directive,
|
|
@@ -7524,18 +7905,6 @@ export function clearHighMatchSurfaceIfOpened(
|
|
|
7524
7905
|
const normalizedTool = String(toolName ?? "")
|
|
7525
7906
|
.trim()
|
|
7526
7907
|
.toLowerCase();
|
|
7527
|
-
if (
|
|
7528
|
-
normalizedTool.endsWith("submit_private_lesson_candidate") ||
|
|
7529
|
-
normalizedTool.endsWith("retry_private_lesson_candidate") ||
|
|
7530
|
-
normalizedTool.endsWith("submit_private_skill_candidate") ||
|
|
7531
|
-
normalizedTool.endsWith("retry_private_skill_candidate")
|
|
7532
|
-
) {
|
|
7533
|
-
// The PostToolUse adapter calls this function only after a successful tool
|
|
7534
|
-
// response. Clear the content-free denial suppression so future retained
|
|
7535
|
-
// drafts can resume normal lifecycle recovery after exact-action approval.
|
|
7536
|
-
clearPrivateLessonHostPolicyDenials(env);
|
|
7537
|
-
return false;
|
|
7538
|
-
}
|
|
7539
7908
|
const targetType = normalizedTool.endsWith("get_resource")
|
|
7540
7909
|
? "resource"
|
|
7541
7910
|
: normalizedTool.endsWith("get_skill")
|
|
@@ -8094,8 +8463,17 @@ export function foldTurnLedgerIntoSession(
|
|
|
8094
8463
|
const overflowPath = completionObligationOverflowPath(turn, env);
|
|
8095
8464
|
// OpenClaw hands a subagent's session ledger up through here, `forward`:
|
|
8096
8465
|
// it leaves its marker even when it holds nothing, so a nested subagent
|
|
8097
|
-
// whose end comes later still reaches where it went.
|
|
8098
|
-
|
|
8466
|
+
// whose end comes later still reaches where it went. Its private hints are
|
|
8467
|
+
// held per machine, not in its homes, so the ones it owns become the
|
|
8468
|
+
// session's tree's here (adoptPrivateHint), whatever else it holds.
|
|
8469
|
+
if (forward) {
|
|
8470
|
+
writeCompletionObligationForward(turn, session, null, env);
|
|
8471
|
+
for (const hint of privateHintsOnMachine(turn, env)) {
|
|
8472
|
+
if (privateHintOwnedBy(hint, turn) && !privateHintOwnedBy(hint, session)) {
|
|
8473
|
+
adoptPrivateHint(hint, session, env);
|
|
8474
|
+
}
|
|
8475
|
+
}
|
|
8476
|
+
}
|
|
8099
8477
|
// Once folded, a turn leaves nothing behind: one check per home per hook.
|
|
8100
8478
|
// A ledger that only points to debts (an OpenClaw subagent's whose own
|
|
8101
8479
|
// subagent left them) is folded too.
|
|
@@ -8196,141 +8574,226 @@ function privateSkillOutboxDirectoryForHook(env = process.env) {
|
|
|
8196
8574
|
);
|
|
8197
8575
|
}
|
|
8198
8576
|
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8577
|
+
const PRIVATE_LESSON_STATES = [
|
|
8578
|
+
"ready",
|
|
8579
|
+
"awaiting_authorization",
|
|
8580
|
+
"retry_scheduled",
|
|
8581
|
+
"awaiting_compatible_profile",
|
|
8582
|
+
"held_safety",
|
|
8583
|
+
"held_policy",
|
|
8584
|
+
"held_validation",
|
|
8585
|
+
"submitted",
|
|
8586
|
+
"superseded_redactor",
|
|
8587
|
+
];
|
|
8588
|
+
const PRIVATE_HOLD_TELEMETRY_STATUSES = [
|
|
8589
|
+
"not_required",
|
|
8590
|
+
"pending",
|
|
8591
|
+
"awaiting_authorization",
|
|
8592
|
+
"retry_scheduled",
|
|
8593
|
+
"awaiting_compatible_profile",
|
|
8594
|
+
"submitted",
|
|
8595
|
+
"disabled",
|
|
8596
|
+
];
|
|
8597
|
+
// The held_policy codes a retry refreshes, as the server's retry gate allows.
|
|
8598
|
+
const PRIVATE_POLICY_REFRESH_CODES = [
|
|
8599
|
+
"policy_expired",
|
|
8600
|
+
"policy_changed",
|
|
8601
|
+
"policy_sequence_conflict",
|
|
8602
|
+
"unsupported_redaction_version",
|
|
8603
|
+
"unsupported_redactor_digest",
|
|
8604
|
+
];
|
|
8605
|
+
|
|
8606
|
+
function privateLessonEnvelopeValid(value, draftId) {
|
|
8607
|
+
return (
|
|
8608
|
+
value?.version === "private-lesson-outbox-envelope-v1" &&
|
|
8609
|
+
value.draft_id === draftId &&
|
|
8610
|
+
PRIVATE_LESSON_STATES.includes(value.state) &&
|
|
8611
|
+
(value.last_error_code == null ||
|
|
8612
|
+
typeof value.last_error_code === "string") &&
|
|
8613
|
+
PRIVATE_HOLD_TELEMETRY_STATUSES.includes(value.hold_telemetry_status)
|
|
8614
|
+
);
|
|
8615
|
+
}
|
|
8616
|
+
|
|
8617
|
+
// The one exact agent action a lesson draft's content-free envelope allows
|
|
8618
|
+
// now, as the server's retry gate permits it: { tool, count, due }, where
|
|
8619
|
+
// `count` names the lifecycle tally it adds to. Null when only inspection, a
|
|
8620
|
+
// user decision or nothing is left: held_validation, a classified safety
|
|
8621
|
+
// hold (or any safety hold while health reporting is off), a policy hold a
|
|
8622
|
+
// retry cannot refresh, a terminal state. A line, a notice and the lifecycle
|
|
8623
|
+
// counts all come from this one rule.
|
|
8624
|
+
function privateLessonDraftAction(envelope, env) {
|
|
8625
|
+
const submit = "submit_private_lesson_candidate";
|
|
8626
|
+
const retry = "retry_private_lesson_candidate";
|
|
8627
|
+
const whenDue = (count) => ({
|
|
8628
|
+
tool: retry,
|
|
8629
|
+
count,
|
|
8630
|
+
due: retryIsDue(envelope.next_retry_at),
|
|
8631
|
+
});
|
|
8632
|
+
const authorization = () => ({
|
|
8633
|
+
tool: retry,
|
|
8634
|
+
count: "awaiting_authorization",
|
|
8635
|
+
due: privateAuthorizationRetryIsDue(
|
|
8636
|
+
envelope.draft_id,
|
|
8637
|
+
envelope.updated_at,
|
|
8638
|
+
env,
|
|
8639
|
+
),
|
|
8640
|
+
});
|
|
8641
|
+
const { state } = envelope;
|
|
8642
|
+
if (state === "ready") return { tool: submit, count: "ready", due: true };
|
|
8643
|
+
if (state === "retry_scheduled" || state === "awaiting_compatible_profile")
|
|
8644
|
+
return whenDue(state);
|
|
8645
|
+
if (state === "awaiting_authorization") return authorization();
|
|
8646
|
+
if (state === "held_policy") {
|
|
8647
|
+
return PRIVATE_POLICY_REFRESH_CODES.includes(envelope.last_error_code)
|
|
8648
|
+
? { tool: retry, count: "held_policy_refresh", due: true }
|
|
8649
|
+
: null;
|
|
8650
|
+
}
|
|
8651
|
+
if (state !== "held_safety" || !privateLessonHealthReportingEnabledForHook(env))
|
|
8652
|
+
return null;
|
|
8653
|
+
const status = envelope.hold_telemetry_status;
|
|
8654
|
+
if (status === "pending")
|
|
8655
|
+
return { tool: submit, count: "hold_telemetry_pending", due: true };
|
|
8656
|
+
if (status === "retry_scheduled" || status === "awaiting_compatible_profile")
|
|
8657
|
+
return whenDue(`hold_telemetry_${status}`);
|
|
8658
|
+
return status === "awaiting_authorization" ? authorization() : null;
|
|
8659
|
+
}
|
|
8660
|
+
|
|
8661
|
+
// One read of an outbox, bounded by its directory: each draft named there
|
|
8662
|
+
// with what `evaluate` makes of its envelope ({ envelope: null } when it
|
|
8663
|
+
// cannot be read or trusted). `readable` is false when no organization
|
|
8664
|
+
// credential applies or the directory itself is refused; nothing is then
|
|
8665
|
+
// known about any draft.
|
|
8666
|
+
function scanPrivateOutbox(env, directory, pattern, maxBytes, evaluate) {
|
|
8667
|
+
const drafts = new Map();
|
|
8668
|
+
if (!privateOutboxReadable(env, directory)) return { readable: false, drafts };
|
|
8202
8669
|
try {
|
|
8203
|
-
const
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
(isPosixHookRuntime() && (directoryMetadata.mode & 0o077) !== 0)
|
|
8208
|
-
) {
|
|
8209
|
-
return null;
|
|
8210
|
-
}
|
|
8211
|
-
assertCurrentHookUser(directoryMetadata.uid);
|
|
8212
|
-
const entries = readdirSync(directory, { withFileTypes: true });
|
|
8213
|
-
const counts = {
|
|
8214
|
-
ready: 0,
|
|
8215
|
-
retry_scheduled: 0,
|
|
8216
|
-
awaiting_authorization: 0,
|
|
8217
|
-
awaiting_compatible_profile: 0,
|
|
8218
|
-
held_policy_refresh: 0,
|
|
8219
|
-
hold_telemetry_pending: 0,
|
|
8220
|
-
hold_telemetry_retry_scheduled: 0,
|
|
8221
|
-
hold_telemetry_awaiting_compatible_profile: 0,
|
|
8222
|
-
};
|
|
8223
|
-
const authorizationIds = [];
|
|
8224
|
-
for (const entry of entries) {
|
|
8225
|
-
if (!entry.name.endsWith(".lesson.json")) continue;
|
|
8670
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
8671
|
+
const draftId = entry.name.match(pattern)?.[1];
|
|
8672
|
+
if (!draftId) continue;
|
|
8673
|
+
let known = null;
|
|
8226
8674
|
try {
|
|
8227
|
-
if (entry.isSymbolicLink()
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
join(directory, entry.name),
|
|
8233
|
-
MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
|
|
8234
|
-
),
|
|
8235
|
-
);
|
|
8236
|
-
if (
|
|
8237
|
-
parsed?.version !== "private-lesson-outbox-envelope-v1" ||
|
|
8238
|
-
parsed?.draft_id !== expectedDraftId ||
|
|
8239
|
-
![
|
|
8240
|
-
"ready",
|
|
8241
|
-
"awaiting_authorization",
|
|
8242
|
-
"retry_scheduled",
|
|
8243
|
-
"awaiting_compatible_profile",
|
|
8244
|
-
"held_safety",
|
|
8245
|
-
"held_policy",
|
|
8246
|
-
"held_validation",
|
|
8247
|
-
"submitted",
|
|
8248
|
-
"superseded_redactor",
|
|
8249
|
-
].includes(parsed?.state) ||
|
|
8250
|
-
(parsed?.last_error_code !== undefined &&
|
|
8251
|
-
parsed?.last_error_code !== null &&
|
|
8252
|
-
typeof parsed?.last_error_code !== "string") ||
|
|
8253
|
-
![
|
|
8254
|
-
"not_required",
|
|
8255
|
-
"pending",
|
|
8256
|
-
"awaiting_authorization",
|
|
8257
|
-
"retry_scheduled",
|
|
8258
|
-
"awaiting_compatible_profile",
|
|
8259
|
-
"submitted",
|
|
8260
|
-
"disabled",
|
|
8261
|
-
].includes(parsed?.hold_telemetry_status)
|
|
8262
|
-
) {
|
|
8263
|
-
continue;
|
|
8264
|
-
}
|
|
8265
|
-
if (
|
|
8266
|
-
parsed.state === "ready" ||
|
|
8267
|
-
((parsed.state === "retry_scheduled" ||
|
|
8268
|
-
parsed.state === "awaiting_compatible_profile") &&
|
|
8269
|
-
retryIsDue(parsed.next_retry_at))
|
|
8270
|
-
) {
|
|
8271
|
-
counts[parsed.state] += 1;
|
|
8272
|
-
}
|
|
8273
|
-
if (
|
|
8274
|
-
(parsed.state === "awaiting_authorization" ||
|
|
8275
|
-
(parsed.state === "held_safety" &&
|
|
8276
|
-
parsed.hold_telemetry_status === "awaiting_authorization" &&
|
|
8277
|
-
privateLessonHealthReportingEnabledForHook(env))) &&
|
|
8278
|
-
privateAuthorizationRetryIsDue(
|
|
8279
|
-
parsed.draft_id,
|
|
8280
|
-
parsed.updated_at,
|
|
8281
|
-
env,
|
|
8282
|
-
)
|
|
8283
|
-
) {
|
|
8284
|
-
counts.awaiting_authorization += 1;
|
|
8285
|
-
authorizationIds.push(parsed.draft_id);
|
|
8286
|
-
}
|
|
8287
|
-
if (
|
|
8288
|
-
parsed.state === "held_policy" &&
|
|
8289
|
-
[
|
|
8290
|
-
"policy_expired",
|
|
8291
|
-
"policy_changed",
|
|
8292
|
-
"policy_sequence_conflict",
|
|
8293
|
-
"unsupported_redaction_version",
|
|
8294
|
-
"unsupported_redactor_digest",
|
|
8295
|
-
].includes(parsed.last_error_code)
|
|
8296
|
-
) {
|
|
8297
|
-
counts.held_policy_refresh += 1;
|
|
8298
|
-
}
|
|
8299
|
-
if (
|
|
8300
|
-
privateLessonHealthReportingEnabledForHook(env) &&
|
|
8301
|
-
parsed.state === "held_safety" &&
|
|
8302
|
-
parsed.hold_telemetry_status === "pending"
|
|
8303
|
-
) {
|
|
8304
|
-
counts.hold_telemetry_pending += 1;
|
|
8305
|
-
}
|
|
8306
|
-
if (
|
|
8307
|
-
privateLessonHealthReportingEnabledForHook(env) &&
|
|
8308
|
-
parsed.state === "held_safety" &&
|
|
8309
|
-
(parsed.hold_telemetry_status === "retry_scheduled" ||
|
|
8310
|
-
parsed.hold_telemetry_status === "awaiting_compatible_profile") &&
|
|
8311
|
-
retryIsDue(parsed.next_retry_at)
|
|
8312
|
-
) {
|
|
8313
|
-
counts[
|
|
8314
|
-
parsed.hold_telemetry_status === "awaiting_compatible_profile"
|
|
8315
|
-
? "hold_telemetry_awaiting_compatible_profile"
|
|
8316
|
-
: "hold_telemetry_retry_scheduled"
|
|
8317
|
-
] += 1;
|
|
8675
|
+
if (!entry.isSymbolicLink() && entry.isFile()) {
|
|
8676
|
+
known = evaluate(
|
|
8677
|
+
JSON.parse(readSecureHookFile(join(directory, entry.name), maxBytes)),
|
|
8678
|
+
draftId,
|
|
8679
|
+
);
|
|
8318
8680
|
}
|
|
8319
8681
|
} catch {
|
|
8320
8682
|
// An interrupted or unreadable entry must not hide independent drafts.
|
|
8321
8683
|
}
|
|
8684
|
+
drafts.set(draftId, known ?? { envelope: null });
|
|
8322
8685
|
}
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8686
|
+
return { readable: true, drafts };
|
|
8687
|
+
} catch {
|
|
8688
|
+
return { readable: false, drafts: new Map() };
|
|
8689
|
+
}
|
|
8690
|
+
}
|
|
8691
|
+
|
|
8692
|
+
// Whether an outbox can answer for its drafts: an organization credential
|
|
8693
|
+
// applies and its directory is the current user's own, private one.
|
|
8694
|
+
function privateOutboxReadable(env, directory) {
|
|
8695
|
+
if (!organizationPrivateLessonAuthorized(env)) return false;
|
|
8696
|
+
try {
|
|
8697
|
+
const metadata = lstatSync(directory);
|
|
8698
|
+
if (
|
|
8699
|
+
metadata.isSymbolicLink() ||
|
|
8700
|
+
!metadata.isDirectory() ||
|
|
8701
|
+
(isPosixHookRuntime() && (metadata.mode & 0o077) !== 0)
|
|
8702
|
+
) {
|
|
8703
|
+
return false;
|
|
8704
|
+
}
|
|
8705
|
+
assertCurrentHookUser(metadata.uid);
|
|
8706
|
+
return true;
|
|
8707
|
+
} catch {
|
|
8708
|
+
return false;
|
|
8709
|
+
}
|
|
8710
|
+
}
|
|
8711
|
+
|
|
8712
|
+
function scanPrivateLessonOutbox(env) {
|
|
8713
|
+
return scanPrivateOutbox(
|
|
8714
|
+
env,
|
|
8715
|
+
privateLessonOutboxDirectoryForHook(env),
|
|
8716
|
+
/^(pld_[A-Za-z0-9_-]{8,96})\.lesson\.json$/,
|
|
8717
|
+
MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
|
|
8718
|
+
(envelope, draftId) =>
|
|
8719
|
+
privateLessonEnvelopeValid(envelope, draftId)
|
|
8720
|
+
? { envelope, action: privateLessonDraftAction(envelope, env) }
|
|
8721
|
+
: null,
|
|
8722
|
+
);
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
// One draft's envelope, or null when it cannot be read or trusted.
|
|
8726
|
+
function readPrivateLessonEnvelope(draftId, env) {
|
|
8727
|
+
try {
|
|
8728
|
+
const envelope = JSON.parse(
|
|
8729
|
+
readSecureHookFile(
|
|
8730
|
+
join(privateLessonOutboxDirectoryForHook(env), `${draftId}.lesson.json`),
|
|
8731
|
+
MAX_PRIVATE_LESSON_ENVELOPE_BYTES,
|
|
8732
|
+
),
|
|
8733
|
+
);
|
|
8734
|
+
return privateLessonEnvelopeValid(envelope, draftId) ? envelope : null;
|
|
8329
8735
|
} catch {
|
|
8330
8736
|
return null;
|
|
8331
8737
|
}
|
|
8332
8738
|
}
|
|
8333
8739
|
|
|
8740
|
+
// What a lesson hint's draft allows, as its outbox answers: { action } (null
|
|
8741
|
+
// when no agent action is due from it, or no such draft is retained), or
|
|
8742
|
+
// null when the outbox cannot answer and the hint's recorded fields decide.
|
|
8743
|
+
// `scan` is this prompt's scanPrivateLessonOutbox; without it, one read.
|
|
8744
|
+
function privateLessonHintAction(draftId, env, scan = null) {
|
|
8745
|
+
if (!scan) {
|
|
8746
|
+
const envelope = readPrivateLessonEnvelope(draftId, env);
|
|
8747
|
+
if (envelope) return { action: privateLessonDraftAction(envelope, env) };
|
|
8748
|
+
// A readable outbox without the draft has nothing to act on, as a scan
|
|
8749
|
+
// finds it.
|
|
8750
|
+
const directory = privateLessonOutboxDirectoryForHook(env);
|
|
8751
|
+
try {
|
|
8752
|
+
lstatSync(join(directory, `${draftId}.lesson.json`));
|
|
8753
|
+
return null;
|
|
8754
|
+
} catch (error) {
|
|
8755
|
+
return error?.code === "ENOENT" && privateOutboxReadable(env, directory)
|
|
8756
|
+
? { action: null }
|
|
8757
|
+
: null;
|
|
8758
|
+
}
|
|
8759
|
+
}
|
|
8760
|
+
if (!scan.readable) return null;
|
|
8761
|
+
const entry = scan.drafts.get(draftId);
|
|
8762
|
+
if (!entry) return { action: null };
|
|
8763
|
+
return entry.envelope ? { action: entry.action } : null;
|
|
8764
|
+
}
|
|
8765
|
+
|
|
8766
|
+
// `scan` (scanPrivateLessonOutbox) may hold only some drafts: the counts are
|
|
8767
|
+
// theirs.
|
|
8768
|
+
export function readPrivateLessonLifecycleState(
|
|
8769
|
+
env = process.env,
|
|
8770
|
+
scan = scanPrivateLessonOutbox(env),
|
|
8771
|
+
) {
|
|
8772
|
+
if (!scan.readable) return null;
|
|
8773
|
+
const counts = {
|
|
8774
|
+
ready: 0,
|
|
8775
|
+
retry_scheduled: 0,
|
|
8776
|
+
awaiting_authorization: 0,
|
|
8777
|
+
awaiting_compatible_profile: 0,
|
|
8778
|
+
held_policy_refresh: 0,
|
|
8779
|
+
hold_telemetry_pending: 0,
|
|
8780
|
+
hold_telemetry_retry_scheduled: 0,
|
|
8781
|
+
hold_telemetry_awaiting_compatible_profile: 0,
|
|
8782
|
+
};
|
|
8783
|
+
const authorizationIds = [];
|
|
8784
|
+
for (const [draftId, { action }] of scan.drafts) {
|
|
8785
|
+
if (!action?.due) continue;
|
|
8786
|
+
counts[action.count] += 1;
|
|
8787
|
+
if (action.count === "awaiting_authorization") authorizationIds.push(draftId);
|
|
8788
|
+
}
|
|
8789
|
+
if (Object.values(counts).every((count) => count === 0)) return null;
|
|
8790
|
+
return {
|
|
8791
|
+
...counts,
|
|
8792
|
+
next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
|
|
8793
|
+
host_denial_active: recentPrivateLessonHostDenial(env),
|
|
8794
|
+
};
|
|
8795
|
+
}
|
|
8796
|
+
|
|
8334
8797
|
function retryIsDue(value) {
|
|
8335
8798
|
if (!value) return true;
|
|
8336
8799
|
const timestamp = Date.parse(String(value));
|
|
@@ -8730,159 +9193,150 @@ export async function bindPrivateSkillNativeCapture(
|
|
|
8730
9193
|
}
|
|
8731
9194
|
}
|
|
8732
9195
|
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
9196
|
+
// The lifecycle tally a private skill draft adds to now (a count key of
|
|
9197
|
+
// readPrivateSkillLifecycleState), from its content-free envelope and
|
|
9198
|
+
// recovery action; null when nothing is due from it.
|
|
9199
|
+
function privateSkillDraftCategory(parsed, env) {
|
|
9200
|
+
const action = privateSkillRecoveryAction(parsed, true);
|
|
9201
|
+
if (
|
|
9202
|
+
parsed.state === "awaiting_authorization" &&
|
|
9203
|
+
privateAuthorizationRetryIsDue(parsed.draft_id, parsed.updated_at, env)
|
|
9204
|
+
) {
|
|
9205
|
+
return "awaiting_authorization";
|
|
9206
|
+
}
|
|
9207
|
+
const staleInspectorAction =
|
|
9208
|
+
action &&
|
|
9209
|
+
typeof action.inspector_digest === "string" &&
|
|
9210
|
+
action.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
|
|
9211
|
+
((action.kind === "submit_candidate" && parsed.state === "ready") ||
|
|
9212
|
+
(["reformulate", "retry_operational", "capture_native_context"].includes(
|
|
9213
|
+
action.kind,
|
|
9214
|
+
) &&
|
|
9215
|
+
parsed.state === "held_validation"));
|
|
9216
|
+
const staleInspectorBlockedRecovery =
|
|
9217
|
+
action === null &&
|
|
9218
|
+
parsed.next_action === null &&
|
|
9219
|
+
typeof parsed.inspector_digest === "string" &&
|
|
9220
|
+
parsed.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
|
|
9221
|
+
parsed.state === "held_validation" &&
|
|
9222
|
+
parsed.reformulation_available === false &&
|
|
9223
|
+
typeof parsed.reformulation_reason === "string" &&
|
|
9224
|
+
Number.isSafeInteger(parsed.reformulation_attempt_count) &&
|
|
9225
|
+
Number.isSafeInteger(parsed.recovery_epoch_attempt_count) &&
|
|
9226
|
+
parsed.reformulation_attempt_count - parsed.recovery_epoch_attempt_count >=
|
|
9227
|
+
3;
|
|
9228
|
+
if (staleInspectorAction || staleInspectorBlockedRecovery)
|
|
9229
|
+
return "inspector_refresh";
|
|
9230
|
+
if (action?.kind === "submit_candidate") return "ready";
|
|
9231
|
+
if (action?.kind === "retry_delivery" && retryIsDue(parsed.next_retry_at))
|
|
9232
|
+
return "retry_delivery";
|
|
9233
|
+
if (
|
|
9234
|
+
action?.kind === "retry_resolution" &&
|
|
9235
|
+
(parsed.state === "held_validation" ||
|
|
9236
|
+
(parsed.state === "retry_scheduled" && retryIsDue(parsed.next_retry_at)))
|
|
9237
|
+
) {
|
|
9238
|
+
return "retry_resolution";
|
|
9239
|
+
}
|
|
9240
|
+
return (
|
|
9241
|
+
{
|
|
9242
|
+
reformulate: "reformulation_available",
|
|
9243
|
+
retry_operational: "operational_retry",
|
|
9244
|
+
reinspect: "inspector_refresh",
|
|
9245
|
+
capture_native_context: "native_context_needed",
|
|
9246
|
+
}[action?.kind] ?? null
|
|
9247
|
+
);
|
|
9248
|
+
}
|
|
9249
|
+
|
|
9250
|
+
function scanPrivateSkillOutbox(env) {
|
|
9251
|
+
return scanPrivateOutbox(
|
|
9252
|
+
env,
|
|
9253
|
+
privateSkillOutboxDirectoryForHook(env),
|
|
9254
|
+
/^(psd_[A-Za-z0-9_-]{8,96})\.json$/,
|
|
9255
|
+
MAX_PRIVATE_SKILL_ENVELOPE_BYTES,
|
|
9256
|
+
(envelope, draftId) =>
|
|
9257
|
+
envelope?.version === "private-skill-outbox-envelope-v1" &&
|
|
9258
|
+
envelope.draft_id === draftId &&
|
|
9259
|
+
[
|
|
9260
|
+
"ready",
|
|
9261
|
+
"awaiting_authorization",
|
|
9262
|
+
"retry_scheduled",
|
|
9263
|
+
"held_safety",
|
|
9264
|
+
"held_validation",
|
|
9265
|
+
].includes(envelope.state)
|
|
9266
|
+
? { envelope, category: privateSkillDraftCategory(envelope, env) }
|
|
9267
|
+
: null,
|
|
9268
|
+
);
|
|
9269
|
+
}
|
|
9270
|
+
|
|
9271
|
+
// `scan` (scanPrivateSkillOutbox) may hold only some drafts: the counts are
|
|
9272
|
+
// theirs.
|
|
9273
|
+
export function readPrivateSkillLifecycleState(
|
|
9274
|
+
env = process.env,
|
|
9275
|
+
scan = scanPrivateSkillOutbox(env),
|
|
9276
|
+
) {
|
|
9277
|
+
if (!scan.readable) return null;
|
|
9278
|
+
const counts = {
|
|
9279
|
+
ready: 0,
|
|
9280
|
+
retry_scheduled: 0,
|
|
9281
|
+
awaiting_authorization: 0,
|
|
9282
|
+
retry_delivery: 0,
|
|
9283
|
+
retry_resolution: 0,
|
|
9284
|
+
reformulation_available: 0,
|
|
9285
|
+
operational_retry: 0,
|
|
9286
|
+
native_context_needed: 0,
|
|
9287
|
+
native_context_pending: 0,
|
|
9288
|
+
inspector_refresh: 0,
|
|
9289
|
+
};
|
|
9290
|
+
const recoveryIds = [];
|
|
9291
|
+
const authorizationIds = [];
|
|
9292
|
+
for (const [draftId, { category }] of scan.drafts) {
|
|
9293
|
+
if (!category) continue;
|
|
9294
|
+
counts[category] += 1;
|
|
9295
|
+
if (category === "awaiting_authorization") authorizationIds.push(draftId);
|
|
8738
9296
|
if (
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
9297
|
+
["inspector_refresh", "reformulation_available", "operational_retry"].includes(
|
|
9298
|
+
category,
|
|
9299
|
+
)
|
|
8742
9300
|
) {
|
|
8743
|
-
|
|
8744
|
-
}
|
|
8745
|
-
assertCurrentHookUser(directoryMetadata.uid);
|
|
8746
|
-
const counts = {
|
|
8747
|
-
ready: 0,
|
|
8748
|
-
retry_scheduled: 0,
|
|
8749
|
-
awaiting_authorization: 0,
|
|
8750
|
-
retry_delivery: 0,
|
|
8751
|
-
retry_resolution: 0,
|
|
8752
|
-
reformulation_available: 0,
|
|
8753
|
-
operational_retry: 0,
|
|
8754
|
-
native_context_needed: 0,
|
|
8755
|
-
native_context_pending: 0,
|
|
8756
|
-
inspector_refresh: 0,
|
|
8757
|
-
};
|
|
8758
|
-
const recoveryIds = [];
|
|
8759
|
-
const authorizationIds = [];
|
|
8760
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
8761
|
-
if (!/^psd_[A-Za-z0-9_-]{8,96}\.json$/.test(entry.name)) continue;
|
|
8762
|
-
try {
|
|
8763
|
-
if (entry.isSymbolicLink() || !entry.isFile()) continue;
|
|
8764
|
-
const expectedDraftId = entry.name.slice(0, -".json".length);
|
|
8765
|
-
const parsed = JSON.parse(
|
|
8766
|
-
readSecureHookFile(
|
|
8767
|
-
join(directory, entry.name),
|
|
8768
|
-
MAX_PRIVATE_SKILL_ENVELOPE_BYTES,
|
|
8769
|
-
),
|
|
8770
|
-
);
|
|
8771
|
-
if (
|
|
8772
|
-
parsed?.version !== "private-skill-outbox-envelope-v1" ||
|
|
8773
|
-
parsed?.draft_id !== expectedDraftId ||
|
|
8774
|
-
![
|
|
8775
|
-
"ready",
|
|
8776
|
-
"awaiting_authorization",
|
|
8777
|
-
"retry_scheduled",
|
|
8778
|
-
"held_safety",
|
|
8779
|
-
"held_validation",
|
|
8780
|
-
].includes(parsed?.state)
|
|
8781
|
-
) {
|
|
8782
|
-
continue;
|
|
8783
|
-
}
|
|
8784
|
-
const action = privateSkillRecoveryAction(parsed, true);
|
|
8785
|
-
if (
|
|
8786
|
-
parsed.state === "awaiting_authorization" &&
|
|
8787
|
-
privateAuthorizationRetryIsDue(
|
|
8788
|
-
parsed.draft_id,
|
|
8789
|
-
parsed.updated_at,
|
|
8790
|
-
env,
|
|
8791
|
-
)
|
|
8792
|
-
) {
|
|
8793
|
-
counts.awaiting_authorization += 1;
|
|
8794
|
-
authorizationIds.push(parsed.draft_id);
|
|
8795
|
-
continue;
|
|
8796
|
-
}
|
|
8797
|
-
const staleInspectorAction =
|
|
8798
|
-
action &&
|
|
8799
|
-
typeof action.inspector_digest === "string" &&
|
|
8800
|
-
action.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
|
|
8801
|
-
((action.kind === "submit_candidate" && parsed.state === "ready") ||
|
|
8802
|
-
([
|
|
8803
|
-
"reformulate",
|
|
8804
|
-
"retry_operational",
|
|
8805
|
-
"capture_native_context",
|
|
8806
|
-
].includes(action.kind) &&
|
|
8807
|
-
parsed.state === "held_validation"));
|
|
8808
|
-
const staleInspectorBlockedRecovery =
|
|
8809
|
-
action === null &&
|
|
8810
|
-
parsed.next_action === null &&
|
|
8811
|
-
typeof parsed.inspector_digest === "string" &&
|
|
8812
|
-
parsed.inspector_digest !== PRIVATE_LEARNING_INSPECTOR_DIGEST &&
|
|
8813
|
-
parsed.state === "held_validation" &&
|
|
8814
|
-
parsed.reformulation_available === false &&
|
|
8815
|
-
typeof parsed.reformulation_reason === "string" &&
|
|
8816
|
-
Number.isSafeInteger(parsed.reformulation_attempt_count) &&
|
|
8817
|
-
Number.isSafeInteger(parsed.recovery_epoch_attempt_count) &&
|
|
8818
|
-
parsed.reformulation_attempt_count -
|
|
8819
|
-
parsed.recovery_epoch_attempt_count >=
|
|
8820
|
-
3;
|
|
8821
|
-
if (staleInspectorAction || staleInspectorBlockedRecovery) {
|
|
8822
|
-
counts.inspector_refresh += 1;
|
|
8823
|
-
recoveryIds.push(parsed.draft_id);
|
|
8824
|
-
} else if (action?.kind === "submit_candidate") {
|
|
8825
|
-
counts.ready += 1;
|
|
8826
|
-
} else if (
|
|
8827
|
-
action?.kind === "retry_delivery" &&
|
|
8828
|
-
retryIsDue(parsed.next_retry_at)
|
|
8829
|
-
) {
|
|
8830
|
-
counts.retry_delivery += 1;
|
|
8831
|
-
} else if (
|
|
8832
|
-
action?.kind === "retry_resolution" &&
|
|
8833
|
-
(parsed.state === "held_validation" ||
|
|
8834
|
-
(parsed.state === "retry_scheduled" &&
|
|
8835
|
-
retryIsDue(parsed.next_retry_at)))
|
|
8836
|
-
) {
|
|
8837
|
-
counts.retry_resolution += 1;
|
|
8838
|
-
} else if (action?.kind === "reformulate") {
|
|
8839
|
-
counts.reformulation_available += 1;
|
|
8840
|
-
recoveryIds.push(parsed.draft_id);
|
|
8841
|
-
} else if (action?.kind === "retry_operational") {
|
|
8842
|
-
counts.operational_retry += 1;
|
|
8843
|
-
recoveryIds.push(parsed.draft_id);
|
|
8844
|
-
} else if (action?.kind === "reinspect") {
|
|
8845
|
-
counts.inspector_refresh += 1;
|
|
8846
|
-
recoveryIds.push(parsed.draft_id);
|
|
8847
|
-
} else if (action?.kind === "capture_native_context") {
|
|
8848
|
-
counts.native_context_needed += 1;
|
|
8849
|
-
}
|
|
8850
|
-
} catch {
|
|
8851
|
-
// One unsafe or malformed entry cannot hide independent due drafts.
|
|
8852
|
-
}
|
|
8853
|
-
}
|
|
8854
|
-
recoveryIds.sort();
|
|
8855
|
-
let cursor = "";
|
|
8856
|
-
try {
|
|
8857
|
-
const value = readSecureHookFile(
|
|
8858
|
-
join(usageDir(env), "private-skill-recovery-cursor"),
|
|
8859
|
-
104,
|
|
8860
|
-
);
|
|
8861
|
-
if (/^psd_[A-Za-z0-9_-]{8,96}$/.test(value)) cursor = value;
|
|
8862
|
-
} catch {
|
|
8863
|
-
// A missing/stale hint cannot withhold recoverable drafts.
|
|
9301
|
+
recoveryIds.push(draftId);
|
|
8864
9302
|
}
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
9303
|
+
}
|
|
9304
|
+
if (Object.values(counts).every((count) => count === 0)) return null;
|
|
9305
|
+
recoveryIds.sort();
|
|
9306
|
+
let cursor = "";
|
|
9307
|
+
try {
|
|
9308
|
+
const value = readSecureHookFile(
|
|
9309
|
+
join(usageDir(env), "private-skill-recovery-cursor"),
|
|
9310
|
+
104,
|
|
9311
|
+
);
|
|
9312
|
+
if (/^psd_[A-Za-z0-9_-]{8,96}$/.test(value)) cursor = value;
|
|
8874
9313
|
} catch {
|
|
8875
|
-
|
|
9314
|
+
// A missing/stale hint cannot withhold recoverable drafts.
|
|
8876
9315
|
}
|
|
9316
|
+
return {
|
|
9317
|
+
...counts,
|
|
9318
|
+
next_authorization_draft_id: authorizationIds.sort()[0] ?? null,
|
|
9319
|
+
next_reformulation_draft_id:
|
|
9320
|
+
recoveryIds.find((id) => id > cursor) ?? recoveryIds[0] ?? null,
|
|
9321
|
+
host_denial_active: recentPrivateLessonHostDenial(env),
|
|
9322
|
+
};
|
|
8877
9323
|
}
|
|
8878
9324
|
|
|
8879
9325
|
export function privateSkillNativeContextCaptureNeeded(env = process.env) {
|
|
8880
9326
|
return (readPrivateSkillLifecycleState(env)?.native_context_needed ?? 0) > 0;
|
|
8881
9327
|
}
|
|
8882
9328
|
|
|
9329
|
+
// The machine-wide recovery, for a caller that names no session.
|
|
8883
9330
|
export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
|
|
8884
|
-
|
|
8885
|
-
|
|
9331
|
+
return privateLearningLifecycleText(
|
|
9332
|
+
readPrivateLessonLifecycleState(env),
|
|
9333
|
+
readPrivateSkillLifecycleState(env),
|
|
9334
|
+
);
|
|
9335
|
+
}
|
|
9336
|
+
|
|
9337
|
+
// `own` (privateLearningSessionRecovery): the reading session tree's due
|
|
9338
|
+
// drafts, the only ones its silent recovery may act on.
|
|
9339
|
+
function privateLearningLifecycleText(lessons, skills, own = null) {
|
|
8886
9340
|
const hostDenialActive = Boolean(
|
|
8887
9341
|
lessons?.host_denial_active || skills?.host_denial_active,
|
|
8888
9342
|
);
|
|
@@ -8925,7 +9379,18 @@ export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
|
|
|
8925
9379
|
return null;
|
|
8926
9380
|
return [
|
|
8927
9381
|
`Private learning recovery: ${lessonCandidates} lesson candidate${lessonCandidates === 1 ? "" : "s"}, ${skillCandidates} complete skill candidate${skillCandidates === 1 ? "" : "s"}, ${skillDeliveryRetries} private skill delivery retr${skillDeliveryRetries === 1 ? "y" : "ies"}${authorizationRetries > 0 ? `, ${authorizationRetries} authorization retr${authorizationRetries === 1 ? "y" : "ies"}` : ""}, and ${lessonTelemetry} content-free hold event${lessonTelemetry === 1 ? "" : "s"} are due.`,
|
|
8928
|
-
|
|
9382
|
+
...(!own
|
|
9383
|
+
? [
|
|
9384
|
+
"Silently call inspect_private_learning_outbox. Submit ready lessons with submit_private_lesson_candidate and ready complete skills with submit_private_skill_candidate. Use the matching retry tool only for a due retry.",
|
|
9385
|
+
]
|
|
9386
|
+
: own.length > 0
|
|
9387
|
+
? [
|
|
9388
|
+
// One directive unit each, so the context allocator keeps the
|
|
9389
|
+
// drafts with the instruction that names them.
|
|
9390
|
+
`Silently call inspect_private_learning_outbox only for this session's due private drafts (${own.slice(0, 12).join(", ")}${own.length > 12 ? `, and ${own.length - 12} more named in a later prompt` : ""}): submit a ready lesson with submit_private_lesson_candidate and a ready complete skill with submit_private_skill_candidate, and use the matching retry tool only for a due retry.`,
|
|
9391
|
+
"Do not submit or retry, on your own, any other draft inspect_private_learning_outbox lists.",
|
|
9392
|
+
]
|
|
9393
|
+
: []),
|
|
8929
9394
|
...(authorizationReminder ? [authorizationReminder] : []),
|
|
8930
9395
|
...(reformulations > 0
|
|
8931
9396
|
? [
|
|
@@ -8941,6 +9406,543 @@ export function privateLearningLifecycleRecoveryInstruction(env = process.env) {
|
|
|
8941
9406
|
].join("\n");
|
|
8942
9407
|
}
|
|
8943
9408
|
|
|
9409
|
+
// --- Private drafts: ownership, parking and the user decisions ---------------
|
|
9410
|
+
//
|
|
9411
|
+
// Silent recovery names only drafts the reading session tree holds a hint
|
|
9412
|
+
// for. Every other due draft (captured by another session, or by one with no
|
|
9413
|
+
// user prompt for PRIVATE_ORPHAN_AFTER_MS, or by an earlier client, or with
|
|
9414
|
+
// no hint at all) and every parked one reaches a main thread only as one
|
|
9415
|
+
// user decision. Parking (a decline, or a host's denial of that exact draft)
|
|
9416
|
+
// is a content-free marker beside the hints; neither touches a draft, and
|
|
9417
|
+
// only the confirmed delete tool removes one.
|
|
9418
|
+
|
|
9419
|
+
// This prompt's one read of both outboxes and of the drafts' markers.
|
|
9420
|
+
function readPrivateDrafts(env) {
|
|
9421
|
+
const parked = new Map();
|
|
9422
|
+
const held = new Set();
|
|
9423
|
+
const directory = privateLearningLifecycleDirectory(env);
|
|
9424
|
+
if (organizationPrivateLessonAuthorized(env)) {
|
|
9425
|
+
try {
|
|
9426
|
+
assertSecureHookDirectory(lstatSync(directory));
|
|
9427
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
9428
|
+
const match = entry.name.match(
|
|
9429
|
+
/^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.(parked|held-notice)\.json$/,
|
|
9430
|
+
);
|
|
9431
|
+
if (!match || entry.isSymbolicLink() || !entry.isFile()) continue;
|
|
9432
|
+
if (match[2] === "held-notice") held.add(match[1]);
|
|
9433
|
+
const marker =
|
|
9434
|
+
match[2] === "parked" && readPrivateParkedMarker(match[1], env);
|
|
9435
|
+
if (marker) parked.set(match[1], marker);
|
|
9436
|
+
}
|
|
9437
|
+
} catch {
|
|
9438
|
+
// No lifecycle directory yet: nothing is parked or noticed.
|
|
9439
|
+
}
|
|
9440
|
+
}
|
|
9441
|
+
return {
|
|
9442
|
+
lessons: scanPrivateLessonOutbox(env),
|
|
9443
|
+
skills: scanPrivateSkillOutbox(env),
|
|
9444
|
+
parked,
|
|
9445
|
+
held,
|
|
9446
|
+
};
|
|
9447
|
+
}
|
|
9448
|
+
|
|
9449
|
+
// The obligations due now; a private lesson's line names the exact tool its
|
|
9450
|
+
// envelope allows when its outbox answers.
|
|
9451
|
+
function duePendingActions(obligations, env, drafts) {
|
|
9452
|
+
return obligations
|
|
9453
|
+
.filter((obligation) => completionObligationIsDue(obligation, env, drafts))
|
|
9454
|
+
.map((obligation) => {
|
|
9455
|
+
const tool =
|
|
9456
|
+
obligation.kind === "private_lesson" &&
|
|
9457
|
+
drafts.lessons.drafts.get(obligation.draft_id)?.action?.tool;
|
|
9458
|
+
return tool && tool !== obligation.action_tool
|
|
9459
|
+
? { ...obligation, action_tool: tool }
|
|
9460
|
+
: obligation;
|
|
9461
|
+
});
|
|
9462
|
+
}
|
|
9463
|
+
|
|
9464
|
+
// A lesson hint whose readable envelope allows no agent action points at
|
|
9465
|
+
// nothing to do, so it goes, and its draft stays. An earlier client's hints
|
|
9466
|
+
// for such drafts (a classified safety hold kept asking for a submit) go on
|
|
9467
|
+
// the first prompt that reads them.
|
|
9468
|
+
function retireUnactionablePrivateLessonHints(hints, drafts, env) {
|
|
9469
|
+
const stale = new Set(
|
|
9470
|
+
hints
|
|
9471
|
+
.filter((hint) => {
|
|
9472
|
+
const entry = drafts.lessons.drafts.get(hint.draft_id);
|
|
9473
|
+
return hint.kind === "private_lesson" && entry?.envelope && !entry.action;
|
|
9474
|
+
})
|
|
9475
|
+
.map((hint) => hint.id),
|
|
9476
|
+
);
|
|
9477
|
+
if (stale.size > 0) {
|
|
9478
|
+
removeCompletionObligations(null, (item) => stale.has(item.id), env);
|
|
9479
|
+
}
|
|
9480
|
+
return stale;
|
|
9481
|
+
}
|
|
9482
|
+
|
|
9483
|
+
function privateHintsOnMachine(sessionId, env) {
|
|
9484
|
+
return readCompletionObligations(sessionId, env, {
|
|
9485
|
+
pointers: false,
|
|
9486
|
+
ask: false,
|
|
9487
|
+
privateHints: "all",
|
|
9488
|
+
}).filter(isPrivateHint);
|
|
9489
|
+
}
|
|
9490
|
+
|
|
9491
|
+
// This ledger's share of the lifecycle recovery for one prompt or stop: its
|
|
9492
|
+
// silent lines count and name only drafts its own session tree holds an
|
|
9493
|
+
// unparked hint for; a main thread also gets the user decisions
|
|
9494
|
+
// (privateDraftNotices), unless the host says it runs this session for a
|
|
9495
|
+
// subagent (`notices: false`). `hints` are every private hint on the
|
|
9496
|
+
// machine. Null when nothing is due.
|
|
9497
|
+
function privateLearningSessionRecovery(
|
|
9498
|
+
sessionId,
|
|
9499
|
+
hints,
|
|
9500
|
+
drafts,
|
|
9501
|
+
env,
|
|
9502
|
+
{ notices = true } = {},
|
|
9503
|
+
) {
|
|
9504
|
+
const own = new Set(
|
|
9505
|
+
hints
|
|
9506
|
+
.filter(
|
|
9507
|
+
(hint) =>
|
|
9508
|
+
privateHintOwnedBy(hint, sessionId) && !drafts.parked.has(hint.draft_id),
|
|
9509
|
+
)
|
|
9510
|
+
.map((hint) => hint.draft_id),
|
|
9511
|
+
);
|
|
9512
|
+
const only = (scan) => ({
|
|
9513
|
+
readable: scan.readable,
|
|
9514
|
+
drafts: new Map([...scan.drafts].filter(([draftId]) => own.has(draftId))),
|
|
9515
|
+
});
|
|
9516
|
+
return (
|
|
9517
|
+
[
|
|
9518
|
+
privateLearningLifecycleText(
|
|
9519
|
+
readPrivateLessonLifecycleState(env, only(drafts.lessons)),
|
|
9520
|
+
readPrivateSkillLifecycleState(env, only(drafts.skills)),
|
|
9521
|
+
// In hint order (created_at, then id), the order of their lines.
|
|
9522
|
+
[...own].filter((draftId) => privateDraftDue(draftId, drafts)),
|
|
9523
|
+
),
|
|
9524
|
+
...(notices ? privateDraftNotices(sessionId, hints, drafts, env) : []),
|
|
9525
|
+
]
|
|
9526
|
+
.filter(Boolean)
|
|
9527
|
+
.join("\n") || null
|
|
9528
|
+
);
|
|
9529
|
+
}
|
|
9530
|
+
|
|
9531
|
+
// The draft ids each notice sentence this process composed for a ledger
|
|
9532
|
+
// names, until a delivery of that exact sentence acknowledges it
|
|
9533
|
+
// (acknowledgePrivateDraftNotices): a delivery marks and advances only what
|
|
9534
|
+
// it showed. Composition and delivery run in one process on every host; two
|
|
9535
|
+
// sessions of one long-lived process (OpenCode, OpenClaw) can compose the
|
|
9536
|
+
// same sentence from different drafts, so each entry is the ledger's. At
|
|
9537
|
+
// most 16, newest last.
|
|
9538
|
+
const privateNoticesShown = new Map();
|
|
9539
|
+
|
|
9540
|
+
function rememberPrivateNotice(sessionId, text, shown) {
|
|
9541
|
+
const ledger = sessionHash(sessionId);
|
|
9542
|
+
const key = `${ledger}\n${text}`;
|
|
9543
|
+
privateNoticesShown.delete(key);
|
|
9544
|
+
privateNoticesShown.set(key, { ...shown, ledger, text });
|
|
9545
|
+
if (privateNoticesShown.size > 16) {
|
|
9546
|
+
privateNoticesShown.delete(privateNoticesShown.keys().next().value);
|
|
9547
|
+
}
|
|
9548
|
+
return text;
|
|
9549
|
+
}
|
|
9550
|
+
|
|
9551
|
+
// The two user decisions, each one bounded, content-free sentence: one
|
|
9552
|
+
// directive unit, which the context allocator keeps whole or drops, so its
|
|
9553
|
+
// exact text in the delivered context acknowledges it
|
|
9554
|
+
// (acknowledgePrivateDraftNotices). Retained drafts awaiting the user
|
|
9555
|
+
// (privateDraftsAwaitingUser), at most once per
|
|
9556
|
+
// PRIVATE_ORPHAN_NOTICE_INTERVAL_MS on this machine, with the action_id that
|
|
9557
|
+
// keeps them; and lesson drafts a safety hold left with nothing to send,
|
|
9558
|
+
// once per draft, with no reply asked. Main threads only.
|
|
9559
|
+
function privateDraftNotices(sessionId, hints, drafts, env) {
|
|
9560
|
+
if (String(sessionId ?? "").includes(":agent:")) return [];
|
|
9561
|
+
const notices = [];
|
|
9562
|
+
const awaiting = privateDraftsAwaitingUser(sessionId, hints, drafts, env);
|
|
9563
|
+
if (awaiting.length > 0 && privateOrphanNoticeDue(env)) {
|
|
9564
|
+
const lessons = awaiting.filter((id) => id.startsWith("pld_")).length;
|
|
9565
|
+
const skills = awaiting.length - lessons;
|
|
9566
|
+
notices.push(
|
|
9567
|
+
rememberPrivateNotice(
|
|
9568
|
+
sessionId,
|
|
9569
|
+
`- Retained private drafts awaiting a user decision: ${lessons} lesson${lessons === 1 ? "" : "s"} and ${skills} skill${skills === 1 ? "" : "s"} that no active session is handling; do not submit them on your own, but ask the user once whether to deliver them (inspect_private_learning_outbox, then each draft's exact submit or retry action), keep them (local decline_pending_action with action_id ${PRIVATE_ORPHAN_NOTICE_ACTION_ID} and any reason), or delete them (the matching delete tool with confirm=true); if no user can answer in this run, leave them in the outbox.`,
|
|
9570
|
+
{
|
|
9571
|
+
awaiting,
|
|
9572
|
+
parked: awaiting.filter((draftId) => drafts.parked.has(draftId)),
|
|
9573
|
+
},
|
|
9574
|
+
),
|
|
9575
|
+
);
|
|
9576
|
+
}
|
|
9577
|
+
const held = privateHeldDrafts(drafts);
|
|
9578
|
+
if (held.length > 0) {
|
|
9579
|
+
notices.push(
|
|
9580
|
+
rememberPrivateNotice(
|
|
9581
|
+
sessionId,
|
|
9582
|
+
`- ${held.length} retained private lesson draft${held.length === 1 ? " was" : "s were"} held by the local safety check and will not be sent: ask the user once whether to re-capture a safe version with a new capture_learning call or delete ${held.length === 1 ? "it" : "them"} with delete_private_lesson_candidate and confirm=true (inspect_private_learning_outbox lists each draft_id), and never resubmit the held content; this notice is shown once and needs no reply, and if no user can answer in this run, leave the outbox as it is.`,
|
|
9583
|
+
{ held },
|
|
9584
|
+
),
|
|
9585
|
+
);
|
|
9586
|
+
}
|
|
9587
|
+
return notices;
|
|
9588
|
+
}
|
|
9589
|
+
|
|
9590
|
+
// Due drafts no session of the reader's tree holds a hint for: none or an
|
|
9591
|
+
// earlier client's, another session's whose hint is idle
|
|
9592
|
+
// (privateHintIdle), and parked drafts, whoever holds them, whose next
|
|
9593
|
+
// notice is due. In draft id order, whatever order the file system lists
|
|
9594
|
+
// them in.
|
|
9595
|
+
function privateDraftsAwaitingUser(sessionId, hints, drafts, env) {
|
|
9596
|
+
const own = sessionHash(sessionId);
|
|
9597
|
+
const held = new Map(hints.map((hint) => [hint.draft_id, hint]));
|
|
9598
|
+
const idle = new Map();
|
|
9599
|
+
const isIdle = (ledger) => {
|
|
9600
|
+
if (!idle.has(ledger)) idle.set(ledger, privateSessionIdle(ledger, env));
|
|
9601
|
+
return idle.get(ledger);
|
|
9602
|
+
};
|
|
9603
|
+
const awaiting = [];
|
|
9604
|
+
for (const draftId of [...drafts.lessons.drafts.keys(), ...drafts.skills.drafts.keys()]) {
|
|
9605
|
+
if (!privateDraftDue(draftId, drafts)) continue;
|
|
9606
|
+
const parked = drafts.parked.get(draftId);
|
|
9607
|
+
const hint = held.get(draftId);
|
|
9608
|
+
const owner = hint?.owner_session;
|
|
9609
|
+
if (
|
|
9610
|
+
parked
|
|
9611
|
+
? privateParkedNoticeDue(parked)
|
|
9612
|
+
: owner !== own && (!owner || privateHintIdle(hint, isIdle))
|
|
9613
|
+
) {
|
|
9614
|
+
awaiting.push(draftId);
|
|
9615
|
+
}
|
|
9616
|
+
}
|
|
9617
|
+
return awaiting.sort();
|
|
9618
|
+
}
|
|
9619
|
+
|
|
9620
|
+
// Whether a draft allows an agent action now (the native-context wait asks
|
|
9621
|
+
// for none).
|
|
9622
|
+
function privateDraftDue(draftId, drafts) {
|
|
9623
|
+
if (draftId.startsWith("pld_"))
|
|
9624
|
+
return Boolean(drafts.lessons.drafts.get(draftId)?.action?.due);
|
|
9625
|
+
const category = drafts.skills.drafts.get(draftId)?.category;
|
|
9626
|
+
return Boolean(category && category !== "native_context_needed");
|
|
9627
|
+
}
|
|
9628
|
+
|
|
9629
|
+
// A hint's draft is still being handled while its owner's session or the
|
|
9630
|
+
// ledger that captured it has had a user prompt within
|
|
9631
|
+
// PRIVATE_ORPHAN_AFTER_MS: an OpenCode child's own prompts (its tree ledger)
|
|
9632
|
+
// keep its drafts while its root is idle. A ledger with no count, such as a
|
|
9633
|
+
// Claude Code subagent's, is idle, so there the owner decides as before.
|
|
9634
|
+
// One more stat at most, only when the owner is idle.
|
|
9635
|
+
function privateHintIdle(hint, isIdle) {
|
|
9636
|
+
return (
|
|
9637
|
+
isIdle(hint.owner_session) &&
|
|
9638
|
+
(hint.captured_by_ledger === hint.owner_session ||
|
|
9639
|
+
isIdle(hint.captured_by_ledger))
|
|
9640
|
+
);
|
|
9641
|
+
}
|
|
9642
|
+
|
|
9643
|
+
function privateSessionIdle(owner, env) {
|
|
9644
|
+
try {
|
|
9645
|
+
return (
|
|
9646
|
+
lstatSync(join(usageDir(env), `${owner}.user-prompts`)).mtimeMs +
|
|
9647
|
+
PRIVATE_ORPHAN_AFTER_MS <=
|
|
9648
|
+
Date.now()
|
|
9649
|
+
);
|
|
9650
|
+
} catch {
|
|
9651
|
+
return true;
|
|
9652
|
+
}
|
|
9653
|
+
}
|
|
9654
|
+
|
|
9655
|
+
// Lesson drafts a safety hold left with no agent action (classified, or
|
|
9656
|
+
// health reporting off) that have not had their one notice.
|
|
9657
|
+
function privateHeldDrafts(drafts) {
|
|
9658
|
+
return [...drafts.lessons.drafts]
|
|
9659
|
+
.filter(
|
|
9660
|
+
([draftId, { envelope, action }]) =>
|
|
9661
|
+
envelope?.state === "held_safety" && !action && !drafts.held.has(draftId),
|
|
9662
|
+
)
|
|
9663
|
+
.map(([draftId]) => draftId);
|
|
9664
|
+
}
|
|
9665
|
+
|
|
9666
|
+
function privateOrphanNoticePath(env) {
|
|
9667
|
+
return join(privateLearningLifecycleDirectory(env), "orphan-notice.json");
|
|
9668
|
+
}
|
|
9669
|
+
|
|
9670
|
+
// The last delivered notice for drafts awaiting the user: when, and the
|
|
9671
|
+
// draft ids it offered while they are at most PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT,
|
|
9672
|
+
// so the record stays within the bytes read back; a longer offer is recorded
|
|
9673
|
+
// without them. Content-free.
|
|
9674
|
+
const PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT = 256;
|
|
9675
|
+
|
|
9676
|
+
function readPrivateOrphanNotice(env) {
|
|
9677
|
+
try {
|
|
9678
|
+
const value = JSON.parse(
|
|
9679
|
+
readSecureHookFile(privateOrphanNoticePath(env), 32 * 1024),
|
|
9680
|
+
);
|
|
9681
|
+
return {
|
|
9682
|
+
shown_at: Date.parse(value?.shown_at ?? ""),
|
|
9683
|
+
drafts: Array.isArray(value?.drafts)
|
|
9684
|
+
? value.drafts.filter((draftId) =>
|
|
9685
|
+
/^(?:pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId ?? ""),
|
|
9686
|
+
)
|
|
9687
|
+
: null,
|
|
9688
|
+
};
|
|
9689
|
+
} catch {
|
|
9690
|
+
return null;
|
|
9691
|
+
}
|
|
9692
|
+
}
|
|
9693
|
+
|
|
9694
|
+
function privateOrphanNoticeDue(env) {
|
|
9695
|
+
const shown = readPrivateOrphanNotice(env)?.shown_at;
|
|
9696
|
+
// A future time cannot strand the notice.
|
|
9697
|
+
return (
|
|
9698
|
+
!Number.isFinite(shown) ||
|
|
9699
|
+
shown > Date.now() ||
|
|
9700
|
+
shown + PRIVATE_ORPHAN_NOTICE_INTERVAL_MS <= Date.now()
|
|
9701
|
+
);
|
|
9702
|
+
}
|
|
9703
|
+
|
|
9704
|
+
function readPrivateParkedMarker(draftId, env) {
|
|
9705
|
+
const path = privateDraftMarkerPath(draftId, "parked", env);
|
|
9706
|
+
if (!path) return null;
|
|
9707
|
+
try {
|
|
9708
|
+
const marker = JSON.parse(readSecureHookFile(path, 1024));
|
|
9709
|
+
return marker?.version === 1 &&
|
|
9710
|
+
Number.isFinite(Date.parse(marker.parked_at)) &&
|
|
9711
|
+
Number.isSafeInteger(marker.notices) &&
|
|
9712
|
+
marker.notices >= 0
|
|
9713
|
+
? marker
|
|
9714
|
+
: null;
|
|
9715
|
+
} catch {
|
|
9716
|
+
return null;
|
|
9717
|
+
}
|
|
9718
|
+
}
|
|
9719
|
+
|
|
9720
|
+
function writePrivateParkedMarker(draftId, marker, env) {
|
|
9721
|
+
const path = privateDraftMarkerPath(draftId, "parked", env);
|
|
9722
|
+
return Boolean(
|
|
9723
|
+
path &&
|
|
9724
|
+
writePrivateUsageMarker(path, `${JSON.stringify({ version: 1, ...marker })}\n`),
|
|
9725
|
+
);
|
|
9726
|
+
}
|
|
9727
|
+
|
|
9728
|
+
// When a parked draft's notice is next due after `notices` deliveries: on
|
|
9729
|
+
// the PRIVATE_PARKED_NOTICE_DELAYS_MS schedule, then never on its own.
|
|
9730
|
+
function privateParkedNoticeAt(notices, now = Date.now()) {
|
|
9731
|
+
return notices < PRIVATE_PARKED_NOTICE_DELAYS_MS.length
|
|
9732
|
+
? new Date(now + PRIVATE_PARKED_NOTICE_DELAYS_MS[notices]).toISOString()
|
|
9733
|
+
: null;
|
|
9734
|
+
}
|
|
9735
|
+
|
|
9736
|
+
// A parked draft's notice is due on its schedule, and once after a client
|
|
9737
|
+
// update.
|
|
9738
|
+
function privateParkedNoticeDue(marker) {
|
|
9739
|
+
const next = Date.parse(marker.next_notice_at ?? "");
|
|
9740
|
+
const installed = installedClientVersion();
|
|
9741
|
+
return (
|
|
9742
|
+
(Number.isFinite(next) && next <= Date.now()) ||
|
|
9743
|
+
Boolean(
|
|
9744
|
+
installed &&
|
|
9745
|
+
marker.client_version &&
|
|
9746
|
+
compareStableClientVersions(installed, marker.client_version) > 0,
|
|
9747
|
+
)
|
|
9748
|
+
);
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9751
|
+
// The client version a parked marker records: the newest that showed or
|
|
9752
|
+
// parked its draft. A host of an older version on the machine never moves
|
|
9753
|
+
// it back, so a newer host does not take its notice or park for an update.
|
|
9754
|
+
function newerClientVersion(recorded) {
|
|
9755
|
+
const installed = installedClientVersion();
|
|
9756
|
+
return compareStableClientVersions(recorded, installed) > 0
|
|
9757
|
+
? recorded
|
|
9758
|
+
: (installed ?? recorded ?? null);
|
|
9759
|
+
}
|
|
9760
|
+
|
|
9761
|
+
// Parks one draft: its line stops and it returns only in the user-decision
|
|
9762
|
+
// notice, on its schedule. A repeated park keeps the count already shown.
|
|
9763
|
+
// The parked marker now holds the draft, so its hint goes, whoever held it,
|
|
9764
|
+
// and leaves room in the machine's bound; its authorization backoff stays,
|
|
9765
|
+
// and an explicit submit or retry records a hint again.
|
|
9766
|
+
function parkPrivateDraft(
|
|
9767
|
+
draftId,
|
|
9768
|
+
reason,
|
|
9769
|
+
env,
|
|
9770
|
+
prior = readPrivateParkedMarker(draftId, env),
|
|
9771
|
+
) {
|
|
9772
|
+
const notices = prior?.notices ?? 0;
|
|
9773
|
+
const parked = writePrivateParkedMarker(
|
|
9774
|
+
draftId,
|
|
9775
|
+
{
|
|
9776
|
+
reason,
|
|
9777
|
+
parked_at: new Date().toISOString(),
|
|
9778
|
+
notices,
|
|
9779
|
+
next_notice_at: privateParkedNoticeAt(notices),
|
|
9780
|
+
client_version: newerClientVersion(prior?.client_version),
|
|
9781
|
+
},
|
|
9782
|
+
env,
|
|
9783
|
+
);
|
|
9784
|
+
if (parked) dropPrivateHint(draftId, env);
|
|
9785
|
+
return parked;
|
|
9786
|
+
}
|
|
9787
|
+
|
|
9788
|
+
// Removes a draft's hint where any client kept it, and nothing else of it:
|
|
9789
|
+
// not its draft, not its markers.
|
|
9790
|
+
function dropPrivateHint(draftId, env) {
|
|
9791
|
+
const id = `${draftId.startsWith("pld_") ? "private_lesson" : "private_skill"}:${draftId}`;
|
|
9792
|
+
try {
|
|
9793
|
+
if (lifecycleDirectoryUsable(env)) {
|
|
9794
|
+
rmSync(completionObligationPath(null, id, env), { force: true });
|
|
9795
|
+
}
|
|
9796
|
+
rmSync(
|
|
9797
|
+
join(
|
|
9798
|
+
legacyPrivateCompletionObligationDir(env),
|
|
9799
|
+
completionObligationFileName(id),
|
|
9800
|
+
),
|
|
9801
|
+
{ force: true },
|
|
9802
|
+
);
|
|
9803
|
+
} catch {
|
|
9804
|
+
// A hint left behind is only a pointer; its parked draft stays parked.
|
|
9805
|
+
}
|
|
9806
|
+
}
|
|
9807
|
+
|
|
9808
|
+
// An observed submit or retry the host let through allows its lane again:
|
|
9809
|
+
// the lane-wide denial observations go, and each draft of that lane parked
|
|
9810
|
+
// for a host denial returns once, as a user decision.
|
|
9811
|
+
function allowPrivateLane(prefix, env) {
|
|
9812
|
+
clearPrivateLessonHostPolicyDenials(env);
|
|
9813
|
+
try {
|
|
9814
|
+
for (const name of readdirSync(privateLearningLifecycleDirectory(env))) {
|
|
9815
|
+
const draftId = name.match(/^((?:pld|psd)_[A-Za-z0-9_-]{8,96})\.parked\.json$/)?.[1];
|
|
9816
|
+
if (!draftId?.startsWith(prefix)) continue;
|
|
9817
|
+
const marker = readPrivateParkedMarker(draftId, env);
|
|
9818
|
+
if (marker?.reason !== PRIVATE_DECLINE_REASON || marker.lane_allowed_at)
|
|
9819
|
+
continue;
|
|
9820
|
+
const now = new Date().toISOString();
|
|
9821
|
+
writePrivateParkedMarker(
|
|
9822
|
+
draftId,
|
|
9823
|
+
{ ...marker, next_notice_at: now, lane_allowed_at: now },
|
|
9824
|
+
env,
|
|
9825
|
+
);
|
|
9826
|
+
}
|
|
9827
|
+
} catch {
|
|
9828
|
+
// Nothing parked yet.
|
|
9829
|
+
}
|
|
9830
|
+
}
|
|
9831
|
+
|
|
9832
|
+
// A notice counts as shown once its sentence reached the agent, as a retry
|
|
9833
|
+
// reference does, and only for the drafts that sentence was composed from
|
|
9834
|
+
// (privateNoticesShown): the delivering ledger's composition, or, where the
|
|
9835
|
+
// host names none (one session per process), the newest. Nothing else is
|
|
9836
|
+
// read. The orphan interval restarts with the drafts it offered recorded,
|
|
9837
|
+
// each parked draft it named moves to its next wait, once: one another
|
|
9838
|
+
// delivery already moved, or a park restarted, is no longer due. Each held
|
|
9839
|
+
// draft it counted has had its one notice.
|
|
9840
|
+
function acknowledgePrivateDraftNotices(context, env, ledgerId) {
|
|
9841
|
+
const ledger = ledgerId == null ? null : sessionHash(ledgerId);
|
|
9842
|
+
const delivered = new Set();
|
|
9843
|
+
for (const [key, shown] of [...privateNoticesShown].reverse()) {
|
|
9844
|
+
if (
|
|
9845
|
+
!context.includes(shown.text) ||
|
|
9846
|
+
(ledger ? shown.ledger !== ledger : delivered.has(shown.text))
|
|
9847
|
+
) {
|
|
9848
|
+
continue;
|
|
9849
|
+
}
|
|
9850
|
+
delivered.add(shown.text);
|
|
9851
|
+
privateNoticesShown.delete(key);
|
|
9852
|
+
const now = new Date().toISOString();
|
|
9853
|
+
for (const draftId of shown.held ?? []) {
|
|
9854
|
+
writePrivateUsageMarker(
|
|
9855
|
+
privateDraftMarkerPath(draftId, "held-notice", env),
|
|
9856
|
+
`${JSON.stringify({ version: 1, shown_at: now })}\n`,
|
|
9857
|
+
);
|
|
9858
|
+
}
|
|
9859
|
+
if (!shown.awaiting) continue;
|
|
9860
|
+
writePrivateUsageMarker(
|
|
9861
|
+
privateOrphanNoticePath(env),
|
|
9862
|
+
`${JSON.stringify({
|
|
9863
|
+
version: 1,
|
|
9864
|
+
shown_at: now,
|
|
9865
|
+
...(shown.awaiting.length <= PRIVATE_ORPHAN_NOTICE_DRAFT_LIMIT
|
|
9866
|
+
? { drafts: shown.awaiting }
|
|
9867
|
+
: {}),
|
|
9868
|
+
})}\n`,
|
|
9869
|
+
);
|
|
9870
|
+
for (const draftId of shown.parked) {
|
|
9871
|
+
const marker = readPrivateParkedMarker(draftId, env);
|
|
9872
|
+
if (!marker || !privateParkedNoticeDue(marker)) continue;
|
|
9873
|
+
const notices = marker.notices + 1;
|
|
9874
|
+
writePrivateParkedMarker(
|
|
9875
|
+
draftId,
|
|
9876
|
+
{
|
|
9877
|
+
...marker,
|
|
9878
|
+
notices,
|
|
9879
|
+
next_notice_at: privateParkedNoticeAt(notices),
|
|
9880
|
+
client_version: newerClientVersion(marker.client_version),
|
|
9881
|
+
},
|
|
9882
|
+
env,
|
|
9883
|
+
);
|
|
9884
|
+
}
|
|
9885
|
+
}
|
|
9886
|
+
}
|
|
9887
|
+
|
|
9888
|
+
// A decline of the notice for drafts awaiting the user parks the drafts it
|
|
9889
|
+
// offered that still await the user; a draft that reached the user after it
|
|
9890
|
+
// was delivered, or one the user had delivered meanwhile, is not parked.
|
|
9891
|
+
// Without a recorded offer (none was delivered, or it offered more drafts
|
|
9892
|
+
// than the record keeps), every draft awaiting the user now that is not
|
|
9893
|
+
// parked yet is: one that began awaiting after the delivery then returns as
|
|
9894
|
+
// a user decision a day later, about when the next notice would have
|
|
9895
|
+
// offered it. A parked draft keeps its own schedule and comes with the next
|
|
9896
|
+
// notice: a delivery moves on each parked draft it shows, so one due now was
|
|
9897
|
+
// not in the offer.
|
|
9898
|
+
function parkPrivateDraftsAwaitingUser(sessionId, reason, env) {
|
|
9899
|
+
const drafts = readPrivateDrafts(env);
|
|
9900
|
+
const offered = readPrivateOrphanNotice(env)?.drafts;
|
|
9901
|
+
for (const draftId of privateDraftsAwaitingUser(
|
|
9902
|
+
sessionId,
|
|
9903
|
+
privateHintsOnMachine(sessionId, env),
|
|
9904
|
+
drafts,
|
|
9905
|
+
env,
|
|
9906
|
+
)) {
|
|
9907
|
+
if (offered ? !offered.includes(draftId) : drafts.parked.has(draftId)) continue;
|
|
9908
|
+
parkPrivateDraft(draftId, reason, env, drafts.parked.get(draftId));
|
|
9909
|
+
}
|
|
9910
|
+
}
|
|
9911
|
+
|
|
9912
|
+
// The draft a host denied, when its event names the exact private submit or
|
|
9913
|
+
// retry tool and that call's own draft_id.
|
|
9914
|
+
function privateDeniedDraftId(toolName, value) {
|
|
9915
|
+
const prefix = {
|
|
9916
|
+
submit_private_lesson_candidate: "pld_",
|
|
9917
|
+
retry_private_lesson_candidate: "pld_",
|
|
9918
|
+
submit_private_skill_candidate: "psd_",
|
|
9919
|
+
retry_private_skill_candidate: "psd_",
|
|
9920
|
+
}[normalizedCompletionToolName(toolName)];
|
|
9921
|
+
if (!prefix || !value || typeof value !== "object") return null;
|
|
9922
|
+
let args =
|
|
9923
|
+
value.tool_input ??
|
|
9924
|
+
value.toolInput ??
|
|
9925
|
+
value.arguments ??
|
|
9926
|
+
value.args ??
|
|
9927
|
+
value.params;
|
|
9928
|
+
if (typeof args === "string") {
|
|
9929
|
+
try {
|
|
9930
|
+
args = JSON.parse(args);
|
|
9931
|
+
} catch {
|
|
9932
|
+
return null;
|
|
9933
|
+
}
|
|
9934
|
+
}
|
|
9935
|
+
const draftId =
|
|
9936
|
+
args && typeof args === "object" && !Array.isArray(args)
|
|
9937
|
+
? args.draft_id
|
|
9938
|
+
: null;
|
|
9939
|
+
return typeof draftId === "string" &&
|
|
9940
|
+
draftId.startsWith(prefix) &&
|
|
9941
|
+
/^(?:pld|psd)_[A-Za-z0-9_-]{8,96}$/.test(draftId)
|
|
9942
|
+
? draftId
|
|
9943
|
+
: null;
|
|
9944
|
+
}
|
|
9945
|
+
|
|
8944
9946
|
export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
|
|
8945
9947
|
return privateLearningLifecycleRecoveryInstruction(env);
|
|
8946
9948
|
}
|
|
@@ -10448,6 +11450,13 @@ export function recordHostPolicyDenial(
|
|
|
10448
11450
|
toolName,
|
|
10449
11451
|
value,
|
|
10450
11452
|
});
|
|
11453
|
+
// The exact private submit or retry denied parks that one draft, by the
|
|
11454
|
+
// tool and its own draft_id rather than by wording: always for a host
|
|
11455
|
+
// permission denial, and for a generic failure the classifier accepted.
|
|
11456
|
+
const deniedDraft = privateDeniedDraftId(toolName, value);
|
|
11457
|
+
if (deniedDraft && (classification || eventType === "PermissionDenied")) {
|
|
11458
|
+
parkPrivateDraft(deniedDraft, PRIVATE_DECLINE_REASON, env);
|
|
11459
|
+
}
|
|
10451
11460
|
if (!normalizedSurface || !classification) return null;
|
|
10452
11461
|
// Hosts that expose denial events can acknowledge the exact receipt locally.
|
|
10453
11462
|
// Other hosts use the explicit local tool; no transcript inference is needed.
|