@remembrance-ai/opencode-plugin 0.1.45

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.
@@ -0,0 +1,3027 @@
1
+ #!/usr/bin/env node
2
+ // Runtime-agnostic Remembrance hook core.
3
+ //
4
+ // This module holds the shared logic that both Codex adapters
5
+ // (query-on-prompt.mjs, contribute-on-stop.mjs) build on, so those adapters are
6
+ // thin: they only read the runtime's stdin JSON, call into here, and print the
7
+ // runtime's expected stdout shape. Everything here is Node-builtins-only and
8
+ // fail-open by design.
9
+ //
10
+ // Ported from packages/claude-code-plugin/scripts/{query-on-prompt,contribute-on-stop}.mjs.
11
+ // Key difference from the Claude plugin: Codex's Stop payload has NO transcript
12
+ // path, so we cannot count registry consumption by scanning a transcript.
13
+ // Instead the prompt adapter records completed queries and eligible reusable
14
+ // tasks separately. The Stop adapter compares those counters with a
15
+ // last-prompted sentinel — the same count-sentinel pattern the Claude hook uses,
16
+ // but driven by markers instead of transcript scans.
17
+
18
+ import { createHash, randomBytes } from "node:crypto";
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ renameSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { homedir, tmpdir } from "node:os";
30
+ import { dirname, join } from "node:path";
31
+ import process from "node:process";
32
+
33
+ const DEFAULT_API_URL = "https://remembrance.dev";
34
+ const DEFAULT_LIMIT = 3;
35
+ const DEFAULT_TIMEOUT_MS = 2000;
36
+ const DEFAULT_DIRECTIVE_EVENT_TIMEOUT_MS = 750;
37
+ const DIRECTIVE_MARKER_TTL_MS = 30 * 60 * 1000;
38
+ const MAX_SUMMARY_CHARS = 1200;
39
+ const MAX_CONTEXT_CHARS = 4000;
40
+ const MAX_CONTEXT_FIELD_CHARS = 280;
41
+ const MAX_DIRECTIVE_CHARS = 900;
42
+ const VALUE_EPISODE_MARKER_LIMIT = 20;
43
+ const VALUE_EPISODE_MARKER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
44
+ const DIRECT_SELECTION_MARKER_LIMIT = 20;
45
+ const DIRECT_SELECTION_MARKER_TTL_MS = 24 * 60 * 60 * 1000;
46
+ const PLUGIN_HEALTH_DIR = "remembrance-plugin-health";
47
+ const PLUGIN_HEALTH_COMPONENTS = new Set([
48
+ "session_start",
49
+ "prompt_hook",
50
+ "tool_observer",
51
+ "completion_hook",
52
+ ]);
53
+ const PLUGIN_HEALTH_SURFACES = new Set([
54
+ "codex",
55
+ "claude_code",
56
+ "cursor",
57
+ "openclaw",
58
+ "vs_code",
59
+ "opencode",
60
+ ]);
61
+
62
+ const SERVICE_PATTERNS = [
63
+ /\b(vercel|heroku|netlify|cloudflare|aws|gcp|azure)\b/i,
64
+ /\b(github actions?|circleci|gitlab ci|buildkite|jenkins)\b/i,
65
+ /\b(stripe|x402|mpp|model payment protocol|mcp servers?)\b/i,
66
+ /\b(openai|anthropic|claude|cursor|codex|voyage|mongodb atlas)\b/i,
67
+ ];
68
+
69
+ const TOOL_PATTERNS = [
70
+ /\b(next\.?js|turbopack|webpack|vite|react|prisma|drizzle)\b/i,
71
+ /\b(esbuild|playwright|vitest|jest|typescript|node\.?js|npm)\b/i,
72
+ /\b(mongodb|redis|bullmq|atlas vector search)\b/i,
73
+ ];
74
+
75
+ const WORKFLOW_PATTERNS = [
76
+ /\b(deploy|deployment|migrate|migration|ci\/cd|ci|pipeline)\b/i,
77
+ /\b(payment integration|schema upgrade|observability|monitoring)\b/i,
78
+ /\b(build error|release|rollback|provision|backfill)\b/i,
79
+ ];
80
+
81
+ const UI_PATTERNS = [
82
+ /\b(web ?ui|ux|usability|accessibility|a11y|responsive|frontend|front-end)\b/i,
83
+ /\b(dashboard|admin (page|panel|surface)|review card|settings layout)\b/i,
84
+ /\b(layout|nav(igation| bar| panel)?|sidebar|modal|tooltip|popover)\b/i,
85
+ /\b(tailwind|styling|component|button|form design|redesign|declutter|design system)\b/i,
86
+ ];
87
+
88
+ // Short action prompts often depend entirely on earlier conversation. The hook
89
+ // cannot safely reconstruct that context in every runtime, but it can make the
90
+ // agent (which does have the thread) perform the missing full-context query.
91
+ // Keep these anchored and action-oriented so ordinary prose does not become a
92
+ // noisy auto-query trigger.
93
+ const CONTEXTUAL_CONTINUATION_PATTERNS = [
94
+ /^\s*(?:continue|proceed|go ahead|do it|try again|one more pass)\s*[.!?]*\s*$/i,
95
+ /^\s*(?:fix|address|resolve|implement|apply|tackle|clean up)\s+(?:all\s+)?(?:these|those|the|your)\s+(?:issues|findings|comments|changes|fixes|recommendations|items)\b/i,
96
+ /^\s*(?:review|check|inspect|look at|take a look at)\s+(?:the\s+)?(?:latest|last|new|recent|remaining)\s+(?:changes?|commits?|updates?|failure|issues?)\b/i,
97
+ /^\s*(?:how(?:'s| is) it looking|does this look|what about now)\b/i,
98
+ /^\s*(?:run|rerun)\s+(?:it|that|the tests?|the checks?)\b/i,
99
+ ];
100
+
101
+ const EXPLICIT_SKILL_REFERENCE_PATTERNS = [
102
+ /\bremembrance:\/\/skills\/[^\s"'<>]+/i,
103
+ /\/remembrance:use\b/i,
104
+ /\binvoke_skill\b/i,
105
+ /\b(?:use|invoke|open|load|select)\s+(?:the\s+)?remembrance\s+skill(?:\s+(?:named|called))?\s+[`"']?[a-z0-9][a-z0-9._-]*[`"']?/i,
106
+ /\b(?:use|invoke|open|load|select)\s+(?:the\s+)?skill\s+(?:named|called)\s+[`"']?[a-z0-9][a-z0-9._-]*[`"']?\s+(?:from|in)\s+remembrance\b/i,
107
+ ];
108
+
109
+ const SKIP_PATTERNS = [
110
+ /\b(general web search|search the web|google this|look up current facts?)\b/i,
111
+ /^\s*(what|who|when|where)\s+(is|are|was|were)\b/i,
112
+ /\b(one[- ]off fact|private scratch memory|brainstorm)\b/i,
113
+ ];
114
+
115
+ const SECRET_PATTERNS = [
116
+ /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9_-]{12,}\b/g,
117
+ /\bsk-proj-[A-Za-z0-9_-]{12,}\b/g,
118
+ /\bsk-[A-Za-z0-9_-]{20,}\b/g,
119
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
120
+ /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
121
+ /\bxox[abp]-[A-Za-z0-9-]{20,}\b/g,
122
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
123
+ /\bya29\.[A-Za-z0-9_-]{20,}\b/g,
124
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
125
+ /\bAIza[0-9A-Za-z_-]{16,}\b/g,
126
+ /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}\b/gi,
127
+ /\b(?:aws_secret_access_key|aws_secret_key|secret_access_key)\s*[:=]\s*["']?[A-Za-z0-9/+=]{32,}["']?/gi,
128
+ /\b(password|secret|token|api[_-]?key)\s*[:=]\s*["']?[^"'\s]+/gi,
129
+ /\b(?:mongodb(?:\+srv)?|redis(?:s)?|postgres(?:ql)?):\/\/[^\s"'<>]+/gi,
130
+ /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[0-1])\.\d+\.\d+)[^\s)"']*/gi,
131
+ /\bhttps?:\/\/[^/\s)"']*(?:\.internal|\.local|\.corp|\.onion)(?::\d+)?[^\s)"']*/gi,
132
+ ];
133
+
134
+ // --- Trigger heuristic -------------------------------------------------------
135
+
136
+ export function shouldQueryPrompt(prompt) {
137
+ const normalized = String(prompt ?? "").trim();
138
+ if (!normalized || normalized.length < 8) {
139
+ return { likely_match: false, reason: "empty_or_too_short" };
140
+ }
141
+ if (
142
+ EXPLICIT_SKILL_REFERENCE_PATTERNS.some((pattern) =>
143
+ pattern.test(normalized),
144
+ )
145
+ ) {
146
+ return { likely_match: false, reason: "explicit_skill_reference" };
147
+ }
148
+ if (SKIP_PATTERNS.some((pattern) => pattern.test(normalized))) {
149
+ return { likely_match: false, reason: "skip_pattern" };
150
+ }
151
+ const matches = [
152
+ ...SERVICE_PATTERNS.map((pattern) => [pattern, "external_service"]),
153
+ ...TOOL_PATTERNS.map((pattern) => [pattern, "tool_or_framework"]),
154
+ ...WORKFLOW_PATTERNS.map((pattern) => [pattern, "workflow_shape"]),
155
+ ...UI_PATTERNS.map((pattern) => [pattern, "ui_or_dashboard_work"]),
156
+ ];
157
+ for (const [pattern, reason] of matches) {
158
+ if (pattern.test(normalized)) {
159
+ return { likely_match: true, reason };
160
+ }
161
+ }
162
+ if (/\b(integrate|integration|configure|setup|set up)\b/i.test(normalized)) {
163
+ return { likely_match: true, reason: "third_party_integration" };
164
+ }
165
+ return { likely_match: false, reason: "no_trigger_match" };
166
+ }
167
+
168
+ export function isContextualContinuationPrompt(prompt) {
169
+ const normalized = String(prompt ?? "").trim();
170
+ if (
171
+ !normalized ||
172
+ SKIP_PATTERNS.some((pattern) => pattern.test(normalized)) ||
173
+ EXPLICIT_SKILL_REFERENCE_PATTERNS.some((pattern) =>
174
+ pattern.test(normalized),
175
+ )
176
+ ) {
177
+ return false;
178
+ }
179
+ return CONTEXTUAL_CONTINUATION_PATTERNS.some((pattern) =>
180
+ pattern.test(normalized),
181
+ );
182
+ }
183
+
184
+ // --- Redaction ---------------------------------------------------------------
185
+
186
+ export function redactPrompt(prompt) {
187
+ let redacted = String(prompt ?? "");
188
+ for (const pattern of SECRET_PATTERNS) {
189
+ redacted = redacted.replace(pattern, (match, prefix) => {
190
+ if (typeof prefix === "string" && /^Bearer\s+/i.test(prefix)) {
191
+ return `${prefix}[redacted-token]`;
192
+ }
193
+ if (/^https?:\/\//i.test(match)) {
194
+ return "[redacted-private-url]";
195
+ }
196
+ return "[redacted-secret]";
197
+ });
198
+ }
199
+ return redacted;
200
+ }
201
+
202
+ // --- Query payload -----------------------------------------------------------
203
+
204
+ // Canonical (Codex) agent identity. "codex" must be a value the server's
205
+ // agentProviderSchema accepts — a non-enum provider (the old "openai") makes the
206
+ // /api/v1/agent/query request fail validation, so the fail-open hook silently
207
+ // injects nothing. Other runtimes override via runQuery's identity/userAgent
208
+ // options; the Claude adapter builds its own payload with its own identity.
209
+ export const DEFAULT_AGENT_IDENTITY = { provider: "codex", model: "codex" };
210
+ export const DEFAULT_USER_AGENT = "@remembrance/codex-plugin";
211
+
212
+ export function buildQueryPayload(
213
+ prompt,
214
+ env = process.env,
215
+ identity = DEFAULT_AGENT_IDENTITY,
216
+ clientContext = null,
217
+ ) {
218
+ const redacted = redactPrompt(prompt).trim();
219
+ const summary =
220
+ redacted.length <= MAX_SUMMARY_CHARS
221
+ ? redacted
222
+ : `${redacted.slice(0, MAX_SUMMARY_CHARS - 3).trim()}...`;
223
+ return {
224
+ agent: {
225
+ provider: identity.provider,
226
+ model: identity.model,
227
+ },
228
+ task: {
229
+ domain: inferDomain(summary),
230
+ summary,
231
+ constraints: inferConstraints(summary),
232
+ },
233
+ ...(clientContext ? { client_context: clientContext } : {}),
234
+ economics_context: buildEconomicsContext(summary, env, identity),
235
+ limit: limitFromEnv(env),
236
+ };
237
+ }
238
+
239
+ export function buildEconomicsContext(summary, env, identity) {
240
+ const observedModel = stringOrNull(
241
+ env.REMEMBRANCE_OBSERVED_MODEL_REVISION ?? env.REMEMBRANCE_MODEL_REVISION,
242
+ );
243
+ const requestedModel = stringOrNull(
244
+ env.REMEMBRANCE_REQUESTED_MODEL ?? identity?.model,
245
+ );
246
+ return {
247
+ runtime: runtimeFromIdentity(identity),
248
+ ...(stringOrNull(env.REMEMBRANCE_RUNTIME_VERSION)
249
+ ? { runtime_version: safeText(env.REMEMBRANCE_RUNTIME_VERSION, 120) }
250
+ : {}),
251
+ ...(requestedModel
252
+ ? { requested_model: safeText(requestedModel, 160) }
253
+ : {}),
254
+ ...(observedModel
255
+ ? { observed_model_revision: safeText(observedModel, 160) }
256
+ : {}),
257
+ reasoning_effort: normalizeReasoningEffort(
258
+ env.REMEMBRANCE_REASONING_EFFORT,
259
+ ),
260
+ task_stage: inferTaskStage(summary),
261
+ complexity: inferTaskComplexity(summary),
262
+ scope: {},
263
+ measurement_capabilities: ["latency"],
264
+ };
265
+ }
266
+
267
+ function inferTaskStage(summary) {
268
+ if (/\b(review|audit|inspect)\b/i.test(summary)) return "review";
269
+ if (/\b(test|verify|e2e|qa)\b/i.test(summary)) return "testing";
270
+ if (/\b(deploy|release|publish|rollout)\b/i.test(summary))
271
+ return "deployment";
272
+ if (/\b(debug|fix|failure|error|broken)\b/i.test(summary)) return "debugging";
273
+ if (/\b(plan|design|architect|approach)\b/i.test(summary)) return "planning";
274
+ if (/\b(research|evaluate|compare|investigate)\b/i.test(summary))
275
+ return "research";
276
+ if (/\b(build|implement|add|create|update|change)\b/i.test(summary)) {
277
+ return "implementation";
278
+ }
279
+ return "unknown";
280
+ }
281
+
282
+ function inferTaskComplexity(summary) {
283
+ const text = String(summary ?? "");
284
+ if (
285
+ /\b(full|complete|end[- ]to[- ]end|architecture|migration|security)\b/i.test(
286
+ text,
287
+ )
288
+ ) {
289
+ return "high";
290
+ }
291
+ if (text.length > 400 || inferConstraints(text).length >= 2) return "medium";
292
+ return "unknown";
293
+ }
294
+
295
+ // Map a prompt to a seeded registry domain so the auto-query is filtered to the
296
+ // right area instead of falling back to a generic catch-all (which surfaces the
297
+ // entry skills regardless of task). Seeded domains: agent-skills, web-ui-qa,
298
+ // resource-discovery, agent-commerce, mcp, mpp. Order matters — most specific
299
+ // first. The web-ui vocabulary is intentionally broad (frontend / dashboard /
300
+ // design work rarely says the words "web ui" or "accessibility").
301
+ export function inferDomain(prompt) {
302
+ if (/\b(mpp|x402)\b/i.test(prompt)) {
303
+ return "mpp";
304
+ }
305
+ if (
306
+ /\b(payment|stripe|checkout|billing|invoice|commerce|receipt)\b/i.test(
307
+ prompt,
308
+ )
309
+ ) {
310
+ return "agent-commerce";
311
+ }
312
+ if (
313
+ /\b(web ?ui|ux|usability|accessibility|a11y|responsive|playwright|frontend|front-end|dashboard|admin (page|panel|surface)|layout|nav(igation| bar| panel)?|sidebar|css|tailwind|styling|component|modal|tooltip|popover|button|form design|redesign|declutter|design system)\b/i.test(
314
+ prompt,
315
+ )
316
+ ) {
317
+ return "web-ui-qa";
318
+ }
319
+ if (
320
+ /\b(vercel|heroku|deploy|deployment|ci\/cd|github actions?|pipeline|rollback)\b/i.test(
321
+ prompt,
322
+ )
323
+ ) {
324
+ return "deployment";
325
+ }
326
+ if (/\b(mongodb|atlas|redis|database|postgres|sql)\b/i.test(prompt)) {
327
+ return "database";
328
+ }
329
+ if (/\b(mcp|model context protocol|tool server)\b/i.test(prompt)) {
330
+ return "mcp";
331
+ }
332
+ if (
333
+ /\b(skill|registry|review queue|reviewer|verifier|remembranc\w*|agent memory|skill idea|suggestion)\b/i.test(
334
+ prompt,
335
+ )
336
+ ) {
337
+ return "agent-skills";
338
+ }
339
+ if (
340
+ /\b(api|endpoint|rest|graphql|webhook|resource|integration|integrate|sdk|service|connector|provider|dataset|docs site)\b/i.test(
341
+ prompt,
342
+ )
343
+ ) {
344
+ return "resource-discovery";
345
+ }
346
+ // No seeded domain fits; agent-skills is the safest default (its entry skill
347
+ // covers the query/submit workflow itself) rather than a non-existent domain.
348
+ return "agent-skills";
349
+ }
350
+
351
+ export function inferConstraints(prompt) {
352
+ const constraints = [];
353
+ for (const [pattern, value] of [
354
+ [/\b(mcp|model context protocol|tool server)\b/i, "mcp"],
355
+ [/\b(install|setup|set up|configure)\b/i, "setup"],
356
+ [
357
+ /\b(api key|enterprise key|organization key|credential)\b/i,
358
+ "api-key",
359
+ ],
360
+ [/\b(troubleshoot|troubleshooting)\b/i, "troubleshooting"],
361
+ [/\b(ci|github actions?|circleci)\b/i, "ci"],
362
+ [/\b(deploy|deployment|vercel|heroku)\b/i, "deployment"],
363
+ [/\b(payment|stripe|mpp|x402)\b/i, "payment"],
364
+ [/\b(migration|migrate|schema)\b/i, "migration"],
365
+ [/\b(playwright|browser|responsive|accessibility|a11y)\b/i, "qa"],
366
+ [
367
+ /\b(frontend|front-end|dashboard|ux|css|tailwind|react|next\.?js|component|layout|nav|redesign|declutter)\b/i,
368
+ "frontend",
369
+ ],
370
+ ]) {
371
+ if (pattern.test(prompt)) {
372
+ constraints.push(value);
373
+ }
374
+ }
375
+ return [...new Set(constraints)];
376
+ }
377
+
378
+ // --- Query (fetch + timeout, fail-open) --------------------------------------
379
+
380
+ // Query Remembrance. Returns { body } on success, null on any failure (HTTP
381
+ // error, timeout, malformed JSON, thrown error). Never throws.
382
+ export async function queryRemembrance(payload, options = {}) {
383
+ const env = options.env ?? process.env;
384
+ const fetchImpl = options.fetchImpl ?? fetch;
385
+ const controller = new AbortController();
386
+ const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
387
+ try {
388
+ const credential = resolveApiCredential(env);
389
+ if (isUnusableConfigurationSource(credential.source)) {
390
+ debugLog(env, "shared_config_unusable", {}, options);
391
+ return null;
392
+ }
393
+ const headers = {
394
+ "content-type": "application/json",
395
+ "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
396
+ };
397
+ const apiKey = credential.apiKey;
398
+ if (apiKey) {
399
+ headers["x-remembrance-api-key"] = apiKey;
400
+ }
401
+ const response = await fetchImpl(`${apiUrl(env)}/api/v1/agent/query`, {
402
+ method: "POST",
403
+ headers,
404
+ body: JSON.stringify(payload),
405
+ signal: controller.signal,
406
+ });
407
+ if (!response.ok) {
408
+ debugLog(
409
+ env,
410
+ "http_error",
411
+ { status: response.status, api_url: apiUrl(env) },
412
+ options,
413
+ );
414
+ return null;
415
+ }
416
+ let body;
417
+ try {
418
+ body = await response.json();
419
+ } catch (error) {
420
+ debugLog(env, "malformed_response", { error: errorName(error) }, options);
421
+ return null;
422
+ }
423
+ return { body };
424
+ } catch (error) {
425
+ debugLog(
426
+ env,
427
+ errorName(error) === "AbortError" ? "timeout" : "request_error",
428
+ { error: errorName(error) },
429
+ options,
430
+ );
431
+ return null;
432
+ } finally {
433
+ clearTimeout(timeout);
434
+ }
435
+ }
436
+
437
+ export async function createContinuationDirective(options = {}) {
438
+ const directive = {
439
+ directive_id: `dir_${randomBytes(16).toString("hex")}`,
440
+ runtime: normalizeRuntime(options.runtime),
441
+ trigger_reason: safeText(
442
+ options.triggerReason ?? "contextual_continuation",
443
+ 160,
444
+ ),
445
+ shown_at: new Date().toISOString(),
446
+ };
447
+ if (options.reportShown !== false) {
448
+ await reportDirectiveEvent(
449
+ {
450
+ event: "shown",
451
+ directive_id: directive.directive_id,
452
+ surface: "plugin_hook",
453
+ runtime: directive.runtime,
454
+ trigger_reason: directive.trigger_reason,
455
+ },
456
+ options,
457
+ );
458
+ }
459
+ return directive;
460
+ }
461
+
462
+ // Directive telemetry is deliberately fail-open and analytics-only. Hooks wait
463
+ // briefly for the shown event so a fast subsequent MCP query cannot overtake
464
+ // its denominator record, but an unavailable registry never blocks the prompt.
465
+ export async function reportDirectiveEvent(event, options = {}) {
466
+ const env = options.env ?? process.env;
467
+ const fetchImpl = options.fetchImpl ?? fetch;
468
+ const controller = new AbortController();
469
+ const timeout = setTimeout(
470
+ () => controller.abort(),
471
+ directiveEventTimeoutMs(env),
472
+ );
473
+ try {
474
+ const credential = resolveApiCredential(env);
475
+ if (isUnusableConfigurationSource(credential.source)) return false;
476
+ const headers = {
477
+ "content-type": "application/json",
478
+ "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
479
+ };
480
+ const apiKey = credential.apiKey;
481
+ if (apiKey) headers["x-remembrance-api-key"] = apiKey;
482
+ const response = await fetchImpl(
483
+ `${apiUrl(env)}/api/v1/agent/directive-events`,
484
+ {
485
+ method: "POST",
486
+ headers,
487
+ body: JSON.stringify(event),
488
+ signal: controller.signal,
489
+ },
490
+ );
491
+ if (!response?.ok) {
492
+ debugLog(
493
+ env,
494
+ "directive_event_http_error",
495
+ { status: response?.status ?? "unavailable" },
496
+ options,
497
+ );
498
+ return false;
499
+ }
500
+ return true;
501
+ } catch (error) {
502
+ debugLog(
503
+ env,
504
+ "directive_event_error",
505
+ { error: errorName(error) },
506
+ options,
507
+ );
508
+ return false;
509
+ } finally {
510
+ clearTimeout(timeout);
511
+ }
512
+ }
513
+
514
+ function runtimeFromIdentity(identity) {
515
+ return normalizeRuntime(identity?.provider ?? "codex");
516
+ }
517
+
518
+ function normalizeRuntime(value) {
519
+ if (value === "claude") return "claude_code";
520
+ if (value === "vscode") return "vs_code";
521
+ if (
522
+ [
523
+ "codex",
524
+ "claude_code",
525
+ "cursor",
526
+ "openclaw",
527
+ "vs_code",
528
+ "opencode",
529
+ "other",
530
+ ].includes(value)
531
+ ) {
532
+ return value;
533
+ }
534
+ return "unknown";
535
+ }
536
+
537
+ // --- Context formatting ------------------------------------------------------
538
+
539
+ function publicSkillCandidateAllowed(body, candidate) {
540
+ return (
541
+ body?.skill_access?.public_skills_allowed !== false ||
542
+ candidate?.source === "org_overlay"
543
+ );
544
+ }
545
+
546
+ export function highMatchFromResponse(response) {
547
+ const body = responseBodyFromToolResponse(response);
548
+ const queryId = body?.query_feedback?.query_id ?? body?.query_id ?? null;
549
+ const candidates = [
550
+ ...(Array.isArray(body?.skills)
551
+ ? body.skills
552
+ .filter((item) => publicSkillCandidateAllowed(body, item))
553
+ .map((item) => ({ ...item, target_type: "skill" }))
554
+ : []),
555
+ ...(Array.isArray(body?.resources)
556
+ ? body.resources.map((item) => ({ ...item, target_type: "resource" }))
557
+ : []),
558
+ ];
559
+ const candidate = candidates.find((item) => item?.match_tier === "high");
560
+ if (!candidate?.slug) return null;
561
+ return {
562
+ query_id: queryId ? safeText(queryId, 80) : null,
563
+ result_id: candidate.result_id ? safeText(candidate.result_id, 80) : null,
564
+ target_type: candidate.target_type,
565
+ slug: safeText(candidate.slug, 120),
566
+ match_reason: safeText(
567
+ candidate.match_reason ?? "Strong task coverage",
568
+ 160,
569
+ ),
570
+ estimated_tokens: Number.isFinite(candidate.estimated_tokens)
571
+ ? Math.max(0, Math.round(candidate.estimated_tokens))
572
+ : null,
573
+ verified_uses: numberValue(candidate.verified_uses),
574
+ risk_level: safeText(candidate.risk_level ?? "unknown", 24),
575
+ why_matched: sanitizeWhyMatched(candidate.why_matched),
576
+ applicability: sanitizeApplicability(candidate.applicability),
577
+ value_estimate_id: candidate.value_estimate_id
578
+ ? safeText(candidate.value_estimate_id, 80)
579
+ : null,
580
+ potential_savings: sanitizePotentialSavings(candidate.potential_savings),
581
+ };
582
+ }
583
+
584
+ // Format the query response into the plain context string Codex injects via
585
+ // additionalContext. Returns null when there is nothing useful to inject.
586
+ export function formatContext(
587
+ response,
588
+ reason = "trigger_match",
589
+ limit = DEFAULT_LIMIT,
590
+ ) {
591
+ const body = responseBodyFromToolResponse(response);
592
+ const itemLimit = Math.min(Math.max(Number(limit) || DEFAULT_LIMIT, 1), 10);
593
+ const skills = Array.isArray(body?.skills)
594
+ ? body.skills
595
+ .filter((item) => publicSkillCandidateAllowed(body, item))
596
+ .slice(0, itemLimit)
597
+ : [];
598
+ const resources = Array.isArray(body?.resources)
599
+ ? body.resources.slice(0, itemLimit)
600
+ : [];
601
+ const noResults = body?.no_results ?? null;
602
+ if (skills.length === 0 && resources.length === 0 && !noResults) {
603
+ return null;
604
+ }
605
+
606
+ const lines = [
607
+ "Remembrance auto-query context:",
608
+ `Trigger: ${reason}. Use these results only if they fit the user's task constraints.`,
609
+ ];
610
+ if (body?.skill_access?.policy === "org_only") {
611
+ lines.push(
612
+ "Organization policy: use organization skills only. Do not use public skills or bundled public skill references, including as an offline fallback.",
613
+ );
614
+ }
615
+ const queryId = body?.query_feedback?.query_id ?? body?.query_id;
616
+ const highMatch = highMatchFromResponse(body);
617
+ if (queryId) {
618
+ lines.push(
619
+ `Query receipt: ${safeText(queryId, 80)}. Use this query_id with the result IDs below when calling submit_query_feedback.`,
620
+ );
621
+ }
622
+ if (highMatch) {
623
+ const tool =
624
+ highMatch.target_type === "resource" ? "get_resource" : "get_skill";
625
+ const args = {
626
+ slug: highMatch.slug,
627
+ ...(highMatch.query_id ? { query_id: highMatch.query_id } : {}),
628
+ ...(highMatch.result_id ? { result_id: highMatch.result_id } : {}),
629
+ };
630
+ lines.push(
631
+ `HIGH MATCH — required next step: call ${tool} with ${JSON.stringify(args)} before proceeding. ${highMatch.match_reason}; ${matchEvidenceLabel(highMatch)}. If it does not actually fit, do not force its use: submit query feedback with fit poor and a concrete reason.`,
632
+ );
633
+ }
634
+ // Tail lines (the no-results payload, the contribution directive, and the
635
+ // delegation instruction) are the actionable part of this context. They are
636
+ // reserved BEFORE candidate lines fill the remaining budget — the previous
637
+ // append-then-truncate order let rich per-candidate decision labels push
638
+ // them past MAX_CONTEXT_CHARS, silently evicting the directive.
639
+ const tailLines = [];
640
+ if (noResults) {
641
+ const payload = safeText(
642
+ JSON.stringify(noResults.propose_skill_idea_payload ?? noResults),
643
+ 1200,
644
+ );
645
+ tailLines.push(
646
+ `No matching skill/resource. Proposed skill idea payload: ${payload}`,
647
+ );
648
+ }
649
+ const contributionDirective =
650
+ body?.contribution_directive?.message ?? body?.fallback_instruction;
651
+ if (contributionDirective) {
652
+ tailLines.push(
653
+ `After using Remembrance: ${safeText(contributionDirective, MAX_DIRECTIVE_CHARS)}`,
654
+ );
655
+ }
656
+ if (skills.length > 0 || resources.length > 0) {
657
+ tailLines.push(
658
+ "Delegating this task? Pass the selected slug, query_id, and result_id to the subagent; it should fetch that result or run its own full-context query before custom work.",
659
+ );
660
+ }
661
+
662
+ const candidateSections = [];
663
+ if (skills.length > 0) {
664
+ candidateSections.push({
665
+ header: "Skills:",
666
+ lines: skills.map(
667
+ (skill) =>
668
+ `- [${matchTierLabel(skill.match_tier)}] ${safeText(skill.slug, 80)} (${candidateEvidenceLabel(skill)}${skill.result_id ? `, result ${safeText(skill.result_id, 80)}` : ""}): ${safeText(skill.description, MAX_CONTEXT_FIELD_CHARS)}${candidateDecisionLabel(skill)}`,
669
+ ),
670
+ });
671
+ }
672
+ if (resources.length > 0) {
673
+ candidateSections.push({
674
+ header: "Resources:",
675
+ lines: resources.map(
676
+ (resource) =>
677
+ `- [${matchTierLabel(resource.match_tier)}] ${safeText(resource.slug, 80)} [${safeText(resource.kind, 40)}, ${candidateEvidenceLabel(resource)}${resource.result_id ? `, result ${safeText(resource.result_id, 80)}` : ""}]: ${safeText(resource.description, MAX_CONTEXT_FIELD_CHARS)}${candidateDecisionLabel(resource)}`,
678
+ ),
679
+ });
680
+ }
681
+ lines.push(
682
+ ...budgetedCandidateLines(
683
+ candidateSections,
684
+ MAX_CONTEXT_CHARS - joinedLength([...lines, ...tailLines]),
685
+ ),
686
+ );
687
+ lines.push(...tailLines);
688
+ return safeText(lines.join("\n"), MAX_CONTEXT_CHARS);
689
+ }
690
+
691
+ function joinedLength(lines) {
692
+ return lines.reduce(
693
+ (total, line) => total + line.length,
694
+ Math.max(0, lines.length - 1),
695
+ );
696
+ }
697
+
698
+ // Fit candidate lines (with their section headers) into the character budget
699
+ // left after the head/tail lines. Dropping is explicit, never silent: when
700
+ // candidates are omitted, a short note says how many and gives the agent the
701
+ // explicit recovery action.
702
+ function budgetedCandidateLines(sections, budget) {
703
+ const total = sections.reduce(
704
+ (count, section) => count + section.lines.length,
705
+ 0,
706
+ );
707
+ const omissionNote = (count) =>
708
+ `(+${count} more match${count === 1 ? "" : "es"} omitted to fit this context; call query_skills with the current task context to retrieve the full list.)`;
709
+ // +1: inserting this block between head and tail costs one MORE newline
710
+ // than the block's own internal joins (joinedLength counts n-1 separators
711
+ // for n lines; the block itself joins to its neighbors with an nth).
712
+ const fits = (lines) => joinedLength(lines) + 1 <= budget;
713
+
714
+ const chosen = [];
715
+ let shown = 0;
716
+ for (const section of sections) {
717
+ let headerAdded = false;
718
+ for (const line of section.lines) {
719
+ const attempt = headerAdded
720
+ ? [...chosen, line]
721
+ : [...chosen, section.header, line];
722
+ const remainingAfter = total - (shown + 1);
723
+ // Every non-final line must leave room for the omission note, so that
724
+ // if the NEXT line does not fit, the note (for exactly that many
725
+ // dropped candidates) is guaranteed to fit alongside what was kept.
726
+ const accepted =
727
+ remainingAfter > 0
728
+ ? fits([...attempt, omissionNote(remainingAfter)])
729
+ : fits(attempt);
730
+ if (!accepted) {
731
+ const dropped = total - shown;
732
+ if (fits([...chosen, omissionNote(dropped)])) {
733
+ chosen.push(omissionNote(dropped));
734
+ }
735
+ return chosen;
736
+ }
737
+ chosen.length = 0;
738
+ chosen.push(...attempt);
739
+ headerAdded = true;
740
+ shown += 1;
741
+ }
742
+ }
743
+ return chosen;
744
+ }
745
+
746
+ function matchTierLabel(value) {
747
+ if (value === "high") return "HIGH MATCH";
748
+ if (value === "possible") return "POSSIBLE MATCH";
749
+ return "EXPLORATORY";
750
+ }
751
+
752
+ function candidateEvidenceLabel(candidate) {
753
+ const tokens = Number.isFinite(candidate?.estimated_tokens)
754
+ ? `~${Math.max(0, Math.round(candidate.estimated_tokens))} tokens`
755
+ : "size unknown";
756
+ const verified = numberValue(candidate?.verified_uses);
757
+ const savings = potentialSavingsLabel(candidate?.potential_savings);
758
+ return `${tokens}, ${verified} verified ${verified === 1 ? "use" : "uses"}, risk ${safeText(candidate?.risk_level ?? "unknown", 24)}${savings ? `, ${savings}` : ""}`;
759
+ }
760
+
761
+ function candidateDecisionLabel(candidate) {
762
+ const why = sanitizeWhyMatched(candidate?.why_matched);
763
+ const applicability = sanitizeApplicability(candidate?.applicability);
764
+ const evidence = [];
765
+ if (why?.matched_terms.length) {
766
+ evidence.push(`terms ${why.matched_terms.join(", ")}`);
767
+ }
768
+ if (why?.matched_capabilities.length) {
769
+ evidence.push(`capabilities ${why.matched_capabilities.join(", ")}`);
770
+ }
771
+ if (why?.domain_match) evidence.push("exact domain");
772
+ if (why?.satisfied_constraints.length) {
773
+ evidence.push(`constraints met ${why.satisfied_constraints.join(", ")}`);
774
+ }
775
+ if (why?.missed_constraints.length) {
776
+ evidence.push(`constraints missing ${why.missed_constraints.join(", ")}`);
777
+ }
778
+ if (why) {
779
+ evidence.push(
780
+ `signals lexical ${why.lexical_signal}, semantic ${why.semantic_signal}`,
781
+ );
782
+ }
783
+
784
+ const applicabilityParts = [];
785
+ if (applicability) {
786
+ applicabilityParts.push(
787
+ `applicability ${applicability.fit}/${applicability.scope}: ${applicability.reason}`,
788
+ );
789
+ if (applicability.use_when.length) {
790
+ applicabilityParts.push(
791
+ `use only when ${applicability.use_when.join("; ")}`,
792
+ );
793
+ }
794
+ if (applicability.avoid_when.length) {
795
+ applicabilityParts.push(
796
+ `avoid when ${applicability.avoid_when.join("; ")}`,
797
+ );
798
+ }
799
+ }
800
+ const details = [...evidence, ...applicabilityParts];
801
+ return details.length > 0
802
+ ? ` Decision: ${safeText(details.join("; "), 640)}.`
803
+ : "";
804
+ }
805
+
806
+ function sanitizeWhyMatched(value) {
807
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
808
+ return {
809
+ matched_terms: safeStringList(value.matched_terms, 4, 48),
810
+ matched_capabilities: safeStringList(value.matched_capabilities, 3, 80),
811
+ domain_match: value.domain_match === true,
812
+ satisfied_constraints: safeStringList(value.satisfied_constraints, 3, 100),
813
+ missed_constraints: safeStringList(value.missed_constraints, 3, 100),
814
+ lexical_signal: signalStrength(value.lexical_signal),
815
+ semantic_signal: signalStrength(value.semantic_signal),
816
+ };
817
+ }
818
+
819
+ function sanitizeApplicability(value) {
820
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
821
+ const fit = ["likely", "conditional", "unlikely", "unknown"].includes(
822
+ value.fit,
823
+ )
824
+ ? value.fit
825
+ : "unknown";
826
+ const scope = ["general", "specialized", "corner_case", "unknown"].includes(
827
+ value.scope,
828
+ )
829
+ ? value.scope
830
+ : "unknown";
831
+ return {
832
+ fit,
833
+ scope,
834
+ reason: safeText(value.reason ?? "No applicability reason provided", 180),
835
+ use_when: safeStringList(value.use_when, 2, 120),
836
+ avoid_when: safeStringList(value.avoid_when, 2, 120),
837
+ };
838
+ }
839
+
840
+ function safeStringList(value, limit, maxChars) {
841
+ if (!Array.isArray(value)) return [];
842
+ return [
843
+ ...new Set(
844
+ value
845
+ .filter((item) => typeof item === "string" && item.trim())
846
+ .map((item) => safeText(item, maxChars)),
847
+ ),
848
+ ].slice(0, limit);
849
+ }
850
+
851
+ function signalStrength(value) {
852
+ return ["none", "weak", "moderate", "strong"].includes(value)
853
+ ? value
854
+ : "none";
855
+ }
856
+
857
+ function matchEvidenceLabel(match) {
858
+ const tokens = Number.isFinite(match.estimated_tokens)
859
+ ? `~${match.estimated_tokens} tokens`
860
+ : "size unknown";
861
+ const savings = potentialSavingsLabel(match.potential_savings);
862
+ return `${tokens}, ${match.verified_uses} verified ${match.verified_uses === 1 ? "use" : "uses"}, risk ${match.risk_level}${savings ? `, ${savings}` : ""}${candidateDecisionLabel(match)}`;
863
+ }
864
+
865
+ export function continuationQueryContext(directive = null) {
866
+ const clientContext = directive
867
+ ? ` Use client_context ${JSON.stringify({
868
+ runtime: directive.runtime,
869
+ trigger_reason: directive.trigger_reason,
870
+ directive_id: directive.directive_id,
871
+ })} so Remembrance can measure whether this instruction was followed.`
872
+ : "";
873
+ return [
874
+ "Remembrance task-continuation reminder:",
875
+ `This prompt depends on earlier conversation. Infer the concrete task, domain, and constraints from the full thread, then call the Remembrance MCP tool query_skills before acting; do not wait for the current prompt to repeat service, framework, workflow, or UI keywords.${clientContext}`,
876
+ "If MCP is unavailable, use POST /api/v1/agent/query. Do not send raw prior conversation: submit a redacted task summary and constraints.",
877
+ "Before finishing, use submit_query_feedback once with one complete set of explicit good/partial/poor query matches, submit_feedback and submit_remembrance when a returned skill was actually used, or submit a failure_report / skill idea only when a reusable lesson or genuinely missing workflow was found.",
878
+ ].join("\n");
879
+ }
880
+
881
+ export function unavailableQueryContext(env = process.env) {
882
+ const credential = resolveApiCredential(env);
883
+ const organizationFallback = isUnusableConfigurationSource(credential.source)
884
+ ? "The shared Remembrance config exists but is unreadable or invalid. Fail closed: do not use bundled public references or remote tools until the config is fixed or intentionally removed."
885
+ : credential.apiKey
886
+ ? "An organization API key is configured, but the organization skill policy could not be confirmed. Fail closed: do not use bundled public skill references while the query is unavailable."
887
+ : "No organization API key is configured; bundled public references remain an optional offline fallback.";
888
+ return [
889
+ "Remembrance query-unavailable context:",
890
+ "This task qualified for Remembrance, but the automatic query did not complete. Continue without blocking, and call query_skills directly when the MCP tool is visible.",
891
+ organizationFallback,
892
+ "Before finishing, submit a failure_report only if the outage or workaround produced a reusable lesson. Redact secrets, private URLs, credentials, and proprietary content.",
893
+ ].join("\n");
894
+ }
895
+
896
+ export function emptyQueryContext(reason = "trigger_match") {
897
+ return [
898
+ "Remembrance auto-query context:",
899
+ `Trigger: ${reason}. The query completed but returned no matching skill or resource.`,
900
+ "Use the full conversation before deciding whether the workflow is genuinely missing. Propose a skill idea only when you built a reusable workflow that the registry does not already cover.",
901
+ "Before finishing, submit a failure_report when the task exposed a reusable failure lesson.",
902
+ ].join("\n");
903
+ }
904
+
905
+ // --- High-level query orchestration ------------------------------------------
906
+
907
+ // Full prompt-hook result. `eligible` means this turn should be recovered at
908
+ // completion even if no registry result was consumed. `consumed` means a real
909
+ // query completed; `matched` means that response contained at least one result
910
+ // the current credential is allowed to use.
911
+ export async function runPromptHook(prompt, options = {}) {
912
+ const env = options.env ?? process.env;
913
+ const includeSharedConfigCredentialNotice =
914
+ options.includeSharedConfigCredentialNotice !== false;
915
+ if (disabled(env.REMEMBRANCE_AUTO_QUERY)) {
916
+ debugLog(env, "disabled", {}, options);
917
+ return null;
918
+ }
919
+ const redacted = redactPrompt(String(prompt ?? ""));
920
+ const decision = shouldQueryPrompt(redacted);
921
+ if (!decision.likely_match) {
922
+ if (isContextualContinuationPrompt(redacted)) {
923
+ const directive = await createContinuationDirective({
924
+ env,
925
+ fetchImpl: options.fetchImpl ?? fetch,
926
+ reportShown: options.reportDirectiveShown,
927
+ runtime: runtimeFromIdentity(options.identity),
928
+ stderr: options.stderr,
929
+ userAgent: options.userAgent,
930
+ });
931
+ return {
932
+ consumed: false,
933
+ context: continuationQueryContext(directive),
934
+ directive,
935
+ eligible: true,
936
+ reason: "contextual_continuation",
937
+ };
938
+ }
939
+ debugLog(env, "skip", { reason: decision.reason }, options);
940
+ return null;
941
+ }
942
+ const payload = buildQueryPayload(redacted, env, options.identity, {
943
+ surface: "plugin_hook",
944
+ runtime: runtimeFromIdentity(options.identity),
945
+ trigger_reason: decision.reason,
946
+ });
947
+ const response = await queryRemembrance(payload, {
948
+ env,
949
+ fetchImpl: options.fetchImpl ?? fetch,
950
+ stderr: options.stderr,
951
+ userAgent: options.userAgent,
952
+ });
953
+ if (!response) {
954
+ return {
955
+ consumed: false,
956
+ context: includeSharedConfigCredentialNotice
957
+ ? withSharedConfigCredentialNotice(unavailableQueryContext(env), env)
958
+ : unavailableQueryContext(env),
959
+ eligible: true,
960
+ reason: "query_unavailable",
961
+ };
962
+ }
963
+ return {
964
+ consumed: true,
965
+ matched: queryResponseHasMatches(response),
966
+ context: includeSharedConfigCredentialNotice
967
+ ? withSharedConfigCredentialNotice(
968
+ formatContext(response, decision.reason, limitFromEnv(env)) ??
969
+ emptyQueryContext(decision.reason),
970
+ env,
971
+ )
972
+ : (formatContext(response, decision.reason, limitFromEnv(env)) ??
973
+ emptyQueryContext(decision.reason)),
974
+ eligible: true,
975
+ highMatch: highMatchFromResponse(response),
976
+ valueEpisode: valueEpisodeFromResponse(response),
977
+ reason: decision.reason,
978
+ };
979
+ }
980
+
981
+ export function queryResponseHasMatches(response) {
982
+ const body = responseBodyFromToolResponse(response);
983
+ const skills = Array.isArray(body?.skills)
984
+ ? body.skills.filter((item) => publicSkillCandidateAllowed(body, item))
985
+ : [];
986
+ const resources = Array.isArray(body?.resources) ? body.resources : [];
987
+ return skills.length > 0 || resources.length > 0;
988
+ }
989
+
990
+ function withSharedConfigCredentialNotice(context, env) {
991
+ const notice = sharedConfigCredentialNotice(env);
992
+ return notice ? `${notice}\n\n${context}` : context;
993
+ }
994
+
995
+ // Backward-compatible string-only wrapper used by callers that do not need the
996
+ // eligibility/consumption distinction.
997
+ export async function runQuery(prompt, options = {}) {
998
+ return (await runPromptHook(prompt, options))?.context ?? null;
999
+ }
1000
+
1001
+ // --- Contribution decision (stop hook) ---------------------------------------
1002
+
1003
+ // Count only CONSUMPTION of the registry (queries / explicit skill retrieval) —
1004
+ // not the agent's own submissions. Kept for parity with the Claude hook and for
1005
+ // any transcript-shaped input a caller wants to scan; Codex's own Stop payload
1006
+ // has no transcript, so the marker mechanism below is what actually drives the
1007
+ // Codex stop decision.
1008
+ const CONSUMPTION_MARKERS =
1009
+ /Remembrance auto-query context|mcp__[a-z0-9_]*remembrance[a-z0-9_]*__(query_skills|get_skill|get_resource|invoke_skill)|\/api\/v1\/agent\/(?:query(?!-feedback)|skill-invocations)\b/gi;
1010
+
1011
+ const TASK_ELIGIBILITY_MARKERS =
1012
+ /Remembrance task-continuation reminder|Remembrance query-unavailable context/gi;
1013
+
1014
+ const CONTRIBUTION_SUBMITTED_PATTERNS = [
1015
+ /\brpub_[a-f0-9]{16,}\b/i,
1016
+ /\bsubmitted (?:it|this|the lesson|the failure|the remembrance) (?:to|as) Remembrance\b/i,
1017
+ /\bsubmit_remembrance\b/i,
1018
+ /\bREMEMBRANCE_SUBMISSION_PAYLOAD\b/,
1019
+ ];
1020
+
1021
+ const HIGH_VALUE_LESSON_PATTERNS = [
1022
+ {
1023
+ pattern:
1024
+ /\b(?:missed|missing|forgot(?:ten)?)\b.{0,80}\b(?:version bump|package version|plugin version|mcp package version)\b/i,
1025
+ reason: "release versioning miss",
1026
+ },
1027
+ {
1028
+ pattern:
1029
+ /\b(?:publish-impacting|check:mcp-package-version|sync:versions|refresh:generated)\b/i,
1030
+ reason: "publish or generated-artifact release lesson",
1031
+ },
1032
+ {
1033
+ pattern:
1034
+ /\b(?:regression|circleci failed|ci failed|deployment failed|deploy failed)\b/i,
1035
+ reason: "failure or regression",
1036
+ },
1037
+ {
1038
+ pattern:
1039
+ /\bI (?:missed|forgot|overlooked|failed to|should have|should've|did not|didn't)\b/i,
1040
+ reason: "self-correction or missed step",
1041
+ },
1042
+ {
1043
+ pattern: /\b(?:you|the user) (?:caught|pointed out|had to ask)\b/i,
1044
+ reason: "user-caught agent miss",
1045
+ },
1046
+ ];
1047
+
1048
+ export function countRegistryConsumption(transcript) {
1049
+ const matches = String(transcript ?? "").match(CONSUMPTION_MARKERS);
1050
+ return matches ? matches.length : 0;
1051
+ }
1052
+
1053
+ export function countTaskEligibility(transcript) {
1054
+ const matches = String(transcript ?? "").match(TASK_ELIGIBILITY_MARKERS);
1055
+ return matches ? matches.length : 0;
1056
+ }
1057
+
1058
+ export function contributionReason(
1059
+ signal = null,
1060
+ highMatch = null,
1061
+ directSelection = null,
1062
+ ) {
1063
+ const signalLine = signal
1064
+ ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1065
+ : null;
1066
+ const highMatchLine = highMatch
1067
+ ? highMatchClosureInstruction(highMatch)
1068
+ : null;
1069
+ const directSelections = (
1070
+ Array.isArray(directSelection)
1071
+ ? directSelection
1072
+ : directSelection
1073
+ ? [directSelection]
1074
+ : []
1075
+ ).slice(0, DIRECT_SELECTION_MARKER_LIMIT);
1076
+ if (directSelections.length > 0) {
1077
+ const selectionLines = directSelections.map((selection) => {
1078
+ const correlation =
1079
+ selection.query_id && selection.result_id
1080
+ ? ` Use query_id ${selection.query_id} and result_id ${selection.result_id}.`
1081
+ : "";
1082
+ return `- ${selection.slug}${selection.version ? ` version ${selection.version}` : ""}.${correlation}`;
1083
+ });
1084
+ return [
1085
+ directSelections.length === 1
1086
+ ? `Before you finish: you explicitly used this Remembrance skill:`
1087
+ : `Before you finish: you explicitly used ${directSelections.length} Remembrance skills:`,
1088
+ ...selectionLines,
1089
+ signalLine,
1090
+ "Do not submit query-fit feedback for these selections; they were chosen directly rather than ranked for a query.",
1091
+ "Call submit_feedback once for each listed skill with useful true or false and a concise post-use lesson.",
1092
+ "If submit_feedback returns next_step.submit_remembrance_payload, submit that reviewed evidence when the lesson is reusable.",
1093
+ "Report failed attempts, corrections, security findings, and reusable workflow improvements instead of silently changing the skill.",
1094
+ "If host privacy policy blocks private repository-derived instructions, do not retry through another transport: queue organization skills locally with queue_private_skill_import (or the bundled handoff script) and wait for an admin import receipt.",
1095
+ "Redact secrets, private URLs, credentials, prompts, outputs, source paths, and proprietary content.",
1096
+ "If nothing is genuinely worth capturing, say so in one line; you will not be asked again for this use.",
1097
+ ]
1098
+ .filter(Boolean)
1099
+ .join("\n");
1100
+ }
1101
+ return [
1102
+ "Before you finish: you used Remembrance this session.",
1103
+ signalLine,
1104
+ highMatchLine,
1105
+ "If you learned something reusable — a lesson, a gotcha, a fix, a skill that helped, or a skill that was missing — contribute it now so the next agent inherits it instead of rediscovering it:",
1106
+ "• submit_query_feedback — send one complete good / partial / poor verdict set using query_id and returned result_id values; leave uncertain results unrated. A poor query match is not the same as a globally bad skill.",
1107
+ "• submit_feedback — a quick useful / not-useful signal plus a one-line lesson.",
1108
+ "• submit_remembrance — post the ready next_step.submit_remembrance_payload returned by submit_feedback when the lesson is reusable, adding redacted evidence when available.",
1109
+ "• propose_private_skill — the default proposal path. Use it for anything repository-derived or organization-specific, after the host and organization approve Remembrance as the destination. It cannot create a public candidate under any credential state, so it stays private even if your key stops resolving.",
1110
+ "• propose_skill_idea — only if no existing skill fit, you built a reusable workflow, AND a public candidate is an acceptable outcome. An active organization key keeps it private; intentionally omitting a key makes the call PUBLIC. A supplied invalid/inactive key fails with 401 and an insufficient key fails with 403, creating no candidate. Read `visibility` in a successful response (organization_private or public_candidate) and report where it landed. Never remove or bypass the key to force a public candidate.",
1111
+ "Use the Remembrance MCP tools directly when they are visible. If this runtime has no MCP tools exposed, use the REST endpoints from the query response. A host privacy-policy denial is not API unavailability: never retry private repository content through another transport; queue organization skills locally with queue_private_skill_import (or the bundled handoff script) and wait for an admin import receipt. If the API itself is unavailable, emit REMEMBRANCE_SUBMISSION_PAYLOAD with the redacted JSON you would submit.",
1112
+ "Redact secrets, private URLs, and proprietary content; submit redacted summaries and hashes, not raw traces.",
1113
+ "Attach evidence: reproduction detail in outcome.failure_modes, artifact hashes of redacted logs/diffs, or an attestation. Evidence-less public reports wait in unverified intake until corroborated; evidence-backed ones verify faster and rank higher.",
1114
+ "If nothing is genuinely worth capturing, just say so in one line — you will not be asked again this session.",
1115
+ ]
1116
+ .filter(Boolean)
1117
+ .join("\n");
1118
+ }
1119
+
1120
+ export function taskClosureReason(signal = null) {
1121
+ const signalLine = signal
1122
+ ? `High-value lesson detected: ${signal}. Capture it unless you already submitted the lesson.`
1123
+ : null;
1124
+ return [
1125
+ "Before you finish: this session involved a reusable service, API, tool, workflow, UI, review, or engineering task, but no completed Remembrance query/use was recorded.",
1126
+ signalLine,
1127
+ "Infer the concrete task from the full conversation and call query_skills now when the MCP tool is visible. If MCP is unavailable, use POST /api/v1/agent/query with a redacted summary.",
1128
+ "Evaluate returned result IDs explicitly: call submit_query_feedback once with the complete good/partial/poor verdict set, then use submit_feedback only after actually using a skill. If nothing fits, report overall_fit none and propose a skill idea only when you actually built a reusable workflow.",
1129
+ "Submit a failure_report for a reusable self-correction, user-caught mistake, CI/deploy failure, security issue, or release/versioning miss even when no skill was used.",
1130
+ "Redact secrets, private URLs, credentials, raw logs, and proprietary content; attach bounded reproduction detail or artifact hashes when available.",
1131
+ "If the task was truly trivial and produced no reusable lesson, say so in one line; you will not be asked again for this task.",
1132
+ ]
1133
+ .filter(Boolean)
1134
+ .join("\n");
1135
+ }
1136
+
1137
+ export function detectHighValueLessonSignal(input) {
1138
+ return detectHighValueLessonSignalInText(
1139
+ input?.last_assistant_message ??
1140
+ input?.lastAssistantMessage ??
1141
+ input?.assistant_message ??
1142
+ input?.message ??
1143
+ "",
1144
+ );
1145
+ }
1146
+
1147
+ export function detectHighValueLessonSignalInText(text) {
1148
+ const value = String(text ?? "");
1149
+ if (!value.trim()) {
1150
+ return null;
1151
+ }
1152
+ if (CONTRIBUTION_SUBMITTED_PATTERNS.some((pattern) => pattern.test(value))) {
1153
+ return null;
1154
+ }
1155
+ for (const { pattern, reason } of HIGH_VALUE_LESSON_PATTERNS) {
1156
+ if (pattern.test(value)) {
1157
+ return reason;
1158
+ }
1159
+ }
1160
+ return null;
1161
+ }
1162
+
1163
+ // Pure decision function (unit-tested): compare completed registry use and task
1164
+ // eligibility with the last-prompted count. A completed use prompts for a
1165
+ // contribution; eligible work with no completed query prompts for full-context
1166
+ // recovery. `useCount`/`promptedCount` are injectable so tests never touch the
1167
+ // filesystem.
1168
+ export function decideStop(input, options = {}) {
1169
+ const env = options.env ?? process.env;
1170
+ if (contributeDisabled(env.REMEMBRANCE_AUTO_CONTRIBUTE)) {
1171
+ return { allow: true, why: "disabled" };
1172
+ }
1173
+ if (input?.stop_hook_active) {
1174
+ return { allow: true, why: "stop_hook_active" };
1175
+ }
1176
+ const sessionId = sessionIdFor(input);
1177
+ const readUse = options.readUseCount ?? readRegistryUseCount;
1178
+ const useCount = readUse(sessionId, env);
1179
+ const readEligible = options.readEligibilityCount ?? readTaskEligibilityCount;
1180
+ const eligibilityCount = readEligible(sessionId, env);
1181
+ const engagementCount = Math.max(useCount, eligibilityCount);
1182
+ const readPrompted = options.readPromptedCount ?? readPromptedCount;
1183
+ const promptedCount = readPrompted(sessionId, env);
1184
+ const readHighMatch = options.readHighMatch ?? readHighMatchSurface;
1185
+ const highMatch = readHighMatch(sessionId, env);
1186
+ const directSelections = options.readDirectSelections
1187
+ ? options.readDirectSelections(sessionId, env)
1188
+ : options.readDirectSelection
1189
+ ? [options.readDirectSelection(sessionId, env)].filter(Boolean)
1190
+ : readDirectSelectionSurfaces(sessionId, env);
1191
+ const newDirectSelections = directSelections.filter(
1192
+ (selection) => !selection?.prompted_at,
1193
+ );
1194
+ const feedbackDirectSelections = newDirectSelections.filter(
1195
+ (selection) => selection.feedback_available !== false,
1196
+ );
1197
+ const hasUnclosedEligibility =
1198
+ eligibilityCount > useCount && newDirectSelections.length === 0;
1199
+ const highValueSignal = detectHighValueLessonSignal(input);
1200
+ if (newDirectSelections.length > 0 && feedbackDirectSelections.length === 0) {
1201
+ return { allow: true, why: "direct_feedback_unavailable" };
1202
+ }
1203
+ if (engagementCount === 0 && !highValueSignal) {
1204
+ return { allow: true, why: "registry_not_used" };
1205
+ }
1206
+ if (
1207
+ engagementCount <= promptedCount &&
1208
+ newDirectSelections.length === 0 &&
1209
+ !highValueSignal
1210
+ ) {
1211
+ return { allow: true, why: "no_new_usage" };
1212
+ }
1213
+ if (
1214
+ highValueSignal &&
1215
+ promptedCount > 0 &&
1216
+ engagementCount <= promptedCount
1217
+ ) {
1218
+ return { allow: true, why: "high_value_lesson_already_prompted" };
1219
+ }
1220
+ return {
1221
+ allow: false,
1222
+ why: highValueSignal
1223
+ ? "prompt_high_value_lesson_contribution"
1224
+ : hasUnclosedEligibility
1225
+ ? "prompt_task_closure"
1226
+ : "prompt_contribution",
1227
+ reason: hasUnclosedEligibility
1228
+ ? taskClosureReason(highValueSignal)
1229
+ : contributionReason(
1230
+ highValueSignal,
1231
+ highMatch,
1232
+ feedbackDirectSelections.length > 0 ? feedbackDirectSelections : null,
1233
+ ),
1234
+ useCount: highValueSignal
1235
+ ? Math.max(engagementCount, promptedCount + 1, 1)
1236
+ : engagementCount,
1237
+ };
1238
+ }
1239
+
1240
+ function highMatchClosureInstruction(match) {
1241
+ const tool = match.target_type === "resource" ? "get_resource" : "get_skill";
1242
+ const args = {
1243
+ slug: safeText(match.slug, 120),
1244
+ ...(match.query_id ? { query_id: safeText(match.query_id, 80) } : {}),
1245
+ ...(match.result_id ? { result_id: safeText(match.result_id, 80) } : {}),
1246
+ };
1247
+ return `High-confidence result surfaced: ${safeText(match.slug, 120)} (${matchEvidenceLabel(match)}). If you have not opened it, call ${tool} with ${JSON.stringify(args)} now. If it is not a fit, skip it and submit query feedback with fit poor plus the reason; after use, pass the same query_id/result_id to submit_feedback.`;
1248
+ }
1249
+
1250
+ // --- Marker mechanism (Codex has no transcript path) -------------------------
1251
+ //
1252
+ // Three per-session counters and one bounded high-match marker live under
1253
+ // os.tmpdir()/remembrance-usage/<hash>:
1254
+ // <hash>.use — incremented every time the query adapter completes a query.
1255
+ // <hash>.eligible — records that a reusable task should be closed out even if
1256
+ // no query result was consumed.
1257
+ // <hash>.prompt — the use count at which the stop adapter last prompted.
1258
+ // <hash>.high-match.json — latest high result until its exact detail opens.
1259
+ // The stop adapter compares max(.use, .eligible) with .prompt, then records the
1260
+ // new engagement count. This reproduces the Claude hook's sentinel behavior
1261
+ // without retaining a transcript.
1262
+
1263
+ const USAGE_DIR = "remembrance-usage";
1264
+
1265
+ function usageDir(env = process.env) {
1266
+ return env?.REMEMBRANCE_USAGE_DIR
1267
+ ? String(env.REMEMBRANCE_USAGE_DIR)
1268
+ : join(tmpdir(), USAGE_DIR);
1269
+ }
1270
+
1271
+ function sessionHash(sessionId) {
1272
+ return createHash("sha256")
1273
+ .update(String(sessionId ?? "unknown"))
1274
+ .digest("hex")
1275
+ .slice(0, 16);
1276
+ }
1277
+
1278
+ function usePath(sessionId, env) {
1279
+ return join(usageDir(env), `${sessionHash(sessionId)}.use`);
1280
+ }
1281
+
1282
+ function promptPath(sessionId, env) {
1283
+ return join(usageDir(env), `${sessionHash(sessionId)}.prompt`);
1284
+ }
1285
+
1286
+ function eligibilityPath(sessionId, env) {
1287
+ return join(usageDir(env), `${sessionHash(sessionId)}.eligible`);
1288
+ }
1289
+
1290
+ function highMatchPath(sessionId, env) {
1291
+ return join(usageDir(env), `${sessionHash(sessionId)}.high-match.json`);
1292
+ }
1293
+
1294
+ function valueEpisodePath(sessionId, env) {
1295
+ return join(usageDir(env), `${sessionHash(sessionId)}.value-episodes.json`);
1296
+ }
1297
+
1298
+ function directSelectionPath(sessionId, env) {
1299
+ return join(usageDir(env), `${sessionHash(sessionId)}.direct-skill.json`);
1300
+ }
1301
+
1302
+ function directivePath(sessionId, env) {
1303
+ return join(usageDir(env), `${sessionHash(sessionId)}.directive.json`);
1304
+ }
1305
+
1306
+ function readCountFile(path) {
1307
+ try {
1308
+ const raw = readFileSync(path, "utf8");
1309
+ const parsed = Number.parseInt(String(raw).trim(), 10);
1310
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
1311
+ } catch {
1312
+ return 0;
1313
+ }
1314
+ }
1315
+
1316
+ function writeCountFile(path, count) {
1317
+ try {
1318
+ mkdirSync(dirname(path), { recursive: true });
1319
+ writeFileSync(path, String(count));
1320
+ return true;
1321
+ } catch {
1322
+ // Non-fatal: fail-open, the stop_hook_active guard still prevents loops.
1323
+ return false;
1324
+ }
1325
+ }
1326
+
1327
+ // Increment (and return) the per-session registry-use counter. Prompt and tool
1328
+ // adapters call this only after an authorized registry match or explicit skill
1329
+ // invocation. A legitimate empty query remains eligible for completion recovery
1330
+ // without inflating registry-use counts. Fail-open on filesystem errors.
1331
+ export function recordRegistryUse(sessionId, env = process.env) {
1332
+ const path = usePath(sessionId, env);
1333
+ const next = readCountFile(path) + 1;
1334
+ writeCountFile(path, next);
1335
+ return next;
1336
+ }
1337
+
1338
+ export function markCurrentEngagementHandled(sessionId, env = process.env) {
1339
+ const count = Math.max(
1340
+ readRegistryUseCount(sessionId, env),
1341
+ readTaskEligibilityCount(sessionId, env),
1342
+ );
1343
+ if (count > 0) {
1344
+ writePromptedCount(sessionId, count, env);
1345
+ }
1346
+ return count;
1347
+ }
1348
+
1349
+ // Read the per-session registry-use counter (0 if never recorded).
1350
+ export function readRegistryUseCount(sessionId, env = process.env) {
1351
+ return readCountFile(usePath(sessionId, env));
1352
+ }
1353
+
1354
+ // Increment task eligibility for each qualifying prompt. The prompted-count
1355
+ // sentinel makes each task recoverable once without re-blocking the Stop retry.
1356
+ export function recordTaskEligibility(sessionId, env = process.env) {
1357
+ const path = eligibilityPath(sessionId, env);
1358
+ const next = readCountFile(path) + 1;
1359
+ writeCountFile(path, next);
1360
+ return next;
1361
+ }
1362
+
1363
+ export function readTaskEligibilityCount(sessionId, env = process.env) {
1364
+ return readCountFile(eligibilityPath(sessionId, env));
1365
+ }
1366
+
1367
+ export function recordDirectiveSurface(
1368
+ sessionId,
1369
+ directive,
1370
+ env = process.env,
1371
+ ) {
1372
+ const path = directivePath(sessionId, env);
1373
+ try {
1374
+ if (!directive) {
1375
+ rmSync(path, { force: true });
1376
+ return true;
1377
+ }
1378
+ mkdirSync(dirname(path), { recursive: true });
1379
+ writeFileSync(
1380
+ path,
1381
+ JSON.stringify({
1382
+ directive_id: safeText(directive.directive_id, 96),
1383
+ runtime: normalizeRuntime(directive.runtime),
1384
+ trigger_reason: safeText(
1385
+ directive.trigger_reason ?? "contextual_continuation",
1386
+ 160,
1387
+ ),
1388
+ shown_at: safeText(directive.shown_at ?? new Date().toISOString(), 40),
1389
+ }),
1390
+ );
1391
+ return true;
1392
+ } catch {
1393
+ return false;
1394
+ }
1395
+ }
1396
+
1397
+ export function readDirectiveSurface(sessionId, env = process.env) {
1398
+ try {
1399
+ const path = directivePath(sessionId, env);
1400
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1401
+ const shownAt = Date.parse(String(parsed?.shown_at ?? ""));
1402
+ if (
1403
+ !parsed?.directive_id ||
1404
+ !Number.isFinite(shownAt) ||
1405
+ shownAt + DIRECTIVE_MARKER_TTL_MS < Date.now()
1406
+ ) {
1407
+ rmSync(path, { force: true });
1408
+ return null;
1409
+ }
1410
+ return {
1411
+ directive_id: safeText(parsed.directive_id, 96),
1412
+ runtime: normalizeRuntime(parsed.runtime),
1413
+ trigger_reason: safeText(
1414
+ parsed.trigger_reason ?? "contextual_continuation",
1415
+ 160,
1416
+ ),
1417
+ shown_at: new Date(shownAt).toISOString(),
1418
+ };
1419
+ } catch {
1420
+ return null;
1421
+ }
1422
+ }
1423
+
1424
+ export async function recordDirectiveFollowThroughForTool(
1425
+ sessionId,
1426
+ toolName,
1427
+ rawResponse,
1428
+ options = {},
1429
+ ) {
1430
+ const normalizedTool = String(toolName ?? "")
1431
+ .trim()
1432
+ .toLowerCase();
1433
+ if (
1434
+ !normalizedTool.endsWith("query_skills") ||
1435
+ (normalizedTool !== "query_skills" &&
1436
+ !normalizedTool.includes("remembrance"))
1437
+ ) {
1438
+ return false;
1439
+ }
1440
+ const env = options.env ?? process.env;
1441
+ const directive = readDirectiveSurface(sessionId, env);
1442
+ if (!directive) return false;
1443
+ // A completed query consumes this task directive even if its response shape
1444
+ // prevents correlation; never let a later unrelated query claim it.
1445
+ recordDirectiveSurface(sessionId, null, env);
1446
+ const queryId = queryIdFromToolResponse(rawResponse);
1447
+ if (!queryId) return false;
1448
+ return reportDirectiveEvent(
1449
+ {
1450
+ event: "followed",
1451
+ directive_id: directive.directive_id,
1452
+ query_id: queryId,
1453
+ },
1454
+ options,
1455
+ );
1456
+ }
1457
+
1458
+ function queryIdFromToolResponse(value, depth = 0) {
1459
+ if (depth > 5 || value === null || value === undefined) return null;
1460
+ if (typeof value === "string") {
1461
+ try {
1462
+ return queryIdFromToolResponse(JSON.parse(value), depth + 1);
1463
+ } catch {
1464
+ return null;
1465
+ }
1466
+ }
1467
+ if (Array.isArray(value)) {
1468
+ for (const item of value) {
1469
+ const queryId = queryIdFromToolResponse(item, depth + 1);
1470
+ if (queryId) return queryId;
1471
+ }
1472
+ return null;
1473
+ }
1474
+ if (typeof value !== "object") return null;
1475
+ if (typeof value.query_id === "string" && value.query_id.trim()) {
1476
+ return safeText(value.query_id, 80);
1477
+ }
1478
+ if (value.type === "text" && typeof value.text === "string") {
1479
+ const queryId = queryIdFromToolResponse(value.text, depth + 1);
1480
+ if (queryId) return queryId;
1481
+ }
1482
+ for (const key of [
1483
+ "body",
1484
+ "result",
1485
+ "output",
1486
+ "response",
1487
+ "tool_response",
1488
+ "toolResponse",
1489
+ "content",
1490
+ ]) {
1491
+ const queryId = queryIdFromToolResponse(value[key], depth + 1);
1492
+ if (queryId) return queryId;
1493
+ }
1494
+ return null;
1495
+ }
1496
+
1497
+ export function responseBodyFromToolResponse(value, depth = 0) {
1498
+ if (depth > 6 || value === null || value === undefined) return null;
1499
+ if (typeof value === "string") {
1500
+ try {
1501
+ return responseBodyFromToolResponse(JSON.parse(value), depth + 1);
1502
+ } catch {
1503
+ return null;
1504
+ }
1505
+ }
1506
+ if (Array.isArray(value)) {
1507
+ for (const item of value) {
1508
+ const body = responseBodyFromToolResponse(item, depth + 1);
1509
+ if (body) return body;
1510
+ }
1511
+ return null;
1512
+ }
1513
+ if (typeof value !== "object") return null;
1514
+ if (
1515
+ typeof value.query_id === "string" ||
1516
+ value.selection_mode === "explicit" ||
1517
+ (value.next_step && typeof value.next_step === "object") ||
1518
+ Array.isArray(value.skills) ||
1519
+ Array.isArray(value.resources)
1520
+ ) {
1521
+ return value;
1522
+ }
1523
+ if (value.type === "text" && typeof value.text === "string") {
1524
+ const body = responseBodyFromToolResponse(value.text, depth + 1);
1525
+ if (body) return body;
1526
+ }
1527
+ for (const key of [
1528
+ "body",
1529
+ "result",
1530
+ "output",
1531
+ "response",
1532
+ "tool_response",
1533
+ "toolResponse",
1534
+ "content",
1535
+ ]) {
1536
+ const body = responseBodyFromToolResponse(value[key], depth + 1);
1537
+ if (body) return body;
1538
+ }
1539
+ return null;
1540
+ }
1541
+
1542
+ export function responseRequestsRemembranceFollowup(response) {
1543
+ const payload =
1544
+ responseBodyFromToolResponse(response)?.next_step
1545
+ ?.submit_remembrance_payload;
1546
+ return Boolean(
1547
+ payload && typeof payload === "object" && !Array.isArray(payload),
1548
+ );
1549
+ }
1550
+
1551
+ export function toolResponseIndicatesFailure(value, depth = 0) {
1552
+ if (depth > 6 || value === null || value === undefined) return false;
1553
+ if (typeof value === "string") {
1554
+ try {
1555
+ return toolResponseIndicatesFailure(JSON.parse(value), depth + 1);
1556
+ } catch {
1557
+ return false;
1558
+ }
1559
+ }
1560
+ if (Array.isArray(value)) {
1561
+ return value.some((item) => toolResponseIndicatesFailure(item, depth + 1));
1562
+ }
1563
+ if (typeof value !== "object") return false;
1564
+ if (
1565
+ value.ok === false ||
1566
+ value.isError === true ||
1567
+ value.is_error === true ||
1568
+ (typeof value.status === "number" && value.status >= 400)
1569
+ ) {
1570
+ return true;
1571
+ }
1572
+ if (value.type === "text" && typeof value.text === "string") {
1573
+ return toolResponseIndicatesFailure(value.text, depth + 1);
1574
+ }
1575
+ for (const key of [
1576
+ "result",
1577
+ "output",
1578
+ "response",
1579
+ "tool_response",
1580
+ "toolResponse",
1581
+ "content",
1582
+ ]) {
1583
+ if (toolResponseIndicatesFailure(value[key], depth + 1)) return true;
1584
+ }
1585
+ return false;
1586
+ }
1587
+
1588
+ export function directSelectionFromResponse(response) {
1589
+ const body = responseBodyFromToolResponse(response);
1590
+ const skill = body?.skill;
1591
+ if (
1592
+ body?.selection_mode !== "explicit" ||
1593
+ !skill ||
1594
+ typeof skill.slug !== "string" ||
1595
+ !skill.slug.trim() ||
1596
+ typeof skill.skill_md !== "string" ||
1597
+ !skill.skill_md.trim()
1598
+ ) {
1599
+ return null;
1600
+ }
1601
+ return {
1602
+ invocation_id:
1603
+ typeof body.invocation_id === "string"
1604
+ ? safeText(body.invocation_id, 80)
1605
+ : null,
1606
+ query_id:
1607
+ typeof body.query_id === "string" ? safeText(body.query_id, 80) : null,
1608
+ result_id:
1609
+ typeof body.result_id === "string" ? safeText(body.result_id, 80) : null,
1610
+ slug: safeText(skill.slug, 120),
1611
+ version:
1612
+ skill.version === null || skill.version === undefined
1613
+ ? null
1614
+ : safeText(skill.version, 80),
1615
+ version_id:
1616
+ typeof skill.version_id === "string"
1617
+ ? safeText(skill.version_id, 80)
1618
+ : null,
1619
+ source:
1620
+ typeof skill.source === "string" ? safeText(skill.source, 40) : null,
1621
+ feedback_available: body?.feedback?.available === true,
1622
+ task_outcome_available: body?.task_outcome?.available === true,
1623
+ used_at: new Date().toISOString(),
1624
+ };
1625
+ }
1626
+
1627
+ export function recordDirectSelectionSurface(
1628
+ sessionId,
1629
+ selection,
1630
+ env = process.env,
1631
+ ) {
1632
+ const path = directSelectionPath(sessionId, env);
1633
+ try {
1634
+ if (!selection) {
1635
+ rmSync(path, { force: true });
1636
+ return true;
1637
+ }
1638
+ const normalized = normalizeDirectSelectionSurface(
1639
+ selection,
1640
+ sessionId,
1641
+ env,
1642
+ );
1643
+ const records = readDirectSelectionSurfaces(sessionId, env).filter(
1644
+ (item) =>
1645
+ !normalized.query_id ||
1646
+ !normalized.result_id ||
1647
+ item.query_id !== normalized.query_id ||
1648
+ item.result_id !== normalized.result_id,
1649
+ );
1650
+ records.push(normalized);
1651
+ return writeDirectSelectionSurfaces(sessionId, records, env);
1652
+ } catch {
1653
+ return false;
1654
+ }
1655
+ }
1656
+
1657
+ export function readDirectSelectionSurfaces(sessionId, env = process.env) {
1658
+ try {
1659
+ const parsed = JSON.parse(
1660
+ readFileSync(directSelectionPath(sessionId, env), "utf8"),
1661
+ );
1662
+ const records = (Array.isArray(parsed) ? parsed : [parsed])
1663
+ .map((item) => normalizeStoredDirectSelectionSurface(item))
1664
+ .filter(Boolean)
1665
+ .filter(
1666
+ (item) =>
1667
+ Date.parse(item.used_at) + DIRECT_SELECTION_MARKER_TTL_MS >=
1668
+ Date.now(),
1669
+ )
1670
+ .slice(-DIRECT_SELECTION_MARKER_LIMIT);
1671
+ if (records.length === 0) {
1672
+ rmSync(directSelectionPath(sessionId, env), { force: true });
1673
+ }
1674
+ return records;
1675
+ } catch {
1676
+ return [];
1677
+ }
1678
+ }
1679
+
1680
+ export function readDirectSelectionSurface(sessionId, env = process.env) {
1681
+ return readDirectSelectionSurfaces(sessionId, env).at(-1) ?? null;
1682
+ }
1683
+
1684
+ function normalizeDirectSelectionSurface(selection, sessionId, env) {
1685
+ return {
1686
+ invocation_id: selection.invocation_id
1687
+ ? safeText(selection.invocation_id, 80)
1688
+ : null,
1689
+ query_id: selection.query_id ? safeText(selection.query_id, 80) : null,
1690
+ result_id: selection.result_id ? safeText(selection.result_id, 80) : null,
1691
+ slug: safeText(selection.slug, 120),
1692
+ version: selection.version ? safeText(selection.version, 80) : null,
1693
+ version_id: selection.version_id
1694
+ ? safeText(selection.version_id, 80)
1695
+ : null,
1696
+ source: selection.source ? safeText(selection.source, 40) : null,
1697
+ feedback_available: selection.feedback_available === true,
1698
+ task_outcome_available: selection.task_outcome_available === true,
1699
+ prompted_at: selection.prompted_at
1700
+ ? safeText(selection.prompted_at, 40)
1701
+ : null,
1702
+ use_count: Number.isFinite(selection.use_count)
1703
+ ? Math.max(0, Math.round(selection.use_count))
1704
+ : readRegistryUseCount(sessionId, env),
1705
+ used_at: safeText(selection.used_at ?? new Date().toISOString(), 40),
1706
+ };
1707
+ }
1708
+
1709
+ function normalizeStoredDirectSelectionSurface(selection) {
1710
+ const usedAt = Date.parse(String(selection?.used_at ?? ""));
1711
+ if (!selection?.slug || !Number.isFinite(usedAt)) return null;
1712
+ return {
1713
+ invocation_id: selection.invocation_id
1714
+ ? safeText(selection.invocation_id, 80)
1715
+ : null,
1716
+ query_id: selection.query_id ? safeText(selection.query_id, 80) : null,
1717
+ result_id: selection.result_id ? safeText(selection.result_id, 80) : null,
1718
+ slug: safeText(selection.slug, 120),
1719
+ version: selection.version ? safeText(selection.version, 80) : null,
1720
+ version_id: selection.version_id
1721
+ ? safeText(selection.version_id, 80)
1722
+ : null,
1723
+ source: selection.source ? safeText(selection.source, 40) : null,
1724
+ feedback_available: selection.feedback_available === true,
1725
+ task_outcome_available: selection.task_outcome_available === true,
1726
+ prompted_at: selection.prompted_at
1727
+ ? safeText(selection.prompted_at, 40)
1728
+ : null,
1729
+ use_count: Number.isFinite(selection.use_count)
1730
+ ? Math.max(0, Math.round(selection.use_count))
1731
+ : 0,
1732
+ used_at: new Date(usedAt).toISOString(),
1733
+ };
1734
+ }
1735
+
1736
+ function writeDirectSelectionSurfaces(sessionId, records, env) {
1737
+ try {
1738
+ const path = directSelectionPath(sessionId, env);
1739
+ mkdirSync(dirname(path), { recursive: true });
1740
+ writeFileSync(
1741
+ path,
1742
+ JSON.stringify(records.slice(-DIRECT_SELECTION_MARKER_LIMIT)),
1743
+ );
1744
+ return true;
1745
+ } catch {
1746
+ return false;
1747
+ }
1748
+ }
1749
+
1750
+ export function markDirectSelectionSurfacesPrompted(
1751
+ sessionId,
1752
+ useCount,
1753
+ env = process.env,
1754
+ ) {
1755
+ const records = readDirectSelectionSurfaces(sessionId, env);
1756
+ const now = new Date().toISOString();
1757
+ let changed = false;
1758
+ for (const selection of records) {
1759
+ if (
1760
+ !selection.prompted_at &&
1761
+ Number.isFinite(selection.use_count) &&
1762
+ selection.use_count <= useCount
1763
+ ) {
1764
+ selection.prompted_at = now;
1765
+ changed = true;
1766
+ }
1767
+ }
1768
+ return changed
1769
+ ? writeDirectSelectionSurfaces(sessionId, records, env)
1770
+ : false;
1771
+ }
1772
+
1773
+ export function clearHighMatchSurfaceForExplicitSelection(
1774
+ sessionId,
1775
+ slug,
1776
+ env = process.env,
1777
+ ) {
1778
+ const match = readHighMatchSurface(sessionId, env);
1779
+ if (!match || match.target_type !== "skill" || match.slug !== String(slug)) {
1780
+ return false;
1781
+ }
1782
+ return recordHighMatchSurface(sessionId, null, env);
1783
+ }
1784
+
1785
+ // Store only bounded public result metadata. A later completed query replaces
1786
+ // or clears the marker, so Stop never repeats a stale high-match instruction.
1787
+ export function recordHighMatchSurface(sessionId, match, env = process.env) {
1788
+ const path = highMatchPath(sessionId, env);
1789
+ try {
1790
+ if (!match) {
1791
+ rmSync(path, { force: true });
1792
+ return true;
1793
+ }
1794
+ mkdirSync(dirname(path), { recursive: true });
1795
+ writeFileSync(
1796
+ path,
1797
+ JSON.stringify({
1798
+ query_id: match.query_id ? safeText(match.query_id, 80) : null,
1799
+ result_id: match.result_id ? safeText(match.result_id, 80) : null,
1800
+ target_type: match.target_type === "resource" ? "resource" : "skill",
1801
+ slug: safeText(match.slug, 120),
1802
+ match_reason: safeText(
1803
+ match.match_reason ?? "Strong task coverage",
1804
+ 160,
1805
+ ),
1806
+ estimated_tokens: Number.isFinite(match.estimated_tokens)
1807
+ ? Math.max(0, Math.round(match.estimated_tokens))
1808
+ : null,
1809
+ verified_uses: numberValue(match.verified_uses),
1810
+ risk_level: safeText(match.risk_level ?? "unknown", 24),
1811
+ value_estimate_id: match.value_estimate_id
1812
+ ? safeText(match.value_estimate_id, 80)
1813
+ : null,
1814
+ potential_savings: sanitizePotentialSavings(match.potential_savings),
1815
+ }),
1816
+ );
1817
+ return true;
1818
+ } catch {
1819
+ return false;
1820
+ }
1821
+ }
1822
+
1823
+ export function readHighMatchSurface(sessionId, env = process.env) {
1824
+ try {
1825
+ const parsed = JSON.parse(
1826
+ readFileSync(highMatchPath(sessionId, env), "utf8"),
1827
+ );
1828
+ if (!parsed || typeof parsed.slug !== "string" || !parsed.slug.trim()) {
1829
+ return null;
1830
+ }
1831
+ return {
1832
+ query_id:
1833
+ typeof parsed.query_id === "string"
1834
+ ? safeText(parsed.query_id, 80)
1835
+ : null,
1836
+ result_id:
1837
+ typeof parsed.result_id === "string"
1838
+ ? safeText(parsed.result_id, 80)
1839
+ : null,
1840
+ target_type: parsed.target_type === "resource" ? "resource" : "skill",
1841
+ slug: safeText(parsed.slug, 120),
1842
+ match_reason: safeText(
1843
+ parsed.match_reason ?? "Strong task coverage",
1844
+ 160,
1845
+ ),
1846
+ estimated_tokens: Number.isFinite(parsed.estimated_tokens)
1847
+ ? Math.max(0, Math.round(parsed.estimated_tokens))
1848
+ : null,
1849
+ verified_uses: numberValue(parsed.verified_uses),
1850
+ risk_level: safeText(parsed.risk_level ?? "unknown", 24),
1851
+ value_estimate_id:
1852
+ typeof parsed.value_estimate_id === "string"
1853
+ ? safeText(parsed.value_estimate_id, 80)
1854
+ : null,
1855
+ potential_savings: sanitizePotentialSavings(parsed.potential_savings),
1856
+ };
1857
+ } catch {
1858
+ return null;
1859
+ }
1860
+ }
1861
+
1862
+ export function clearHighMatchSurfaceIfOpened(
1863
+ sessionId,
1864
+ toolName,
1865
+ rawArguments,
1866
+ env = process.env,
1867
+ ) {
1868
+ const normalizedTool = String(toolName ?? "")
1869
+ .trim()
1870
+ .toLowerCase();
1871
+ const targetType = normalizedTool.endsWith("get_resource")
1872
+ ? "resource"
1873
+ : normalizedTool.endsWith("get_skill")
1874
+ ? "skill"
1875
+ : null;
1876
+ if (
1877
+ !targetType ||
1878
+ (normalizedTool !== `get_${targetType}` &&
1879
+ !normalizedTool.includes("remembrance"))
1880
+ ) {
1881
+ return false;
1882
+ }
1883
+ const args =
1884
+ rawArguments && typeof rawArguments === "object" ? rawArguments : {};
1885
+ const match = readHighMatchSurface(sessionId, env);
1886
+ if (!match || match.target_type !== targetType) return false;
1887
+ if (String(args.slug ?? "") !== match.slug) return false;
1888
+ if (match.query_id && String(args.query_id ?? "") !== match.query_id) {
1889
+ return false;
1890
+ }
1891
+ if (match.result_id && String(args.result_id ?? "") !== match.result_id) {
1892
+ return false;
1893
+ }
1894
+ markValueEpisodeSelection(
1895
+ sessionId,
1896
+ String(args.query_id ?? match.query_id ?? ""),
1897
+ String(args.result_id ?? match.result_id ?? ""),
1898
+ env,
1899
+ );
1900
+ return recordHighMatchSurface(sessionId, null, env);
1901
+ }
1902
+
1903
+ export function valueEpisodeFromResponse(response) {
1904
+ const body = responseBodyFromToolResponse(response);
1905
+ const queryId = body?.query_id ?? body?.query_feedback?.query_id;
1906
+ if (!queryId || body?.task_outcome?.available !== true) return null;
1907
+ const eligibleResultIds = new Set(
1908
+ Array.isArray(body?.task_outcome?.eligible_result_ids)
1909
+ ? body.task_outcome.eligible_result_ids.map((id) => String(id))
1910
+ : [],
1911
+ );
1912
+ if (eligibleResultIds.size === 0) return null;
1913
+ const directSelection = body?.selection_mode === "explicit";
1914
+ const candidates = [
1915
+ ...(directSelection && body?.skill
1916
+ ? [
1917
+ {
1918
+ ...body.skill,
1919
+ result_id: body.result_id,
1920
+ task_outcome_eligible:
1921
+ body.skill.task_outcome_eligible === true &&
1922
+ body?.task_outcome?.eligible_result_ids?.includes(body.result_id),
1923
+ },
1924
+ ]
1925
+ : []),
1926
+ ...(Array.isArray(body?.skills) ? body.skills : []),
1927
+ ...(Array.isArray(body?.resources) ? body.resources : []),
1928
+ ]
1929
+ .filter(
1930
+ (item) =>
1931
+ item?.task_outcome_eligible === true &&
1932
+ item?.result_id &&
1933
+ eligibleResultIds.has(String(item.result_id)),
1934
+ )
1935
+ .slice(0, 40)
1936
+ .map((item) => ({
1937
+ result_id: safeText(item.result_id, 80),
1938
+ value_estimate_id: item.value_estimate_id
1939
+ ? safeText(item.value_estimate_id, 80)
1940
+ : null,
1941
+ }));
1942
+ const bundles = (Array.isArray(body?.skill_bundles) ? body.skill_bundles : [])
1943
+ .filter((bundle) => {
1944
+ const resultIds = Array.isArray(bundle?.result_ids)
1945
+ ? bundle.result_ids.map((id) => String(id))
1946
+ : [];
1947
+ return (
1948
+ bundle?.task_outcome_eligible === true &&
1949
+ bundle?.bundle_id &&
1950
+ resultIds.length > 0 &&
1951
+ resultIds.length <= 3 &&
1952
+ resultIds.every((resultId) => eligibleResultIds.has(resultId))
1953
+ );
1954
+ })
1955
+ .slice(0, 20)
1956
+ .map((bundle) => ({
1957
+ bundle_id: safeText(bundle.bundle_id, 80),
1958
+ result_ids: bundle.result_ids.slice(0, 3).map((id) => safeText(id, 80)),
1959
+ value_estimate_id: bundle.value_estimate_id
1960
+ ? safeText(bundle.value_estimate_id, 80)
1961
+ : null,
1962
+ }));
1963
+ return {
1964
+ query_id: safeText(queryId, 80),
1965
+ interaction_kind: directSelection ? "direct_selection" : "query",
1966
+ candidates,
1967
+ bundles,
1968
+ selected_result_ids:
1969
+ directSelection && candidates[0]?.result_id
1970
+ ? [candidates[0].result_id]
1971
+ : [],
1972
+ feedback_available: directSelection
1973
+ ? body?.feedback?.available === true
1974
+ : true,
1975
+ created_at: new Date().toISOString(),
1976
+ reported_at: null,
1977
+ };
1978
+ }
1979
+
1980
+ export function recordValueEpisodeSurface(
1981
+ sessionId,
1982
+ episode,
1983
+ env = process.env,
1984
+ ) {
1985
+ if (!episode?.query_id) return false;
1986
+ const records = readValueEpisodeSurfaces(sessionId, env).filter(
1987
+ (item) => item.query_id !== episode.query_id,
1988
+ );
1989
+ records.push(episode);
1990
+ return writeValueEpisodeSurfaces(
1991
+ sessionId,
1992
+ records.slice(-VALUE_EPISODE_MARKER_LIMIT),
1993
+ env,
1994
+ );
1995
+ }
1996
+
1997
+ export function readValueEpisodeSurfaces(sessionId, env = process.env) {
1998
+ try {
1999
+ const parsed = JSON.parse(
2000
+ readFileSync(valueEpisodePath(sessionId, env), "utf8"),
2001
+ );
2002
+ if (!Array.isArray(parsed)) return [];
2003
+ return parsed
2004
+ .filter((item) => {
2005
+ const createdAt = Date.parse(String(item?.created_at ?? ""));
2006
+ return (
2007
+ item &&
2008
+ typeof item.query_id === "string" &&
2009
+ Number.isFinite(createdAt) &&
2010
+ createdAt + VALUE_EPISODE_MARKER_TTL_MS > Date.now()
2011
+ );
2012
+ })
2013
+ .slice(-VALUE_EPISODE_MARKER_LIMIT)
2014
+ .map((item) => ({
2015
+ query_id: safeText(item.query_id, 80),
2016
+ interaction_kind:
2017
+ item.interaction_kind === "direct_selection"
2018
+ ? "direct_selection"
2019
+ : "query",
2020
+ candidates: Array.isArray(item.candidates)
2021
+ ? item.candidates.slice(0, 40).map((candidate) => ({
2022
+ result_id: safeText(candidate?.result_id, 80),
2023
+ value_estimate_id: candidate?.value_estimate_id
2024
+ ? safeText(candidate.value_estimate_id, 80)
2025
+ : null,
2026
+ }))
2027
+ : [],
2028
+ bundles: Array.isArray(item.bundles)
2029
+ ? item.bundles.slice(0, 20).map((bundle) => ({
2030
+ bundle_id: safeText(bundle?.bundle_id, 80),
2031
+ result_ids: Array.isArray(bundle?.result_ids)
2032
+ ? bundle.result_ids.slice(0, 3).map((id) => safeText(id, 80))
2033
+ : [],
2034
+ value_estimate_id: bundle?.value_estimate_id
2035
+ ? safeText(bundle.value_estimate_id, 80)
2036
+ : null,
2037
+ }))
2038
+ : [],
2039
+ selected_result_ids: Array.isArray(item.selected_result_ids)
2040
+ ? item.selected_result_ids.slice(0, 3).map((id) => safeText(id, 80))
2041
+ : [],
2042
+ feedback_available: item.feedback_available !== false,
2043
+ created_at: safeText(item.created_at ?? "", 40),
2044
+ reported_at: item.reported_at ? safeText(item.reported_at, 40) : null,
2045
+ }));
2046
+ } catch {
2047
+ return [];
2048
+ }
2049
+ }
2050
+
2051
+ export function markValueEpisodeSelection(
2052
+ sessionId,
2053
+ queryId,
2054
+ resultId,
2055
+ env = process.env,
2056
+ ) {
2057
+ if (!queryId || !resultId) return false;
2058
+ const records = readValueEpisodeSurfaces(sessionId, env);
2059
+ const record = records.find((item) => item.query_id === queryId);
2060
+ if (
2061
+ !record ||
2062
+ !record.candidates.some((item) => item.result_id === resultId)
2063
+ ) {
2064
+ return false;
2065
+ }
2066
+ record.selected_result_ids = [
2067
+ ...new Set([...record.selected_result_ids, resultId]),
2068
+ ].slice(0, 3);
2069
+ return writeValueEpisodeSurfaces(sessionId, records, env);
2070
+ }
2071
+
2072
+ export async function reportTaskOutcomesOnStop(sessionId, input, options = {}) {
2073
+ const env = options.env ?? process.env;
2074
+ const records = readValueEpisodeSurfaces(sessionId, env);
2075
+ const pending = records.filter((item) => !item.reported_at).slice(0, 5);
2076
+ if (pending.length === 0) return 0;
2077
+ const tokenUsage = tokenUsageFromRuntime(input);
2078
+ const modelRevision = stringOrNull(
2079
+ input?.observed_model_revision ??
2080
+ input?.model_revision ??
2081
+ input?.model ??
2082
+ env.REMEMBRANCE_OBSERVED_MODEL_REVISION,
2083
+ );
2084
+ const reasoningEffort = normalizeReasoningEffort(
2085
+ input?.reasoning_effort ?? env.REMEMBRANCE_REASONING_EFFORT,
2086
+ );
2087
+ let recorded = 0;
2088
+ for (const episode of pending) {
2089
+ const selected = episode.selected_result_ids.slice(0, 3);
2090
+ const selectedKey = [...selected].sort().join("\u0000");
2091
+ const selectedBundle = episode.bundles.find(
2092
+ (bundle) => [...bundle.result_ids].sort().join("\u0000") === selectedKey,
2093
+ );
2094
+ const estimateId = selectedBundle?.value_estimate_id
2095
+ ? selectedBundle.value_estimate_id
2096
+ : selected.length === 1
2097
+ ? episode.candidates.find((item) => item.result_id === selected[0])
2098
+ ?.value_estimate_id
2099
+ : null;
2100
+ const response = await postTaskOutcome(
2101
+ {
2102
+ query_id: episode.query_id,
2103
+ result_ids: selected,
2104
+ estimate_id: estimateId ?? null,
2105
+ bundle_id: selectedBundle?.bundle_id ?? null,
2106
+ status: "completed",
2107
+ success: null,
2108
+ latency_ms: null,
2109
+ token_usage: tokenUsage,
2110
+ observed_model_revision: modelRevision,
2111
+ reasoning_effort: reasoningEffort,
2112
+ provider_response_ids: [],
2113
+ measurement_source: "plugin_observed",
2114
+ idempotency_key: `hook_${sessionHash(sessionId)}_${safeText(episode.query_id, 64)}`,
2115
+ },
2116
+ {
2117
+ env,
2118
+ fetchImpl: options.fetchImpl ?? fetch,
2119
+ userAgent: options.userAgent,
2120
+ },
2121
+ );
2122
+ if (response) {
2123
+ episode.reported_at = new Date().toISOString();
2124
+ recorded += 1;
2125
+ }
2126
+ }
2127
+ writeValueEpisodeSurfaces(sessionId, records, env);
2128
+ return recorded;
2129
+ }
2130
+
2131
+ async function postTaskOutcome(payload, options = {}) {
2132
+ const env = options.env ?? process.env;
2133
+ const controller = new AbortController();
2134
+ const timeout = setTimeout(() => controller.abort(), autoQueryTimeoutMs(env));
2135
+ try {
2136
+ const credential = resolveApiCredential(env);
2137
+ if (isUnusableConfigurationSource(credential.source)) return false;
2138
+ const headers = {
2139
+ "content-type": "application/json",
2140
+ "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
2141
+ };
2142
+ const apiKey = credential.apiKey;
2143
+ if (apiKey) headers["x-remembrance-api-key"] = apiKey;
2144
+ const response = await (options.fetchImpl ?? fetch)(
2145
+ `${apiUrl(env)}/api/v1/agent/task-outcomes`,
2146
+ {
2147
+ method: "POST",
2148
+ headers,
2149
+ body: JSON.stringify(payload),
2150
+ signal: controller.signal,
2151
+ },
2152
+ );
2153
+ return Boolean(response?.ok);
2154
+ } catch {
2155
+ return false;
2156
+ } finally {
2157
+ clearTimeout(timeout);
2158
+ }
2159
+ }
2160
+
2161
+ function writeValueEpisodeSurfaces(sessionId, records, env) {
2162
+ try {
2163
+ const path = valueEpisodePath(sessionId, env);
2164
+ mkdirSync(dirname(path), { recursive: true });
2165
+ writeFileSync(
2166
+ path,
2167
+ JSON.stringify(records.slice(-VALUE_EPISODE_MARKER_LIMIT)),
2168
+ );
2169
+ return true;
2170
+ } catch {
2171
+ return false;
2172
+ }
2173
+ }
2174
+
2175
+ function tokenUsageFromRuntime(input) {
2176
+ const usage =
2177
+ input?.token_usage ??
2178
+ input?.tokenUsage ??
2179
+ input?.usage ??
2180
+ input?.model_usage;
2181
+ if (!usage || typeof usage !== "object") return null;
2182
+ const inputTokens = finiteNonNegative(
2183
+ usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens,
2184
+ );
2185
+ const outputTokens = finiteNonNegative(
2186
+ usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens,
2187
+ );
2188
+ if (inputTokens === null || outputTokens === null) return null;
2189
+ const cacheRead =
2190
+ finiteNonNegative(usage.cache_read_tokens ?? usage.cacheReadTokens) ?? 0;
2191
+ const cacheWrite =
2192
+ finiteNonNegative(usage.cache_write_tokens ?? usage.cacheWriteTokens) ?? 0;
2193
+ const reasoning =
2194
+ finiteNonNegative(usage.reasoning_tokens ?? usage.reasoningTokens) ?? 0;
2195
+ return {
2196
+ uncached_input_tokens: Math.max(0, inputTokens - cacheRead - cacheWrite),
2197
+ cache_read_tokens: cacheRead,
2198
+ cache_write_tokens: cacheWrite,
2199
+ visible_output_tokens: Math.max(0, outputTokens - reasoning),
2200
+ reasoning_tokens: reasoning,
2201
+ };
2202
+ }
2203
+
2204
+ function finiteNonNegative(value) {
2205
+ const number = Number(value);
2206
+ return Number.isFinite(number) && number >= 0 ? Math.round(number) : null;
2207
+ }
2208
+
2209
+ function normalizeReasoningEffort(value) {
2210
+ const normalized = String(value ?? "")
2211
+ .trim()
2212
+ .toLowerCase();
2213
+ return ["none", "minimal", "low", "medium", "high", "max"].includes(
2214
+ normalized,
2215
+ )
2216
+ ? normalized
2217
+ : "unknown";
2218
+ }
2219
+
2220
+ function sanitizePotentialSavings(value) {
2221
+ if (!value || typeof value !== "object") return null;
2222
+ const estimated = value.estimated_saved;
2223
+ if (!estimated || typeof estimated !== "object") return null;
2224
+ const low = finiteNonNegative(estimated.low);
2225
+ const medianValue = finiteNonNegative(estimated.median);
2226
+ const high = finiteNonNegative(estimated.high);
2227
+ if (low === null || medianValue === null || high === null) return null;
2228
+ return {
2229
+ unit: "tokens",
2230
+ context_tokens: finiteNonNegative(value.context_tokens) ?? 0,
2231
+ estimated_saved: { low, median: medianValue, high },
2232
+ proof_grade: value.proof_grade === "A" ? "A" : "B",
2233
+ measured_episodes: finiteNonNegative(value.measured_episodes) ?? 0,
2234
+ proof_url: safeText(value.proof_url ?? "", 300),
2235
+ caveat: "Estimate, not a guarantee.",
2236
+ };
2237
+ }
2238
+
2239
+ function potentialSavingsLabel(value) {
2240
+ const savings = sanitizePotentialSavings(value);
2241
+ if (!savings) return null;
2242
+ return `${formatCompactNumber(savings.estimated_saved.low)}-${formatCompactNumber(savings.estimated_saved.high)} potential tokens saved (grade ${savings.proof_grade} signed proof)`;
2243
+ }
2244
+
2245
+ function formatCompactNumber(value) {
2246
+ if (value >= 1000) {
2247
+ const rounded = Math.round((value / 1000) * 10) / 10;
2248
+ return `${rounded}k`;
2249
+ }
2250
+ return String(value);
2251
+ }
2252
+
2253
+ // Read the use count at which this session was last prompted to contribute.
2254
+ export function readPromptedCount(sessionId, env = process.env) {
2255
+ return readCountFile(promptPath(sessionId, env));
2256
+ }
2257
+
2258
+ // Record the use count at which we just prompted this session.
2259
+ export function writePromptedCount(sessionId, count, env = process.env) {
2260
+ const written = writeCountFile(promptPath(sessionId, env), count);
2261
+ if (written) {
2262
+ markDirectSelectionSurfacesPrompted(sessionId, count, env);
2263
+ }
2264
+ return written;
2265
+ }
2266
+
2267
+ // --- Shared helpers ----------------------------------------------------------
2268
+
2269
+ export function sessionIdFor(input) {
2270
+ return input?.turn_id ?? input?.session_id ?? "unknown";
2271
+ }
2272
+
2273
+ export function disabled(value) {
2274
+ return /^(0|false|no)$/i.test(String(value ?? ""));
2275
+ }
2276
+
2277
+ export function contributeDisabled(value) {
2278
+ return /^(0|false|no)$/i.test(String(value ?? "").trim());
2279
+ }
2280
+
2281
+ function pluginHealthDir(env = process.env) {
2282
+ return env?.REMEMBRANCE_PLUGIN_HEALTH_DIR
2283
+ ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR)
2284
+ : join(tmpdir(), PLUGIN_HEALTH_DIR);
2285
+ }
2286
+
2287
+ function normalizedPluginHealthSurface(value) {
2288
+ const surface = String(value ?? "")
2289
+ .trim()
2290
+ .toLowerCase();
2291
+ return PLUGIN_HEALTH_SURFACES.has(surface) ? surface : null;
2292
+ }
2293
+
2294
+ export function pluginHealthPath(surface, env = process.env) {
2295
+ return pluginHealthSessionPath(surface, null, env);
2296
+ }
2297
+
2298
+ function pluginHealthSessionPath(surface, sessionId, env = process.env) {
2299
+ const normalized = normalizedPluginHealthSurface(surface);
2300
+ if (!normalized) return null;
2301
+ const normalizedSession = safeText(sessionId ?? "", 256);
2302
+ if (!normalizedSession || normalizedSession === "unknown") {
2303
+ return join(pluginHealthDir(env), `${normalized}.json`);
2304
+ }
2305
+ const sessionHash = createHash("sha256")
2306
+ .update(normalizedSession, "utf8")
2307
+ .digest("hex")
2308
+ .slice(0, 24);
2309
+ return join(pluginHealthDir(env), `${normalized}.${sessionHash}.json`);
2310
+ }
2311
+
2312
+ export function readPluginLifecycleHealth(
2313
+ surface,
2314
+ env = process.env,
2315
+ sessionId = null,
2316
+ ) {
2317
+ const path = pluginHealthSessionPath(surface, sessionId, env);
2318
+ if (!path) return null;
2319
+ try {
2320
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2321
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2322
+ ? parsed
2323
+ : null;
2324
+ } catch {
2325
+ return null;
2326
+ }
2327
+ }
2328
+
2329
+ // Record that a native lifecycle component actually executed. The marker is
2330
+ // intentionally local and content-free: it contains only surface/version,
2331
+ // component timestamps, and the credential source category. Local MCP reads
2332
+ // it through get_connection_status so partial activation cannot look healthy.
2333
+ export function recordPluginLifecycleHealth(
2334
+ {
2335
+ surface,
2336
+ component,
2337
+ pluginVersion = null,
2338
+ hostVersion = null,
2339
+ credentialSource = null,
2340
+ sessionId = null,
2341
+ },
2342
+ env = process.env,
2343
+ ) {
2344
+ const normalizedSurface = normalizedPluginHealthSurface(surface);
2345
+ const normalizedComponent = String(component ?? "")
2346
+ .trim()
2347
+ .toLowerCase();
2348
+ if (
2349
+ !normalizedSurface ||
2350
+ !PLUGIN_HEALTH_COMPONENTS.has(normalizedComponent)
2351
+ ) {
2352
+ return false;
2353
+ }
2354
+ const path = pluginHealthSessionPath(normalizedSurface, sessionId, env);
2355
+ if (!path) return false;
2356
+ const now = new Date().toISOString();
2357
+ let existing =
2358
+ readPluginLifecycleHealth(normalizedSurface, env, sessionId) ?? {};
2359
+ // Hosts may emit SessionStart without the stable session id later supplied
2360
+ // to prompt/tool/completion hooks. Seed that startup observation into the
2361
+ // first session-specific marker, but never copy another session's later
2362
+ // components.
2363
+ if (
2364
+ sessionId &&
2365
+ sessionId !== "unknown" &&
2366
+ Object.keys(existing).length === 0 &&
2367
+ normalizedComponent !== "session_start"
2368
+ ) {
2369
+ const startup = readPluginLifecycleHealth(normalizedSurface, env);
2370
+ const startupComponents =
2371
+ startup?.components &&
2372
+ typeof startup.components === "object" &&
2373
+ !Array.isArray(startup.components) &&
2374
+ typeof startup.components.session_start === "string"
2375
+ ? { session_start: startup.components.session_start }
2376
+ : {};
2377
+ existing = {
2378
+ ...(startup ?? {}),
2379
+ components: startupComponents,
2380
+ };
2381
+ }
2382
+ const existingComponents =
2383
+ existing.components &&
2384
+ typeof existing.components === "object" &&
2385
+ !Array.isArray(existing.components)
2386
+ ? existing.components
2387
+ : {};
2388
+ // A new host session must not inherit prompt/tool/completion observations
2389
+ // from a prior session. Otherwise a current partial activation could look
2390
+ // healthy for as long as the old marker remains fresh.
2391
+ const currentComponents =
2392
+ normalizedComponent === "session_start" ? {} : existingComponents;
2393
+ const payload = {
2394
+ schema_version: 1,
2395
+ surface: normalizedSurface,
2396
+ session_hash:
2397
+ sessionId && sessionId !== "unknown"
2398
+ ? createHash("sha256")
2399
+ .update(String(sessionId), "utf8")
2400
+ .digest("hex")
2401
+ .slice(0, 24)
2402
+ : null,
2403
+ plugin_version: safeText(
2404
+ pluginVersion ?? existing.plugin_version ?? "",
2405
+ 64,
2406
+ ),
2407
+ host_version: safeText(hostVersion ?? existing.host_version ?? "", 64),
2408
+ credential_source: ["environment", "shared_config", "none"].includes(
2409
+ credentialSource,
2410
+ )
2411
+ ? credentialSource
2412
+ : ["environment", "shared_config", "none"].includes(
2413
+ existing.credential_source,
2414
+ )
2415
+ ? existing.credential_source
2416
+ : "none",
2417
+ components: {
2418
+ ...currentComponents,
2419
+ [normalizedComponent]: now,
2420
+ },
2421
+ last_seen_at: now,
2422
+ };
2423
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
2424
+ try {
2425
+ mkdirSync(dirname(path), { recursive: true });
2426
+ writeFileSync(temporaryPath, `${JSON.stringify(payload)}\n`, {
2427
+ mode: 0o600,
2428
+ });
2429
+ renameSync(temporaryPath, path);
2430
+ prunePluginHealthSessionMarkers(normalizedSurface, path, env);
2431
+ return true;
2432
+ } catch {
2433
+ try {
2434
+ rmSync(temporaryPath, { force: true });
2435
+ } catch {
2436
+ // Health recording is best-effort and must never break a host hook.
2437
+ }
2438
+ return false;
2439
+ }
2440
+ }
2441
+
2442
+ function prunePluginHealthSessionMarkers(surface, keepPath, env) {
2443
+ const directory = pluginHealthDir(env);
2444
+ try {
2445
+ const candidates = readdirSync(directory)
2446
+ .filter(
2447
+ (name) =>
2448
+ name.startsWith(`${surface}.`) &&
2449
+ /^[a-z_]+\.[a-f0-9]{24}\.json$/.test(name),
2450
+ )
2451
+ .map((name) => {
2452
+ const path = join(directory, name);
2453
+ return { path, modified: statSync(path).mtimeMs };
2454
+ })
2455
+ .sort((left, right) => right.modified - left.modified);
2456
+ for (const candidate of candidates.slice(32)) {
2457
+ if (candidate.path !== keepPath) rmSync(candidate.path, { force: true });
2458
+ }
2459
+ } catch {
2460
+ // Pruning is advisory; health recording must remain fail-open.
2461
+ }
2462
+ }
2463
+
2464
+ // Well-known config file that carries the org API key (and, optionally, the API
2465
+ // URL). It exists so a plugin user can authenticate ONCE — via one copy-paste
2466
+ // command that writes this file — and have prompt hooks plus local/bundled MCP
2467
+ // servers pick the key up, regardless of how the runtime happens to pass (or
2468
+ // not pass) environment variables to hook commands. Hosted MCP cannot read a
2469
+ // file on the caller's machine and authenticates separately. Co-located with
2470
+ // the agent attestation key under the XDG config dir. General reads remain
2471
+ // best-effort for diagnostics; credential resolution separately distinguishes
2472
+ // an absent file from a present malformed file and blocks remote calls for the
2473
+ // latter instead of silently changing scope.
2474
+ export function remembranceConfigPath(env = process.env) {
2475
+ return join(
2476
+ env.XDG_CONFIG_HOME || join(homedir(), ".config"),
2477
+ "remembrance",
2478
+ "config.json",
2479
+ );
2480
+ }
2481
+
2482
+ export function readRemembranceConfig(env = process.env) {
2483
+ try {
2484
+ const parsed = JSON.parse(readFileSync(remembranceConfigPath(env), "utf8"));
2485
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2486
+ ? parsed
2487
+ : {};
2488
+ } catch {
2489
+ return {};
2490
+ }
2491
+ }
2492
+
2493
+ export function resolveApiCredential(env = process.env) {
2494
+ const apiConfiguration = resolveApiConfiguration(env);
2495
+ if (isUnusableConfigurationSource(apiConfiguration.source)) {
2496
+ return { apiKey: "", source: apiConfiguration.source };
2497
+ }
2498
+ const environmentKey = String(env.REMEMBRANCE_API_KEY ?? "").trim();
2499
+ if (environmentKey) {
2500
+ return {
2501
+ apiKey: environmentKey,
2502
+ source: "environment",
2503
+ };
2504
+ }
2505
+ const path = remembranceConfigPath(env);
2506
+ if (!existsSync(path)) {
2507
+ return { apiKey: "", source: "none" };
2508
+ }
2509
+ try {
2510
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2511
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2512
+ return { apiKey: "", source: "unusable_shared_config" };
2513
+ }
2514
+ if (
2515
+ Object.prototype.hasOwnProperty.call(parsed, "apiKey") &&
2516
+ (typeof parsed.apiKey !== "string" || !parsed.apiKey.trim())
2517
+ ) {
2518
+ return { apiKey: "", source: "unusable_shared_config" };
2519
+ }
2520
+ const fromFile = parsed.apiKey;
2521
+ return typeof fromFile === "string" && fromFile.trim()
2522
+ ? { apiKey: fromFile.trim(), source: "shared_config" }
2523
+ : { apiKey: "", source: "none" };
2524
+ } catch {
2525
+ return { apiKey: "", source: "unusable_shared_config" };
2526
+ }
2527
+ }
2528
+
2529
+ // The org API key: an explicit env var wins, then the config file. Returns ""
2530
+ // when neither is present (the request then goes out anonymously).
2531
+ export function resolveApiKey(env = process.env) {
2532
+ return resolveApiCredential(env).apiKey;
2533
+ }
2534
+
2535
+ export function sharedConfigCredentialNotice(env = process.env) {
2536
+ const source = resolveApiCredential(env).source;
2537
+ if (source === "unusable_environment") {
2538
+ return (
2539
+ "Remembrance setup needs attention: REMEMBRANCE_API_URL is invalid. " +
2540
+ "Remote Remembrance calls are paused locally instead of changing " +
2541
+ "destinations. Set it to an absolute HTTP(S) registry URL or " +
2542
+ "intentionally remove it, then call get_connection_status."
2543
+ );
2544
+ }
2545
+ if (source === "unusable_shared_config") {
2546
+ return (
2547
+ "Remembrance setup needs attention: the shared config file exists but " +
2548
+ "is unreadable or invalid. Remote Remembrance calls are paused locally " +
2549
+ "instead of falling back to anonymous scope. Fix or intentionally remove " +
2550
+ "~/.config/remembrance/config.json, then call get_connection_status."
2551
+ );
2552
+ }
2553
+ if (source !== "shared_config") {
2554
+ return null;
2555
+ }
2556
+ return (
2557
+ `Remembrance credential source: this plugin hook resolved its key from ` +
2558
+ `the shared Remembrance config file (normally ` +
2559
+ `~/.config/remembrance/config.json). REMEMBRANCE_API_KEY may be unset; do not ` +
2560
+ `use an anonymous REST/browser probe to infer this hook's scope. Call ` +
2561
+ `get_connection_status for the MCP transport you will use.`
2562
+ );
2563
+ }
2564
+
2565
+ export function resolveApiConfiguration(env = process.env) {
2566
+ const environmentUrl = String(env.REMEMBRANCE_API_URL ?? "").trim();
2567
+ if (environmentUrl) {
2568
+ const normalized = normalizeApiUrl(environmentUrl);
2569
+ return normalized
2570
+ ? { apiUrl: normalized, source: "environment" }
2571
+ : { apiUrl: DEFAULT_API_URL, source: "unusable_environment" };
2572
+ }
2573
+ const path = remembranceConfigPath(env);
2574
+ if (!existsSync(path)) {
2575
+ return { apiUrl: DEFAULT_API_URL, source: "default" };
2576
+ }
2577
+ try {
2578
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2579
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2580
+ return {
2581
+ apiUrl: DEFAULT_API_URL,
2582
+ source: "unusable_shared_config",
2583
+ };
2584
+ }
2585
+ if (!Object.prototype.hasOwnProperty.call(parsed, "apiUrl")) {
2586
+ return { apiUrl: DEFAULT_API_URL, source: "default" };
2587
+ }
2588
+ const normalized = normalizeApiUrl(parsed.apiUrl);
2589
+ return normalized
2590
+ ? { apiUrl: normalized, source: "shared_config" }
2591
+ : { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
2592
+ } catch {
2593
+ return { apiUrl: DEFAULT_API_URL, source: "unusable_shared_config" };
2594
+ }
2595
+ }
2596
+
2597
+ function apiUrl(env) {
2598
+ return resolveApiConfiguration(env).apiUrl;
2599
+ }
2600
+
2601
+ function normalizeApiUrl(value) {
2602
+ if (typeof value !== "string" || !value.trim()) return null;
2603
+ const candidate = value.trim();
2604
+ try {
2605
+ const parsed = new URL(candidate);
2606
+ if (
2607
+ !["http:", "https:"].includes(parsed.protocol) ||
2608
+ parsed.username ||
2609
+ parsed.password ||
2610
+ parsed.search ||
2611
+ parsed.hash
2612
+ ) {
2613
+ return null;
2614
+ }
2615
+ return candidate.replace(/\/+$/, "");
2616
+ } catch {
2617
+ return null;
2618
+ }
2619
+ }
2620
+
2621
+ function isUnusableConfigurationSource(source) {
2622
+ return (
2623
+ source === "unusable_environment" || source === "unusable_shared_config"
2624
+ );
2625
+ }
2626
+
2627
+ // A manually configured hosted Codex MCP registration can diverge from the
2628
+ // hook runtime registry. The packaged Codex plugin now uses its bundled local
2629
+ // MCP server, which resolves the same shared config as the hooks and therefore
2630
+ // has no split to warn about.
2631
+ export function hostedMcpSplitNotice(env = process.env) {
2632
+ const hookBase = normalizeRegistryBaseUrl(apiUrl(env));
2633
+ if (hookBase === normalizeRegistryBaseUrl(DEFAULT_API_URL)) {
2634
+ return null;
2635
+ }
2636
+ const hostedMcp = resolveHostedMcpRegistry(env);
2637
+ if (hostedMcp.apiBase === hookBase) {
2638
+ return null;
2639
+ }
2640
+ return (
2641
+ `Note: Remembrance prompt hooks are querying ${hookBase}, but Codex ` +
2642
+ `hosted MCP tools are configured for ${hostedMcp.apiBase} ` +
2643
+ `(${hostedMcp.source}). Update REMEMBRANCE_API_URL or the Codex MCP ` +
2644
+ `configuration so both surfaces use the same registry.`
2645
+ );
2646
+ }
2647
+
2648
+ export function resolveHostedMcpRegistry(env = process.env) {
2649
+ const codexMcpUrl = stringOrNull(env.REMEMBRANCE_CODEX_MCP_URL);
2650
+ if (codexMcpUrl) {
2651
+ return {
2652
+ apiBase: normalizeRegistryBaseUrl(codexMcpUrl),
2653
+ mcpUrl: codexMcpUrl,
2654
+ source: "REMEMBRANCE_CODEX_MCP_URL",
2655
+ };
2656
+ }
2657
+ const genericMcpUrl = stringOrNull(env.REMEMBRANCE_MCP_URL);
2658
+ if (genericMcpUrl) {
2659
+ return {
2660
+ apiBase: normalizeRegistryBaseUrl(genericMcpUrl),
2661
+ mcpUrl: genericMcpUrl,
2662
+ source: "REMEMBRANCE_MCP_URL",
2663
+ };
2664
+ }
2665
+
2666
+ const config = readCodexMcpConfig(env);
2667
+ if (config?.command) {
2668
+ return {
2669
+ apiBase: normalizeRegistryBaseUrl(apiUrl(env)),
2670
+ mcpUrl: "local stdio",
2671
+ source: "active Codex MCP config",
2672
+ };
2673
+ }
2674
+ if (config?.url) {
2675
+ return {
2676
+ apiBase: normalizeRegistryBaseUrl(config.url),
2677
+ mcpUrl: config.url,
2678
+ source: "active Codex MCP config",
2679
+ };
2680
+ }
2681
+
2682
+ const packaged = readPackagedCodexMcpRegistration();
2683
+ if (packaged?.command) {
2684
+ return {
2685
+ apiBase: normalizeRegistryBaseUrl(apiUrl(env)),
2686
+ mcpUrl: "local stdio",
2687
+ source: "bundled local Codex MCP server",
2688
+ };
2689
+ }
2690
+ const packagedUrl = packaged?.url ?? `${DEFAULT_API_URL}/api/mcp`;
2691
+ return {
2692
+ apiBase: normalizeRegistryBaseUrl(packagedUrl),
2693
+ mcpUrl: packagedUrl,
2694
+ source: "packaged Codex MCP manifest",
2695
+ };
2696
+ }
2697
+
2698
+ export function resolveCodexHostedMcpRegistration(env = process.env) {
2699
+ const config = readCodexMcpConfig(env);
2700
+ if (config) {
2701
+ return {
2702
+ url: config.url,
2703
+ ...(config.command ? { command: config.command } : {}),
2704
+ credentialEnvVars: config.credentialEnvVars,
2705
+ hasStaticCredential: config.hasStaticCredential,
2706
+ source: "active Codex MCP config",
2707
+ };
2708
+ }
2709
+ const packaged = readPackagedCodexMcpRegistration();
2710
+ if (packaged) {
2711
+ return {
2712
+ ...packaged,
2713
+ source: "packaged Codex MCP manifest",
2714
+ };
2715
+ }
2716
+ return {
2717
+ url: null,
2718
+ credentialEnvVars: [],
2719
+ hasStaticCredential: false,
2720
+ source: "Codex MCP registration",
2721
+ };
2722
+ }
2723
+
2724
+ export function normalizeRegistryBaseUrl(value) {
2725
+ const raw = String(value ?? "")
2726
+ .trim()
2727
+ .replace(/\/+$/, "");
2728
+ if (!raw) {
2729
+ return "";
2730
+ }
2731
+ try {
2732
+ const url = new URL(raw);
2733
+ let pathname = url.pathname.replace(/\/+$/, "");
2734
+ if (pathname === "/api/mcp") {
2735
+ pathname = "";
2736
+ } else if (pathname.endsWith("/api/mcp")) {
2737
+ pathname = pathname.slice(0, -"/api/mcp".length);
2738
+ }
2739
+ const normalized = `${url.origin}${pathname}`.replace(/\/+$/, "");
2740
+ return normalized || url.origin;
2741
+ } catch {
2742
+ return raw.replace(/\/api\/mcp$/, "").replace(/\/+$/, "");
2743
+ }
2744
+ }
2745
+
2746
+ export function readCodexMcpConfig(env = process.env) {
2747
+ for (const path of codexConfigPaths(env)) {
2748
+ try {
2749
+ if (!existsSync(path)) {
2750
+ continue;
2751
+ }
2752
+ const registration = parseCodexMcpRegistration(
2753
+ readFileSync(path, "utf8"),
2754
+ );
2755
+ if (registration) {
2756
+ return { path, ...registration };
2757
+ }
2758
+ } catch {
2759
+ // Fail open: a malformed/unreadable Codex config should not break hooks.
2760
+ }
2761
+ }
2762
+ return null;
2763
+ }
2764
+
2765
+ export function parseCodexMcpUrl(toml) {
2766
+ return parseCodexMcpRegistration(toml)?.url ?? null;
2767
+ }
2768
+
2769
+ export function parseCodexMcpRegistration(toml) {
2770
+ let section = "";
2771
+ let found = false;
2772
+ let url = null;
2773
+ let command = null;
2774
+ const credentialEnvVars = new Set();
2775
+ let hasStaticCredential = false;
2776
+
2777
+ for (const rawLine of String(toml ?? "").split(/\r?\n/)) {
2778
+ const line = rawLine.trim();
2779
+ if (!line || line.startsWith("#")) {
2780
+ continue;
2781
+ }
2782
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
2783
+ if (sectionMatch) {
2784
+ section = sectionMatch[1].trim().replace(/["']/g, "");
2785
+ if (
2786
+ section === "mcp_servers.remembrance" ||
2787
+ section.startsWith("mcp_servers.remembrance.")
2788
+ ) {
2789
+ found = true;
2790
+ }
2791
+ continue;
2792
+ }
2793
+ if (
2794
+ section !== "mcp_servers.remembrance" &&
2795
+ !section.startsWith("mcp_servers.remembrance.")
2796
+ ) {
2797
+ continue;
2798
+ }
2799
+
2800
+ const assignment = line.match(/^(.+?)\s*=\s*(.+)$/);
2801
+ if (!assignment) {
2802
+ continue;
2803
+ }
2804
+ const key = parseTomlKey(assignment[1]);
2805
+ const value = assignment[2];
2806
+
2807
+ if (section === "mcp_servers.remembrance") {
2808
+ if (key === "url") {
2809
+ url = parseTomlString(value);
2810
+ } else if (key === "command") {
2811
+ command = parseTomlString(value);
2812
+ } else if (key === "bearer_token_env_var") {
2813
+ addCredentialEnvVar(credentialEnvVars, parseTomlString(value));
2814
+ } else if (key === "env_http_headers") {
2815
+ for (const [header, envName] of parseTomlInlineTable(value)) {
2816
+ if (isRemembranceAuthHeader(header)) {
2817
+ addCredentialEnvVar(credentialEnvVars, envName);
2818
+ }
2819
+ }
2820
+ } else if (key === "http_headers" || key === "headers") {
2821
+ hasStaticCredential ||= parseTomlInlineTable(value).some(([header]) =>
2822
+ isRemembranceAuthHeader(header),
2823
+ );
2824
+ }
2825
+ } else if (section === "mcp_servers.remembrance.env_http_headers") {
2826
+ if (isRemembranceAuthHeader(key)) {
2827
+ addCredentialEnvVar(credentialEnvVars, parseTomlString(value));
2828
+ }
2829
+ } else if (
2830
+ section === "mcp_servers.remembrance.http_headers" ||
2831
+ section === "mcp_servers.remembrance.headers"
2832
+ ) {
2833
+ hasStaticCredential ||= isRemembranceAuthHeader(key);
2834
+ }
2835
+ }
2836
+
2837
+ return found
2838
+ ? {
2839
+ url,
2840
+ credentialEnvVars: [...credentialEnvVars],
2841
+ hasStaticCredential,
2842
+ ...(command ? { command } : {}),
2843
+ }
2844
+ : null;
2845
+ }
2846
+
2847
+ function codexConfigPaths(env) {
2848
+ const explicit = stringOrNull(env.REMEMBRANCE_CODEX_CONFIG_PATH);
2849
+ if (explicit) {
2850
+ return [explicit];
2851
+ }
2852
+ const codexHome = stringOrNull(env.CODEX_HOME) ?? join(homedir(), ".codex");
2853
+ return [
2854
+ join(process.cwd(), ".codex", "config.toml"),
2855
+ join(codexHome, "config.toml"),
2856
+ ];
2857
+ }
2858
+
2859
+ function readPackagedCodexMcpRegistration() {
2860
+ for (const relativePath of ["../.mcp.codex.json", "../.mcp.json"]) {
2861
+ try {
2862
+ const parsed = JSON.parse(
2863
+ readFileSync(new URL(relativePath, import.meta.url), "utf8"),
2864
+ );
2865
+ const server = parsed?.mcpServers?.remembrance;
2866
+ if (!server || typeof server !== "object" || Array.isArray(server)) {
2867
+ continue;
2868
+ }
2869
+ const credentialEnvVars = new Set();
2870
+ addCredentialEnvVar(
2871
+ credentialEnvVars,
2872
+ stringOrNull(server.bearer_token_env_var),
2873
+ );
2874
+ for (const [header, envName] of Object.entries(
2875
+ server.env_http_headers ?? {},
2876
+ )) {
2877
+ if (isRemembranceAuthHeader(header)) {
2878
+ addCredentialEnvVar(credentialEnvVars, stringOrNull(envName));
2879
+ }
2880
+ }
2881
+ const staticHeaders = {
2882
+ ...(server.http_headers ?? {}),
2883
+ ...(server.headers ?? {}),
2884
+ };
2885
+ return {
2886
+ url: stringOrNull(server.url),
2887
+ ...(stringOrNull(server.command)
2888
+ ? { command: stringOrNull(server.command) }
2889
+ : {}),
2890
+ credentialEnvVars: [...credentialEnvVars],
2891
+ hasStaticCredential: Object.keys(staticHeaders).some((header) =>
2892
+ isRemembranceAuthHeader(header),
2893
+ ),
2894
+ };
2895
+ } catch {
2896
+ // Hook-core is copied into multiple plugin packages; not every copy has a
2897
+ // hosted Codex MCP manifest next to it.
2898
+ }
2899
+ }
2900
+ return null;
2901
+ }
2902
+
2903
+ function addCredentialEnvVar(target, value) {
2904
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value ?? ""))) {
2905
+ target.add(String(value));
2906
+ }
2907
+ }
2908
+
2909
+ function isRemembranceAuthHeader(value) {
2910
+ return ["authorization", "x-remembrance-api-key", "x-api-key"].includes(
2911
+ String(value ?? "")
2912
+ .trim()
2913
+ .toLowerCase(),
2914
+ );
2915
+ }
2916
+
2917
+ function parseTomlKey(value) {
2918
+ return String(value ?? "")
2919
+ .trim()
2920
+ .replace(/^["']|["']$/g, "");
2921
+ }
2922
+
2923
+ function parseTomlInlineTable(value) {
2924
+ const entries = [];
2925
+ const source = String(value ?? "").trim();
2926
+ if (!source.startsWith("{") || !source.endsWith("}")) {
2927
+ return entries;
2928
+ }
2929
+ const body = source.slice(1, -1);
2930
+ const pattern =
2931
+ /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*=\s*("(?:\\.|[^"])*"|'[^']*'|[^,}]+)/g;
2932
+ let match;
2933
+ while ((match = pattern.exec(body)) !== null) {
2934
+ const key = match[1] ?? match[2] ?? match[3] ?? "";
2935
+ const parsedValue = parseTomlString(match[4]);
2936
+ if (key && parsedValue) {
2937
+ entries.push([key, parsedValue]);
2938
+ }
2939
+ }
2940
+ return entries;
2941
+ }
2942
+
2943
+ function parseTomlString(value) {
2944
+ const trimmed = String(value ?? "").trim();
2945
+ if (!trimmed) {
2946
+ return null;
2947
+ }
2948
+ const quote = trimmed[0];
2949
+ if (quote === '"' || quote === "'") {
2950
+ const end = trimmed.indexOf(quote, 1);
2951
+ return end > 0 ? trimmed.slice(1, end) : null;
2952
+ }
2953
+ const unquoted = trimmed.split("#")[0]?.trim();
2954
+ return unquoted || null;
2955
+ }
2956
+
2957
+ function stringOrNull(value) {
2958
+ const text = String(value ?? "").trim();
2959
+ return text || null;
2960
+ }
2961
+
2962
+ function limitFromEnv(env) {
2963
+ const parsed = Number.parseInt(
2964
+ String(env.REMEMBRANCE_AUTO_QUERY_LIMIT ?? ""),
2965
+ 10,
2966
+ );
2967
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 10
2968
+ ? parsed
2969
+ : DEFAULT_LIMIT;
2970
+ }
2971
+
2972
+ export function autoQueryTimeoutMs(env = process.env) {
2973
+ const parsed = Number.parseInt(
2974
+ String(env.REMEMBRANCE_AUTO_QUERY_TIMEOUT_MS ?? ""),
2975
+ 10,
2976
+ );
2977
+ return Number.isFinite(parsed) && parsed >= 100 && parsed <= 30_000
2978
+ ? parsed
2979
+ : DEFAULT_TIMEOUT_MS;
2980
+ }
2981
+
2982
+ function directiveEventTimeoutMs(env) {
2983
+ const parsed = Number.parseInt(
2984
+ String(env.REMEMBRANCE_DIRECTIVE_EVENT_TIMEOUT_MS ?? ""),
2985
+ 10,
2986
+ );
2987
+ return Number.isFinite(parsed) && parsed >= 100 && parsed <= 2_000
2988
+ ? parsed
2989
+ : DEFAULT_DIRECTIVE_EVENT_TIMEOUT_MS;
2990
+ }
2991
+
2992
+ export function debugLog(env, event, fields = {}, options = {}) {
2993
+ if (!debugEnabled(env?.REMEMBRANCE_DEBUG)) {
2994
+ return;
2995
+ }
2996
+ const writer = options.stderr ?? process.stderr;
2997
+ const body = redactPrompt(JSON.stringify({ event, ...fields })).slice(
2998
+ 0,
2999
+ 1000,
3000
+ );
3001
+ writer.write(`[remembrance] ${body}\n`);
3002
+ }
3003
+
3004
+ function debugEnabled(value) {
3005
+ return /^(1|true|yes)$/i.test(String(value ?? ""));
3006
+ }
3007
+
3008
+ function errorName(error) {
3009
+ return error instanceof Error ? error.name || error.message : "Error";
3010
+ }
3011
+
3012
+ function stringValue(value) {
3013
+ const text = String(value ?? "").trim();
3014
+ return text || "unknown";
3015
+ }
3016
+
3017
+ function safeText(value, maxLength) {
3018
+ const text = redactPrompt(stringValue(value)).replace(/\s+/g, " ").trim();
3019
+ if (text.length <= maxLength) {
3020
+ return text;
3021
+ }
3022
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trim()}...`;
3023
+ }
3024
+
3025
+ function numberValue(value) {
3026
+ return Number.isFinite(Number(value)) ? String(Number(value)) : "0";
3027
+ }