@remembrance-ai/opencode-plugin 0.1.77 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mjs CHANGED
@@ -16,9 +16,11 @@
16
16
  // plugin loaded and knows to call run_connection_doctor.
17
17
  //
18
18
  // PROMPT (chat.message -> experimental.chat.system.transform): queries from
19
- // the current user turn, then injects the bounded result into the model's
20
- // system context before dispatch. Legacy message events remain a fallback
21
- // for older builds and are deduplicated by message id.
19
+ // the current user turn, then injects the bounded result into the system
20
+ // context of every model request of that turn (the host rebuilds the
21
+ // system prompt per request and asks its title model first). Legacy
22
+ // message events remain a fallback for older builds and are deduplicated
23
+ // by message id.
22
24
  //
23
25
  // TOOL OBSERVER (tool.execute.after): correlates Remembrance tool calls with
24
26
  // the active directive exactly like the other hosts.
@@ -37,18 +39,20 @@
37
39
  import { readFileSync } from "node:fs";
38
40
  import process from "node:process";
39
41
  import {
42
+ bindPrivateSkillNativeCapture,
40
43
  HOST_POLICY_ALERT_TEXT,
41
44
  checkForClientUpdate,
42
45
  clearHighMatchSurfaceForExplicitSelection,
43
46
  clearHighMatchSurfaceIfOpened,
44
47
  debugLog,
45
48
  decideStop,
49
+ acknowledgePrivateAuthorizationReminders,
46
50
  directSelectionFromResponse,
47
51
  highMatchFromResponse,
48
52
  markHostPolicyAlertDelivered,
49
53
  observeSuccessfulCompletionTool,
50
54
  recordDirectiveFollowThroughForTool,
51
- recordDirectSelectionSurface,
55
+ recordDirectSelectionUse,
52
56
  recordDirectiveSurface,
53
57
  recordHighMatchSurface,
54
58
  recordPluginLifecycleHealth,
@@ -73,18 +77,22 @@ const SERVICE = "remembrance";
73
77
  const CONTRIBUTION_TOOLS = [
74
78
  "submit_query_feedback",
75
79
  "submit_feedback",
80
+ "capture_learning",
76
81
  "submit_remembrance",
77
82
  "propose_skill_idea",
78
83
  "propose_private_skill",
79
84
  "submit_private_lesson_candidate",
80
85
  "retry_private_lesson_candidate",
86
+ "submit_private_skill_candidate",
87
+ "retry_private_skill_candidate",
88
+ "delete_private_lesson_candidate",
89
+ "delete_private_skill_candidate",
90
+ "record_preference",
81
91
  "submit_suggestion",
82
92
  "submit_resource",
83
93
  "submit_resource_review",
84
94
  ];
85
- const PREFERENCE_EVIDENCE_TOOLS = [
86
- "submit_preference_compatibility_feedback",
87
- ];
95
+ const PREFERENCE_EVIDENCE_TOOLS = ["submit_preference_compatibility_feedback"];
88
96
 
89
97
  export function pluginVersion(readFile = readFileSync) {
90
98
  try {
@@ -96,14 +104,22 @@ export function pluginVersion(readFile = readFileSync) {
96
104
  }
97
105
  }
98
106
 
107
+ // OpenCode marks the text it adds to a user message itself (a file it read in,
108
+ // an assistant-audience prompt) synthetic, and text shown only to the user
109
+ // ignored; its own prompt assembly keeps neither as what the user typed.
110
+ function hostAddedPart(part) {
111
+ return part?.synthetic === true || part?.ignored === true;
112
+ }
113
+
99
114
  // OpenCode's published SDK uses the properties.* fields first. Legacy probes
100
115
  // keep older host builds compatible, and "not found" always means "skip".
101
116
  export function promptFromMessageEvent(input, output) {
117
+ const eventPartText = (part) => (hostAddedPart(part) ? null : part?.text);
102
118
  for (const candidate of [
103
119
  output?.parts,
104
120
  input?.parts,
105
- output?.properties?.part?.text,
106
- input?.properties?.part?.text,
121
+ eventPartText(output?.properties?.part),
122
+ eventPartText(input?.properties?.part),
107
123
  output?.message?.content,
108
124
  output?.message?.text,
109
125
  output?.content,
@@ -118,6 +134,7 @@ export function promptFromMessageEvent(input, output) {
118
134
  }
119
135
  if (Array.isArray(candidate)) {
120
136
  const joined = candidate
137
+ .filter((part) => !hostAddedPart(part))
121
138
  .map((part) =>
122
139
  typeof part === "string" ? part : (part?.text ?? part?.content ?? ""),
123
140
  )
@@ -168,6 +185,28 @@ export function sessionIdFromEvent(input, output) {
168
185
  return "opencode-session";
169
186
  }
170
187
 
188
+ export function nativeSessionIdFromEvent(input, output) {
189
+ for (const candidate of [
190
+ output?.properties?.sessionID,
191
+ output?.properties?.info?.sessionID,
192
+ output?.properties?.part?.sessionID,
193
+ input?.properties?.sessionID,
194
+ input?.properties?.info?.sessionID,
195
+ input?.properties?.part?.sessionID,
196
+ output?.sessionID,
197
+ output?.sessionId,
198
+ output?.session?.id,
199
+ output?.message?.sessionID,
200
+ output?.message?.sessionId,
201
+ input?.sessionID,
202
+ input?.sessionId,
203
+ input?.session?.id,
204
+ ]) {
205
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
206
+ }
207
+ return null;
208
+ }
209
+
171
210
  export function messageIdFromEvent(input, output) {
172
211
  for (const candidate of [
173
212
  output?.properties?.info?.id,
@@ -207,10 +246,13 @@ export function toolNameFromEvent(input, output) {
207
246
  }
208
247
 
209
248
  async function log(client, level, message) {
249
+ if (!client?.app?.log) return false;
210
250
  try {
211
- await client?.app?.log?.({ body: { service: SERVICE, level, message } });
251
+ await client.app.log({ body: { service: SERVICE, level, message } });
252
+ return true;
212
253
  } catch {
213
254
  // Logging is best effort; never let it break the host.
255
+ return false;
214
256
  }
215
257
  }
216
258
 
@@ -220,12 +262,12 @@ async function notify(client, message, variant = "info") {
220
262
  await client.tui.showToast({
221
263
  body: { title: "Remembrance", message, variant },
222
264
  });
223
- return;
265
+ return true;
224
266
  }
225
267
  } catch {
226
268
  // Fall through to structured logging when this host has no active TUI.
227
269
  }
228
- await log(client, variant === "error" ? "error" : "info", message);
270
+ return log(client, variant === "error" ? "error" : "info", message);
229
271
  }
230
272
 
231
273
  export const Remembrance = async (context = {}) => {
@@ -237,7 +279,13 @@ export const Remembrance = async (context = {}) => {
237
279
  const queriedMessages = new Set();
238
280
  const messageSessions = new Map();
239
281
  const pendingGuidance = new Map();
282
+ // An update notice belongs to the session, not to one turn: it waits
283
+ // through session.idle (OpenCode publishes one after every turn) for a
284
+ // later turn's hook, and is dropped only once delivered or when the session
285
+ // is deleted.
240
286
  const pendingClientUpdates = new Map();
287
+ // What the current turn delivered, repeated on each of its later requests.
288
+ const turnContext = new Map();
241
289
  const userMessages = new Map();
242
290
  const pendingTextParts = new Map();
243
291
 
@@ -266,7 +314,7 @@ export const Remembrance = async (context = {}) => {
266
314
 
267
315
  function clearSessionState(sessionId) {
268
316
  pendingGuidance.delete(sessionId);
269
- pendingClientUpdates.delete(sessionId);
317
+ turnContext.delete(sessionId);
270
318
  for (const [messageId, trackedSessionId] of messageSessions) {
271
319
  if (trackedSessionId !== sessionId) continue;
272
320
  messageSessions.delete(messageId);
@@ -341,16 +389,30 @@ export const Remembrance = async (context = {}) => {
341
389
  }
342
390
  }
343
391
 
344
- async function handlePrompt(prompt, sessionId, messageId) {
392
+ async function handlePrompt(prompt, sessionId, messageId, nativeSessionId) {
345
393
  const messageKey = messageId ?? `${sessionId}:${prompt}`;
346
394
  if (queriedMessages.has(messageKey)) return;
347
395
  queriedMessages.add(messageKey);
348
396
  messageSessions.set(messageKey, sessionId);
397
+ turnContext.delete(sessionId);
349
398
  recordLifecycle("prompt_hook", sessionId);
350
- const result = await runPromptHook(prompt, {
399
+ const result = await (context.runPromptHook ?? runPromptHook)(prompt, {
351
400
  env,
401
+ contextAdditions: pendingClientUpdates.has(sessionId)
402
+ ? [
403
+ {
404
+ id: "client_update",
405
+ kind: "notice",
406
+ text: pendingClientUpdates.get(sessionId),
407
+ },
408
+ ]
409
+ : [],
352
410
  identity: { provider: SURFACE, model: "opencode" },
353
411
  reportDirectiveShown: false,
412
+ deferReminderAcknowledgement: true,
413
+ sessionId,
414
+ nativeSessionId,
415
+ nativeContextBridge: context.nativeContextBridge,
354
416
  userAgent: "@remembrance-ai/opencode-plugin",
355
417
  });
356
418
  if (result?.eligible) {
@@ -372,21 +434,28 @@ export const Remembrance = async (context = {}) => {
372
434
  directive: result?.directive ?? null,
373
435
  highMatch: result?.highMatch ?? null,
374
436
  text: guidance.trim(),
437
+ deliveredContextAdditionIds: result?.deliveredContextAdditionIds ?? [],
375
438
  countsAsUse: Boolean(result?.consumed && result?.matched),
376
439
  queryFeedback: result?.queryFeedback ?? null,
377
440
  valueEpisode: result?.valueEpisode ?? null,
378
441
  });
442
+ } else if (result?.consumed) {
443
+ // An empty task-shape search delivers nothing (owner decision 34C);
444
+ // like any completed search it still ends an earlier directive.
445
+ recordDirectiveSurface(sessionId, null, env);
379
446
  }
380
447
  }
381
448
 
382
449
  async function handleChatMessage(input, output) {
383
450
  try {
451
+ if (!isUserMessageEvent(input, output)) return;
384
452
  const prompt = promptFromMessageEvent(input, output);
385
453
  if (!prompt) return;
386
454
  await handlePrompt(
387
455
  prompt,
388
456
  sessionIdFromEvent(input, output),
389
457
  messageIdFromEvent(input, output),
458
+ nativeSessionIdFromEvent(input, output),
390
459
  );
391
460
  } catch (error) {
392
461
  debugLog(env, "opencode_chat_message_failed", {
@@ -401,13 +470,26 @@ export const Remembrance = async (context = {}) => {
401
470
  const guidance = pendingGuidance.get(sessionId);
402
471
  const updateNotice = pendingClientUpdates.get(sessionId);
403
472
  if (!Array.isArray(output?.system)) return;
404
- if (updateNotice) {
405
- output.system.push(updateNotice);
473
+ // OpenCode builds the system prompt again for every model request of a
474
+ // turn, and on a session's first turn its title request comes first:
475
+ // what this turn delivered goes into each of them, recorded once.
476
+ const delivered = turnContext.get(sessionId) ?? [];
477
+ // The raw update notice is only for a turn that carries nothing else;
478
+ // a notice the bounded hook withheld waits for a later turn's hook, as
479
+ // does one still pending when the session went idle (handleSessionIdle).
480
+ if (updateNotice && !guidance && !turnContext.has(sessionId)) {
481
+ delivered.push(updateNotice);
406
482
  pendingClientUpdates.delete(sessionId);
407
483
  }
484
+ if (guidance) delivered.push(guidance.text);
485
+ if (delivered.length > 0) turnContext.set(sessionId, delivered);
486
+ output.system.push(...delivered);
408
487
  if (!guidance) return;
409
- output.system.push(guidance.text);
488
+ acknowledgePrivateAuthorizationReminders(guidance.text, env);
410
489
  pendingGuidance.delete(sessionId);
490
+ if (guidance.deliveredContextAdditionIds.includes("client_update")) {
491
+ pendingClientUpdates.delete(sessionId);
492
+ }
411
493
  // Delivery happened: only now is the guidance actually in the model's
412
494
  // context, so only now does it count as registry use.
413
495
  if (guidance.countsAsUse) {
@@ -415,11 +497,7 @@ export const Remembrance = async (context = {}) => {
415
497
  recordHighMatchSurface(sessionId, guidance.highMatch, env);
416
498
  recordValueEpisodeSurface(sessionId, guidance.valueEpisode, env);
417
499
  }
418
- recordQueryFeedbackObligation(
419
- sessionId,
420
- guidance.queryFeedback,
421
- env,
422
- );
500
+ recordQueryFeedbackObligation(sessionId, guidance.queryFeedback, env);
423
501
  recordDirectiveSurface(sessionId, guidance.directive, env);
424
502
  if (guidance.directive) {
425
503
  await reportDirectiveEvent(
@@ -458,7 +536,12 @@ export const Remembrance = async (context = {}) => {
458
536
  const pending = pendingTextParts.get(messageId);
459
537
  if (pending) {
460
538
  pendingTextParts.delete(messageId);
461
- await handlePrompt(pending.prompt, pending.sessionId, messageId);
539
+ await handlePrompt(
540
+ pending.prompt,
541
+ pending.sessionId,
542
+ messageId,
543
+ pending.nativeSessionId,
544
+ );
462
545
  }
463
546
  } catch (error) {
464
547
  debugLog(env, "opencode_message_updated_failed", {
@@ -474,11 +557,16 @@ export const Remembrance = async (context = {}) => {
474
557
  if (!prompt || !messageId) return;
475
558
  const sessionId =
476
559
  userMessages.get(messageId) ?? sessionIdFromEvent(event, event);
560
+ const nativeSessionId = nativeSessionIdFromEvent(event, event);
477
561
  if (!userMessages.has(messageId)) {
478
- pendingTextParts.set(messageId, { prompt, sessionId });
562
+ pendingTextParts.set(messageId, {
563
+ prompt,
564
+ sessionId,
565
+ nativeSessionId,
566
+ });
479
567
  return;
480
568
  }
481
- await handlePrompt(prompt, sessionId, messageId);
569
+ await handlePrompt(prompt, sessionId, messageId, nativeSessionId);
482
570
  } catch (error) {
483
571
  debugLog(env, "opencode_prompt_hook_failed", { error: String(error) });
484
572
  }
@@ -495,14 +583,19 @@ export const Remembrance = async (context = {}) => {
495
583
  });
496
584
  const decision = decideStop({ session_id: sessionId }, { env });
497
585
  if (decision?.allow !== false || !decision?.reason) return;
586
+ const delivered = await notify(client, decision.reason);
587
+ if (!delivered) return;
498
588
  writePromptedCount(sessionId, decision.useCount ?? 1, env);
499
- await notify(client, decision.reason);
589
+ acknowledgePrivateAuthorizationReminders(decision.reason, env);
500
590
  } catch (error) {
501
591
  debugLog(env, "opencode_completion_hook_failed", {
502
592
  error: String(error),
503
593
  });
504
594
  } finally {
505
595
  clearSessionState(sessionId);
596
+ // Until the next user prompt a model request is no agent turn (OpenCode
597
+ // runs one for a manual compaction), so it gets no pending notice.
598
+ if (pendingClientUpdates.has(sessionId)) turnContext.set(sessionId, []);
506
599
  }
507
600
  }
508
601
 
@@ -545,7 +638,9 @@ export const Remembrance = async (context = {}) => {
545
638
  return;
546
639
  }
547
640
  if (event.type === "session.deleted") {
548
- clearSessionState(sessionIdFromEvent(event, event));
641
+ const sessionId = sessionIdFromEvent(event, event);
642
+ pendingClientUpdates.delete(sessionId);
643
+ clearSessionState(sessionId);
549
644
  }
550
645
  } catch (error) {
551
646
  debugLog(env, "opencode_event_dispatch_failed", {
@@ -598,12 +693,7 @@ export const Remembrance = async (context = {}) => {
598
693
  if (normalizedTool.endsWith("invoke_skill")) {
599
694
  const selection = directSelectionFromResponse(output);
600
695
  if (!selection) return;
601
- const useCount = recordRegistryUse(sessionId, env);
602
- recordDirectSelectionSurface(
603
- sessionId,
604
- { ...selection, use_count: useCount },
605
- env,
606
- );
696
+ recordDirectSelectionUse(sessionId, selection, env);
607
697
  recordValueEpisodeSurface(
608
698
  sessionId,
609
699
  valueEpisodeFromResponse(output),
@@ -645,6 +735,12 @@ export const Remembrance = async (context = {}) => {
645
735
  normalizedTool.endsWith(candidate),
646
736
  )
647
737
  ) {
738
+ await bindPrivateSkillNativeCapture(tool, toolArguments, output, {
739
+ env,
740
+ nativeContextBridge: context.nativeContextBridge,
741
+ // Tool output is untrusted payload, not native provenance.
742
+ nativeSessionId: nativeSessionIdFromEvent(input, null),
743
+ });
648
744
  observeSuccessfulCompletionTool(
649
745
  sessionId,
650
746
  tool,
@@ -654,12 +750,7 @@ export const Remembrance = async (context = {}) => {
654
750
  );
655
751
  return;
656
752
  }
657
- clearHighMatchSurfaceIfOpened(
658
- sessionId,
659
- tool,
660
- toolArguments,
661
- env,
662
- );
753
+ clearHighMatchSurfaceIfOpened(sessionId, tool, toolArguments, env);
663
754
  observeSuccessfulCompletionTool(
664
755
  sessionId,
665
756
  tool,