@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/README.md +172 -32
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/feedback-disposition.mjs +376 -0
- package/scripts/hook-core.mjs +6145 -685
- package/scripts/pending-action-payload.mjs +1044 -0
- package/scripts/private-learning-hook-policy.mjs +2515 -0
- package/servers/remembrance-mcp.mjs +62249 -6956
- package/skills/remembrancer/SKILL.md +219 -105
- package/skills/remembrancer/references/identity-preferences-topology.md +41 -0
- package/skills/remembrancer/references/remembrance-payloads.md +94 -17
- package/skills/remembrancer/references/remembrance-setup.md +186 -74
- package/src/index.mjs +131 -40
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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
|
106
|
-
input?.properties?.part
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|