@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.
- package/bin/eai +55 -0
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +152 -23
- package/lib/stage.js +92 -11
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/compaction.ts +600 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +370 -0
- package/plugin/opencode-plugin/src/index.ts +476 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +73 -0
|
@@ -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
|
+
}
|