@elliotsystems/eai 0.1.5 → 0.1.9

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/lib/stage.js CHANGED
@@ -29,6 +29,35 @@ const fs = require("fs")
29
29
  const os = require("os")
30
30
  const path = require("path")
31
31
 
32
+ /**
33
+ * The LiteLLM MCP gateway's alias, from `~/.elliot/config.json`'s
34
+ * `gateway_alias` field -- `eai setup` writes it there straight out of
35
+ * `/onboarding/config/{tenant_id}`'s own `gateway_alias`, which is
36
+ * `litellm_mcp_server_alias` (default `"elliot"`) read server-side.
37
+ *
38
+ * REQUIRED by the plugin's own options schema
39
+ * (`opencode-plugin/src/policy.ts`), with no default there on purpose: it
40
+ * used to be a hardcoded literal in the plugin itself, a fourth copy of a
41
+ * value that already lived in the backend settings, the backend's own
42
+ * namespace module and the LiteLLM YAML -- and it went stale exactly once,
43
+ * silently. This file must not reintroduce that by guessing `"elliot"` when
44
+ * the config is missing it; `undefined` here means "do not configure the
45
+ * plugin with a value nobody actually set", not "assume the default".
46
+ *
47
+ * Best-effort, like `token-env.js::injectToken` right next to it in the same
48
+ * launch path: a missing, unreadable or pre-this-field config must not block
49
+ * startup over branding.
50
+ */
51
+ function readGatewayAlias() {
52
+ try {
53
+ const configPath = path.join(os.homedir(), ".elliot", "config.json")
54
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"))
55
+ return typeof config.gateway_alias === "string" && config.gateway_alias ? config.gateway_alias : undefined
56
+ } catch {
57
+ return undefined
58
+ }
59
+ }
60
+
32
61
  /**
33
62
  * Where the staged overlay lives.
34
63
  *
@@ -104,6 +133,12 @@ function stageOverlay(opts) {
104
133
  const packageRoot = opts.packageRoot
105
134
  const version = opts.version
106
135
  const env = opts.env || process.env
136
+ // Resolved by the CALLER's choice of source (bin/eai reads it from
137
+ // ~/.elliot/config.json; tests pass one directly) rather than read again in
138
+ // here, so there is exactly one place that decides where this value comes
139
+ // from. See readGatewayAlias's own docstring for why `undefined` must not
140
+ // become a guessed `"elliot"`.
141
+ const gatewayAlias = opts.gatewayAlias
107
142
 
108
143
  const pluginSource = path.join(packageRoot, "plugin", "opencode-plugin")
109
144
  const themeSource = path.join(packageRoot, "config", "themes")
@@ -114,18 +149,36 @@ function stageOverlay(opts) {
114
149
  const pluginDir = path.join(root, "plugin", "opencode-plugin")
115
150
 
116
151
  try {
117
- // `.staged` records the version AND a content fingerprint of the source
118
- // this tree was built from. Re-staging on every launch would add hundreds
119
- // of file copies to startup; keying on the version alone would skip
120
- // re-staging when the contents changed underneath it (see `fingerprint`).
121
- const stamp = `${version} ${fingerprint(pluginSource)}`
152
+ // `.staged` records the version, a content fingerprint of the source this
153
+ // tree was built from, AND the gateway alias baked into the written
154
+ // config -- re-staging on every launch would add hundreds of file copies
155
+ // to startup, but skipping it on an alias change would leave a machine
156
+ // running `eai setup` against a renamed gateway alias (or configuring it
157
+ // for the first time) stuck on the stale tui.json/opencode.json until
158
+ // something else invalidated the stamp.
159
+ const stamp = `${version} ${fingerprint(pluginSource)} ${gatewayAlias || ""}`.trim()
122
160
  const done = fs.existsSync(marker) && fs.readFileSync(marker, "utf8").trim() === stamp
123
161
  if (!done) {
124
162
  fs.rmSync(root, { recursive: true, force: true })
125
163
  copyDir(pluginSource, pluginDir)
126
164
  if (fs.existsSync(themeSource)) copyDir(themeSource, path.join(root, "themes"))
127
165
 
128
- const plugin = [pluginDir]
166
+ // The plugin's own OptionsSchema makes `gatewayAlias` REQUIRED with no
167
+ // default (`opencode-plugin/src/policy.ts`) -- a plugin spec with no
168
+ // options tuple here, the bare-path form `[pluginDir]`, fails that
169
+ // validation on every launch. That was this file's actual behaviour
170
+ // until the incident this comment documents: every bit of TUI branding
171
+ // the plugin provides (the "Elliot AI" footer, the knowledge panel)
172
+ // silently never rendered, logged but never surfaced.
173
+ //
174
+ // With no alias available (a config from before this field existed, or
175
+ // `eai setup` never run against an updated backend), the plugin is left
176
+ // OUT of the array entirely rather than included in a form guaranteed
177
+ // to fail — "plain OpenCode chrome" is the same visible outcome either
178
+ // way, but without logging a load error on every single launch.
179
+ const plugin = gatewayAlias
180
+ ? [[pluginDir, { serverNames: ["elliot"], gatewayAlias }]]
181
+ : []
129
182
  fs.writeFileSync(
130
183
  path.join(root, "tui.json"),
131
184
  JSON.stringify(
@@ -154,4 +207,4 @@ function stageOverlay(opts) {
154
207
  }
155
208
  }
156
209
 
157
- module.exports = { stageOverlay, stageRoot }
210
+ module.exports = { stageOverlay, stageRoot, readGatewayAlias }
package/lib/status.js CHANGED
@@ -24,6 +24,7 @@
24
24
  const fs = require("fs")
25
25
 
26
26
  const { elliotConfigPath } = require("./setup.js")
27
+ const { billingContext } = require("./context.js")
27
28
 
28
29
  /** Read ~/.elliot/config.json, or null when absent/unreadable. */
29
30
  function readElliotConfig() {
@@ -107,6 +108,7 @@ async function runStatus(opts = {}) {
107
108
  log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
108
109
  if (config.stack) log(`Stack: ${config.stack}`)
109
110
  log(`Backend: ${config.backend_url || "(unset)"}`)
111
+ log(`Billing: ${billingContext(config).label}`)
110
112
 
111
113
  const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
112
114
  log(`Reachable: ${healthy ? "yes" : "no"}`)
package/lib/teams.js ADDED
@@ -0,0 +1,233 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai teams` — list, select, verify and clear the team billing context.
5
+ *
6
+ * Approved model (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §4/§6):
7
+ * the BACKEND is the only authority for membership and for the budgeted
8
+ * credential. These commands never trust a client-supplied team id as proof
9
+ * of membership — `GET /teams` returns only the caller's authorized teams,
10
+ * and `teams use`/`teams clear` re-fetch `/onboarding/config` through the
11
+ * exact provisioning path `eai setup` uses, so the materialised `opencode.json`
12
+ * carries the correct team (or personal) budgeted LiteLLM key. Fail-closed:
13
+ * a revoked membership, unknown/ambiguous team, or missing budgeted
14
+ * credential aborts with an actionable message and no config change.
15
+ *
16
+ * Switching teams rematerialises configuration and requires a FRESH `eai`
17
+ * session — OpenCode cannot swap the embedded key mid-session, so in-flight
18
+ * requests keep their original billing context (audit §3, decision 3).
19
+ */
20
+
21
+ const { ensureFreshAccessToken, authorizedGet } = require("./auth.js")
22
+ const { runSetup } = require("./setup.js")
23
+
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
25
+
26
+ /** Unwrap a /teams response whether the backend returns a bare array or an
27
+ * object with a `teams` key. */
28
+ function teamsFromBody(body) {
29
+ if (Array.isArray(body)) return body
30
+ if (body && Array.isArray(body.teams)) return body.teams
31
+ return []
32
+ }
33
+
34
+ /** Fetch the caller's authorized teams (empty on failure). */
35
+ async function fetchTeams(opts = {}) {
36
+ const session = await ensureFreshAccessToken(opts)
37
+ if (!session) return null
38
+ const { config } = session
39
+ if (!config.tenant_id) {
40
+ opts.log && opts.log("Your login has no tenant. Run `eai login` again.")
41
+ return null
42
+ }
43
+ const result = await authorizedGet(
44
+ `/teams?tenant_id=${encodeURIComponent(config.tenant_id)}`,
45
+ opts,
46
+ )
47
+ if (!result || !result.ok) return []
48
+ return teamsFromBody(result.body)
49
+ }
50
+
51
+ async function runTeamsList(opts = {}) {
52
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
53
+ const teams = await fetchTeams(opts)
54
+ if (teams === null) return 1
55
+ if (!teams.length) {
56
+ log("No teams are available to you yet.")
57
+ log("Ask an administrator to add you to a team, or run `eai` to use individual billing.")
58
+ return 0
59
+ }
60
+ log("Your teams:")
61
+ for (const team of teams) {
62
+ const name = team.name || "(unnamed)"
63
+ const budget =
64
+ typeof team.max_budget_usd === "number" && team.max_budget_usd > 0
65
+ ? ` — budget $${team.max_budget_usd}/day`
66
+ : ""
67
+ log(` ${name}${budget} (${team.id})`)
68
+ }
69
+ log("")
70
+ log('Select one with: eai teams use "<team name>"')
71
+ log("Show the active context with: eai teams current")
72
+ return 0
73
+ }
74
+
75
+ /** Resolve a `teams use` argument to a single team id, or null (with a
76
+ * message) when it is unknown or ambiguous. */
77
+ function resolveTeamId(teams, arg) {
78
+ if (UUID_RE.test(arg)) {
79
+ const match = teams.find((t) => String(t.id) === arg)
80
+ if (match) return { id: String(match.id), name: match.name }
81
+ return null
82
+ }
83
+ const matches = teams.filter((t) => String(t.name) === arg)
84
+ if (matches.length === 1) {
85
+ return { id: String(matches[0].id), name: matches[0].name }
86
+ }
87
+ if (matches.length > 1) {
88
+ const list = matches.map((t) => ` ${t.name} (${t.id})`).join("\n")
89
+ return {
90
+ ambiguous: true,
91
+ message:
92
+ `Multiple teams are named "${arg}". Use the id instead:\n${list}\n` +
93
+ ' eai teams use "<id>"',
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ async function runTeamsUse(opts = {}) {
100
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
101
+ const arg = String(opts.team || "").trim()
102
+ if (!arg) {
103
+ log('Usage: eai teams use "<team name or id>"')
104
+ return 1
105
+ }
106
+ const session = await ensureFreshAccessToken(opts)
107
+ if (!session) return 1
108
+ const { token, config } = session
109
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
110
+ if (!backendUrl) {
111
+ log("No backend configured. Run `eai login` first.")
112
+ return 1
113
+ }
114
+
115
+ const teams = await fetchTeams(opts)
116
+ if (teams === null) return 1
117
+ const resolved = resolveTeamId(teams, arg)
118
+ if (!resolved) {
119
+ log(`You are not a member of a team named or id'd "${arg}".`)
120
+ log("Run `eai teams` to see the teams available to you.")
121
+ return 1
122
+ }
123
+ if (resolved.ambiguous) {
124
+ log(resolved.message)
125
+ return 1
126
+ }
127
+
128
+ // Rematerialise through the exact setup path: the backend validates
129
+ // membership, resolves the TEAM's budgeted LiteLLM key (fail-closed if
130
+ // none resolves), and writes the config + opencode.json. A failure here
131
+ // leaves the previous config untouched.
132
+ log(`Selecting team "${resolved.name}"...`)
133
+ try {
134
+ await runSetup({ token, backendUrl, teamId: resolved.id, log })
135
+ } catch (error) {
136
+ log(String(error && error.message ? error.message : error))
137
+ log("Team selection did not change. Your previous billing context is unchanged.")
138
+ return 1
139
+ }
140
+ log("")
141
+ log(`Billing team set to "${resolved.name}".`)
142
+ log("Restart EAI (`eai`) to apply the new context. In-flight work is unaffected.")
143
+ return 0
144
+ }
145
+
146
+ async function runTeamsCurrent(opts = {}) {
147
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
148
+ const session = await ensureFreshAccessToken(opts)
149
+ if (!session) return 1
150
+ const { config } = session
151
+
152
+ const displayUser = config.email || config.user_id || "(unknown)"
153
+ const teamId = config.team_id
154
+ const isTeam = Boolean(teamId)
155
+
156
+ // Verify server-side, not from local config alone: resolve the team from
157
+ // the authorized list (revoked membership => 404/absent => unverified).
158
+ let teamName = null
159
+ let verified = false
160
+ const teams = await fetchTeams(opts)
161
+ if (teams) {
162
+ if (isTeam) {
163
+ const team = teams.find((t) => String(t.id) === String(teamId))
164
+ if (team) {
165
+ teamName = team.name
166
+ verified = true
167
+ }
168
+ } else {
169
+ // Individual billing: verified as long as we can fetch the authorized
170
+ // list (i.e. the session is live and the caller is an identified user).
171
+ verified = true
172
+ }
173
+ }
174
+
175
+ log("Elliot AI context")
176
+ log("─────────────────────────────")
177
+ log(`Signed in: ${displayUser}`)
178
+ if (isTeam && teamName) {
179
+ log(`Billing: Team — ${teamName}`)
180
+ } else if (isTeam && !teamName) {
181
+ log("Billing: Team (name unavailable)")
182
+ } else {
183
+ log("Billing: Individual")
184
+ }
185
+ log(`Status: ${verified ? "Verified" : "Unverified — cannot confirm with the backend"}`)
186
+ if (isTeam) {
187
+ log("Action: Restart EAI to apply the current context")
188
+ } else {
189
+ log("Action: none — requests are charged to your individual budget")
190
+ }
191
+ log("")
192
+ return verified ? 0 : 1
193
+ }
194
+
195
+ async function runTeamsClear(opts = {}) {
196
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
197
+ const session = await ensureFreshAccessToken(opts)
198
+ if (!session) return 1
199
+ const { token, config } = session
200
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
201
+ if (!backendUrl) {
202
+ log("No backend configured. Run `eai login` first.")
203
+ return 1
204
+ }
205
+
206
+ log("Clearing team selection — reverting to individual billing...")
207
+ try {
208
+ // No team_id => /onboarding/config resolves the caller's PERSONAL key
209
+ // (fail-closed if none resolves — never the shared master key).
210
+ await runSetup({ token, backendUrl, log })
211
+ } catch (error) {
212
+ log(String(error && error.message ? error.message : error))
213
+ log(
214
+ "Individual billing could not be established. Model execution stays " +
215
+ "blocked until a personal budgeted credential is available. Contact " +
216
+ "your administrator.",
217
+ )
218
+ return 1
219
+ }
220
+ log("")
221
+ log("Team selection cleared — billing is now Individual.")
222
+ log("Restart EAI (`eai`) to apply the new context.")
223
+ return 0
224
+ }
225
+
226
+ module.exports = {
227
+ fetchTeams,
228
+ resolveTeamId,
229
+ runTeamsList,
230
+ runTeamsUse,
231
+ runTeamsCurrent,
232
+ runTeamsClear,
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
4
4
  "description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
5
5
  "bin": {
6
6
  "eai": "./bin/eai"
@@ -27,6 +27,6 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "opencode-ai": "1.18.30"
30
+ "opencode-ai": "1.19.4"
31
31
  }
32
32
  }
@@ -24,7 +24,7 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "elliot": {
27
- "opencodeRange": ">=1.18.19 <1.19.0"
27
+ "opencodeRange": ">=1.18.19 <1.20.0"
28
28
  },
29
29
  "oc-themes": [
30
30
  "themes/elliot.json"
@@ -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
+ }
@@ -50,6 +50,16 @@ export interface ElliotConfig {
50
50
  backend_url?: string
51
51
  litellm_base_url?: string
52
52
  litellm_master_key?: string
53
+ /** The organization this config was written for — present since `eai
54
+ * setup` has always spread the onboarding response (which always
55
+ * includes it) into this file. Typed here (was previously only reachable
56
+ * through the index signature) because guardrail-sync.ts needs it to
57
+ * call `GET /teams/{team_id}` with the right tenant context. */
58
+ tenant_id?: string
59
+ /** The single team this config was scoped to at `eai setup` time — absent
60
+ * for a tenant-wide (owner/admin) setup, which has no one team to check
61
+ * a guardrail for. See guardrail-sync.ts. */
62
+ team_id?: string
53
63
  /**
54
64
  * Every credential this person can use, personal key first (Phase 4).
55
65
  * Absent on a config written by an older `eai setup` (or a backend that