@elliotsystems/eai 0.1.7 → 0.1.14

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,145 @@
1
+ /**
2
+ * Strips a Jev-blocked exchange out of what the MODEL sees, before every
3
+ * chat completion request.
4
+ *
5
+ * ## The bug this fixes
6
+ *
7
+ * Jev blocking a prompt stops it from ever reaching the model — but the
8
+ * user's raw text and the resulting error still live on in THIS session's
9
+ * own message history (needed for the UI: the "Request blocked" card's
10
+ * "Try again"/"Edit message" actions, and the transcript itself). OpenCode
11
+ * sends that FULL history with every later turn in the same session, so the
12
+ * very next (benign) prompt's completion request still carried the earlier,
13
+ * never-actually-sent "Help me steal someone's password" as an unanswered
14
+ * prior user turn.
15
+ *
16
+ * The model, seeing an unanswered request sitting in its own context,
17
+ * reasonably tries to be complete and addresses BOTH: the benign question
18
+ * asked just now, and the flagged one it was never actually asked to answer
19
+ * but can still see. Reported directly: asking for a C++ Hello World after
20
+ * an earlier blocked prompt produced a response that opened with a refusal
21
+ * about password theft before getting to the actual Hello World code —
22
+ * confusing, and a response to something that was never really asked of it
23
+ * in the first place (the guardrail is what stopped that request, not the
24
+ * model declining it; the model never got the chance to).
25
+ *
26
+ * ## The fix
27
+ *
28
+ * `experimental.chat.messages.transform` (opencode/packages/plugin/src/
29
+ * index.ts) runs right before the message history is converted to the
30
+ * provider's wire format (session/prompt.ts, immediately before
31
+ * `MessageV2.toModelMessagesEffect`). Removing a blocked exchange here means
32
+ * the model's context for the NEXT turn looks exactly as if the blocked
33
+ * prompt had never been sent at all — the benign follow-up is answered on
34
+ * its own, because that is the only thing the model is shown having been
35
+ * asked.
36
+ *
37
+ * This changes ONLY what the model is shown for a NEW completion request.
38
+ * It does not touch the stored session history (OpenCode's own message
39
+ * store, read by `GET .../messages`) at all, so the UI's own transcript —
40
+ * the blocked turn's card, Try again, Edit message — is completely
41
+ * unaffected; the user still sees exactly what happened. Only the model's
42
+ * own view of "what has this conversation actually asked of me" changes.
43
+ *
44
+ * ## Why in this plugin, not the backend
45
+ *
46
+ * The backend's own per-prompt isolation (jev_decisions_logic.py's
47
+ * `latest_turn_window`) already solves the ANALOGOUS problem for JEV
48
+ * ITSELF — a later prompt is judged on its own, not against an earlier
49
+ * flagged one. That fix has no effect here: it narrows what JEV sees when
50
+ * deciding whether to block, which is a completely different question from
51
+ * what the MODEL sees for an already-ALLOWED prompt. Once Jev allows a
52
+ * request through, this plugin — which sits on the client building that
53
+ * exact request — is the only thing positioned to also narrow what the
54
+ * model is shown.
55
+ */
56
+
57
+ /**
58
+ * Every wording `jev_policy_guardrail.py`'s `BLOCKED_MESSAGE` has had.
59
+ * Mirrors `terminal/src/opencode/guardrail.ts`'s own `KNOWN_BLOCKED_
60
+ * MESSAGES` deliberately rather than importing it (different package,
61
+ * different runtime — this one ships inside the OpenCode plugin host, that
62
+ * one inside the Web Chat bundle). Matched by substring for the same reason
63
+ * that file matches by substring: a message blocked under an older backend
64
+ * wording still carries that exact text in session history forever, and
65
+ * this must keep recognizing it as blocked rather than start leaking old
66
+ * blocked prompts into model context the moment the backend's wording next
67
+ * changes. Keep all three lists (here, guardrail.ts, and
68
+ * jev_policy_guardrail.py's own BLOCKED_MESSAGE) in sync.
69
+ */
70
+ const KNOWN_BLOCKED_MESSAGES = [
71
+ "Guardrail raised an exception, Guardrail: jev-policy, Message: Blocked by policy:",
72
+ "Request blocked by EAI's security policy. Please revise your request and try again.",
73
+ "This message couldn't be sent because it may violate our usage policy. Try rephrasing it, or ask something else.",
74
+ // The four stage/outcome-distinguishing wordings (Jev audit, 2026-10) --
75
+ // this file only ever needs flat yes/no detection (strip from model
76
+ // context, or don't), never which specific kind, so all four are listed
77
+ // here without the `BlockKind` tagging `terminal/src/opencode/
78
+ // guardrail.ts`'s own copy carries for its UI. Mirrors
79
+ // `jev_policy_logic.py`'s four `MESSAGE_*` constants exactly --
80
+ // `opencode-plugin/test/jev-blocked-message-parity.test.ts` pins it.
81
+ "This message was blocked by EAI's usage policy. Please rephrase your request and try again.",
82
+ "EAI couldn't verify this message against its usage policy in time. Please try sending it again.",
83
+ "EAI's usage policy blocked this response before it reached you. Try rephrasing your request, or ask something else.",
84
+ "EAI generated a response but couldn't verify it against its usage policy in time. Please try again.",
85
+ ]
86
+
87
+ interface MessageError {
88
+ name?: unknown
89
+ data?: { message?: unknown }
90
+ }
91
+
92
+ interface HistoryEntry {
93
+ info?: { role?: unknown; error?: unknown; [k: string]: unknown }
94
+ parts?: unknown[]
95
+ }
96
+
97
+ function isKnownBlockedText(message: unknown): boolean {
98
+ return typeof message === "string" && KNOWN_BLOCKED_MESSAGES.some((known) => message.includes(known))
99
+ }
100
+
101
+ /** `true` for an assistant turn whose `info.error` is a confirmed Jev
102
+ * guardrail block — never for an ordinary provider error (rate limit,
103
+ * context overflow, a dead connection), which the model legitimately
104
+ * needs to see to make sense of its own earlier failed attempt. */
105
+ export function isBlockedAssistantTurn(info: HistoryEntry["info"]): boolean {
106
+ const error = info?.error as MessageError | undefined
107
+ if (!error || error.name !== "APIError") return false
108
+ return isKnownBlockedText(error.data?.message)
109
+ }
110
+
111
+ /**
112
+ * Removes every blocked exchange (the blocked assistant turn AND the user
113
+ * turn immediately before it) from a session's message history. Returns the
114
+ * SAME array instance when nothing needed removing — the common case, so a
115
+ * normal, never-blocked session incurs no extra allocation on every turn.
116
+ *
117
+ * Walks backward from each blocked assistant turn to find its own user
118
+ * turn, skipping only turns already claimed by a LATER blocked exchange
119
+ * (so consecutive blocked turns in the same session each still find their
120
+ * own user message rather than double-claiming one). Stops looking the
121
+ * moment it hits another assistant turn first — an unexpected shape (two
122
+ * assistant turns with no user turn between them) is left alone rather
123
+ * than guessed at, since removing the wrong turn would be worse than
124
+ * removing none.
125
+ */
126
+ export function stripBlockedTurns<T extends HistoryEntry>(messages: T[]): T[] {
127
+ const drop = new Set<number>()
128
+
129
+ for (let i = 0; i < messages.length; i++) {
130
+ if (messages[i]?.info?.role !== "assistant" || !isBlockedAssistantTurn(messages[i].info)) continue
131
+ drop.add(i)
132
+ for (let j = i - 1; j >= 0; j--) {
133
+ if (drop.has(j)) continue
134
+ const role = messages[j]?.info?.role
135
+ if (role === "user") {
136
+ drop.add(j)
137
+ break
138
+ }
139
+ if (role === "assistant") break
140
+ }
141
+ }
142
+
143
+ if (drop.size === 0) return messages
144
+ return messages.filter((_, index) => !drop.has(index))
145
+ }