@elliotsystems/eai 0.1.3 → 0.1.4
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 +9 -50
- package/bin/eai +21 -0
- package/lib/resolve.js +9 -15
- package/lib/setup.js +231 -0
- package/package.json +1 -4
- package/plugin/opencode-plugin/src/identity.ts +69 -0
- package/plugin/opencode-plugin/src/index.ts +12 -0
package/README.md
CHANGED
|
@@ -31,16 +31,11 @@ In precedence order (`lib/resolve.js`):
|
|
|
31
31
|
| # | Source | Path |
|
|
32
32
|
|---|---|---|
|
|
33
33
|
| 1 | `ELLIOT_OPENCODE_BIN` | the binary itself |
|
|
34
|
-
| 2 |
|
|
35
|
-
| 3 |
|
|
36
|
-
| 4 |
|
|
37
|
-
| 5 |
|
|
38
|
-
| 6 |
|
|
39
|
-
| 7 | sibling fork checkout | `../../opencode/…` — **development only**, last resort |
|
|
40
|
-
|
|
41
|
-
Stock sits ahead of the Elliot platform packages deliberately. Those are fork
|
|
42
|
-
builds, and preferring them would quietly reintroduce the patched OpenCode this
|
|
43
|
-
architecture exists to remove.
|
|
34
|
+
| 2 | platform npm package | `@elliotsystems/eai-<platform>-<arch>/bin/opencode[.exe]`, via `require.resolve` -- **this is how an installed `eai` finds its runtime** |
|
|
35
|
+
| 3 | bundled runtime | `vendor/opencode-<platform>-<arch>/bin/opencode[.exe]` |
|
|
36
|
+
| 4 | `ELLIOT_OPENCODE_HOME` | `<home>/packages/opencode/dist/…` or `<home>/opencode-<platform>-<arch>/…` |
|
|
37
|
+
| 5 | in-repo fork checkout | `../opencode/packages/opencode/dist/…` -- **development only** |
|
|
38
|
+
| 6 | sibling fork checkout | `../../opencode/packages/opencode/dist/…` -- **development only**, last resort |
|
|
44
39
|
|
|
45
40
|
Two deliberate rules:
|
|
46
41
|
|
|
@@ -67,6 +62,10 @@ CLI keeps working untouched and its retirement stays a separate, later decision.
|
|
|
67
62
|
|
|
68
63
|
Run it locally, without installing, with `node bin/eai`.
|
|
69
64
|
|
|
65
|
+
> This section previously said the package was `private: true` and that publishing
|
|
66
|
+
> was out of scope. It has been published as `@elliotsystems/eai` since 0.1.0; the
|
|
67
|
+
> note was left behind and is corrected here.
|
|
68
|
+
|
|
70
69
|
## Install
|
|
71
70
|
|
|
72
71
|
```
|
|
@@ -105,43 +104,3 @@ To publish a new release to npm:
|
|
|
105
104
|
```
|
|
106
105
|
npm test # node --test, no dependencies
|
|
107
106
|
```
|
|
108
|
-
|
|
109
|
-
## Architecture
|
|
110
|
-
|
|
111
|
-
```
|
|
112
|
-
stock upstream OpenCode (opencode-ai, an npm dependency)
|
|
113
|
-
|
|
|
114
|
-
Elliot launcher (this package)
|
|
115
|
-
|-- config/ OPENCODE_CONFIG_DIR: theme + plugin registration
|
|
116
|
-
|-- config/themes/ the Elliot palette
|
|
117
|
-
|-- plugin/ the bundled Elliot plugin (wordmark, chrome)
|
|
118
|
-
+-- bin/eai OSC 0 terminal title
|
|
119
|
-
|
|
|
120
|
-
eai -> Elliot-branded TUI
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
**OpenCode core modifications: 0.** Elliot ships no compiled OpenCode. An
|
|
124
|
-
upstream upgrade is a version bump of `opencode-ai` in `package.json`.
|
|
125
|
-
|
|
126
|
-
Three mechanisms carry the branding, none of which touch OpenCode source:
|
|
127
|
-
|
|
128
|
-
| Surface | Mechanism |
|
|
129
|
-
|---|---|
|
|
130
|
-
| Wordmark, palette, accent, chrome | the bundled plugin, via `home_logo` (`mode="replace"`) and theme discovery |
|
|
131
|
-
| Theme selection + plugin registration | `OPENCODE_CONFIG_DIR` -> this package's `config/` |
|
|
132
|
-
| Terminal title | `OPENCODE_DISABLE_TERMINAL_TITLE=1`, then the launcher writes OSC 0 |
|
|
133
|
-
|
|
134
|
-
### Why the plugin is bundled rather than depended on
|
|
135
|
-
|
|
136
|
-
`@elliotsystems/opencode-plugin` is not published to npm. A config that named it
|
|
137
|
-
by package name would install cleanly and then render as plain OpenCode, which
|
|
138
|
-
is the worst failure mode available -- it looks like it worked. `scripts/bundle.js`
|
|
139
|
-
copies it in from `opencode-plugin/` (the single source of truth) and runs from
|
|
140
|
-
`prepack`, so `npm pack` and `npm publish` cannot forget it.
|
|
141
|
-
|
|
142
|
-
### Why the theme file is shipped as well
|
|
143
|
-
|
|
144
|
-
The plugin can install the palette itself via `api.theme.install()`, but that
|
|
145
|
-
WRITES into the config directory, and a global npm install directory is not
|
|
146
|
-
reliably writable. Shipping `config/themes/elliot.json` means the theme resolves
|
|
147
|
-
read-only.
|
package/bin/eai
CHANGED
|
@@ -26,6 +26,7 @@ const {
|
|
|
26
26
|
} = require("../lib/resolve.js")
|
|
27
27
|
const { injectToken } = require("../lib/token-env.js")
|
|
28
28
|
const { stageOverlay } = require("../lib/stage.js")
|
|
29
|
+
const { runSetup, parseArgs } = require("../lib/setup.js")
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Resolve symlinks before deriving the package root.
|
|
@@ -63,6 +64,26 @@ function fail(message) {
|
|
|
63
64
|
process.exit(127) // conventional "command not found"
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// `eai setup` is handled here and never reaches the runtime.
|
|
68
|
+
//
|
|
69
|
+
// It is intercepted BEFORE the runtime is resolved, because setup is what a
|
|
70
|
+
// machine runs when nothing is configured yet -- failing with "could not locate
|
|
71
|
+
// the OpenCode runtime" would be a confusing way to refuse to configure it.
|
|
72
|
+
// `setup` is not an OpenCode command, so nothing is shadowed.
|
|
73
|
+
//
|
|
74
|
+
// This exists so `eai` is the only CLI to install. Onboarding used to hand out
|
|
75
|
+
// a second global package whose whole job was writing two config files, and a
|
|
76
|
+
// user who ran only the first install got a CLI with no backend and no token.
|
|
77
|
+
if (process.argv[2] === "setup") {
|
|
78
|
+
const options = parseArgs(process.argv.slice(3))
|
|
79
|
+
runSetup(options).catch((error) => {
|
|
80
|
+
process.stderr.write(`eai setup: ${error.message}
|
|
81
|
+
`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
})
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
const resolved = resolveOpencodeBin({ packageRoot: PACKAGE_ROOT })
|
|
67
88
|
|
|
68
89
|
if (!resolved) {
|
package/lib/resolve.js
CHANGED
|
@@ -87,20 +87,14 @@ function candidates(opts) {
|
|
|
87
87
|
// opencode-ai not installed.
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 4. Bundled runtime — the shape a published/installed Elliot package will
|
|
101
|
-
// have. Nothing populates this yet; packaging is a later, approved phase.
|
|
102
|
-
out.push({ source: "bundled vendor runtime", path: path.join(root, "vendor", dir, "bin", binary) })
|
|
103
|
-
|
|
90
|
+
// The fork's per-platform packages (@elliotsystems/eai-<os>-<arch>) and the
|
|
91
|
+
// `vendor/` directory used to sit here. Both shipped a fork-built OpenCode;
|
|
92
|
+
// the runtime now comes from upstream's own opencode-ai above, the packages
|
|
93
|
+
// are being retired on npm, and nothing populates `vendor/` since the script
|
|
94
|
+
// that did was removed. Kept as a note rather than dead branches so nobody
|
|
95
|
+
// re-adds them wondering why stock is the only source.
|
|
96
|
+
|
|
97
|
+
// 3. A configured fork location. Accepts either a checkout root or a dist
|
|
104
98
|
// 5. A configured fork location. Accepts either a checkout root or a dist
|
|
105
99
|
// root, because both are things a person reasonably points this at.
|
|
106
100
|
if (env.ELLIOT_OPENCODE_HOME) {
|
|
@@ -112,7 +106,7 @@ function candidates(opts) {
|
|
|
112
106
|
out.push({ source: "ELLIOT_OPENCODE_HOME (dist)", path: path.join(home, dir, "bin", binary) })
|
|
113
107
|
}
|
|
114
108
|
|
|
115
|
-
//
|
|
109
|
+
// 4. Development convenience ONLY: the fork lives in this repo at `opencode/`
|
|
116
110
|
// (subtree), so from the launcher package root that is one level up. Kept
|
|
117
111
|
// explicitly last before PATH so a dev build can never silently win over an
|
|
118
112
|
// override or a packaged runtime.
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `eai setup` — configure this machine against an Elliot backend.
|
|
5
|
+
*
|
|
6
|
+
* This is a port of `elliot-ai setup`, so that `eai` is the ONLY CLI a user has
|
|
7
|
+
* to install. Onboarding previously handed out two global installs plus a
|
|
8
|
+
* second binary whose entire job was writing two files; a user who ran only the
|
|
9
|
+
* first got a CLI with no backend and no token, and nothing said why.
|
|
10
|
+
*
|
|
11
|
+
* It writes exactly what the old command wrote, because the launcher and the
|
|
12
|
+
* TUI already read those files and neither should have to care which CLI
|
|
13
|
+
* produced them:
|
|
14
|
+
*
|
|
15
|
+
* ~/.elliot/config.json the org config plus the token the launcher
|
|
16
|
+
* copies into ELLIOT_TOKEN on every run.
|
|
17
|
+
* Mode 0600 in a 0700 directory: it holds a
|
|
18
|
+
* bearer token and a refresh token.
|
|
19
|
+
* ~/.config/opencode/opencode.json the `elliot` provider, merged into
|
|
20
|
+
* whatever else is already in that file.
|
|
21
|
+
*
|
|
22
|
+
* Two behaviours are deliberately preserved rather than simplified:
|
|
23
|
+
*
|
|
24
|
+
* - The pasted browser token is EXCHANGED at `/auth/cli-token` for a
|
|
25
|
+
* longer-lived credential with a refresh token. Storing the pasted token
|
|
26
|
+
* directly would work for an hour and then start failing silently.
|
|
27
|
+
* - The exchange is best-effort. If the endpoint is missing or errors, setup
|
|
28
|
+
* continues with the pasted token and says so; a backend that predates the
|
|
29
|
+
* endpoint should still be configurable.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require("fs")
|
|
33
|
+
const os = require("os")
|
|
34
|
+
const path = require("path")
|
|
35
|
+
|
|
36
|
+
const PROVIDER_ID = "elliot"
|
|
37
|
+
const MODEL_ID = "elliot-governed"
|
|
38
|
+
|
|
39
|
+
/** Matches OpenCode's own resolution (xdg-basedir, whose fallback is ~/.config everywhere). */
|
|
40
|
+
function opencodeConfigDir(env = process.env) {
|
|
41
|
+
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
42
|
+
return path.join(base, "opencode")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function elliotConfigPath() {
|
|
46
|
+
return path.join(os.homedir(), ".elliot", "config.json")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode a JWT payload without verifying it.
|
|
51
|
+
*
|
|
52
|
+
* Verification is the backend's job — every token here is sent straight back to
|
|
53
|
+
* it and rejected if bad. This only reads `tenant_id` so setup can address the
|
|
54
|
+
* right config, so a malformed token must return undefined rather than throw.
|
|
55
|
+
*/
|
|
56
|
+
function decodeJwtPayload(token) {
|
|
57
|
+
try {
|
|
58
|
+
const part = String(token).split(".")[1]
|
|
59
|
+
if (!part) return undefined
|
|
60
|
+
const padded = part.replace(/-/g, "+").replace(/_/g, "/")
|
|
61
|
+
const json = Buffer.from(padded + "=".repeat((4 - (padded.length % 4)) % 4), "base64").toString("utf8")
|
|
62
|
+
const parsed = JSON.parse(json)
|
|
63
|
+
return parsed && typeof parsed === "object" ? parsed : undefined
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The `provider.elliot` block.
|
|
71
|
+
*
|
|
72
|
+
* `baseURL` ends in `/v1` on purpose: @ai-sdk/anthropic appends `/messages`,
|
|
73
|
+
* and the route is `/llm-proxy/v1/messages`. Dropping the `/v1` yields a 404.
|
|
74
|
+
*
|
|
75
|
+
* `apiKey` is the literal string `{env:ELLIOT_TOKEN}`, not the token. OpenCode
|
|
76
|
+
* substitutes it at config-load time and the launcher sets ELLIOT_TOKEN from
|
|
77
|
+
* ~/.elliot/config.json on every run, which keeps that file the single source
|
|
78
|
+
* of truth for an hourly-expiring credential — a refresh there is picked up
|
|
79
|
+
* without rewriting this one.
|
|
80
|
+
*/
|
|
81
|
+
function elliotProvider(backendUrl) {
|
|
82
|
+
const base = String(backendUrl).replace(/\/+$/, "")
|
|
83
|
+
return {
|
|
84
|
+
npm: "@ai-sdk/anthropic",
|
|
85
|
+
name: "Elliot (LightLLM proxy)",
|
|
86
|
+
options: { baseURL: `${base}/llm-proxy/v1`, apiKey: "{env:ELLIOT_TOKEN}" },
|
|
87
|
+
models: { [MODEL_ID]: { name: "Elliot Governed" } },
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Merge the provider into the user's opencode.json, preserving every other key.
|
|
93
|
+
*
|
|
94
|
+
* `enabled_providers` pins OpenCode to this one provider: `eai` is
|
|
95
|
+
* governed-only, so there must be no direct-to-vendor path. `model` is only set
|
|
96
|
+
* when absent, so a user who picked a model keeps it.
|
|
97
|
+
*/
|
|
98
|
+
function writeOpencodeConfig(backendUrl, env = process.env) {
|
|
99
|
+
const dir = opencodeConfigDir(env)
|
|
100
|
+
const file = path.join(dir, "opencode.json")
|
|
101
|
+
let existing = {}
|
|
102
|
+
try {
|
|
103
|
+
existing = JSON.parse(fs.readFileSync(file, "utf8")) || {}
|
|
104
|
+
} catch {
|
|
105
|
+
// Absent or unparseable. Overwriting unparseable JSON is the lesser evil:
|
|
106
|
+
// OpenCode would refuse to start on it anyway.
|
|
107
|
+
}
|
|
108
|
+
const next = {
|
|
109
|
+
...existing,
|
|
110
|
+
$schema: existing.$schema || "https://opencode.ai/config.json",
|
|
111
|
+
provider: { ...(existing.provider || {}), [PROVIDER_ID]: elliotProvider(backendUrl) },
|
|
112
|
+
enabled_providers: [PROVIDER_ID],
|
|
113
|
+
model: existing.model || `${PROVIDER_ID}/${MODEL_ID}`,
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
116
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
|
|
117
|
+
return file
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Owner-only: this file holds a bearer token and a refresh token. */
|
|
121
|
+
function writeElliotConfig(config) {
|
|
122
|
+
const file = elliotConfigPath()
|
|
123
|
+
const dir = path.dirname(file)
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
125
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 })
|
|
126
|
+
try {
|
|
127
|
+
fs.chmodSync(dir, 0o700)
|
|
128
|
+
} catch {
|
|
129
|
+
// Windows and some mounts do not honour POSIX modes; not worth failing over.
|
|
130
|
+
}
|
|
131
|
+
return file
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function exchangeForCliToken(backendUrl, token) {
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(`${backendUrl}/auth/cli-token`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
139
|
+
})
|
|
140
|
+
if (!res.ok) return undefined
|
|
141
|
+
const body = await res.json()
|
|
142
|
+
if (!body || typeof body.access_token !== "string") return undefined
|
|
143
|
+
return {
|
|
144
|
+
access_token: body.access_token,
|
|
145
|
+
refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
|
|
146
|
+
expires_at: typeof body.access_token_expires_at === "string" ? body.access_token_expires_at : undefined,
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Offline, or a backend that predates the endpoint.
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function fetchOnboardingConfig(backendUrl, tenantId, token) {
|
|
155
|
+
const res = await fetch(`${backendUrl}/onboarding/config/${tenantId}`, {
|
|
156
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
157
|
+
})
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(`backend returned ${res.status} fetching the organisation config`)
|
|
160
|
+
}
|
|
161
|
+
return (await res.json()) || {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {{token: string, backendUrl: string, tenantId?: string, log?: (s: string) => void}} opts
|
|
166
|
+
*/
|
|
167
|
+
async function runSetup(opts) {
|
|
168
|
+
const log = opts.log || ((s) => process.stdout.write(s + "\n"))
|
|
169
|
+
const backendUrl = String(opts.backendUrl || "").replace(/\/+$/, "")
|
|
170
|
+
if (!opts.token) throw new Error("a --token is required; copy the command from the Elliot portal")
|
|
171
|
+
if (!backendUrl) throw new Error("a --backend-url is required")
|
|
172
|
+
|
|
173
|
+
const claims = decodeJwtPayload(opts.token) || {}
|
|
174
|
+
const tenantId = opts.tenantId || claims.tenant_id
|
|
175
|
+
if (!tenantId) {
|
|
176
|
+
throw new Error("no tenant_id in the token; pass --tenant-id, or copy a fresh command from the portal")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const exchanged = await exchangeForCliToken(backendUrl, opts.token)
|
|
180
|
+
if (!exchanged) {
|
|
181
|
+
log("Note: could not exchange for a CLI token — continuing with the pasted token (silent refresh unavailable).")
|
|
182
|
+
}
|
|
183
|
+
const bearer = exchanged ? exchanged.access_token : opts.token
|
|
184
|
+
|
|
185
|
+
log("Fetching configuration from backend...")
|
|
186
|
+
const orgConfig = await fetchOnboardingConfig(backendUrl, tenantId, bearer)
|
|
187
|
+
|
|
188
|
+
const elliotConfig = {
|
|
189
|
+
...orgConfig,
|
|
190
|
+
backend_url: orgConfig.backend_url || backendUrl,
|
|
191
|
+
tenant_id: tenantId,
|
|
192
|
+
jwt_token: bearer,
|
|
193
|
+
...(exchanged && exchanged.refresh_token ? { refresh_token: exchanged.refresh_token } : {}),
|
|
194
|
+
...(exchanged && exchanged.expires_at ? { access_token_expires_at: exchanged.expires_at } : {}),
|
|
195
|
+
configured_at: new Date().toISOString(),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const elliotFile = writeElliotConfig(elliotConfig)
|
|
199
|
+
const opencodeFile = writeOpencodeConfig(elliotConfig.backend_url)
|
|
200
|
+
|
|
201
|
+
log(`Wrote ${elliotFile}`)
|
|
202
|
+
log(`Wrote ${opencodeFile}`)
|
|
203
|
+
log("")
|
|
204
|
+
log("Setup complete. Run `eai` from any directory.")
|
|
205
|
+
return { elliotFile, opencodeFile, tenantId, exchanged: Boolean(exchanged) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Minimal `--flag value` parsing; the launcher has no dependencies by design. */
|
|
209
|
+
function parseArgs(argv) {
|
|
210
|
+
const out = {}
|
|
211
|
+
for (let i = 0; i < argv.length; i++) {
|
|
212
|
+
const arg = argv[i]
|
|
213
|
+
if (arg === "--token") out.token = argv[++i]
|
|
214
|
+
else if (arg === "--backend-url") out.backendUrl = argv[++i]
|
|
215
|
+
else if (arg === "--tenant-id") out.tenantId = argv[++i]
|
|
216
|
+
}
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
runSetup,
|
|
222
|
+
parseArgs,
|
|
223
|
+
decodeJwtPayload,
|
|
224
|
+
elliotProvider,
|
|
225
|
+
writeOpencodeConfig,
|
|
226
|
+
writeElliotConfig,
|
|
227
|
+
opencodeConfigDir,
|
|
228
|
+
elliotConfigPath,
|
|
229
|
+
PROVIDER_ID,
|
|
230
|
+
MODEL_ID,
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elliotsystems/eai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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"
|
|
@@ -14,10 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"test": "node --test test/*.test.js",
|
|
17
|
-
"vendor": "node scripts/vendor.js",
|
|
18
|
-
"assemble": "node scripts/assemble.js",
|
|
19
17
|
"publish:npm": "node scripts/publish.js",
|
|
20
|
-
"build:runtime": "bun run --cwd ../opencode/packages/opencode script/build.ts --skip-embed-web-ui",
|
|
21
18
|
"bundle": "node scripts/bundle.js",
|
|
22
19
|
"prepack": "node scripts/bundle.js",
|
|
23
20
|
"verify": "node scripts/verify-package.js"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make the agent call itself Elliot-AI.
|
|
3
|
+
*
|
|
4
|
+
* The visual layer was branded long before this: wordmark, palette, terminal
|
|
5
|
+
* title. But the agent's own sense of identity lives in the system prompt, and
|
|
6
|
+
* on a stock runtime that prompt opens with "You are OpenCode, ...". So asking
|
|
7
|
+
* the CLI "who are you?" got "I'm opencode" — every surface said Elliot except
|
|
8
|
+
* the one the user actually talks to.
|
|
9
|
+
*
|
|
10
|
+
* Fixed here rather than in the prompt files, because those are compiled into
|
|
11
|
+
* the stock upstream binary and patching them would put Elliot back inside
|
|
12
|
+
* OpenCode. `experimental.chat.system.transform` is upstream's own hook for
|
|
13
|
+
* exactly this (`packages/plugin/src/index.ts`), fired from both
|
|
14
|
+
* `agent/agent.ts` and `session/llm/request.ts`, so the fix stays in the
|
|
15
|
+
* overlay and survives an upstream upgrade.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The product name the assistant should use for itself. */
|
|
19
|
+
export const PRODUCT_NAME = "Elliot-AI"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Matches ONLY the identity clause that opens a system prompt.
|
|
23
|
+
*
|
|
24
|
+
* Anchored to the start and requiring the following comma, because the prompts
|
|
25
|
+
* also contain `https://opencode.ai/docs` and
|
|
26
|
+
* `https://github.com/anomalyco/opencode`. Those are real upstream links: a
|
|
27
|
+
* blanket rename would silently turn working URLs into 404s, and would also
|
|
28
|
+
* corrupt references to `opencode.json` and `.opencode/` that the tool guidance
|
|
29
|
+
* depends on. Eight identity variants exist across the prompt files (`You are
|
|
30
|
+
* OpenCode,` / `You are opencode,`), and all of them share this shape.
|
|
31
|
+
*/
|
|
32
|
+
const IDENTITY = /^You are opencode,/i
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prepended so the model can answer "what is your name" and "who made you"
|
|
36
|
+
* without inferring it from a rewritten sentence halfway down a long prompt.
|
|
37
|
+
*
|
|
38
|
+
* It states only what is true. The underlying model is deliberately not named:
|
|
39
|
+
* that is a provider detail that changes, and guessing it is how an assistant
|
|
40
|
+
* ends up confidently telling a customer something wrong.
|
|
41
|
+
*/
|
|
42
|
+
const IDENTITY_PREAMBLE = [
|
|
43
|
+
`You are ${PRODUCT_NAME}, the coding agent made by Elliot Systems.`,
|
|
44
|
+
`When asked who or what you are, say you are ${PRODUCT_NAME} by Elliot Systems.`,
|
|
45
|
+
`Never refer to yourself as OpenCode.`,
|
|
46
|
+
`OpenCode is the open-source harness you run on, not your identity. If a user`,
|
|
47
|
+
`asks directly what you are built on, you may say you are built on OpenCode.`,
|
|
48
|
+
].join(" ")
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Rewrite the system prompt in place.
|
|
52
|
+
*
|
|
53
|
+
* Idempotent: re-running leaves the result unchanged, which matters because the
|
|
54
|
+
* hook fires per request and on a long session would otherwise stack the
|
|
55
|
+
* preamble dozens of times and eat the context window.
|
|
56
|
+
*/
|
|
57
|
+
export function applyElliotIdentity(system: string[]): string[] {
|
|
58
|
+
if (system.some((entry) => entry.startsWith(IDENTITY_PREAMBLE))) return system
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < system.length; i++) {
|
|
61
|
+
const entry = system[i]
|
|
62
|
+
if (typeof entry === "string" && IDENTITY.test(entry)) {
|
|
63
|
+
system[i] = entry.replace(IDENTITY, `You are ${PRODUCT_NAME},`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
system.unshift(IDENTITY_PREAMBLE)
|
|
68
|
+
return system
|
|
69
|
+
}
|
|
@@ -23,6 +23,7 @@ import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
|
|
|
23
23
|
import { ELLIOT_PROVIDER_ID, configHook, makeAuthHook } from "./proxy-provider.js"
|
|
24
24
|
import { readElliotConfig } from "./elliot-auth.js"
|
|
25
25
|
import { formatScope, scopeForWorktree } from "./scope.js"
|
|
26
|
+
import { applyElliotIdentity } from "./identity.js"
|
|
26
27
|
|
|
27
28
|
// Structural mirrors of the host types (packages/plugin/src/index.ts @
|
|
28
29
|
// v1.18.19). Declared locally so this package type-checks and unit-tests
|
|
@@ -66,6 +67,10 @@ export interface Hooks {
|
|
|
66
67
|
input: PermissionInput,
|
|
67
68
|
output: { status: "ask" | "deny" | "allow" },
|
|
68
69
|
) => Promise<void>
|
|
70
|
+
"experimental.chat.system.transform"?: (
|
|
71
|
+
input: { sessionID?: string; model: unknown },
|
|
72
|
+
output: { system: string[] },
|
|
73
|
+
) => Promise<void>
|
|
69
74
|
config?: (input: any) => Promise<void>
|
|
70
75
|
auth?: AuthHook
|
|
71
76
|
dispose?: () => Promise<void>
|
|
@@ -165,6 +170,13 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
|
|
|
165
170
|
* thrown tool error — so this is the forward-compatible home. It can only ever
|
|
166
171
|
* tighten.
|
|
167
172
|
*/
|
|
173
|
+
// The agent introduces itself as OpenCode on a stock runtime, because that
|
|
174
|
+
// is what the compiled system prompt says. Rewritten here so the fix lives
|
|
175
|
+
// in the overlay instead of in the harness. See ./identity.ts.
|
|
176
|
+
"experimental.chat.system.transform": async (_hookInput, output) => {
|
|
177
|
+
applyElliotIdentity(output.system)
|
|
178
|
+
},
|
|
179
|
+
|
|
168
180
|
"permission.ask": async (permInput, output) => {
|
|
169
181
|
if (permInput.type !== "bash") return
|
|
170
182
|
const command = permInput.metadata?.["command"]
|