Extract Discord gateways from main workspace
This commit is contained in:
parent
f350608a93
commit
6cb76d9f4b
58 changed files with 17 additions and 20628 deletions
34
.github/workflows/publish-codex-flows.yml
vendored
34
.github/workflows/publish-codex-flows.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_package:
|
||||
description: "Type a package name to publish, or publish-codex-flows-packages for all"
|
||||
description: "Type @peezy.tech/codex-flows or publish-codex-flows-packages"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
publish:
|
||||
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-flows' || inputs.confirm_package == '@peezy.tech/codex-discord-bridge' || inputs.confirm_package == '@peezy.tech/codex-workspace-voice-gateway'
|
||||
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-flows'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
steps:
|
||||
|
|
@ -59,33 +59,3 @@ jobs:
|
|||
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
npm publish "$tarball" --access public --provenance
|
||||
fi
|
||||
|
||||
- name: Publish @peezy.tech/codex-discord-bridge
|
||||
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-discord-bridge'
|
||||
working-directory: apps/discord-bridge
|
||||
run: |
|
||||
version=$(node -p "require('./package.json').version")
|
||||
if npm view "@peezy.tech/codex-discord-bridge@$version" version --json >/dev/null 2>&1; then
|
||||
echo "@peezy.tech/codex-discord-bridge@$version is already published"
|
||||
else
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
pnpm pack --pack-destination "$tmpdir"
|
||||
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
npm publish "$tarball" --access public --provenance
|
||||
fi
|
||||
|
||||
- name: Publish @peezy.tech/codex-workspace-voice-gateway
|
||||
if: inputs.confirm_package == 'publish-codex-flows-packages' || inputs.confirm_package == '@peezy.tech/codex-workspace-voice-gateway'
|
||||
working-directory: apps/workspace-voice-gateway
|
||||
run: |
|
||||
version=$(node -p "require('./package.json').version")
|
||||
if npm view "@peezy.tech/codex-workspace-voice-gateway@$version" version --json >/dev/null 2>&1; then
|
||||
echo "@peezy.tech/codex-workspace-voice-gateway@$version is already published"
|
||||
else
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
pnpm pack --pack-destination "$tmpdir"
|
||||
tarball=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
npm publish "$tarball" --access public --provenance
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ or docs surface that solves the problem:
|
|||
- `packages/codex-client` owns the public `@peezy.tech/codex-flows` package,
|
||||
app-server clients, generated protocol types, workspace helpers, and bundled
|
||||
bins.
|
||||
- `apps/discord-bridge` and `apps/workspace-voice-gateway` are gateway packages
|
||||
that depend on `@peezy.tech/codex-flows`.
|
||||
- `apps/workspace-backend`, `apps/web`, and `apps/cli` are
|
||||
workspace-local apps that are also bundled into the core package where
|
||||
appropriate.
|
||||
- Discord gateway integrations are extracted from the main monorepo lifecycle.
|
||||
Keep shared runtime surfaces in `@peezy.tech/codex-flows`; channel-specific
|
||||
packages should live in their own repository and depend on the published core
|
||||
package.
|
||||
- `docs/pages` is the canonical user documentation.
|
||||
|
||||
Generated app-server protocol files live under
|
||||
|
|
|
|||
21
RELEASE.md
21
RELEASE.md
|
|
@ -83,27 +83,22 @@ Canonical user-facing package:
|
|||
|
||||
- `@peezy.tech/codex-flows`
|
||||
|
||||
Gateway packages:
|
||||
|
||||
- `@peezy.tech/codex-discord-bridge`
|
||||
- `@peezy.tech/codex-workspace-voice-gateway`
|
||||
|
||||
The GitHub publish workflow checks whether each package version already exists
|
||||
on npm. It publishes new versions and skips versions that are already present.
|
||||
Published packages are packed with `pnpm pack` and then handed to `npm publish`
|
||||
so workspace and catalog dependency specifiers are converted before the npm
|
||||
registry sees the package while GitHub provenance still comes from npm.
|
||||
on npm. It publishes a new `@peezy.tech/codex-flows` version and skips versions
|
||||
that are already present. Published packages are packed with `pnpm pack` and
|
||||
then handed to `npm publish` so workspace and catalog dependency specifiers are
|
||||
converted before the npm registry sees the package while GitHub provenance still
|
||||
comes from npm.
|
||||
Version numbers intentionally track the upstream Codex release line rather than
|
||||
strict semantic-versioning meaning. For example, if the current Codex-aligned
|
||||
line is `0.132.x`, a breaking codex-flows stack release should usually advance
|
||||
to `0.132.1` rather than `0.133.0`. Keep public package versions aligned across
|
||||
the stack.
|
||||
to `0.132.1` rather than `0.133.0`.
|
||||
|
||||
New public core runtime surfaces should be exported through
|
||||
`@peezy.tech/codex-flows` first, including reusable protocol helpers and
|
||||
runnable local backend bins. Product- or channel-specific gateways, such as
|
||||
Discord text or voice packages, should publish separately and depend on
|
||||
`@peezy.tech/codex-flows`.
|
||||
`@peezy.tech/codex-flows` from their own repositories.
|
||||
|
||||
Before publishing:
|
||||
|
||||
|
|
@ -129,6 +124,4 @@ To publish through GitHub trusted publishing:
|
|||
|
||||
```bash
|
||||
npm dist-tag ls @peezy.tech/codex-flows
|
||||
npm dist-tag ls @peezy.tech/codex-discord-bridge
|
||||
npm dist-tag ls @peezy.tech/codex-workspace-voice-gateway
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
# @peezy.tech/codex-discord-bridge
|
||||
|
||||
Long-lived Discord gateway for connecting Discord to Codex app-server threads
|
||||
through `@peezy.tech/codex-flows`.
|
||||
|
||||
```bash
|
||||
pnpm add @peezy.tech/codex-discord-bridge
|
||||
```
|
||||
|
||||
## Workspace Mode
|
||||
|
||||
Workspace mode is opt-in. It keeps one Discord home surface as the primary UX, or
|
||||
several guild-scoped surfaces when multi-guild routing is configured, and one
|
||||
main Codex thread as the operator memory for the workspace. Legacy
|
||||
thread-per-task behavior remains available outside the configured workspace
|
||||
channels.
|
||||
|
||||
Set these environment values before starting the bridge:
|
||||
|
||||
```bash
|
||||
CODEX_DISCORD_HOME_CHANNEL_ID=1502107617512919220
|
||||
CODEX_DISCORD_MAIN_THREAD_ID=019e2509-ddbb-7380-b97b-41575092d86b
|
||||
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
|
||||
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
|
||||
CODEX_DISCORD_ALLOWED_CHANNEL_IDS=1502107617512919220
|
||||
CODEX_DISCORD_DIR=/home/peezy
|
||||
CODEX_DISCORD_HOOK_SPOOL_DIR=/home/peezy/.codex/discord-bridge/stop-hooks
|
||||
```
|
||||
|
||||
Single-surface `.env` configuration remains supported and acts as the default
|
||||
surface. For multiple guilds, define a workspace-owned surface in that
|
||||
workspace's `.codex/workspace.toml`, and keep one bridge process. The bridge
|
||||
checks the resolved `CODEX_DISCORD_DIR` / `--dir` root and each discoverable
|
||||
top-level workspace under it:
|
||||
|
||||
```toml
|
||||
# /home/peezy/crypto-workspace/.codex/workspace.toml
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "crypto"
|
||||
home_channel_id = "1503107617512919220"
|
||||
workspace_forum_channel_id = "1503107617512919221"
|
||||
task_threads_channel_id = "1503107617512919222"
|
||||
```
|
||||
|
||||
Each surface owns its home channel, workspace forum, and task-thread channel.
|
||||
The workspace file does not list workspace paths; the file's containing
|
||||
workspace is the route. Workspaces without a Discord surface entry use the
|
||||
default `.env` surface. If multiple workspaces name the same surface key with
|
||||
the same channel ids, they are merged into one guild surface. Surface keys and
|
||||
channel ids must be unique, and each workspace file may contain at most one
|
||||
`[[discord.workspace.surfaces]]` entry.
|
||||
|
||||
`CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new
|
||||
main operator thread, attaches the privileged workspace tools to it, and stores it
|
||||
in the bridge state file. Existing configured main threads are resumed as-is;
|
||||
recreate the main operator thread if you need to attach workspace tools to a
|
||||
thread that predates workspace mode.
|
||||
|
||||
In each configured home channel:
|
||||
|
||||
- normal messages are sent to the main operator thread
|
||||
- bot mentions are treated as workspace messages and do not create Discord task
|
||||
threads
|
||||
- `/status` replies directly with workspace state instead of starting a Codex turn
|
||||
- `/status` also lists active Codex threads, linking any opened Discord thread
|
||||
on the same surface and offering private buttons to open active threads that
|
||||
are not yet in Discord
|
||||
- `/goals` is available from workspace forum posts and opens an ephemeral goal
|
||||
management picker for that workspace
|
||||
- `/goals` inside an opened Codex Discord thread manages that specific thread's
|
||||
goal; use the slash options to set the objective/status/token budget or clear it
|
||||
|
||||
The prompt sent to the main thread uses `[discord-workspace]` framing so the model
|
||||
knows it is operating as the workspace over the codex-flows backend, not as a
|
||||
single task thread.
|
||||
|
||||
## Delegation Tools
|
||||
|
||||
Discord should not become a workspace registry. The main operator thread is the
|
||||
place where routing decisions happen. Privileged `codex_workspace` dynamic tools
|
||||
are attached only to that main thread and expose:
|
||||
|
||||
- `list_delegations`
|
||||
- `start_delegation`
|
||||
- `resume_delegation`
|
||||
- `send_delegation`
|
||||
- `read_delegation`
|
||||
- `set_delegation_policy`
|
||||
- `flush_delegation_results`
|
||||
- `list_delegation_groups`
|
||||
|
||||
Those tools can:
|
||||
|
||||
- list tracked delegated Codex sessions
|
||||
- start a delegated Codex session in a requested cwd
|
||||
- resume a delegated Codex session by thread id
|
||||
- send a turn to a delegated session
|
||||
- observe or summarize delegated session state
|
||||
- group delegations for fan-out/fan-in coordination
|
||||
- record completed delegation results into the main operator thread
|
||||
|
||||
Workspace state stores delegation records, including optional Discord detail
|
||||
thread ids for noisy work. Delegated Codex sessions do not receive the privileged
|
||||
workspace tools; only the main operator thread can manage delegation.
|
||||
|
||||
## Workbench Prototype
|
||||
|
||||
The workspace can optionally maintain a noisy Discord workbench beside each home
|
||||
channel. Configure both channels on the default `.env` surface, or on a
|
||||
workspace-owned `[[discord.workspace.surfaces]]` entry, to enable it:
|
||||
|
||||
```bash
|
||||
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID=1502107617512919221
|
||||
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID=1502107617512919222
|
||||
```
|
||||
|
||||
The home channel remains the compact operator chat. Each surface's workspace
|
||||
forum gets one post for each discoverable top-level folder under
|
||||
`CODEX_DISCORD_DIR` that routes to that surface. `CODEX_DISCORD_DIR` is the
|
||||
workspace's main workspace root. For the home-folder workspace, set
|
||||
`CODEX_DISCORD_DIR=/home/peezy`; a delegated cwd such as
|
||||
`/home/peezy/codex-fork-workspace/codex-flows` maps to the
|
||||
`/home/peezy/codex-fork-workspace` workspace post. Hidden folders and
|
||||
`node_modules` are skipped. Workspace posts are compact dashboards that only
|
||||
show Codex threads already opened into Discord. Run `/threads` in a workspace
|
||||
post to list all Codex threads for that workspace; the bridge replies with an
|
||||
ephemeral numbered button picker visible only to the command sender. Choosing a
|
||||
number opens or reuses one Discord task thread in that surface's task thread
|
||||
channel, and messages in that Discord thread are routed directly to the opened
|
||||
Codex thread.
|
||||
|
||||
When the workbench is enabled:
|
||||
|
||||
- `start_delegation` and `resume_delegation` create or reuse the workspace forum
|
||||
post for the top-level workspace containing the delegation cwd
|
||||
- bridge startup creates missing workspace forum posts for discoverable folders
|
||||
under the main workspace root
|
||||
- workspace dashboards list opened Discord task threads plus active hook-observed
|
||||
workspace threads that have not been opened into Discord yet
|
||||
- `/threads` lists known Codex threads from `thread/list` plus tracked
|
||||
delegations that may not have appeared in the list yet
|
||||
- choosing an item from the ephemeral `/threads` picker creates or reuses one
|
||||
Discord task thread in that surface's task thread channel
|
||||
- `/status` shows active Codex threads for the current surface and uses the same
|
||||
surface-scoped ephemeral button flow to open active threads without Discord
|
||||
task threads
|
||||
- `/goals` in workspace forum posts lists recent workspace thread goals and lets
|
||||
the command sender mark existing goals active, paused, or complete, clear
|
||||
them, or open the thread into Discord
|
||||
- `/goals` in an opened Discord task thread scopes CRUD to that Codex thread:
|
||||
no options reads the current goal, `objective`/`status`/`token_budget` create
|
||||
or update it, and `clear` removes it
|
||||
- repeated delegations in the same cwd reuse the same workspace post and update
|
||||
the workspace thread list
|
||||
- Stop lifecycle events update the workspace dashboard and any already-opened
|
||||
task thread on the routed surface
|
||||
- the routed home channel receives only compact status/link messages for
|
||||
completed delegations
|
||||
- main-thread injection and wake behavior still follow the delegation return
|
||||
mode
|
||||
|
||||
If both workbench channels are omitted, the workbench is disabled and the bridge
|
||||
keeps the legacy home-channel result mirroring behavior. Setting only one
|
||||
workbench channel is rejected as an invalid partial configuration.
|
||||
|
||||
Delegations support return modes:
|
||||
|
||||
- `wake_on_done`: inject and mirror the result, then wake the main operator when idle
|
||||
- `wake_on_group`: inject and mirror each result, then wake once the whole group is terminal
|
||||
- `record_only`: inject and mirror results without waking the main operator
|
||||
- `manual`: keep results in workspace state until `flush_delegation_results`
|
||||
- `detached`: do not loop results back to the main thread; useful for human-continued threads
|
||||
|
||||
Automatic result return uses `thread/inject_items` to append structured
|
||||
delegation results to the main operator thread's model-visible history. Codex
|
||||
hooks, not background thread polling, drive automatic result return and passive
|
||||
observability. Plugin-bundled Codex hooks write durable lifecycle events into
|
||||
the spool directory, and the workspace drains that spool on startup and while
|
||||
running.
|
||||
Starting a main-thread turn is a separate wake step, so long-running main goals
|
||||
are not interrupted; wakes are queued until the main operator thread is idle.
|
||||
For sessions that were not created through the workspace, the same hook stream
|
||||
updates an observed-thread index used by `/threads`.
|
||||
|
||||
## Codex Hooks
|
||||
|
||||
Prefer installing the `codex-flows` Codex plugin. The plugin carries
|
||||
`hooks/hooks.json` and a self-contained `hooks/hook-event.mjs` command, so Codex
|
||||
can discover the lifecycle hooks through the native plugin hook surface instead
|
||||
of a global `~/.codex/hooks.json` install.
|
||||
|
||||
Enable hooks for the Codex runtime that backs the workspace:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
hooks = true
|
||||
plugin_hooks = true
|
||||
```
|
||||
|
||||
Then install the plugin from the repository marketplace and start a new thread
|
||||
or restart the backing Codex runtime:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace add peezy-tech/codex-flows --ref main
|
||||
codex plugin add codex-flows@codex-flows
|
||||
```
|
||||
|
||||
The bridge and plugin hook default to `~/.codex/discord-bridge/stop-hooks` for
|
||||
compatibility. Override the hook writer with `CODEX_FLOWS_HOOK_SPOOL_DIR` or
|
||||
`CODEX_DISCORD_HOOK_SPOOL_DIR`, and pass the same directory to the bridge with
|
||||
`--hook-spool-dir` when needed.
|
||||
|
||||
The plugin registers passive observability hooks equivalent to:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/hooks/hook-event.mjs\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin also registers `PreToolUse`, `PermissionRequest`, and
|
||||
`PostToolUse` with the same command. Those higher-volume events update local
|
||||
observed-thread metadata such as status, current tool, or waiting reason; they
|
||||
do not create Discord messages.
|
||||
|
||||
The old global hook installer remains available as a manual fallback when plugin
|
||||
hooks are unavailable:
|
||||
|
||||
```bash
|
||||
codex-discord-bridge hook install
|
||||
codex-discord-bridge hook install --dlx
|
||||
```
|
||||
|
||||
The hook is intentionally dumb: it does not read workspace state or call the
|
||||
backend. It only writes idempotent lifecycle-event files and lets Codex
|
||||
continue. The workspace treats known delegated `Stop` events according to their
|
||||
return mode, uses main-operator `Stop` events to drain queued wakes, and records
|
||||
unknown non-main sessions as observed threads. Observed threads are visible from
|
||||
`/threads` for their workspace and can be opened into the task thread channel
|
||||
on demand.
|
||||
|
||||
After changing hook configuration, restart the Codex runtime that backs the
|
||||
workspace and trust the hook when Codex asks for review. `hooks/list` should show
|
||||
the hook as `trusted`; untrusted hooks are discovered but do not run.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"name": "@peezy.tech/codex-discord-bridge",
|
||||
"version": "0.132.6",
|
||||
"description": "Long-lived Discord sidecar for bridging Discord threads to Codex app-server threads.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/peezy-tech/codex-flows.git",
|
||||
"directory": "apps/discord-bridge"
|
||||
},
|
||||
"keywords": [
|
||||
"codex",
|
||||
"codex-app-server",
|
||||
"discord",
|
||||
"gateway"
|
||||
],
|
||||
"bin": {
|
||||
"codex-discord-bridge": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp run clean && tsx scripts/build-package.ts",
|
||||
"check:types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"pack:dry-run": "npm pack --dry-run --json",
|
||||
"pretty-log": "tsx ./src/pretty-log.ts",
|
||||
"prepack": "vp run build",
|
||||
"release:check": "vp run test && vp run check:types && vp run build && vp run smoke:bin && vp run pack:dry-run",
|
||||
"smoke:bin": "tsx scripts/smoke-bin.ts",
|
||||
"start:debug:commentary": "tsx ./src/index.ts --local-app-server --debug --progress-mode commentary",
|
||||
"test": "vp test run --root ../.. apps/discord-bridge/test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@peezy.tech/codex-flows": "workspace:*",
|
||||
"discord.js": "^14.22.1",
|
||||
"smol-toml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const outDir = path.join(appRoot, "dist");
|
||||
const packOutDir = path.join(outDir, ".pack");
|
||||
const outfile = path.join(outDir, "index.js");
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const proc = spawn("vp", [
|
||||
"pack",
|
||||
"src/index.ts",
|
||||
"--platform=node",
|
||||
"--format=esm",
|
||||
"--target=node24",
|
||||
"--out-dir",
|
||||
packOutDir,
|
||||
"--deps.never-bundle",
|
||||
"@peezy.tech/codex-flows",
|
||||
"--deps.never-bundle",
|
||||
"@peezy.tech/codex-flows/*",
|
||||
"--deps.never-bundle",
|
||||
"discord.js",
|
||||
], {
|
||||
cwd: appRoot,
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
collectText(proc.stdout),
|
||||
collectText(proc.stderr),
|
||||
exitCodeFor(proc),
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.stderr.write(stderr);
|
||||
process.stderr.write(stdout);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
await movePackOutput(packOutDir, outfile);
|
||||
await rm(packOutDir, { recursive: true, force: true });
|
||||
await chmod(outfile, 0o755);
|
||||
process.stderr.write(`built ${path.relative(appRoot, outfile)}\n`);
|
||||
|
||||
async function movePackOutput(packDir: string, entryOutfile: string): Promise<void> {
|
||||
await rename(path.join(packDir, "index.mjs"), entryOutfile);
|
||||
for (const entry of await readdir(packDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
await rename(path.join(packDir, entry.name), path.join(outDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
if (!stream) {
|
||||
resolve(output);
|
||||
return;
|
||||
}
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk: string) => {
|
||||
output += chunk;
|
||||
});
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(output));
|
||||
});
|
||||
}
|
||||
|
||||
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const binPath = path.join(appRoot, "dist", "index.js");
|
||||
|
||||
await access(binPath);
|
||||
|
||||
const proc = spawn(process.execPath, [binPath, "--help"], {
|
||||
cwd: appRoot,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
collectText(proc.stdout),
|
||||
collectText(proc.stderr),
|
||||
exitCodeFor(proc),
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.stderr.write(stderr);
|
||||
process.stderr.write(stdout);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
if (!stdout.includes("codex-discord-bridge")) {
|
||||
throw new Error("codex-discord-bridge --help did not mention codex-discord-bridge");
|
||||
}
|
||||
|
||||
console.log("bin smoke test passed");
|
||||
|
||||
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
if (!stream) {
|
||||
resolve(output);
|
||||
return;
|
||||
}
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk: string) => {
|
||||
output += chunk;
|
||||
});
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(output));
|
||||
});
|
||||
}
|
||||
|
||||
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import type { DiscordBridgeLogger } from "./logger.ts";
|
||||
import {
|
||||
createDiscordBridgeLogger,
|
||||
} from "./logger.ts";
|
||||
import type {
|
||||
DiscordBridgeConfig,
|
||||
DiscordBridgeState,
|
||||
DiscordBridgeTransport,
|
||||
} from "./types.ts";
|
||||
import type {
|
||||
CodexWorkspaceBackend,
|
||||
CodexWorkspacePresenter,
|
||||
} from "./workspace-backend.ts";
|
||||
import {
|
||||
LocalCodexWorkspaceBackend,
|
||||
type LocalCodexWorkspaceBackendOptions,
|
||||
parseThreadStartIntent,
|
||||
splitDiscordMessage,
|
||||
} from "./local-workspace-backend.ts";
|
||||
|
||||
export { parseThreadStartIntent, splitDiscordMessage };
|
||||
export { LocalCodexWorkspaceBackend };
|
||||
export type { LocalCodexWorkspaceBackendOptions };
|
||||
|
||||
export type DiscordCodexBridgeLocalOptions =
|
||||
Omit<LocalCodexWorkspaceBackendOptions, "presenter"> & {
|
||||
transport: DiscordBridgeTransport;
|
||||
backend?: undefined;
|
||||
};
|
||||
|
||||
export type DiscordCodexBridgeBackendOptions = {
|
||||
backend: CodexWorkspaceBackend;
|
||||
transport: DiscordBridgeTransport;
|
||||
logger?: DiscordBridgeLogger;
|
||||
config?: Pick<DiscordBridgeConfig, "debug" | "logLevel">;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
export type DiscordCodexBridgeOptions =
|
||||
| DiscordCodexBridgeLocalOptions
|
||||
| DiscordCodexBridgeBackendOptions;
|
||||
|
||||
export class DiscordCodexBridge {
|
||||
readonly transport: DiscordBridgeTransport;
|
||||
readonly backend: CodexWorkspaceBackend;
|
||||
#logger: DiscordBridgeLogger;
|
||||
|
||||
constructor(options: DiscordCodexBridgeOptions) {
|
||||
this.transport = options.transport;
|
||||
this.#logger = options.logger ??
|
||||
createDiscordBridgeLogger({
|
||||
debug: options.config?.debug,
|
||||
logLevel: options.config?.logLevel,
|
||||
now: options.now,
|
||||
});
|
||||
this.backend = options.backend ?? new LocalCodexWorkspaceBackend({
|
||||
...options,
|
||||
presenter: discordTransportPresenter(options.transport),
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.backend.start();
|
||||
await this.transport.start({
|
||||
onInbound: (inbound) => {
|
||||
void this.backend.handleInbound(inbound).catch((error) => {
|
||||
this.#logger.debug("inbound.error", {
|
||||
kind: inbound.kind,
|
||||
channelId: inbound.channelId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
this.#logger.error("inbound.failed", {
|
||||
kind: inbound.kind,
|
||||
channelId: inbound.channelId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
await this.backend.startTransportDependentWork?.();
|
||||
await this.transport.registerCommands(this.backend.commandRegistration());
|
||||
await this.backend.startBackgroundWork?.();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await this.backend.stop();
|
||||
} finally {
|
||||
await this.transport.stop();
|
||||
}
|
||||
}
|
||||
|
||||
stateForTest(): DiscordBridgeState {
|
||||
if (!this.backend.stateForTest) {
|
||||
throw new Error("Workspace backend does not expose test state.");
|
||||
}
|
||||
return this.backend.stateForTest();
|
||||
}
|
||||
|
||||
async flushSummariesForTest(): Promise<void> {
|
||||
await this.backend.flushSummariesForTest?.();
|
||||
}
|
||||
}
|
||||
|
||||
function discordTransportPresenter(
|
||||
transport: DiscordBridgeTransport,
|
||||
): CodexWorkspacePresenter {
|
||||
return {
|
||||
createWorkspacePost: transport.createForumPost?.bind(transport),
|
||||
createThread: transport.createThread.bind(transport),
|
||||
sendMessage: transport.sendMessage.bind(transport),
|
||||
updateMessage: transport.updateMessage?.bind(transport),
|
||||
deleteMessage: transport.deleteMessage.bind(transport),
|
||||
deleteWebhookMessages: transport.deleteWebhookMessages?.bind(transport),
|
||||
deleteThread: transport.deleteThread?.bind(transport),
|
||||
addThreadMembers: transport.addThreadMembers?.bind(transport),
|
||||
addReactions: transport.addReactions?.bind(transport),
|
||||
pinMessage: transport.pinMessage?.bind(transport),
|
||||
sendTyping: transport.sendTyping.bind(transport),
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -1,698 +0,0 @@
|
|||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
|
||||
import type {
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
v2,
|
||||
} from "@peezy.tech/codex-flows/generated";
|
||||
|
||||
import type {
|
||||
DiscordBridgeConfig,
|
||||
DiscordConsoleOutputMode,
|
||||
DiscordWorkspaceSurfaceConfig,
|
||||
DiscordProgressMode,
|
||||
} from "./types.ts";
|
||||
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
|
||||
|
||||
export type ParsedConfig =
|
||||
| {
|
||||
type: "run";
|
||||
discordToken: string;
|
||||
appServerUrl?: string;
|
||||
localAppServer?: boolean;
|
||||
config: DiscordBridgeConfig;
|
||||
}
|
||||
| { type: "help"; text: string };
|
||||
|
||||
const effortValues = new Set<ReasoningEffort>([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
const summaryValues = new Set<ReasoningSummary>([
|
||||
"auto",
|
||||
"concise",
|
||||
"detailed",
|
||||
"none",
|
||||
]);
|
||||
const progressModeValues = new Set<DiscordProgressMode>([
|
||||
"summary",
|
||||
"commentary",
|
||||
"none",
|
||||
]);
|
||||
const consoleOutputValues = new Set<DiscordConsoleOutputMode>([
|
||||
"messages",
|
||||
"none",
|
||||
]);
|
||||
const logLevelValues = new Set<DiscordBridgeLogLevelSetting>([
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"silent",
|
||||
]);
|
||||
const approvalPolicyValues = new Set<string>([
|
||||
"untrusted",
|
||||
"on-failure",
|
||||
"on-request",
|
||||
"never",
|
||||
]);
|
||||
const sandboxValues = new Set<v2.SandboxMode>([
|
||||
"read-only",
|
||||
"workspace-write",
|
||||
"danger-full-access",
|
||||
]);
|
||||
const defaultWorkspaceSurfaceKey = "default";
|
||||
|
||||
export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig {
|
||||
const args = parseFlags(argv);
|
||||
if (args.has("help") || args.has("h")) {
|
||||
return { type: "help", text: helpText() };
|
||||
}
|
||||
const discordToken = stringFlag(args, "token") ?? env.CODEX_DISCORD_BOT_TOKEN;
|
||||
if (!discordToken) {
|
||||
throw new Error("Missing Discord bot token. Set CODEX_DISCORD_BOT_TOKEN or pass --token.");
|
||||
}
|
||||
const allowedUserIds = csvSet(
|
||||
stringFlag(args, "allowed-user-ids") ?? env.CODEX_DISCORD_ALLOWED_USER_IDS,
|
||||
);
|
||||
if (allowedUserIds.size === 0) {
|
||||
throw new Error(
|
||||
"Missing allowed Discord users. Set CODEX_DISCORD_ALLOWED_USER_IDS or pass --allowed-user-ids.",
|
||||
);
|
||||
}
|
||||
const explicitAppServerUrl =
|
||||
stringFlag(args, "app-server-url") ??
|
||||
stringFlag(args, "url");
|
||||
const localAppServer = booleanFlag(args, "local-app-server");
|
||||
if (localAppServer && explicitAppServerUrl) {
|
||||
throw new Error("Cannot set both --local-app-server and --app-server-url.");
|
||||
}
|
||||
const appServerUrl = localAppServer
|
||||
? undefined
|
||||
: explicitAppServerUrl ?? env.CODEX_WORKSPACE_APP_SERVER_WS_URL;
|
||||
const statePath =
|
||||
stringFlag(args, "state-path") ??
|
||||
env.CODEX_DISCORD_STATE_PATH ??
|
||||
path.join(os.homedir(), ".codex", "discord-bridge", "state.json");
|
||||
const permissionsProfile = stringFlag(args, "permissions-profile") ??
|
||||
env.CODEX_DISCORD_PERMISSIONS_PROFILE;
|
||||
const approvalPolicy = optionalApprovalPolicy(
|
||||
stringFlag(args, "approval-policy") ?? env.CODEX_DISCORD_APPROVAL_POLICY,
|
||||
);
|
||||
const sandbox = optionalSandbox(
|
||||
stringFlag(args, "sandbox") ?? env.CODEX_DISCORD_SANDBOX,
|
||||
);
|
||||
if (sandbox && permissionsProfile) {
|
||||
throw new Error("Cannot set both --sandbox and --permissions-profile.");
|
||||
}
|
||||
const debug = booleanFlag(args, "debug") || envFlag(env.CODEX_DISCORD_DEBUG);
|
||||
const logLevel = optionalLogLevel(
|
||||
stringFlag(args, "log-level") ?? env.CODEX_DISCORD_LOG_LEVEL,
|
||||
) ?? (debug ? "debug" : undefined);
|
||||
const cwd = resolveHomeDir(
|
||||
stringFlag(args, "dir") ??
|
||||
stringFlag(args, "positional-dir") ??
|
||||
env.CODEX_DISCORD_DIR ??
|
||||
stringFlag(args, "cwd") ??
|
||||
env.CODEX_DISCORD_CWD,
|
||||
);
|
||||
|
||||
return {
|
||||
type: "run",
|
||||
discordToken,
|
||||
appServerUrl,
|
||||
localAppServer,
|
||||
config: {
|
||||
allowedUserIds,
|
||||
allowedChannelIds: csvSet(
|
||||
stringFlag(args, "allowed-channel-ids") ??
|
||||
env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS,
|
||||
),
|
||||
statePath,
|
||||
workspace: workspaceConfig(args, env, cwd),
|
||||
cwd,
|
||||
model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL,
|
||||
modelProvider:
|
||||
stringFlag(args, "model-provider") ??
|
||||
env.CODEX_DISCORD_MODEL_PROVIDER,
|
||||
serviceTier:
|
||||
stringFlag(args, "service-tier") ?? env.CODEX_DISCORD_SERVICE_TIER,
|
||||
effort: optionalEffort(
|
||||
stringFlag(args, "effort") ?? env.CODEX_DISCORD_EFFORT,
|
||||
),
|
||||
summary: optionalSummary(
|
||||
stringFlag(args, "summary") ??
|
||||
env.CODEX_DISCORD_REASONING_SUMMARY ??
|
||||
"auto",
|
||||
),
|
||||
progressMode: optionalProgressMode(
|
||||
stringFlag(args, "progress-mode") ??
|
||||
env.CODEX_DISCORD_PROGRESS_MODE ??
|
||||
"summary",
|
||||
),
|
||||
consoleOutput: optionalConsoleOutput(
|
||||
stringFlag(args, "console-output") ??
|
||||
env.CODEX_DISCORD_CONSOLE_OUTPUT,
|
||||
),
|
||||
logLevel,
|
||||
approvalPolicy,
|
||||
sandbox,
|
||||
permissions: permissionsProfile,
|
||||
hookSpoolDir: resolveHomeDir(
|
||||
stringFlag(args, "hook-spool-dir") ??
|
||||
env.CODEX_DISCORD_HOOK_SPOOL_DIR,
|
||||
),
|
||||
debug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): Map<string, string | boolean> {
|
||||
const flags = new Map<string, string | boolean>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg?.startsWith("--")) {
|
||||
if (flags.has("positional-dir")) {
|
||||
throw new Error(`Unexpected argument: ${arg ?? ""}`);
|
||||
}
|
||||
flags.set("positional-dir", arg ?? "");
|
||||
continue;
|
||||
}
|
||||
const [rawName, inlineValue] = arg.slice(2).split("=", 2);
|
||||
if (!rawName) {
|
||||
throw new Error(`Invalid flag: ${arg}`);
|
||||
}
|
||||
if (inlineValue !== undefined) {
|
||||
flags.set(rawName, inlineValue);
|
||||
continue;
|
||||
}
|
||||
if (booleanFlagNames.has(rawName)) {
|
||||
flags.set(rawName, true);
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
flags.set(rawName, true);
|
||||
continue;
|
||||
}
|
||||
flags.set(rawName, next);
|
||||
index += 1;
|
||||
}
|
||||
if (
|
||||
flags.has("positional-dir") &&
|
||||
(flags.has("dir") || flags.has("cwd"))
|
||||
) {
|
||||
throw new Error("Cannot set both positional directory and --dir/--cwd.");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const booleanFlagNames = new Set(["debug", "help", "h", "local-app-server"]);
|
||||
|
||||
function stringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = flags.get(name);
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function csvSet(value: string | undefined): Set<string> {
|
||||
return new Set(
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function booleanFlag(flags: Map<string, string | boolean>, name: string): boolean {
|
||||
const value = flags.get(name);
|
||||
if (value === true) {
|
||||
return true;
|
||||
}
|
||||
return envFlag(typeof value === "string" ? value : undefined);
|
||||
}
|
||||
|
||||
function envFlag(value: string | undefined): boolean {
|
||||
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
function optionalEffort(value: string | undefined): ReasoningEffort | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!effortValues.has(value as ReasoningEffort)) {
|
||||
throw new Error("Invalid effort. Expected none, minimal, low, medium, high, or xhigh.");
|
||||
}
|
||||
return value as ReasoningEffort;
|
||||
}
|
||||
|
||||
function optionalSummary(value: string | undefined): ReasoningSummary | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!summaryValues.has(value as ReasoningSummary)) {
|
||||
throw new Error("Invalid summary. Expected auto, concise, detailed, or none.");
|
||||
}
|
||||
return value as ReasoningSummary;
|
||||
}
|
||||
|
||||
function optionalProgressMode(value: string | undefined): DiscordProgressMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!progressModeValues.has(value as DiscordProgressMode)) {
|
||||
throw new Error("Invalid progress mode. Expected summary, commentary, or none.");
|
||||
}
|
||||
return value as DiscordProgressMode;
|
||||
}
|
||||
|
||||
function workspaceConfig(
|
||||
flags: Map<string, string | boolean>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
workspaceRoot: string | undefined,
|
||||
): DiscordBridgeConfig["workspace"] {
|
||||
const workspaceSurfaces = workspaceSurfacesConfig(workspaceRoot);
|
||||
const configuredHomeChannelId =
|
||||
stringFlag(flags, "home-channel-id") ??
|
||||
stringFlag(flags, "workspace-home-channel-id") ??
|
||||
env.CODEX_DISCORD_HOME_CHANNEL_ID ??
|
||||
env.CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID;
|
||||
const useWorkspacePrimaryDefaults = !configuredHomeChannelId;
|
||||
const homeChannelId = configuredHomeChannelId ??
|
||||
workspaceSurfaces[0]?.homeChannelId;
|
||||
const mainThreadId =
|
||||
stringFlag(flags, "main-thread-id") ??
|
||||
stringFlag(flags, "workspace-main-thread-id") ??
|
||||
env.CODEX_DISCORD_MAIN_THREAD_ID ??
|
||||
env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID;
|
||||
const configuredWorkspaceForumChannelId =
|
||||
stringFlag(flags, "workspace-forum-channel-id") ??
|
||||
stringFlag(flags, "workspace-workspace-forum-channel-id") ??
|
||||
env.CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID ??
|
||||
env.CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID;
|
||||
const workspaceForumChannelId = configuredWorkspaceForumChannelId ??
|
||||
(useWorkspacePrimaryDefaults
|
||||
? workspaceSurfaces[0]?.workspaceForumChannelId
|
||||
: undefined);
|
||||
const configuredTaskThreadsChannelId =
|
||||
stringFlag(flags, "task-threads-channel-id") ??
|
||||
stringFlag(flags, "workspace-task-threads-channel-id") ??
|
||||
env.CODEX_DISCORD_TASK_THREADS_CHANNEL_ID ??
|
||||
env.CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID;
|
||||
const taskThreadsChannelId = configuredTaskThreadsChannelId ??
|
||||
(useWorkspacePrimaryDefaults
|
||||
? workspaceSurfaces[0]?.taskThreadsChannelId
|
||||
: undefined);
|
||||
if (!homeChannelId) {
|
||||
if (mainThreadId) {
|
||||
throw new Error("Cannot set a workspace main thread without a workspace home channel.");
|
||||
}
|
||||
if (workspaceForumChannelId || taskThreadsChannelId) {
|
||||
throw new Error("Cannot set Discord workbench channels without a workspace home channel.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
|
||||
throw new Error(
|
||||
"Discord workbench requires both workspace forum and task threads channels.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
workspaceForumChannelId &&
|
||||
taskThreadsChannelId &&
|
||||
(workspaceForumChannelId === homeChannelId ||
|
||||
taskThreadsChannelId === homeChannelId ||
|
||||
workspaceForumChannelId === taskThreadsChannelId)
|
||||
) {
|
||||
throw new Error(
|
||||
"Discord workbench channels must be separate from the workspace home channel and each other.",
|
||||
);
|
||||
}
|
||||
const defaultSurface = {
|
||||
key: defaultWorkspaceSurfaceKey,
|
||||
homeChannelId,
|
||||
workspaceForumChannelId,
|
||||
taskThreadsChannelId,
|
||||
};
|
||||
const surfaces = workspaceSurfaces.length > 0
|
||||
? mergeWorkspaceSurfaces(
|
||||
configuredHomeChannelId
|
||||
? [defaultSurface, ...workspaceSurfaces]
|
||||
: workspaceSurfaces,
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
homeChannelId,
|
||||
mainThreadId,
|
||||
workspaceForumChannelId,
|
||||
taskThreadsChannelId,
|
||||
surfaces: surfaces.length > 0 ? surfaces : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceSurfacesConfig(
|
||||
workspaceRoot: string | undefined,
|
||||
): DiscordWorkspaceSurfaceConfig[] {
|
||||
if (!workspaceRoot) {
|
||||
return [];
|
||||
}
|
||||
const workspaceCwds = discoverWorkspaceConfigCwds(workspaceRoot);
|
||||
if (workspaceCwds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const surfaces: DiscordWorkspaceSurfaceConfig[] = [];
|
||||
for (const workspaceCwd of workspaceCwds) {
|
||||
const surface = workspaceWorkspaceSurfaceConfig(workspaceCwd);
|
||||
if (surface) {
|
||||
surfaces.push(surface);
|
||||
}
|
||||
}
|
||||
return mergeWorkspaceSurfaces(surfaces);
|
||||
}
|
||||
|
||||
function discoverWorkspaceConfigCwds(workspaceRoot: string): string[] {
|
||||
const normalizedRoot = path.normalize(workspaceRoot);
|
||||
const cwds = [normalizedRoot];
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(normalizedRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return cwds;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!isDiscoverableWorkspaceEntry(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(normalizedRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
cwds.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (statSync(fullPath).isDirectory()) {
|
||||
cwds.push(fullPath);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return uniqueStringList(cwds.map((cwd) => path.normalize(cwd))).sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function isDiscoverableWorkspaceEntry(name: string): boolean {
|
||||
return Boolean(name) &&
|
||||
!name.startsWith(".") &&
|
||||
name !== "node_modules";
|
||||
}
|
||||
|
||||
function workspaceWorkspaceSurfaceConfig(
|
||||
workspaceCwd: string,
|
||||
): DiscordWorkspaceSurfaceConfig | undefined {
|
||||
const configPath = path.join(workspaceCwd, ".codex", "workspace.toml");
|
||||
if (!existsSync(configPath)) {
|
||||
return undefined;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseToml(readFileSync(configPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid workspace config TOML at ${configPath}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
const surfacesInput = workspaceSurfaceEntries(parsed);
|
||||
if (surfacesInput === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(surfacesInput)) {
|
||||
throw new Error(
|
||||
`workspace.toml discord.workspace.surfaces must be an array: ${configPath}`,
|
||||
);
|
||||
}
|
||||
if (surfacesInput.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (surfacesInput.length > 1) {
|
||||
throw new Error(
|
||||
`workspace.toml discord.workspace.surfaces must contain one surface: ${configPath}`,
|
||||
);
|
||||
}
|
||||
return parseWorkspaceSurface(surfacesInput[0], 0, workspaceCwd);
|
||||
}
|
||||
|
||||
function workspaceSurfaceEntries(input: unknown): unknown {
|
||||
const parsed = record(input);
|
||||
if (parsed.discord === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const discord = record(parsed.discord);
|
||||
if (discord.workspace === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const workspace = record(discord.workspace);
|
||||
return workspace.surfaces;
|
||||
}
|
||||
|
||||
function parseWorkspaceSurface(
|
||||
input: unknown,
|
||||
index: number,
|
||||
workspaceCwd: string,
|
||||
): DiscordWorkspaceSurfaceConfig {
|
||||
const parsed = record(input);
|
||||
const key = optionalString(parsed.key) ?? optionalString(parsed.name);
|
||||
const homeChannelId = optionalString(parsed.homeChannelId) ??
|
||||
optionalString(parsed.home_channel_id);
|
||||
const workspaceForumChannelId = optionalString(parsed.workspaceForumChannelId) ??
|
||||
optionalString(parsed.workspace_forum_channel_id);
|
||||
const taskThreadsChannelId = optionalString(parsed.taskThreadsChannelId) ??
|
||||
optionalString(parsed.task_threads_channel_id);
|
||||
if (!key) {
|
||||
throw new Error(`Workspace surface at index ${index} is missing key.`);
|
||||
}
|
||||
if (!homeChannelId) {
|
||||
throw new Error(`Workspace surface ${key} is missing homeChannelId.`);
|
||||
}
|
||||
if (Boolean(workspaceForumChannelId) !== Boolean(taskThreadsChannelId)) {
|
||||
throw new Error(
|
||||
`Workspace surface ${key} requires both workspaceForumChannelId and taskThreadsChannelId.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
workspaceForumChannelId &&
|
||||
taskThreadsChannelId &&
|
||||
(homeChannelId === workspaceForumChannelId ||
|
||||
homeChannelId === taskThreadsChannelId ||
|
||||
workspaceForumChannelId === taskThreadsChannelId)
|
||||
) {
|
||||
throw new Error(`Workspace surface ${key} channels must be distinct.`);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
homeChannelId,
|
||||
workspaceForumChannelId,
|
||||
taskThreadsChannelId,
|
||||
workspaceCwds: [path.normalize(workspaceCwd)],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWorkspaceSurfaces(
|
||||
surfaces: DiscordWorkspaceSurfaceConfig[],
|
||||
): DiscordWorkspaceSurfaceConfig[] {
|
||||
const byKey = new Map<string, DiscordWorkspaceSurfaceConfig>();
|
||||
for (const surface of surfaces) {
|
||||
const existing = byKey.get(surface.key);
|
||||
if (!existing) {
|
||||
byKey.set(surface.key, {
|
||||
...surface,
|
||||
workspaceCwds: surface.workspaceCwds
|
||||
? uniqueStringList(surface.workspaceCwds.map((cwd) => path.normalize(cwd)))
|
||||
: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
existing.homeChannelId !== surface.homeChannelId ||
|
||||
existing.workspaceForumChannelId !== surface.workspaceForumChannelId ||
|
||||
existing.taskThreadsChannelId !== surface.taskThreadsChannelId
|
||||
) {
|
||||
throw new Error(
|
||||
`Workspace surface key ${surface.key} is configured with different channels.`,
|
||||
);
|
||||
}
|
||||
existing.workspaceCwds = existing.workspaceCwds && surface.workspaceCwds
|
||||
? uniqueStringList([
|
||||
...existing.workspaceCwds,
|
||||
...surface.workspaceCwds.map((cwd) => path.normalize(cwd)),
|
||||
])
|
||||
: undefined;
|
||||
}
|
||||
const merged = [...byKey.values()];
|
||||
validateWorkspaceSurfaces(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function validateWorkspaceSurfaces(surfaces: DiscordWorkspaceSurfaceConfig[]): void {
|
||||
const channelIds = new Set<string>();
|
||||
let catchAllSurfaces = 0;
|
||||
for (const surface of surfaces) {
|
||||
if (!surface.workspaceCwds || surface.workspaceCwds.length === 0) {
|
||||
catchAllSurfaces += 1;
|
||||
}
|
||||
for (const channelId of [
|
||||
surface.homeChannelId,
|
||||
surface.workspaceForumChannelId,
|
||||
surface.taskThreadsChannelId,
|
||||
]) {
|
||||
if (!channelId) {
|
||||
continue;
|
||||
}
|
||||
if (channelIds.has(channelId)) {
|
||||
throw new Error(
|
||||
`Workspace surface channel is configured more than once: ${channelId}`,
|
||||
);
|
||||
}
|
||||
channelIds.add(channelId);
|
||||
}
|
||||
}
|
||||
if (catchAllSurfaces > 1) {
|
||||
throw new Error("Only one workspace surface may omit workspaceCwds.");
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueStringList(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function optionalConsoleOutput(
|
||||
value: string | undefined,
|
||||
): DiscordConsoleOutputMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!consoleOutputValues.has(value as DiscordConsoleOutputMode)) {
|
||||
throw new Error("Invalid console output. Expected messages or none.");
|
||||
}
|
||||
return value as DiscordConsoleOutputMode;
|
||||
}
|
||||
|
||||
function optionalLogLevel(
|
||||
value: string | undefined,
|
||||
): DiscordBridgeLogLevelSetting | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!logLevelValues.has(value as DiscordBridgeLogLevelSetting)) {
|
||||
throw new Error("Invalid log level. Expected debug, info, warn, error, or silent.");
|
||||
}
|
||||
return value as DiscordBridgeLogLevelSetting;
|
||||
}
|
||||
|
||||
function optionalApprovalPolicy(
|
||||
value: string | undefined,
|
||||
): v2.AskForApproval | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!approvalPolicyValues.has(value)) {
|
||||
throw new Error(
|
||||
"Invalid approval policy. Expected untrusted, on-failure, on-request, or never.",
|
||||
);
|
||||
}
|
||||
return value as v2.AskForApproval;
|
||||
}
|
||||
|
||||
function optionalSandbox(value: string | undefined): v2.SandboxMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!sandboxValues.has(value as v2.SandboxMode)) {
|
||||
throw new Error(
|
||||
"Invalid sandbox. Expected read-only, workspace-write, or danger-full-access.",
|
||||
);
|
||||
}
|
||||
return value as v2.SandboxMode;
|
||||
}
|
||||
|
||||
function helpText(): string {
|
||||
return `codex-discord-bridge connects Discord threads to Codex app-server threads.
|
||||
|
||||
Usage:
|
||||
codex-discord-bridge [options] [dir]
|
||||
|
||||
Required:
|
||||
--token <token> Discord bot token, or CODEX_DISCORD_BOT_TOKEN
|
||||
--allowed-user-ids <ids> Comma-separated Discord user ids, or CODEX_DISCORD_ALLOWED_USER_IDS
|
||||
|
||||
Options:
|
||||
--app-server-url <url> Existing app-server WebSocket URL
|
||||
--local-app-server Start a local app-server over stdio
|
||||
--state-path <path> Persistent bridge state file
|
||||
--allowed-channel-ids <ids> Comma-separated parent channel ids
|
||||
--home-channel-id <id> Enable workspace mode for one Discord home channel
|
||||
--main-thread-id <id> Resume an existing Codex operator thread for workspace mode
|
||||
--workspace-forum-channel-id <id>
|
||||
Optional workbench forum channel for workspace posts
|
||||
--task-threads-channel-id <id> Optional workbench text channel for task threads
|
||||
--hook-spool-dir <path> Directory drained for Codex hook events
|
||||
[dir] Optional Codex thread directory, resolved from home
|
||||
--dir <path> Codex thread directory, resolved from home
|
||||
--cwd <path> Alias for --dir
|
||||
--model <model> Codex model override
|
||||
--model-provider <provider> Codex model provider override
|
||||
--service-tier <tier> Codex service tier override
|
||||
--effort <effort> none|minimal|low|medium|high|xhigh
|
||||
--summary <summary> auto|concise|detailed|none
|
||||
--progress-mode <mode> summary|commentary|none
|
||||
--console-output <mode> messages|none
|
||||
--log-level <level> debug|info|warn|error|silent
|
||||
--approval-policy <policy> untrusted|on-failure|on-request|never
|
||||
--sandbox <mode> read-only|workspace-write|danger-full-access
|
||||
--permissions-profile <id> Named Codex permissions profile
|
||||
--debug Emit verbose bridge diagnostics to stderr
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveHomeDir(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
if (path.isAbsolute(value)) {
|
||||
return value;
|
||||
}
|
||||
return path.join(os.homedir(), value);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
export type DiscordConsoleMessageKind =
|
||||
| "summary"
|
||||
| "commentary"
|
||||
| "final"
|
||||
| "error";
|
||||
|
||||
export type DiscordConsoleMessage = {
|
||||
kind: DiscordConsoleMessageKind;
|
||||
text: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
turnId?: string;
|
||||
title?: string;
|
||||
at?: Date;
|
||||
};
|
||||
|
||||
export type DiscordConsoleOutput = {
|
||||
message(message: DiscordConsoleMessage): void;
|
||||
};
|
||||
|
||||
export type ConsoleMessageOutputOptions = {
|
||||
color?: boolean;
|
||||
now?: () => Date;
|
||||
stream?: Pick<NodeJS.WriteStream, "write">;
|
||||
};
|
||||
|
||||
export type ConsoleMessageFormatOptions = {
|
||||
color?: boolean;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
const resetColor = "\x1b[0m";
|
||||
const kindColors: Record<DiscordConsoleMessageKind, string> = {
|
||||
summary: "\x1b[90m",
|
||||
commentary: "\x1b[36m",
|
||||
final: "\x1b[32m",
|
||||
error: "\x1b[31m",
|
||||
};
|
||||
|
||||
export function createDiscordConsoleOutput(
|
||||
options: ConsoleMessageOutputOptions = {},
|
||||
): DiscordConsoleOutput {
|
||||
const stream = options.stream ?? process.stdout;
|
||||
const color = options.color ??
|
||||
Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
||||
const now = options.now ?? (() => new Date());
|
||||
return {
|
||||
message(message) {
|
||||
stream.write(`${formatConsoleMessage(message, { color, now })}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatConsoleMessage(
|
||||
message: DiscordConsoleMessage,
|
||||
options: ConsoleMessageFormatOptions = {},
|
||||
): string {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const time = formatTime(message.at ?? now());
|
||||
const kind = message.kind.toUpperCase().padEnd(10);
|
||||
const coloredKind = colorize(kind, kindColors[message.kind], options.color ?? false);
|
||||
const title = (message.title?.trim() || compactId(message.codexThreadId)).replace(
|
||||
/\s+/g,
|
||||
" ",
|
||||
);
|
||||
const metadata = [
|
||||
`thread=${compactId(message.codexThreadId)}`,
|
||||
message.turnId ? `turn=${compactId(message.turnId)}` : undefined,
|
||||
].filter(Boolean).join(" ");
|
||||
const header = `[${time}] ${coloredKind} ${title} ${metadata}`;
|
||||
const body = formatBody(message.text);
|
||||
return body ? `${header}\n${body}` : header;
|
||||
}
|
||||
|
||||
function formatBody(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function compactId(id: string): string {
|
||||
if (id.length <= 12) {
|
||||
return id;
|
||||
}
|
||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function colorize(text: string, color: string, enabled: boolean): string {
|
||||
return enabled ? `${color}${text}${resetColor}` : text;
|
||||
}
|
||||
|
|
@ -1,953 +0,0 @@
|
|||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
Partials,
|
||||
type Interaction,
|
||||
type ApplicationCommandDataResolvable,
|
||||
type Message,
|
||||
type MessageReaction,
|
||||
type PartialMessageReaction,
|
||||
type PartialUser,
|
||||
type User,
|
||||
} from "discord.js";
|
||||
|
||||
import { splitDiscordMessage } from "./bridge.ts";
|
||||
import type { v2 } from "@peezy.tech/codex-flows/generated";
|
||||
import {
|
||||
createDiscordBridgeLogger,
|
||||
type DiscordBridgeLogger,
|
||||
} from "./logger.ts";
|
||||
import type {
|
||||
DiscordBridgeCommandRegistration,
|
||||
DiscordEphemeralPicker,
|
||||
DiscordBridgeTransport,
|
||||
DiscordBridgeTransportHandlers,
|
||||
} from "./types.ts";
|
||||
|
||||
export type DiscordJsBridgeTransportOptions = {
|
||||
token: string;
|
||||
logger?: DiscordBridgeLogger;
|
||||
};
|
||||
|
||||
const threadPickerCustomIdPrefix = "codex_threads";
|
||||
|
||||
export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
||||
#token: string;
|
||||
#logger: DiscordBridgeLogger;
|
||||
#client: Client | undefined;
|
||||
#handlers: DiscordBridgeTransportHandlers | undefined;
|
||||
|
||||
constructor(options: DiscordJsBridgeTransportOptions) {
|
||||
this.#token = options.token;
|
||||
this.#logger = options.logger ?? createDiscordBridgeLogger();
|
||||
}
|
||||
|
||||
async start(handlers: DiscordBridgeTransportHandlers): Promise<void> {
|
||||
this.#handlers = handlers;
|
||||
if (this.#client) {
|
||||
return;
|
||||
}
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||
});
|
||||
this.#client = client;
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
this.#logger.info("discord.connected", {
|
||||
userId: readyClient.user.id,
|
||||
tag: readyClient.user.tag,
|
||||
});
|
||||
});
|
||||
client.on(Events.MessageCreate, (message) => this.#handleMessage(message));
|
||||
client.on(Events.MessageReactionAdd, (reaction, user) =>
|
||||
void this.#handleReaction(reaction, user).catch((error) => {
|
||||
this.#logger.error("discord.reaction.failed", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
})
|
||||
);
|
||||
client.on(Events.InteractionCreate, (interaction) =>
|
||||
void this.#handleInteraction(interaction).catch((error) => {
|
||||
this.#logger.error("discord.interaction.failed", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
})
|
||||
);
|
||||
await client.login(this.#token);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.#client?.destroy();
|
||||
this.#client = undefined;
|
||||
}
|
||||
|
||||
async registerCommands(
|
||||
options: DiscordBridgeCommandRegistration = {},
|
||||
): Promise<void> {
|
||||
const client = await this.#readyClient();
|
||||
const commands = discordBridgeCommands();
|
||||
const commandNames = commands.map(commandName);
|
||||
const guildIds = await this.#guildIdsForCommandChannels(options.channelIds ?? []);
|
||||
if (guildIds.length === 0) {
|
||||
await client.application.commands.set(commands);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "global",
|
||||
commands: commandNames,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await client.application.commands.set([]);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "global-cleared",
|
||||
commands: [],
|
||||
});
|
||||
for (const guildId of guildIds) {
|
||||
const guild = await client.guilds.fetch(guildId);
|
||||
await guild.commands.set(commands);
|
||||
this.#logger.info("discord.commands.registered", {
|
||||
scope: "guild",
|
||||
guildId,
|
||||
commands: commandNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createThread(
|
||||
channelId: string,
|
||||
name: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
if (sourceMessageId) {
|
||||
const messages = getMessagesManager(channel);
|
||||
if (messages) {
|
||||
const sourceMessage = await messages.fetch(sourceMessageId);
|
||||
if (sourceMessage.startThread) {
|
||||
const thread = await sourceMessage.startThread({
|
||||
name,
|
||||
autoArchiveDuration: 1440,
|
||||
reason: "Codex Discord bridge thread",
|
||||
});
|
||||
if (thread.id) {
|
||||
return thread.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const threads = getThreadsManager(channel);
|
||||
if (!threads) {
|
||||
throw new Error(`Discord channel cannot create threads: ${channelId}`);
|
||||
}
|
||||
const thread = await threads.create({
|
||||
name,
|
||||
autoArchiveDuration: 1440,
|
||||
reason: "Codex Discord bridge thread",
|
||||
});
|
||||
if (!thread.id) {
|
||||
throw new Error("Discord did not return a thread id");
|
||||
}
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
async createForumPost(
|
||||
channelId: string,
|
||||
name: string,
|
||||
message: string,
|
||||
): Promise<{ threadId: string; messageId?: string }> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || typeof channel !== "object") {
|
||||
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
|
||||
}
|
||||
const threads = getThreadsManager(channel as ThreadCreatableChannel);
|
||||
if (!threads) {
|
||||
throw new Error(`Discord channel cannot create forum posts: ${channelId}`);
|
||||
}
|
||||
const thread = await threads.create({
|
||||
name,
|
||||
autoArchiveDuration: 10080,
|
||||
message: {
|
||||
content: splitDiscordMessage(message)[0] ?? "",
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
},
|
||||
reason: "Codex Discord bridge workspace post",
|
||||
});
|
||||
if (!thread.id) {
|
||||
throw new Error("Discord did not return a forum post thread id");
|
||||
}
|
||||
return { threadId: thread.id, messageId: thread.id };
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, text: string): Promise<string[]> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messageIds: string[] = [];
|
||||
const chunks = splitDiscordMessage(text);
|
||||
for (const chunk of chunks) {
|
||||
const sent = await channel.send({
|
||||
content: chunk,
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
if (typeof sent.id === "string") {
|
||||
messageIds.push(sent.id);
|
||||
}
|
||||
}
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
async updateMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
await message.edit({
|
||||
content: splitDiscordMessage(text)[0] ?? "",
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
await message.delete();
|
||||
}
|
||||
|
||||
async deleteWebhookMessages(
|
||||
channelId: string,
|
||||
options: { webhookUrl?: string } = {},
|
||||
): Promise<{ deleted: number; failed: number }> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const webhookId = options.webhookUrl
|
||||
? webhookIdFromUrl(options.webhookUrl)
|
||||
: undefined;
|
||||
let before: string | undefined;
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
for (;;) {
|
||||
const batch = await messages.fetch({ limit: 100, before });
|
||||
const fetched = [...batch.values()];
|
||||
if (fetched.length === 0) {
|
||||
break;
|
||||
}
|
||||
for (const message of fetched) {
|
||||
if (!message.webhookId) {
|
||||
continue;
|
||||
}
|
||||
if (webhookId && message.webhookId !== webhookId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await message.delete();
|
||||
deleted += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.#logger.debug("discord.webhookMessage.deleteFailed", {
|
||||
channelId,
|
||||
messageId: message.id,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
before = fetched[fetched.length - 1]?.id;
|
||||
if (fetched.length < 100 || !before) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { deleted, failed };
|
||||
}
|
||||
|
||||
async deleteThread(channelId: string): Promise<void> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || !("delete" in channel) || typeof channel.delete !== "function") {
|
||||
throw new Error(`Discord channel cannot be deleted: ${channelId}`);
|
||||
}
|
||||
await channel.delete("Codex Discord bridge clear command");
|
||||
}
|
||||
|
||||
async addThreadMembers(channelId: string, userIds: string[]): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const members = getThreadMembersManager(channel);
|
||||
if (!members) {
|
||||
throw new Error(`Discord channel cannot add thread members: ${channelId}`);
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
await members.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async addReactions(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
reactions: string[],
|
||||
): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
if (!message.react) {
|
||||
throw new Error(`Discord message cannot receive reactions: ${messageId}`);
|
||||
}
|
||||
for (const reaction of reactions) {
|
||||
await message.react(reaction);
|
||||
}
|
||||
}
|
||||
|
||||
async pinMessage(channelId: string, messageId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
if (!message.pin) {
|
||||
throw new Error(`Discord message cannot be pinned: ${messageId}`);
|
||||
}
|
||||
if (message.pinned) {
|
||||
return;
|
||||
}
|
||||
await message.pin();
|
||||
}
|
||||
|
||||
async sendTyping(channelId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
await channel.sendTyping?.();
|
||||
}
|
||||
|
||||
#handleMessage(message: Message): void {
|
||||
const botUserId = this.#client?.user?.id;
|
||||
if (
|
||||
botUserId &&
|
||||
!isThreadChannel(message.channel) &&
|
||||
message.mentions.users.has(botUserId)
|
||||
) {
|
||||
const mentionedUserIds = message.mentions.users
|
||||
.filter((user) => user.id !== botUserId && !user.bot)
|
||||
.map((user) => user.id);
|
||||
const prompt = stripUserMentions(message.content ?? "", [
|
||||
botUserId,
|
||||
...mentionedUserIds,
|
||||
]);
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threadStart",
|
||||
sourceMessageId: message.id,
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
name: message.member?.displayName ||
|
||||
message.author.globalName ||
|
||||
message.author.username,
|
||||
isBot: message.author.bot,
|
||||
},
|
||||
prompt,
|
||||
mentionedUserIds,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "message",
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
messageId: message.id,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
name: message.member?.displayName ||
|
||||
message.author.globalName ||
|
||||
message.author.username,
|
||||
isBot: message.author.bot,
|
||||
},
|
||||
content: message.content ?? "",
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async #handleReaction(
|
||||
reaction: MessageReaction | PartialMessageReaction,
|
||||
user: User | PartialUser,
|
||||
): Promise<void> {
|
||||
if (user.bot) {
|
||||
return;
|
||||
}
|
||||
const fullReaction = reaction.partial ? await reaction.fetch() : reaction;
|
||||
const message = fullReaction.message.partial
|
||||
? await fullReaction.message.fetch()
|
||||
: fullReaction.message;
|
||||
const emoji = fullReaction.emoji.name ?? fullReaction.emoji.id;
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "reaction",
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
messageId: message.id,
|
||||
emoji,
|
||||
author: {
|
||||
id: user.id,
|
||||
name: user.globalName ?? user.username ?? user.id,
|
||||
isBot: user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async #handleInteraction(interaction: Interaction): Promise<void> {
|
||||
if (interaction.isButton()) {
|
||||
const selection = threadPickerSelectionFromCustomId(interaction.customId);
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
await interaction.deferUpdate();
|
||||
const reply = async (text: string) => {
|
||||
await interaction.followUp({
|
||||
content: text,
|
||||
ephemeral: true,
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
const update = async (text: string) => {
|
||||
await interaction.editReply({
|
||||
content: text,
|
||||
components: [],
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
const updatePicker = async (picker: DiscordEphemeralPicker) => {
|
||||
await interaction.editReply({
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
});
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threadPicker",
|
||||
channelId: interaction.channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
pickerId: selection.pickerId,
|
||||
optionId: selection.optionId,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
update,
|
||||
updatePicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
interaction.commandName !== "clear" &&
|
||||
interaction.commandName !== "clear-webhooks" &&
|
||||
interaction.commandName !== "status" &&
|
||||
interaction.commandName !== "threads" &&
|
||||
interaction.commandName !== "goals"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const channelId = interaction.channelId;
|
||||
if (
|
||||
interaction.commandName === "status" ||
|
||||
interaction.commandName === "threads" ||
|
||||
interaction.commandName === "goals"
|
||||
) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
}
|
||||
const reply = async (text: string) => {
|
||||
const payload = {
|
||||
content: text,
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
if (interaction.commandName === "clear-webhooks") {
|
||||
const webhookUrl = interaction.options.getString("webhook_url") ?? undefined;
|
||||
this.#handlers?.onInbound({
|
||||
kind: "clearWebhooks",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
webhookUrl,
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "status") {
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "status",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "threads") {
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threads",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (interaction.commandName === "goals") {
|
||||
const goalStatus = goalStatusFromString(
|
||||
interaction.options.getString("status"),
|
||||
);
|
||||
const objective = interaction.options.getString("objective")?.trim() ||
|
||||
undefined;
|
||||
const tokenBudget = interaction.options.getInteger("token_budget") ??
|
||||
undefined;
|
||||
const clear = interaction.options.getBoolean("clear") ?? undefined;
|
||||
const replyPicker = async (picker: DiscordEphemeralPicker) => {
|
||||
const payload = {
|
||||
content: picker.text,
|
||||
components: threadPickerComponents(picker),
|
||||
allowedMentions: emptyAllowedMentions(),
|
||||
};
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(payload);
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ ...payload, ephemeral: true });
|
||||
};
|
||||
this.#handlers?.onInbound({
|
||||
kind: "goals",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
objective,
|
||||
goalStatus,
|
||||
tokenBudget,
|
||||
clear,
|
||||
reply,
|
||||
replyPicker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "clear",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply,
|
||||
});
|
||||
}
|
||||
|
||||
async #sendableChannel(channelId: string): Promise<SendableChannel> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || !("send" in channel)) {
|
||||
throw new Error(`Discord channel is not text-sendable: ${channelId}`);
|
||||
}
|
||||
return channel as unknown as SendableChannel;
|
||||
}
|
||||
|
||||
async #readyClient(): Promise<Client<true>> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
if (client.isReady()) {
|
||||
return client;
|
||||
}
|
||||
return await new Promise<Client<true>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Timed out waiting for Discord client ready event"));
|
||||
}, 15_000);
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(readyClient);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #guildIdsForCommandChannels(channelIds: string[]): Promise<string[]> {
|
||||
const client = await this.#readyClient();
|
||||
const guildIds = new Set<string>();
|
||||
for (const channelId of channelIds) {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
const guildId = guildIdFromChannel(channel);
|
||||
if (guildId) {
|
||||
guildIds.add(guildId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.debug("discord.commands.channelFetchFailed", {
|
||||
channelId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...guildIds];
|
||||
}
|
||||
}
|
||||
|
||||
function discordBridgeCommands(): ApplicationCommandDataResolvable[] {
|
||||
return [
|
||||
{
|
||||
name: "clear",
|
||||
description: "Delete inactive Codex bridge threads",
|
||||
},
|
||||
{
|
||||
name: "clear-webhooks",
|
||||
description: "Delete webhook-authored messages in this channel",
|
||||
options: [
|
||||
{
|
||||
name: "webhook_url",
|
||||
description: "Optional webhook URL to target a single webhook",
|
||||
type: 3,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "Show Codex workspace status",
|
||||
},
|
||||
{
|
||||
name: "threads",
|
||||
description: "List Codex threads for this workspace",
|
||||
},
|
||||
{
|
||||
name: "goals",
|
||||
description: "Manage Codex goals for this workspace or thread",
|
||||
options: [
|
||||
{
|
||||
name: "objective",
|
||||
description: "Create or update this thread's goal objective",
|
||||
type: 3,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
description: "Set this thread's goal status",
|
||||
type: 3,
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: "active", value: "active" },
|
||||
{ name: "paused", value: "paused" },
|
||||
{ name: "budget limited", value: "budgetLimited" },
|
||||
{ name: "complete", value: "complete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "token_budget",
|
||||
description: "Optional token budget for this thread's goal",
|
||||
type: 4,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear this thread's goal",
|
||||
type: 5,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function commandName(command: ApplicationCommandDataResolvable): string {
|
||||
return typeof command === "object" && command !== null && "name" in command
|
||||
? String(command.name)
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function goalStatusFromString(value: string | null): v2.ThreadGoalStatus | undefined {
|
||||
if (
|
||||
value === "active" ||
|
||||
value === "paused" ||
|
||||
value === "budgetLimited" ||
|
||||
value === "complete"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function threadPickerComponents(
|
||||
picker: DiscordEphemeralPicker,
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
for (let index = 0; index < picker.options.length; index += 5) {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
for (const option of picker.options.slice(index, index + 5)) {
|
||||
row.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(threadPickerCustomId(picker.pickerId, option.id))
|
||||
.setLabel(option.label)
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function threadPickerCustomId(pickerId: string, optionId: string): string {
|
||||
return `${threadPickerCustomIdPrefix}:${pickerId}:${optionId}`;
|
||||
}
|
||||
|
||||
function threadPickerSelectionFromCustomId(
|
||||
customId: string,
|
||||
): { pickerId: string; optionId: string } | undefined {
|
||||
const prefix = `${threadPickerCustomIdPrefix}:`;
|
||||
if (!customId.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const rest = customId.slice(prefix.length);
|
||||
const [pickerId, optionId, ...extra] = rest.split(":");
|
||||
if (!pickerId || !optionId || extra.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { pickerId, optionId };
|
||||
}
|
||||
|
||||
function emptyAllowedMentions(): Record<string, unknown> {
|
||||
return {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
function guildIdFromChannel(channel: unknown): string | undefined {
|
||||
if (!channel || typeof channel !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = channel as {
|
||||
guildId?: unknown;
|
||||
guild?: { id?: unknown };
|
||||
};
|
||||
if (typeof candidate.guildId === "string") {
|
||||
return candidate.guildId;
|
||||
}
|
||||
if (typeof candidate.guild?.id === "string") {
|
||||
return candidate.guild.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type ThreadCreateOptions = {
|
||||
name: string;
|
||||
autoArchiveDuration?: number;
|
||||
message?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type ThreadCreatableChannel = {
|
||||
id: string;
|
||||
threads?: {
|
||||
create(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type SendableChannel = ThreadCreatableChannel & {
|
||||
send(options: Record<string, unknown>): Promise<{ id?: string }>;
|
||||
sendTyping?: () => Promise<void>;
|
||||
members?: {
|
||||
add(userId: string): Promise<unknown>;
|
||||
};
|
||||
messages?: {
|
||||
fetch(messageId: string): Promise<DiscordFetchedMessage>;
|
||||
fetch(options: {
|
||||
limit: number;
|
||||
before?: string;
|
||||
}): Promise<{ values(): IterableIterator<DiscordFetchedMessage> }>;
|
||||
};
|
||||
};
|
||||
|
||||
type DiscordFetchedMessage = {
|
||||
id: string;
|
||||
webhookId?: string | null;
|
||||
delete(): Promise<unknown>;
|
||||
edit(options: Record<string, unknown>): Promise<unknown>;
|
||||
pinned?: boolean;
|
||||
pin?(): Promise<unknown>;
|
||||
react?(reaction: string): Promise<unknown>;
|
||||
startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
};
|
||||
|
||||
function getThreadsManager(
|
||||
channel: ThreadCreatableChannel,
|
||||
): ThreadCreatableChannel["threads"] | undefined {
|
||||
return channel.threads;
|
||||
}
|
||||
|
||||
function getMessagesManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["messages"] | undefined {
|
||||
return channel.messages;
|
||||
}
|
||||
|
||||
function getThreadMembersManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["members"] | undefined {
|
||||
return channel.members;
|
||||
}
|
||||
|
||||
function isThreadChannel(channel: unknown): boolean {
|
||||
return Boolean(
|
||||
channel &&
|
||||
typeof channel === "object" &&
|
||||
"isThread" in channel &&
|
||||
typeof channel.isThread === "function" &&
|
||||
channel.isThread(),
|
||||
);
|
||||
}
|
||||
|
||||
function stripUserMentions(content: string, userIds: string[]): string {
|
||||
let stripped = content;
|
||||
for (const userId of userIds) {
|
||||
stripped = stripped.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), "");
|
||||
}
|
||||
return stripped.trim();
|
||||
}
|
||||
|
||||
function webhookIdFromUrl(webhookUrl: string): string {
|
||||
const parsed = new URL(webhookUrl);
|
||||
const match = parsed.pathname.match(/\/webhooks\/(\d+)(?:\/|$)/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("Discord webhook URL does not include a webhook id");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { writeHookSpoolEvent } from "./stop-hook-spool.ts";
|
||||
|
||||
const defaultHookCommand = "codex-discord-bridge hook event";
|
||||
const defaultDlxPackage = "@peezy.tech/codex-discord-bridge";
|
||||
const workspaceHookEvents = [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
] as const;
|
||||
|
||||
export type HookInstallOptions = {
|
||||
command?: string;
|
||||
useDlx?: boolean;
|
||||
dlxPackage?: string;
|
||||
configPath?: string;
|
||||
hooksPath?: string;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export type HookInstallResult = {
|
||||
command: string;
|
||||
configPath: string;
|
||||
hooksPath: string;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export async function handleHookCommand(argv: string[]): Promise<boolean> {
|
||||
if (argv[0] !== "hook") {
|
||||
return false;
|
||||
}
|
||||
const subcommand = argv[1] ?? "help";
|
||||
if (subcommand === "event" || subcommand === "stop") {
|
||||
await runHookEvent();
|
||||
return true;
|
||||
}
|
||||
if (subcommand === "install") {
|
||||
const result = await installStopHook(parseInstallArgs(argv.slice(2)));
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
||||
process.stdout.write(hookHelpText());
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unknown hook subcommand: ${subcommand}`);
|
||||
}
|
||||
|
||||
export async function runHookEvent(): Promise<void> {
|
||||
let input = "";
|
||||
try {
|
||||
input = await readStdinText();
|
||||
const parsed = JSON.parse(input);
|
||||
const event = await writeHookSpoolEvent(parsed);
|
||||
if (eventSupportsContinueOutput(event.eventName)) {
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`discord workspace hook failed: ${errorMessage(error)}\n`);
|
||||
if (eventSupportsContinueOutput(eventNameFromHookInput(input))) {
|
||||
process.stdout.write(`${JSON.stringify({ continue: true })}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const runStopHook = runHookEvent;
|
||||
|
||||
async function readStdinText(): Promise<string> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
export async function installStopHook(
|
||||
options: HookInstallOptions = {},
|
||||
): Promise<HookInstallResult> {
|
||||
const configPath = path.resolve(
|
||||
expandHome(options.configPath ?? path.join(os.homedir(), ".codex", "config.toml")),
|
||||
);
|
||||
const hooksPath = path.resolve(
|
||||
expandHome(options.hooksPath ?? path.join(os.homedir(), ".codex", "hooks.json")),
|
||||
);
|
||||
const command = hookCommand(options);
|
||||
if (!options.dryRun) {
|
||||
const configText = await readTextIfExists(configPath);
|
||||
const hooksText = await readTextIfExists(hooksPath);
|
||||
await mkdir(path.dirname(configPath), { recursive: true });
|
||||
await mkdir(path.dirname(hooksPath), { recursive: true });
|
||||
await writeFile(configPath, enableHooksFeature(configText));
|
||||
await writeFile(hooksPath, `${JSON.stringify(upsertStopHookConfig(hooksText, command), null, 2)}\n`);
|
||||
}
|
||||
return {
|
||||
command,
|
||||
configPath,
|
||||
hooksPath,
|
||||
dryRun: Boolean(options.dryRun),
|
||||
};
|
||||
}
|
||||
|
||||
export function enableHooksFeature(configText: string): string {
|
||||
const lines = configText.replace(/\s*$/, "").split(/\r?\n/);
|
||||
if (lines.length === 1 && lines[0] === "") {
|
||||
return "[features]\nhooks = true\n";
|
||||
}
|
||||
const featureHeaderIndex = lines.findIndex((line) => line.trim() === "[features]");
|
||||
if (featureHeaderIndex < 0) {
|
||||
return `${lines.join("\n")}\n\n[features]\nhooks = true\n`;
|
||||
}
|
||||
let insertIndex = featureHeaderIndex + 1;
|
||||
while (insertIndex < lines.length && !lines[insertIndex]?.trim().startsWith("[")) {
|
||||
const line = lines[insertIndex] ?? "";
|
||||
if (/^\s*hooks\s*=/.test(line)) {
|
||||
lines[insertIndex] = "hooks = true";
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
insertIndex += 1;
|
||||
}
|
||||
lines.splice(featureHeaderIndex + 1, 0, "hooks = true");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function upsertStopHookConfig(
|
||||
hooksText: string,
|
||||
command: string,
|
||||
): Record<string, unknown> {
|
||||
const config = parseHooksJson(hooksText);
|
||||
const hooks = record(config.hooks);
|
||||
for (const eventName of workspaceHookEvents) {
|
||||
const groups = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
|
||||
hooks[eventName] = [
|
||||
hookGroup(command),
|
||||
...groups
|
||||
.map(removeWorkspaceStopHookHandlers)
|
||||
.filter((group): group is Record<string, unknown> => group !== undefined),
|
||||
];
|
||||
}
|
||||
config.hooks = hooks;
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseInstallArgs(argv: string[]): HookInstallOptions {
|
||||
const options: HookInstallOptions = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg) {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dlx") {
|
||||
options.useDlx = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--command") {
|
||||
options.command = requiredNext(argv, ++index, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--command=")) {
|
||||
options.command = arg.slice("--command=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dlx-package") {
|
||||
options.useDlx = true;
|
||||
options.dlxPackage = requiredNext(argv, ++index, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--dlx-package=")) {
|
||||
options.useDlx = true;
|
||||
options.dlxPackage = arg.slice("--dlx-package=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--config-path") {
|
||||
options.configPath = requiredNext(argv, ++index, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--config-path=")) {
|
||||
options.configPath = arg.slice("--config-path=".length);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--hooks-path") {
|
||||
options.hooksPath = requiredNext(argv, ++index, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--hooks-path=")) {
|
||||
options.hooksPath = arg.slice("--hooks-path=".length);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown hook install option: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function hookCommand(options: HookInstallOptions): string {
|
||||
if (options.command && options.useDlx) {
|
||||
throw new Error("Cannot set both --command and --dlx.");
|
||||
}
|
||||
if (options.command) {
|
||||
return options.command;
|
||||
}
|
||||
if (options.useDlx || options.dlxPackage) {
|
||||
return `vp dlx ${options.dlxPackage ?? defaultDlxPackage} ${defaultHookCommand}`;
|
||||
}
|
||||
return defaultHookCommand;
|
||||
}
|
||||
|
||||
function hookGroup(command: string): Record<string, unknown> {
|
||||
return {
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command,
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorkspaceStopHookHandlers(input: unknown): Record<string, unknown> | undefined {
|
||||
const group = record(input);
|
||||
const handlers = Array.isArray(group.hooks)
|
||||
? group.hooks.filter((handler) => !isWorkspaceStopHookHandler(handler))
|
||||
: [];
|
||||
if (handlers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...group, hooks: handlers };
|
||||
}
|
||||
|
||||
function isWorkspaceStopHookHandler(input: unknown): boolean {
|
||||
const handler = record(input);
|
||||
const command = typeof handler.command === "string" ? handler.command : "";
|
||||
return command.includes("codex-discord-bridge hook stop") ||
|
||||
command.includes("codex-discord-bridge hook event") ||
|
||||
command.includes("codex-discord-workspace-stop-hook") ||
|
||||
/apps\/discord-bridge\/(?:dist\/index\.js|src\/(?:index|stop-hook)\.ts)\s+hook\s+(?:event|stop)\b/.test(command);
|
||||
}
|
||||
|
||||
function eventSupportsContinueOutput(eventName: string): boolean {
|
||||
return eventName === "SessionStart" ||
|
||||
eventName === "UserPromptSubmit" ||
|
||||
eventName === "Stop";
|
||||
}
|
||||
|
||||
function eventNameFromHookInput(input: string): string {
|
||||
try {
|
||||
const parsed = record(JSON.parse(input));
|
||||
return typeof parsed.hook_event_name === "string"
|
||||
? parsed.hook_event_name
|
||||
: typeof parsed.eventName === "string"
|
||||
? parsed.eventName
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextIfExists(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: "";
|
||||
if (code === "ENOENT") {
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHooksJson(text: string): Record<string, unknown> {
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return record(JSON.parse(text));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse hooks.json: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredNext(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index];
|
||||
if (!value) {
|
||||
throw new Error(`${flag} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hookHelpText(): string {
|
||||
return `codex-discord-bridge hook manages the global Codex observability hooks.
|
||||
|
||||
Usage:
|
||||
codex-discord-bridge hook install [options]
|
||||
codex-discord-bridge hook event
|
||||
|
||||
Options:
|
||||
--command <cmd> Hook command to write. Defaults to "codex-discord-bridge hook event".
|
||||
--dlx Write a VitePlus package-on-demand command instead of the global binary command.
|
||||
--dlx-package <pkg> Package for vp dlx. Defaults to @peezy.tech/codex-discord-bridge.
|
||||
--config-path <path> Codex config.toml path.
|
||||
--hooks-path <path> Codex hooks.json path.
|
||||
--dry-run Print the planned install result without writing files.
|
||||
`;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import type { DiscordCodexBridge } from "./bridge.ts";
|
||||
import { handleHookCommand } from "./hook-cli.ts";
|
||||
import { parseConfig } from "./config.ts";
|
||||
import { createDiscordBridgeLogger } from "./logger.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let logger = createDiscordBridgeLogger();
|
||||
try {
|
||||
const argv = process.argv.slice(2);
|
||||
if (await handleHookCommand(argv)) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseConfig(argv, process.env);
|
||||
if (parsed.type === "help") {
|
||||
process.stdout.write(parsed.text);
|
||||
return;
|
||||
}
|
||||
logger = createDiscordBridgeLogger({
|
||||
debug: parsed.config.debug,
|
||||
logLevel: parsed.config.logLevel,
|
||||
});
|
||||
const { CodexAppServerClient, CodexStdioTransport } = await import(
|
||||
"@peezy.tech/codex-flows"
|
||||
);
|
||||
const { DiscordCodexBridge } = await import("./bridge.ts");
|
||||
const { createDiscordConsoleOutput } = await import("./console-output.ts");
|
||||
const { DiscordJsBridgeTransport } = await import("./discord-transport.ts");
|
||||
const { JsonFileStateStore } = await import("./state.ts");
|
||||
const consoleOutput = parsed.config.consoleOutput === "messages"
|
||||
? createDiscordConsoleOutput()
|
||||
: undefined;
|
||||
const client = new CodexAppServerClient({
|
||||
transport: parsed.localAppServer
|
||||
? new CodexStdioTransport({
|
||||
args: localAppServerArgs(),
|
||||
requestTimeoutMs: 90_000,
|
||||
})
|
||||
: undefined,
|
||||
webSocketTransportOptions: parsed.appServerUrl
|
||||
? { url: parsed.appServerUrl, requestTimeoutMs: 90_000 }
|
||||
: undefined,
|
||||
clientName: "codex-discord-bridge",
|
||||
clientTitle: "Codex Discord Bridge",
|
||||
clientVersion: "0.1.0",
|
||||
});
|
||||
const bridge = new DiscordCodexBridge({
|
||||
client,
|
||||
transport: new DiscordJsBridgeTransport({
|
||||
token: parsed.discordToken,
|
||||
logger,
|
||||
}),
|
||||
store: new JsonFileStateStore(parsed.config.statePath),
|
||||
config: parsed.config,
|
||||
logger,
|
||||
consoleOutput,
|
||||
});
|
||||
await bridge.start();
|
||||
logger.info("bridge.started", {
|
||||
appServerUrl: parsed.appServerUrl ?? "local",
|
||||
localAppServer: Boolean(parsed.localAppServer),
|
||||
progressMode: parsed.config.progressMode ?? "summary",
|
||||
statePath: parsed.config.statePath,
|
||||
});
|
||||
await waitForShutdown(bridge);
|
||||
} catch (error) {
|
||||
logger.error("bridge.fatal", { error: errorMessage(error) });
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function localAppServerArgs(): string[] {
|
||||
return [
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
"--enable",
|
||||
"apps",
|
||||
"--enable",
|
||||
"hooks",
|
||||
];
|
||||
}
|
||||
|
||||
function waitForShutdown(bridge: DiscordCodexBridge): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const shutdown = () => {
|
||||
process.off("SIGINT", shutdown);
|
||||
process.off("SIGTERM", shutdown);
|
||||
void bridge.stop().finally(resolve);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
await main();
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
|||
export type DiscordBridgeLogLevel = "debug" | "info" | "warn" | "error";
|
||||
export type DiscordBridgeLogLevelSetting = DiscordBridgeLogLevel | "silent";
|
||||
|
||||
export type DiscordBridgeLogFields = Record<string, unknown>;
|
||||
|
||||
export type DiscordBridgeLogger = {
|
||||
debug(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
info(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
warn(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
error(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeLoggerOptions = {
|
||||
component?: string;
|
||||
debug?: boolean;
|
||||
logLevel?: DiscordBridgeLogLevelSetting;
|
||||
now?: () => Date;
|
||||
stream?: Pick<NodeJS.WriteStream, "write">;
|
||||
};
|
||||
|
||||
const logLevelRanks: Record<DiscordBridgeLogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
export function createDiscordBridgeLogger(
|
||||
options: DiscordBridgeLoggerOptions = {},
|
||||
): DiscordBridgeLogger {
|
||||
const component = options.component ?? "codex-discord-bridge";
|
||||
const now = options.now ?? (() => new Date());
|
||||
const stream = options.stream ?? process.stderr;
|
||||
const logLevel = options.logLevel ?? (options.debug ? "debug" : "info");
|
||||
|
||||
const write = (
|
||||
level: DiscordBridgeLogLevel,
|
||||
event: string,
|
||||
fields: DiscordBridgeLogFields = {},
|
||||
): void => {
|
||||
if (!shouldWrite(level, logLevel)) {
|
||||
return;
|
||||
}
|
||||
stream.write(
|
||||
`${JSON.stringify({
|
||||
time: now().toISOString(),
|
||||
component,
|
||||
level,
|
||||
event,
|
||||
...fields,
|
||||
})}\n`,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
debug: (event, fields) => write("debug", event, fields),
|
||||
info: (event, fields) => write("info", event, fields),
|
||||
warn: (event, fields) => write("warn", event, fields),
|
||||
error: (event, fields) => write("error", event, fields),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldWrite(
|
||||
level: DiscordBridgeLogLevel,
|
||||
configured: DiscordBridgeLogLevelSetting,
|
||||
): boolean {
|
||||
if (configured === "silent") {
|
||||
return false;
|
||||
}
|
||||
return logLevelRanks[level] >= logLevelRanks[configured];
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import type { DiscordBridgeLogLevel } from "./logger.ts";
|
||||
|
||||
type PrettyLogOptions = {
|
||||
color?: boolean;
|
||||
name?: string;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
type PrettyLogRecord = Record<string, unknown> & {
|
||||
component?: unknown;
|
||||
event?: unknown;
|
||||
level?: unknown;
|
||||
message?: unknown;
|
||||
time?: unknown;
|
||||
};
|
||||
|
||||
const reservedFields = new Set(["time", "component", "level", "event"]);
|
||||
const resetColor = "\x1b[0m";
|
||||
const levelColors: Record<DiscordBridgeLogLevel, string> = {
|
||||
debug: "\x1b[90m",
|
||||
info: "\x1b[36m",
|
||||
warn: "\x1b[33m",
|
||||
error: "\x1b[31m",
|
||||
};
|
||||
|
||||
export function formatPrettyLogLine(
|
||||
line: string,
|
||||
options: PrettyLogOptions = {},
|
||||
): string {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const record = parseRecord(line);
|
||||
if (!record) {
|
||||
return formatParts({
|
||||
color: options.color ?? false,
|
||||
component: options.name ?? "process",
|
||||
fields: "",
|
||||
level: "info",
|
||||
message: line,
|
||||
time: formatTime(now()),
|
||||
});
|
||||
}
|
||||
|
||||
const level = normalizeLevel(record.level);
|
||||
const message = stringifyMainMessage(record);
|
||||
return formatParts({
|
||||
color: options.color ?? false,
|
||||
component: stringifyComponent(record.component, options.name),
|
||||
fields: stringifyFields(record),
|
||||
level,
|
||||
message,
|
||||
time: formatTime(record.time, now),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPrettyLogCli(
|
||||
args: string[],
|
||||
input: AsyncIterable<string | Uint8Array>,
|
||||
output: Pick<NodeJS.WriteStream, "write">,
|
||||
): Promise<void> {
|
||||
const options = parseCliArgs(args);
|
||||
let buffer = "";
|
||||
for await (const chunk of input) {
|
||||
buffer += typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8");
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = trimTrailingCarriageReturn(buffer.slice(0, newlineIndex));
|
||||
output.write(`${formatPrettyLogLine(line, options)}\n`);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
output.write(
|
||||
`${formatPrettyLogLine(trimTrailingCarriageReturn(buffer), options)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(args: string[]): PrettyLogOptions {
|
||||
const options: PrettyLogOptions = {
|
||||
color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--name") {
|
||||
const name = args[index + 1];
|
||||
if (!name) {
|
||||
throw new Error("Missing value for --name");
|
||||
}
|
||||
options.name = name;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--color") {
|
||||
options.color = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-color") {
|
||||
options.color = false;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected argument: ${arg ?? ""}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseRecord(line: string): PrettyLogRecord | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
return value !== null && typeof value === "object"
|
||||
? value as PrettyLogRecord
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLevel(level: unknown): DiscordBridgeLogLevel {
|
||||
if (typeof level !== "string") {
|
||||
return "info";
|
||||
}
|
||||
const normalized = level.toLowerCase();
|
||||
if (
|
||||
normalized === "debug" || normalized === "info" || normalized === "warn" ||
|
||||
normalized === "error"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function stringifyComponent(component: unknown, fallback: string | undefined): string {
|
||||
return typeof component === "string" && component.length > 0
|
||||
? component
|
||||
: fallback ?? "process";
|
||||
}
|
||||
|
||||
function stringifyMainMessage(record: PrettyLogRecord): string {
|
||||
if (typeof record.event === "string" && record.event.length > 0) {
|
||||
return record.event;
|
||||
}
|
||||
if (typeof record.message === "string" && record.message.length > 0) {
|
||||
return record.message;
|
||||
}
|
||||
return "log";
|
||||
}
|
||||
|
||||
function stringifyFields(record: PrettyLogRecord): string {
|
||||
const fields: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (reservedFields.has(key) || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key === "message" && typeof record.event !== "string") {
|
||||
continue;
|
||||
}
|
||||
fields.push(`${key}=${stringifyFieldValue(value)}`);
|
||||
}
|
||||
return fields.join(" ");
|
||||
}
|
||||
|
||||
function stringifyFieldValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return /^[^\s=]+$/.test(value) ? value : JSON.stringify(value);
|
||||
}
|
||||
if (
|
||||
typeof value === "number" || typeof value === "boolean" || value === null
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
|
||||
function formatTime(time: unknown, now?: () => Date): string {
|
||||
const date = time instanceof Date
|
||||
? time
|
||||
: typeof time === "string" || typeof time === "number"
|
||||
? new Date(time)
|
||||
: now?.() ?? new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
const fallback = now?.() ?? new Date();
|
||||
return fallback.toISOString().slice(11, 23);
|
||||
}
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function formatParts(options: {
|
||||
color: boolean;
|
||||
component: string;
|
||||
fields: string;
|
||||
level: DiscordBridgeLogLevel;
|
||||
message: string;
|
||||
time: string;
|
||||
}): string {
|
||||
const level = options.level.toUpperCase().padEnd(5);
|
||||
const coloredLevel = colorize(level, levelColors[options.level], options.color);
|
||||
const message = options.fields.length > 0
|
||||
? `${options.message} ${options.fields}`
|
||||
: options.message;
|
||||
return `[${options.time}] ${coloredLevel} ${options.component} ${message}`;
|
||||
}
|
||||
|
||||
function colorize(text: string, color: string, enabled: boolean): string {
|
||||
return enabled ? `${color}${text}${resetColor}` : text;
|
||||
}
|
||||
|
||||
function trimTrailingCarriageReturn(line: string): string {
|
||||
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await runPrettyLogCli(process.argv.slice(2), process.stdin, process.stdout);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`pretty-log failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,456 +0,0 @@
|
|||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type {
|
||||
DiscordBridgeActiveTurn,
|
||||
DiscordBridgeDelivery,
|
||||
DiscordBridgeQueueItem,
|
||||
DiscordBridgeSession,
|
||||
DiscordBridgeState,
|
||||
DiscordBridgeStateStore,
|
||||
DiscordWorkspaceDelegation,
|
||||
DiscordWorkspaceHookEventName,
|
||||
DiscordWorkspaceObservedThread,
|
||||
DiscordWorkspaceWorkspaceSurface,
|
||||
DiscordWorkspaceState,
|
||||
} from "./types.ts";
|
||||
|
||||
const maxProcessedMessageIds = 1000;
|
||||
const maxDeliveries = 500;
|
||||
const maxProcessedStopHookEventIds = 2000;
|
||||
const maxProcessedHookEventIds = 5000;
|
||||
const maxObservedThreads = 1000;
|
||||
|
||||
export class JsonFileStateStore implements DiscordBridgeStateStore {
|
||||
readonly path: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.path = path.resolve(filePath);
|
||||
}
|
||||
|
||||
async load(): Promise<DiscordBridgeState> {
|
||||
if (!(await exists(this.path))) {
|
||||
return emptyState();
|
||||
}
|
||||
const parsed = JSON.parse(await readFile(this.path, "utf8")) as unknown;
|
||||
return parseState(parsed);
|
||||
}
|
||||
|
||||
async save(state: DiscordBridgeState): Promise<void> {
|
||||
trimState(state);
|
||||
await mkdir(path.dirname(this.path), { recursive: true });
|
||||
const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
await rename(tempPath, this.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(pathValue: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(pathValue);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryStateStore implements DiscordBridgeStateStore {
|
||||
state: DiscordBridgeState;
|
||||
|
||||
constructor(state: DiscordBridgeState = emptyState()) {
|
||||
this.state = structuredClone(state);
|
||||
}
|
||||
|
||||
async load(): Promise<DiscordBridgeState> {
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async save(state: DiscordBridgeState): Promise<void> {
|
||||
this.state = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyState(): DiscordBridgeState {
|
||||
return {
|
||||
version: 1,
|
||||
workspace: undefined,
|
||||
sessions: [],
|
||||
queue: [],
|
||||
activeTurns: [],
|
||||
processedMessageIds: [],
|
||||
deliveries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function trimState(state: DiscordBridgeState): void {
|
||||
state.processedMessageIds = state.processedMessageIds.slice(
|
||||
-maxProcessedMessageIds,
|
||||
);
|
||||
state.deliveries = state.deliveries.slice(-maxDeliveries);
|
||||
if (state.workspace?.processedStopHookEventIds) {
|
||||
state.workspace.processedStopHookEventIds =
|
||||
state.workspace.processedStopHookEventIds.slice(
|
||||
-maxProcessedStopHookEventIds,
|
||||
);
|
||||
}
|
||||
if (state.workspace?.processedHookEventIds) {
|
||||
state.workspace.processedHookEventIds =
|
||||
state.workspace.processedHookEventIds.slice(-maxProcessedHookEventIds);
|
||||
}
|
||||
if (state.workspace?.observedThreads) {
|
||||
state.workspace.observedThreads = [...state.workspace.observedThreads]
|
||||
.sort((left, right) =>
|
||||
Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt)
|
||||
)
|
||||
.slice(0, maxObservedThreads);
|
||||
}
|
||||
}
|
||||
|
||||
function parseState(value: unknown): DiscordBridgeState {
|
||||
if (!isRecord(value) || value.version !== 1) {
|
||||
throw new Error("Invalid Discord bridge state file");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
workspace: parseWorkspace(value.workspace),
|
||||
sessions: Array.isArray(value.sessions)
|
||||
? value.sessions.map(parseSession)
|
||||
: [],
|
||||
queue: Array.isArray(value.queue) ? value.queue.map(parseQueueItem) : [],
|
||||
activeTurns: Array.isArray(value.activeTurns)
|
||||
? value.activeTurns.map(parseActiveTurn)
|
||||
: [],
|
||||
processedMessageIds: Array.isArray(value.processedMessageIds)
|
||||
? value.processedMessageIds.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string",
|
||||
)
|
||||
: [],
|
||||
deliveries: Array.isArray(value.deliveries)
|
||||
? value.deliveries.map(parseDelivery)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspace(value: unknown): DiscordWorkspaceState | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge workspace state");
|
||||
}
|
||||
return {
|
||||
homeChannelId: requiredString(value.homeChannelId, "workspace.homeChannelId"),
|
||||
mainThreadId: optionalString(value.mainThreadId),
|
||||
statusMessageId: optionalString(value.statusMessageId),
|
||||
createdAt: optionalString(value.createdAt),
|
||||
toolsVersion: optionalNumber(value.toolsVersion),
|
||||
delegations: Array.isArray(value.delegations)
|
||||
? value.delegations.map(parseWorkspaceDelegation)
|
||||
: [],
|
||||
workspaces: Array.isArray(value.workspaces)
|
||||
? value.workspaces.map(parseWorkspaceWorkspace)
|
||||
: [],
|
||||
observedThreads: Array.isArray(value.observedThreads)
|
||||
? value.observedThreads.map(parseWorkspaceObservedThread)
|
||||
: [],
|
||||
pendingWakes: Array.isArray(value.pendingWakes)
|
||||
? value.pendingWakes.map(parseWorkspacePendingWake)
|
||||
: [],
|
||||
processedHookEventIds: uniqueStrings([
|
||||
...(Array.isArray(value.processedHookEventIds)
|
||||
? value.processedHookEventIds
|
||||
: []),
|
||||
...(Array.isArray(value.processedStopHookEventIds)
|
||||
? value.processedStopHookEventIds
|
||||
: []),
|
||||
]),
|
||||
processedStopHookEventIds: Array.isArray(value.processedStopHookEventIds)
|
||||
? uniqueStrings(value.processedStopHookEventIds)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceDelegation(value: unknown): DiscordWorkspaceDelegation {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge workspace delegation");
|
||||
}
|
||||
const status = value.status;
|
||||
if (
|
||||
status !== "active" &&
|
||||
status !== "idle" &&
|
||||
status !== "failed" &&
|
||||
status !== "complete" &&
|
||||
status !== "reported"
|
||||
) {
|
||||
throw new Error("Invalid Discord bridge workspace delegation status");
|
||||
}
|
||||
return {
|
||||
id: requiredString(value.id, "workspace.delegations.id"),
|
||||
codexThreadId: requiredString(
|
||||
value.codexThreadId,
|
||||
"workspace.delegations.codexThreadId",
|
||||
),
|
||||
title: requiredString(value.title, "workspace.delegations.title"),
|
||||
status,
|
||||
cwd: optionalString(value.cwd),
|
||||
workspaceKey: optionalString(value.workspaceKey),
|
||||
surfaceKey: optionalString(value.surfaceKey),
|
||||
groupId: optionalString(value.groupId),
|
||||
returnMode: parseReturnMode(value.returnMode),
|
||||
discordDetailThreadId: optionalString(value.discordDetailThreadId),
|
||||
discordTaskThreadId: optionalString(value.discordTaskThreadId),
|
||||
discordWorkspaceThreadId: optionalString(value.discordWorkspaceThreadId),
|
||||
parentDiscordMessageId: optionalString(value.parentDiscordMessageId),
|
||||
lastTurnId: optionalString(value.lastTurnId),
|
||||
lastStatus: optionalString(value.lastStatus),
|
||||
lastFinal: optionalString(value.lastFinal),
|
||||
completedAt: optionalString(value.completedAt),
|
||||
injectedAt: optionalString(value.injectedAt),
|
||||
mirroredAt: optionalString(value.mirroredAt),
|
||||
taskMirroredAt: optionalString(value.taskMirroredAt),
|
||||
reportedAt: optionalString(value.reportedAt),
|
||||
createdAt: requiredString(value.createdAt, "workspace.delegations.createdAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "workspace.delegations.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceWorkspace(value: unknown): DiscordWorkspaceWorkspaceSurface {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge workspace workspace");
|
||||
}
|
||||
return {
|
||||
key: requiredString(value.key, "workspace.workspaces.key"),
|
||||
surfaceKey: optionalString(value.surfaceKey),
|
||||
cwd: requiredString(value.cwd, "workspace.workspaces.cwd"),
|
||||
title: requiredString(value.title, "workspace.workspaces.title"),
|
||||
discordThreadId: requiredString(
|
||||
value.discordThreadId,
|
||||
"workspace.workspaces.discordThreadId",
|
||||
),
|
||||
statusMessageId: optionalString(value.statusMessageId),
|
||||
delegationIds: Array.isArray(value.delegationIds)
|
||||
? uniqueStrings(value.delegationIds)
|
||||
: [],
|
||||
createdAt: requiredString(value.createdAt, "workspace.workspaces.createdAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "workspace.workspaces.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspacePendingWake(
|
||||
value: unknown,
|
||||
): NonNullable<DiscordWorkspaceState["pendingWakes"]>[number] {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge workspace pending wake");
|
||||
}
|
||||
const kind = value.kind === "delegation" || value.kind === "group"
|
||||
? value.kind
|
||||
: undefined;
|
||||
if (!kind) {
|
||||
throw new Error("Invalid Discord bridge workspace pending wake kind");
|
||||
}
|
||||
return {
|
||||
id: requiredString(value.id, "workspace.pendingWakes.id"),
|
||||
kind,
|
||||
delegationIds: Array.isArray(value.delegationIds)
|
||||
? uniqueStrings(value.delegationIds)
|
||||
: [],
|
||||
groupId: optionalString(value.groupId),
|
||||
reason: requiredString(value.reason, "workspace.pendingWakes.reason"),
|
||||
createdAt: requiredString(value.createdAt, "workspace.pendingWakes.createdAt"),
|
||||
startedAt: optionalString(value.startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceObservedThread(value: unknown): DiscordWorkspaceObservedThread {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge workspace observed thread");
|
||||
}
|
||||
return {
|
||||
threadId: requiredString(value.threadId, "workspace.observedThreads.threadId"),
|
||||
title: optionalString(value.title),
|
||||
status: parseObservedThreadStatus(value.status),
|
||||
cwd: optionalString(value.cwd),
|
||||
workspaceKey: optionalString(value.workspaceKey),
|
||||
surfaceKey: optionalString(value.surfaceKey),
|
||||
model: optionalString(value.model),
|
||||
transcriptPath: optionalString(value.transcriptPath),
|
||||
lastTurnId: optionalString(value.lastTurnId),
|
||||
lastHookEventName: parseHookEventName(value.lastHookEventName),
|
||||
source: optionalString(value.source),
|
||||
promptPreview: optionalString(value.promptPreview),
|
||||
assistantPreview: optionalString(value.assistantPreview),
|
||||
toolName: optionalString(value.toolName),
|
||||
toolUseId: optionalString(value.toolUseId),
|
||||
toolInputPreview: optionalString(value.toolInputPreview),
|
||||
toolResponsePreview: optionalString(value.toolResponsePreview),
|
||||
permissionDescription: optionalString(value.permissionDescription),
|
||||
firstSeenAt: requiredString(value.firstSeenAt, "workspace.observedThreads.firstSeenAt"),
|
||||
lastSeenAt: requiredString(value.lastSeenAt, "workspace.observedThreads.lastSeenAt"),
|
||||
updatedAt: requiredString(value.updatedAt, "workspace.observedThreads.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseObservedThreadStatus(
|
||||
value: unknown,
|
||||
): DiscordWorkspaceObservedThread["status"] {
|
||||
return value === "starting" ||
|
||||
value === "active" ||
|
||||
value === "tool" ||
|
||||
value === "waiting" ||
|
||||
value === "idle"
|
||||
? value
|
||||
: "idle";
|
||||
}
|
||||
|
||||
function parseHookEventName(value: unknown): DiscordWorkspaceHookEventName | undefined {
|
||||
return value === "SessionStart" ||
|
||||
value === "UserPromptSubmit" ||
|
||||
value === "PreToolUse" ||
|
||||
value === "PermissionRequest" ||
|
||||
value === "PostToolUse" ||
|
||||
value === "Stop"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseReturnMode(value: unknown): DiscordWorkspaceDelegation["returnMode"] {
|
||||
return value === "detached" ||
|
||||
value === "record_only" ||
|
||||
value === "wake_on_done" ||
|
||||
value === "wake_on_group" ||
|
||||
value === "manual"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseActiveTurn(value: unknown): DiscordBridgeActiveTurn {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge active turn");
|
||||
}
|
||||
const origin = value.origin === "discord" || value.origin === "external"
|
||||
? value.origin
|
||||
: "external";
|
||||
return {
|
||||
turnId: requiredString(value.turnId, "activeTurns.turnId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "activeTurns.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "activeTurns.codexThreadId"),
|
||||
origin,
|
||||
queueItemId: optionalString(value.queueItemId),
|
||||
startedAt: optionalString(value.startedAt),
|
||||
observedAt: requiredString(value.observedAt, "activeTurns.observedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSession(value: unknown): DiscordBridgeSession {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge session");
|
||||
}
|
||||
return {
|
||||
discordThreadId: requiredString(value.discordThreadId, "session.discordThreadId"),
|
||||
parentChannelId: requiredString(value.parentChannelId, "session.parentChannelId"),
|
||||
guildId: optionalString(value.guildId),
|
||||
surfaceKey: optionalString(value.surfaceKey),
|
||||
sourceMessageId: optionalString(value.sourceMessageId),
|
||||
codexThreadId: requiredString(value.codexThreadId, "session.codexThreadId"),
|
||||
title: requiredString(value.title, "session.title"),
|
||||
createdAt: requiredString(value.createdAt, "session.createdAt"),
|
||||
ownerUserId: optionalString(value.ownerUserId),
|
||||
participantUserIds: Array.isArray(value.participantUserIds)
|
||||
? uniqueStrings(value.participantUserIds)
|
||||
: undefined,
|
||||
cwd: optionalString(value.cwd),
|
||||
mode: parseSessionMode(value.mode),
|
||||
statusMessageId: optionalString(value.statusMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueueItem(value: unknown): DiscordBridgeQueueItem {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge queue item");
|
||||
}
|
||||
const status = value.status;
|
||||
if (status !== "pending" && status !== "processing" && status !== "failed") {
|
||||
throw new Error("Invalid Discord bridge queue item status");
|
||||
}
|
||||
return {
|
||||
id: requiredString(value.id, "queue.id"),
|
||||
status,
|
||||
discordMessageId: requiredString(value.discordMessageId, "queue.discordMessageId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "queue.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "queue.codexThreadId"),
|
||||
authorId: requiredString(value.authorId, "queue.authorId"),
|
||||
authorName: requiredString(value.authorName, "queue.authorName"),
|
||||
content: requiredString(value.content, "queue.content"),
|
||||
createdAt: requiredString(value.createdAt, "queue.createdAt"),
|
||||
receivedAt: requiredString(value.receivedAt, "queue.receivedAt"),
|
||||
attempts: optionalNumber(value.attempts) ?? 0,
|
||||
turnId: optionalString(value.turnId),
|
||||
lastError: optionalString(value.lastError),
|
||||
nextAttemptAt: optionalString(value.nextAttemptAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDelivery(value: unknown): DiscordBridgeDelivery {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge delivery");
|
||||
}
|
||||
const kind = value.kind;
|
||||
if (
|
||||
kind !== "summary" &&
|
||||
kind !== "commentary" &&
|
||||
kind !== "final" &&
|
||||
kind !== "error"
|
||||
) {
|
||||
throw new Error("Invalid Discord bridge delivery kind");
|
||||
}
|
||||
return {
|
||||
discordMessageId: requiredString(value.discordMessageId, "delivery.discordMessageId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "delivery.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "delivery.codexThreadId"),
|
||||
turnId: optionalString(value.turnId),
|
||||
kind,
|
||||
outboundMessageIds: Array.isArray(value.outboundMessageIds)
|
||||
? value.outboundMessageIds.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string",
|
||||
)
|
||||
: [],
|
||||
deliveredAt: requiredString(value.deliveredAt, "delivery.deliveredAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, fieldName: string): string {
|
||||
const parsed = optionalString(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Discord bridge state ${fieldName}: expected string`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] {
|
||||
return value === "new" ||
|
||||
value === "resumed" ||
|
||||
value === "workspace" ||
|
||||
value === "delegated" ||
|
||||
value === "workspace"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: unknown[]): string[] {
|
||||
return [...new Set(values.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
|
||||
))];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
DiscordWorkspaceHookEvent,
|
||||
DiscordWorkspaceHookEventName,
|
||||
} from "./types.ts";
|
||||
|
||||
export type HookEventSpoolDisposition = "processed" | "ignored" | "failed";
|
||||
export type StopHookSpoolDisposition = HookEventSpoolDisposition;
|
||||
|
||||
export type PendingHookEventSpoolFile =
|
||||
| {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
event: DiscordWorkspaceHookEvent;
|
||||
}
|
||||
| {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
error: Error;
|
||||
};
|
||||
export type PendingStopHookSpoolFile = PendingHookEventSpoolFile;
|
||||
|
||||
export function defaultStopHookSpoolDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "discord-bridge", "stop-hooks");
|
||||
}
|
||||
|
||||
export function stopHookSpoolDirFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return env.CODEX_DISCORD_HOOK_SPOOL_DIR || defaultStopHookSpoolDir();
|
||||
}
|
||||
|
||||
export function stopHookSpoolPaths(spoolDir: string): Record<
|
||||
"pending" | HookEventSpoolDisposition,
|
||||
string
|
||||
> {
|
||||
const root = path.resolve(spoolDir);
|
||||
return {
|
||||
pending: path.join(root, "pending"),
|
||||
processed: path.join(root, "processed"),
|
||||
ignored: path.join(root, "ignored"),
|
||||
failed: path.join(root, "failed"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureStopHookSpool(spoolDir: string): Promise<void> {
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await Promise.all(Object.values(paths).map((dir) => mkdir(dir, { recursive: true })));
|
||||
}
|
||||
|
||||
export async function writeStopHookSpoolEvent(
|
||||
input: unknown,
|
||||
options: {
|
||||
spoolDir?: string;
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<DiscordWorkspaceHookEvent> {
|
||||
return await writeHookSpoolEvent(input, options);
|
||||
}
|
||||
|
||||
export async function writeHookSpoolEvent(
|
||||
input: unknown,
|
||||
options: {
|
||||
spoolDir?: string;
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<DiscordWorkspaceHookEvent> {
|
||||
const spoolDir = options.spoolDir ?? stopHookSpoolDirFromEnv();
|
||||
const event = hookEventFromInput(input, options.now ?? (() => new Date()));
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await mkdir(paths.pending, { recursive: true });
|
||||
const fileName = `${event.id}.json`;
|
||||
const finalPath = path.join(paths.pending, fileName);
|
||||
const tempPath = path.join(
|
||||
paths.pending,
|
||||
`.${fileName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
|
||||
);
|
||||
await writeFile(tempPath, `${JSON.stringify(event, null, 2)}\n`);
|
||||
await rename(tempPath, finalPath);
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function readPendingStopHookSpoolFiles(
|
||||
spoolDir: string,
|
||||
): Promise<PendingHookEventSpoolFile[]> {
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await ensureStopHookSpool(spoolDir);
|
||||
const fileNames = (await readdir(paths.pending))
|
||||
.filter((fileName) => fileName.endsWith(".json"))
|
||||
.sort();
|
||||
const files: PendingHookEventSpoolFile[] = [];
|
||||
for (const fileName of fileNames) {
|
||||
const filePath = path.join(paths.pending, fileName);
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(filePath, "utf8")) as unknown;
|
||||
files.push({
|
||||
filePath,
|
||||
fileName,
|
||||
event: parseHookSpoolEvent(parsed),
|
||||
});
|
||||
} catch (error) {
|
||||
files.push({
|
||||
filePath,
|
||||
fileName,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function archiveStopHookSpoolFile(
|
||||
file: Pick<PendingHookEventSpoolFile, "filePath" | "fileName">,
|
||||
spoolDir: string,
|
||||
disposition: HookEventSpoolDisposition,
|
||||
): Promise<void> {
|
||||
const paths = stopHookSpoolPaths(spoolDir);
|
||||
await mkdir(paths[disposition], { recursive: true });
|
||||
const target = path.join(
|
||||
paths[disposition],
|
||||
`${Date.now()}-${randomUUID()}-${file.fileName}`,
|
||||
);
|
||||
try {
|
||||
await rename(file.filePath, target);
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: "";
|
||||
if (code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeStopHookSpool(spoolDir: string): Promise<void> {
|
||||
await rm(path.resolve(spoolDir), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function hookEventFromInput(
|
||||
input: unknown,
|
||||
now: () => Date,
|
||||
): DiscordWorkspaceHookEvent {
|
||||
const parsed = record(input);
|
||||
const eventName = stringValue(parsed.hook_event_name) ?? stringValue(parsed.eventName);
|
||||
if (!isHookEventName(eventName)) {
|
||||
throw new Error(`Unsupported hook event: ${eventName}`);
|
||||
}
|
||||
const sessionId = stringValue(parsed.session_id) ?? stringValue(parsed.sessionId);
|
||||
if (!sessionId) {
|
||||
throw new Error("Hook input is missing session_id");
|
||||
}
|
||||
const turnId = stringValue(parsed.turn_id) ?? stringValue(parsed.turnId);
|
||||
const transcriptPath =
|
||||
stringValue(parsed.transcript_path) ?? stringValue(parsed.transcriptPath);
|
||||
const cwd = stringValue(parsed.cwd);
|
||||
const model = stringValue(parsed.model);
|
||||
const source = stringValue(parsed.source);
|
||||
const toolName = stringValue(parsed.tool_name) ?? stringValue(parsed.toolName);
|
||||
const toolUseId = stringValue(parsed.tool_use_id) ?? stringValue(parsed.toolUseId);
|
||||
const toolInput = parsed.tool_input ?? parsed.toolInput;
|
||||
const toolResponse = parsed.tool_response ?? parsed.toolResponse;
|
||||
const lastAssistantMessage =
|
||||
nullableString(parsed.last_assistant_message) ??
|
||||
nullableString(parsed.lastAssistantMessage);
|
||||
const stopHookActive =
|
||||
typeof parsed.stop_hook_active === "boolean"
|
||||
? parsed.stop_hook_active
|
||||
: typeof parsed.stopHookActive === "boolean"
|
||||
? parsed.stopHookActive
|
||||
: undefined;
|
||||
const id = hookEventId({
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId,
|
||||
transcriptPath,
|
||||
cwd,
|
||||
toolName,
|
||||
toolUseId,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
version: 1,
|
||||
id,
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId,
|
||||
cwd,
|
||||
transcriptPath,
|
||||
model,
|
||||
source,
|
||||
promptPreview: previewString(parsed.prompt),
|
||||
toolName,
|
||||
toolUseId,
|
||||
toolInputPreview: previewJson(toolInput),
|
||||
toolResponsePreview: previewJson(toolResponse),
|
||||
permissionDescription: nullableString(record(toolInput).description),
|
||||
lastAssistantMessage: eventName === "Stop" ? lastAssistantMessage : undefined,
|
||||
stopHookActive: eventName === "Stop" ? stopHookActive : undefined,
|
||||
createdAt: now().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHookSpoolEvent(input: unknown): DiscordWorkspaceHookEvent {
|
||||
const parsed = record(input);
|
||||
if (parsed.version !== 1) {
|
||||
throw new Error("Invalid hook event version");
|
||||
}
|
||||
const eventName = stringValue(parsed.eventName);
|
||||
const id = stringValue(parsed.id);
|
||||
const sessionId = stringValue(parsed.sessionId);
|
||||
const createdAt = stringValue(parsed.createdAt);
|
||||
if (!isHookEventName(eventName) || !id || !sessionId || !createdAt) {
|
||||
throw new Error("Invalid hook event");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
id,
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId: stringValue(parsed.turnId),
|
||||
cwd: stringValue(parsed.cwd),
|
||||
transcriptPath: stringValue(parsed.transcriptPath),
|
||||
model: stringValue(parsed.model),
|
||||
source: stringValue(parsed.source),
|
||||
promptPreview: stringValue(parsed.promptPreview),
|
||||
toolName: stringValue(parsed.toolName),
|
||||
toolUseId: stringValue(parsed.toolUseId),
|
||||
toolInputPreview: stringValue(parsed.toolInputPreview),
|
||||
toolResponsePreview: stringValue(parsed.toolResponsePreview),
|
||||
permissionDescription: stringValue(parsed.permissionDescription),
|
||||
lastAssistantMessage: nullableString(parsed.lastAssistantMessage),
|
||||
stopHookActive: typeof parsed.stopHookActive === "boolean"
|
||||
? parsed.stopHookActive
|
||||
: undefined,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function hookEventId(input: {
|
||||
eventName: DiscordWorkspaceHookEventName;
|
||||
sessionId: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
cwd?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
source?: string;
|
||||
}): string {
|
||||
const identity = input.turnId
|
||||
? {
|
||||
eventName: input.eventName,
|
||||
sessionId: input.sessionId,
|
||||
turnId: input.turnId,
|
||||
toolName: input.toolName,
|
||||
toolUseId: input.toolUseId,
|
||||
}
|
||||
: {
|
||||
eventName: input.eventName,
|
||||
sessionId: input.sessionId,
|
||||
source: input.source,
|
||||
transcriptPath: input.transcriptPath,
|
||||
cwd: input.cwd,
|
||||
};
|
||||
const prefix = input.eventName === "Stop" ? "stop" : "hook";
|
||||
return `${prefix}-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function isHookEventName(value: unknown): value is DiscordWorkspaceHookEventName {
|
||||
return value === "SessionStart" ||
|
||||
value === "UserPromptSubmit" ||
|
||||
value === "PreToolUse" ||
|
||||
value === "PermissionRequest" ||
|
||||
value === "PostToolUse" ||
|
||||
value === "Stop";
|
||||
}
|
||||
|
||||
function previewString(value: unknown, maxLength = 500): string | undefined {
|
||||
const parsed = nullableString(value);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.length <= maxLength ? parsed : `${parsed.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function previewJson(value: unknown, maxLength = 500): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return previewString(text, maxLength);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { runStopHook } from "./hook-cli.ts";
|
||||
|
||||
await runStopHook();
|
||||
|
|
@ -1,437 +0,0 @@
|
|||
import type {
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
v2,
|
||||
} from "@peezy.tech/codex-flows/generated";
|
||||
import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc";
|
||||
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
|
||||
|
||||
export type DiscordBridgeConfig = {
|
||||
allowedUserIds: Set<string>;
|
||||
allowedChannelIds: Set<string>;
|
||||
statePath: string;
|
||||
workspace?: DiscordWorkspaceConfig;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
serviceTier?: string;
|
||||
effort?: ReasoningEffort;
|
||||
summary?: ReasoningSummary;
|
||||
approvalPolicy?: v2.AskForApproval;
|
||||
sandbox?: v2.SandboxMode;
|
||||
permissions?: v2.ThreadStartParams["permissions"];
|
||||
typingIntervalMs?: number;
|
||||
reconcileIntervalMs?: number;
|
||||
hookSpoolDir?: string;
|
||||
progressMode?: DiscordProgressMode;
|
||||
consoleOutput?: DiscordConsoleOutputMode;
|
||||
logLevel?: DiscordBridgeLogLevelSetting;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordProgressMode = "summary" | "commentary" | "none";
|
||||
export type DiscordConsoleOutputMode = "messages" | "none";
|
||||
|
||||
export type DiscordWorkspaceConfig = {
|
||||
homeChannelId: string;
|
||||
mainThreadId?: string;
|
||||
workspaceForumChannelId?: string;
|
||||
taskThreadsChannelId?: string;
|
||||
surfaces?: DiscordWorkspaceSurfaceConfig[];
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceSurfaceConfig = {
|
||||
key: string;
|
||||
homeChannelId: string;
|
||||
workspaceForumChannelId?: string;
|
||||
taskThreadsChannelId?: string;
|
||||
workspaceCwds?: string[];
|
||||
};
|
||||
|
||||
export type DiscordAuthor = {
|
||||
id: string;
|
||||
name: string;
|
||||
isBot: boolean;
|
||||
};
|
||||
|
||||
export type DiscordMessageInbound = {
|
||||
kind: "message";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
messageId: string;
|
||||
author: DiscordAuthor;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadStartInbound = {
|
||||
kind: "threadStart";
|
||||
sourceMessageId: string;
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
prompt?: string;
|
||||
mentionedUserIds?: string[];
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordClearInbound = {
|
||||
kind: "clear";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordClearWebhooksInbound = {
|
||||
kind: "clearWebhooks";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
webhookUrl?: string;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordStatusInbound = {
|
||||
kind: "status";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordThreadsInbound = {
|
||||
kind: "threads";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordGoalsInbound = {
|
||||
kind: "goals";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
objective?: string;
|
||||
goalStatus?: v2.ThreadGoalStatus;
|
||||
tokenBudget?: number;
|
||||
clear?: boolean;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
replyPicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordThreadPickerInbound = {
|
||||
kind: "threadPicker";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
pickerId: string;
|
||||
optionId: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
update?: (text: string) => Promise<void>;
|
||||
updatePicker?: (picker: DiscordEphemeralPicker) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordReactionInbound = {
|
||||
kind: "reaction";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordInbound =
|
||||
| DiscordMessageInbound
|
||||
| DiscordThreadStartInbound
|
||||
| DiscordClearInbound
|
||||
| DiscordClearWebhooksInbound
|
||||
| DiscordStatusInbound
|
||||
| DiscordThreadsInbound
|
||||
| DiscordGoalsInbound
|
||||
| DiscordThreadPickerInbound
|
||||
| DiscordReactionInbound;
|
||||
|
||||
export type DiscordEphemeralPicker = {
|
||||
pickerId: string;
|
||||
text: string;
|
||||
options: DiscordEphemeralPickerOption[];
|
||||
};
|
||||
|
||||
export type DiscordEphemeralPickerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeTransportHandlers = {
|
||||
onInbound(inbound: DiscordInbound): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeCommandRegistration = {
|
||||
channelIds?: string[];
|
||||
};
|
||||
|
||||
export type DiscordBridgeTransport = {
|
||||
start(handlers: DiscordBridgeTransportHandlers): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
registerCommands(options?: DiscordBridgeCommandRegistration): Promise<void>;
|
||||
createForumPost?(
|
||||
channelId: string,
|
||||
name: string,
|
||||
message: string,
|
||||
): Promise<{ threadId: string; messageId?: string }>;
|
||||
createThread(
|
||||
channelId: string,
|
||||
name: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string>;
|
||||
sendMessage(channelId: string, text: string): Promise<string[]>;
|
||||
updateMessage?(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
): Promise<void>;
|
||||
deleteMessage(channelId: string, messageId: string): Promise<void>;
|
||||
deleteWebhookMessages?(
|
||||
channelId: string,
|
||||
options?: { webhookUrl?: string },
|
||||
): Promise<{ deleted: number; failed: number }>;
|
||||
deleteThread?(channelId: string): Promise<void>;
|
||||
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
|
||||
addReactions?(channelId: string, messageId: string, reactions: string[]): Promise<void>;
|
||||
pinMessage?(channelId: string, messageId: string): Promise<void>;
|
||||
sendTyping(channelId: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type CodexBridgeClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): void;
|
||||
on(event: "notification", listener: (message: JsonRpcNotification) => void): unknown;
|
||||
on(event: "request", listener: (message: JsonRpcRequest) => void): unknown;
|
||||
startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse>;
|
||||
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
|
||||
setThreadName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse>;
|
||||
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
|
||||
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse>;
|
||||
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
|
||||
injectThreadItems(params: v2.ThreadInjectItemsParams): Promise<v2.ThreadInjectItemsResponse>;
|
||||
listThreads(params: v2.ThreadListParams): Promise<v2.ThreadListResponse>;
|
||||
setThreadGoal(params: v2.ThreadGoalSetParams): Promise<v2.ThreadGoalSetResponse>;
|
||||
getThreadGoal(params: v2.ThreadGoalGetParams): Promise<v2.ThreadGoalGetResponse>;
|
||||
clearThreadGoal(params: v2.ThreadGoalClearParams): Promise<v2.ThreadGoalClearResponse>;
|
||||
respond(id: string | number, result: unknown): void;
|
||||
respondError(id: string | number, code: number, message: string, data?: unknown): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeState = {
|
||||
version: 1;
|
||||
workspace?: DiscordWorkspaceState;
|
||||
sessions: DiscordBridgeSession[];
|
||||
queue: DiscordBridgeQueueItem[];
|
||||
activeTurns: DiscordBridgeActiveTurn[];
|
||||
processedMessageIds: string[];
|
||||
deliveries: DiscordBridgeDelivery[];
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceState = {
|
||||
homeChannelId: string;
|
||||
mainThreadId?: string;
|
||||
statusMessageId?: string;
|
||||
createdAt?: string;
|
||||
toolsVersion?: number;
|
||||
delegations: DiscordWorkspaceDelegation[];
|
||||
workspaces?: DiscordWorkspaceWorkspaceSurface[];
|
||||
observedThreads?: DiscordWorkspaceObservedThread[];
|
||||
pendingWakes?: DiscordWorkspacePendingWake[];
|
||||
processedHookEventIds?: string[];
|
||||
processedStopHookEventIds?: string[];
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceDelegationReturnMode =
|
||||
| "detached"
|
||||
| "record_only"
|
||||
| "wake_on_done"
|
||||
| "wake_on_group"
|
||||
| "manual";
|
||||
|
||||
export type DiscordWorkspaceDelegation = {
|
||||
id: string;
|
||||
codexThreadId: string;
|
||||
title: string;
|
||||
status: "active" | "idle" | "failed" | "complete" | "reported";
|
||||
cwd?: string;
|
||||
workspaceKey?: string;
|
||||
surfaceKey?: string;
|
||||
groupId?: string;
|
||||
returnMode?: DiscordWorkspaceDelegationReturnMode;
|
||||
discordDetailThreadId?: string;
|
||||
discordTaskThreadId?: string;
|
||||
discordWorkspaceThreadId?: string;
|
||||
parentDiscordMessageId?: string;
|
||||
lastTurnId?: string;
|
||||
lastStatus?: string;
|
||||
lastFinal?: string;
|
||||
completedAt?: string;
|
||||
injectedAt?: string;
|
||||
mirroredAt?: string;
|
||||
taskMirroredAt?: string;
|
||||
reportedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceWorkspaceSurface = {
|
||||
key: string;
|
||||
surfaceKey?: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
discordThreadId: string;
|
||||
statusMessageId?: string;
|
||||
delegationIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordWorkspacePendingWake = {
|
||||
id: string;
|
||||
kind: "delegation" | "group";
|
||||
delegationIds: string[];
|
||||
groupId?: string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceHookEventName =
|
||||
| "SessionStart"
|
||||
| "UserPromptSubmit"
|
||||
| "PreToolUse"
|
||||
| "PermissionRequest"
|
||||
| "PostToolUse"
|
||||
| "Stop";
|
||||
|
||||
export type DiscordWorkspaceHookEvent = {
|
||||
version: 1;
|
||||
id: string;
|
||||
eventName: DiscordWorkspaceHookEventName;
|
||||
sessionId: string;
|
||||
turnId?: string;
|
||||
cwd?: string;
|
||||
transcriptPath?: string;
|
||||
model?: string;
|
||||
source?: string;
|
||||
promptPreview?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
toolInputPreview?: string;
|
||||
toolResponsePreview?: string;
|
||||
permissionDescription?: string;
|
||||
lastAssistantMessage?: string;
|
||||
stopHookActive?: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceStopHookEvent = DiscordWorkspaceHookEvent & {
|
||||
eventName: "Stop";
|
||||
};
|
||||
|
||||
export type DiscordWorkspaceObservedThreadStatus =
|
||||
| "starting"
|
||||
| "active"
|
||||
| "tool"
|
||||
| "waiting"
|
||||
| "idle";
|
||||
|
||||
export type DiscordWorkspaceObservedThread = {
|
||||
threadId: string;
|
||||
title?: string;
|
||||
status: DiscordWorkspaceObservedThreadStatus;
|
||||
cwd?: string;
|
||||
workspaceKey?: string;
|
||||
surfaceKey?: string;
|
||||
model?: string;
|
||||
transcriptPath?: string;
|
||||
lastTurnId?: string;
|
||||
lastHookEventName?: DiscordWorkspaceHookEventName;
|
||||
source?: string;
|
||||
promptPreview?: string;
|
||||
assistantPreview?: string;
|
||||
toolName?: string;
|
||||
toolUseId?: string;
|
||||
toolInputPreview?: string;
|
||||
toolResponsePreview?: string;
|
||||
permissionDescription?: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeSession = {
|
||||
discordThreadId: string;
|
||||
parentChannelId: string;
|
||||
guildId?: string;
|
||||
surfaceKey?: string;
|
||||
sourceMessageId?: string;
|
||||
codexThreadId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
ownerUserId?: string;
|
||||
participantUserIds?: string[];
|
||||
cwd?: string;
|
||||
mode?: "new" | "resumed" | "operator" | "delegated" | "workspace";
|
||||
statusMessageId?: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeQueueItem = {
|
||||
id: string;
|
||||
status: "pending" | "processing" | "failed";
|
||||
discordMessageId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
attempts: number;
|
||||
turnId?: string;
|
||||
lastError?: string;
|
||||
nextAttemptAt?: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeActiveTurn = {
|
||||
turnId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
origin: "discord" | "external";
|
||||
queueItemId?: string;
|
||||
startedAt?: string;
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeDelivery = {
|
||||
discordMessageId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
turnId?: string;
|
||||
kind: "summary" | "commentary" | "final" | "error";
|
||||
outboundMessageIds: string[];
|
||||
deliveredAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeStateStore = {
|
||||
load(): Promise<DiscordBridgeState>;
|
||||
save(state: DiscordBridgeState): Promise<void>;
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type {
|
||||
DiscordBridgeCommandRegistration,
|
||||
DiscordBridgeState,
|
||||
DiscordInbound,
|
||||
} from "./types.ts";
|
||||
|
||||
export type CodexWorkspaceBackend = {
|
||||
start(): Promise<void>;
|
||||
startTransportDependentWork?(): Promise<void>;
|
||||
startBackgroundWork?(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
handleInbound(inbound: DiscordInbound): Promise<void>;
|
||||
commandRegistration(): DiscordBridgeCommandRegistration;
|
||||
stateForTest?(): DiscordBridgeState;
|
||||
flushSummariesForTest?(): Promise<void>;
|
||||
};
|
||||
|
||||
export type CodexWorkspacePresenter = {
|
||||
createWorkspacePost?(
|
||||
locationId: string,
|
||||
title: string,
|
||||
body: string,
|
||||
): Promise<{ threadId: string; messageId?: string }>;
|
||||
createThread(
|
||||
locationId: string,
|
||||
title: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string>;
|
||||
sendMessage(locationId: string, text: string): Promise<string[]>;
|
||||
updateMessage?(
|
||||
locationId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
): Promise<void>;
|
||||
deleteMessage(locationId: string, messageId: string): Promise<void>;
|
||||
deleteWebhookMessages?(
|
||||
locationId: string,
|
||||
options?: { webhookUrl?: string },
|
||||
): Promise<{ deleted: number; failed: number }>;
|
||||
deleteThread?(locationId: string): Promise<void>;
|
||||
addThreadMembers?(threadId: string, userIds: string[]): Promise<void>;
|
||||
addReactions?(locationId: string, messageId: string, reactions: string[]): Promise<void>;
|
||||
pinMessage?(locationId: string, messageId: string): Promise<void>;
|
||||
sendTyping(locationId: string): Promise<void>;
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,499 +0,0 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { parseConfig } from "../src/config.ts";
|
||||
|
||||
describe("parseConfig", () => {
|
||||
test("resolves --dir relative to the home directory", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"projects/demo",
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
|
||||
}
|
||||
});
|
||||
|
||||
test("expands tilde dir paths from the home directory", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"~/projects/demo",
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts one positional directory for root script usage", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
"~/game-protocol-workspace",
|
||||
],
|
||||
{ CODEX_DISCORD_DIR: "env-dir" },
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.localAppServer).toBe(true);
|
||||
expect(parsed.config.cwd).toBe(
|
||||
path.join(os.homedir(), "game-protocol-workspace"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects multiple directory arguments", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"one",
|
||||
"two",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Unexpected argument: two");
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"one",
|
||||
"two",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Cannot set both positional directory and --dir/--cwd.");
|
||||
});
|
||||
|
||||
test("prefers CODEX_DISCORD_DIR over legacy cwd env", () => {
|
||||
const parsed = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{
|
||||
CODEX_DISCORD_DIR: "current",
|
||||
CODEX_DISCORD_CWD: "/legacy",
|
||||
},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "current"));
|
||||
}
|
||||
});
|
||||
|
||||
test("enables debug logging from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1", "--debug"],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{ CODEX_DISCORD_DEBUG: "true" },
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.debug).toBe(true);
|
||||
expect(fromEnv.config.debug).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("parses progress mode from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--progress-mode",
|
||||
"commentary",
|
||||
],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{ CODEX_DISCORD_PROGRESS_MODE: "none" },
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.progressMode).toBe("commentary");
|
||||
expect(fromEnv.config.progressMode).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses console output and log level from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--console-output",
|
||||
"messages",
|
||||
"--log-level",
|
||||
"warn",
|
||||
],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{
|
||||
CODEX_DISCORD_CONSOLE_OUTPUT: "none",
|
||||
CODEX_DISCORD_LOG_LEVEL: "silent",
|
||||
},
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.consoleOutput).toBe("messages");
|
||||
expect(fromFlag.config.logLevel).toBe("warn");
|
||||
expect(fromEnv.config.consoleOutput).toBe("none");
|
||||
expect(fromEnv.config.logLevel).toBe("silent");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses workspace home and main thread ids", () => {
|
||||
const fromFlag = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--home-channel-id",
|
||||
"home-channel",
|
||||
"--main-thread-id",
|
||||
"main-thread",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
"--task-threads-channel-id",
|
||||
"task-channel",
|
||||
],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{
|
||||
CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID: "env-home",
|
||||
CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID: "env-thread",
|
||||
CODEX_DISCORD_GATEWAY_WORKSPACE_FORUM_CHANNEL_ID: "env-workspace-forum",
|
||||
CODEX_DISCORD_GATEWAY_TASK_THREADS_CHANNEL_ID: "env-task-channel",
|
||||
},
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.workspace).toEqual({
|
||||
homeChannelId: "home-channel",
|
||||
mainThreadId: "main-thread",
|
||||
workspaceForumChannelId: "workspace-forum",
|
||||
taskThreadsChannelId: "task-channel",
|
||||
});
|
||||
expect(fromEnv.config.workspace).toEqual({
|
||||
homeChannelId: "env-home",
|
||||
mainThreadId: "env-thread",
|
||||
workspaceForumChannelId: "env-workspace-forum",
|
||||
taskThreadsChannelId: "env-task-channel",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("parses workspace-owned workspace surfaces and keeps env defaults as fallback", () => {
|
||||
const root = workspaceRoot();
|
||||
writeWorkspaceToml(root, "crypto-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "crypto"
|
||||
home_channel_id = "home-b"
|
||||
workspace_forum_channel_id = "forum-b"
|
||||
task_threads_channel_id = "tasks-b"
|
||||
`);
|
||||
writeWorkspaceToml(root, "research-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "crypto"
|
||||
home_channel_id = "home-b"
|
||||
workspace_forum_channel_id = "forum-b"
|
||||
task_threads_channel_id = "tasks-b"
|
||||
`);
|
||||
try {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
root,
|
||||
],
|
||||
{
|
||||
CODEX_DISCORD_HOME_CHANNEL_ID: "home-a",
|
||||
CODEX_DISCORD_WORKSPACE_FORUM_CHANNEL_ID: "forum-a",
|
||||
CODEX_DISCORD_TASK_THREADS_CHANNEL_ID: "tasks-a",
|
||||
},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.workspace).toEqual({
|
||||
homeChannelId: "home-a",
|
||||
workspaceForumChannelId: "forum-a",
|
||||
taskThreadsChannelId: "tasks-a",
|
||||
surfaces: [
|
||||
{
|
||||
key: "default",
|
||||
homeChannelId: "home-a",
|
||||
workspaceForumChannelId: "forum-a",
|
||||
taskThreadsChannelId: "tasks-a",
|
||||
workspaceCwds: undefined,
|
||||
},
|
||||
{
|
||||
key: "crypto",
|
||||
homeChannelId: "home-b",
|
||||
workspaceForumChannelId: "forum-b",
|
||||
taskThreadsChannelId: "tasks-b",
|
||||
workspaceCwds: [
|
||||
path.join(root, "crypto-workspace"),
|
||||
path.join(root, "research-workspace"),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects ambiguous workspace-owned workspace surfaces", () => {
|
||||
const multiple = workspaceRoot();
|
||||
writeWorkspaceToml(multiple, "crypto-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "default"
|
||||
home_channel_id = "home-a"
|
||||
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "other"
|
||||
home_channel_id = "home-b"
|
||||
`);
|
||||
try {
|
||||
expect(() => parseConfig(baseArgsForRoot(multiple), {})).toThrow(
|
||||
"workspace.toml discord.workspace.surfaces must contain one surface",
|
||||
);
|
||||
} finally {
|
||||
rmSync(multiple, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const duplicate = workspaceRoot();
|
||||
writeWorkspaceToml(duplicate, "crypto-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "default"
|
||||
home_channel_id = "home-a"
|
||||
`);
|
||||
writeWorkspaceToml(duplicate, "research-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "default"
|
||||
home_channel_id = "home-b"
|
||||
`);
|
||||
try {
|
||||
expect(() => parseConfig(baseArgsForRoot(duplicate), {})).toThrow(
|
||||
"Workspace surface key default is configured with different channels.",
|
||||
);
|
||||
} finally {
|
||||
rmSync(duplicate, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const channelCollision = workspaceRoot();
|
||||
writeWorkspaceToml(channelCollision, "crypto-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "crypto"
|
||||
home_channel_id = "home-a"
|
||||
`);
|
||||
writeWorkspaceToml(channelCollision, "alpha-workspace", `
|
||||
[[discord.workspace.surfaces]]
|
||||
key = "alpha"
|
||||
home_channel_id = "home-a"
|
||||
`);
|
||||
try {
|
||||
expect(() => parseConfig(baseArgsForRoot(channelCollision), {})).toThrow(
|
||||
"Workspace surface channel is configured more than once: home-a",
|
||||
);
|
||||
} finally {
|
||||
rmSync(channelCollision, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores workspace.toml without workspace surfaces", () => {
|
||||
const root = workspaceRoot();
|
||||
writeRootWorkspaceToml(root, `
|
||||
name = "home"
|
||||
|
||||
[tools]
|
||||
enabled = true
|
||||
`);
|
||||
try {
|
||||
const parsed = parseConfig(baseArgsForRoot(root), {});
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.workspace).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects workspace main thread without home channel", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--main-thread-id",
|
||||
"main-thread",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Cannot set a workspace main thread without a workspace home channel.");
|
||||
});
|
||||
|
||||
test("rejects partial workspace workbench channel configuration", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--home-channel-id",
|
||||
"home-channel",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow(
|
||||
"Discord workbench requires both workspace forum and task threads channels.",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects workspace workbench channels that are not separate", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--home-channel-id",
|
||||
"home-channel",
|
||||
"--workspace-forum-channel-id",
|
||||
"workspace-forum",
|
||||
"--task-threads-channel-id",
|
||||
"home-channel",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow(
|
||||
"Discord workbench channels must be separate from the workspace home channel and each other.",
|
||||
);
|
||||
});
|
||||
|
||||
test("can force a local app-server even when workspace URL env is set", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
],
|
||||
{ CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999" },
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.localAppServer).toBe(true);
|
||||
expect(parsed.appServerUrl).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects mixing local and explicit external app-server modes", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
"--app-server-url",
|
||||
"ws://127.0.0.1:9999",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Cannot set both --local-app-server and --app-server-url.");
|
||||
});
|
||||
});
|
||||
|
||||
function workspaceRoot(): string {
|
||||
return mkdtempSync(path.join(os.tmpdir(), "discord-workspace-config-"));
|
||||
}
|
||||
|
||||
function writeRootWorkspaceToml(root: string, toml: string): void {
|
||||
const codexDir = path.join(root, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
writeFileSync(path.join(codexDir, "workspace.toml"), toml);
|
||||
}
|
||||
|
||||
function writeWorkspaceToml(root: string, workspaceName: string, toml: string): void {
|
||||
const workspaceDir = path.join(root, workspaceName);
|
||||
const codexDir = path.join(workspaceDir, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
writeFileSync(path.join(codexDir, "workspace.toml"), toml);
|
||||
}
|
||||
|
||||
function baseArgsForRoot(root: string): string[] {
|
||||
return [
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
root,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
createDiscordConsoleOutput,
|
||||
formatConsoleMessage,
|
||||
} from "../src/console-output.ts";
|
||||
|
||||
describe("discord bridge console output", () => {
|
||||
test("formats delivered assistant messages for terminal output", () => {
|
||||
expect(
|
||||
formatConsoleMessage(
|
||||
{
|
||||
kind: "final",
|
||||
text: "Repo scan complete.\nNo regressions found.",
|
||||
discordThreadId: "discord-thread-123456",
|
||||
codexThreadId: "codex-thread-abcdef",
|
||||
turnId: "turn-1234567890",
|
||||
title: "Scan repo",
|
||||
at: new Date("2026-05-12T04:22:00.123Z"),
|
||||
},
|
||||
{ color: false },
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"[04:22:00.123] FINAL Scan repo thread=codex-...cdef turn=turn-1...7890",
|
||||
" Repo scan complete.",
|
||||
" No regressions found.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("writes one formatted block per message", () => {
|
||||
const output = createMemoryOutput();
|
||||
const consoleOutput = createDiscordConsoleOutput({
|
||||
color: false,
|
||||
now: () => new Date("2026-05-12T04:22:01.456Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
consoleOutput.message({
|
||||
kind: "commentary",
|
||||
text: "I will inspect the bridge.",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
turnId: "turn-1",
|
||||
title: "Bridge status",
|
||||
});
|
||||
|
||||
expect(output.text).toBe(
|
||||
[
|
||||
"[04:22:01.456] COMMENTARY Bridge status thread=codex-...ad-1 turn=turn-1",
|
||||
" I will inspect the bridge.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMemoryOutput(): {
|
||||
readonly stream: Pick<NodeJS.WriteStream, "write">;
|
||||
readonly text: string;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
stream: {
|
||||
write: ((chunk: string | Uint8Array) => {
|
||||
chunks.push(
|
||||
typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8"),
|
||||
);
|
||||
return true;
|
||||
}) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
get text() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
enableHooksFeature,
|
||||
installStopHook,
|
||||
upsertStopHookConfig,
|
||||
} from "../src/hook-cli.ts";
|
||||
|
||||
describe("discord workspace hook CLI", () => {
|
||||
test("enables the current hooks feature in config.toml", () => {
|
||||
expect(enableHooksFeature("model = \"gpt-5\"\n")).toBe(
|
||||
"model = \"gpt-5\"\n\n[features]\nhooks = true\n",
|
||||
);
|
||||
expect(enableHooksFeature("[features]\ngoals = true\n")).toBe(
|
||||
"[features]\nhooks = true\ngoals = true\n",
|
||||
);
|
||||
expect(enableHooksFeature("[features]\nhooks = false\ngoals = true\n")).toBe(
|
||||
"[features]\nhooks = true\ngoals = true\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("upserts package-bin observability hooks while preserving unrelated hooks", () => {
|
||||
const updated = upsertStopHookConfig(
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo pre" }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command:
|
||||
"node /home/peezy/codex-fork-workspace/codex-flows/apps/discord-bridge/dist/index.js hook event",
|
||||
},
|
||||
{ type: "command", command: "echo other-stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
"codex-discord-bridge hook event",
|
||||
);
|
||||
|
||||
expect(updated).toEqual({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo pre" }],
|
||||
},
|
||||
],
|
||||
PermissionRequest: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
hooks: [{ type: "command", command: "echo other-stop" }],
|
||||
},
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "codex-discord-bridge hook event",
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("install writes config and hooks files", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "discord-hook-cli-"));
|
||||
try {
|
||||
const configPath = path.join(dir, "config.toml");
|
||||
const hooksPath = path.join(dir, "hooks.json");
|
||||
await writeFile(configPath, "[features]\ngoals = true\n");
|
||||
const result = await installStopHook({
|
||||
configPath,
|
||||
hooksPath,
|
||||
useDlx: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command:
|
||||
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
|
||||
configPath,
|
||||
hooksPath,
|
||||
dryRun: false,
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe(
|
||||
"[features]\nhooks = true\ngoals = true\n",
|
||||
);
|
||||
expect(JSON.parse(await readFile(hooksPath, "utf8"))).toEqual(
|
||||
expect.objectContaining({
|
||||
hooks: expect.objectContaining({
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
expect.objectContaining({
|
||||
command:
|
||||
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
expect.objectContaining({
|
||||
command:
|
||||
"vp dlx @peezy.tech/codex-discord-bridge codex-discord-bridge hook event",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { createDiscordBridgeLogger } from "../src/logger.ts";
|
||||
import { formatPrettyLogLine } from "../src/pretty-log.ts";
|
||||
|
||||
describe("discord bridge logger", () => {
|
||||
test("writes info logs as structured json and gates debug logs", () => {
|
||||
const output = createMemoryOutput();
|
||||
const logger = createDiscordBridgeLogger({
|
||||
component: "test-bridge",
|
||||
now: () => new Date("2026-05-12T04:22:00.123Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
logger.debug("hidden.debug", { threadId: "thread-1" });
|
||||
logger.info("bridge.started", {
|
||||
appServerUrl: "local",
|
||||
statePath: "/tmp/discord-state.json",
|
||||
});
|
||||
|
||||
const lines = output.text.trim().split("\n");
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0] ?? "")).toEqual({
|
||||
time: "2026-05-12T04:22:00.123Z",
|
||||
component: "test-bridge",
|
||||
level: "info",
|
||||
event: "bridge.started",
|
||||
appServerUrl: "local",
|
||||
statePath: "/tmp/discord-state.json",
|
||||
});
|
||||
});
|
||||
|
||||
test("filters logs below the configured log level", () => {
|
||||
const output = createMemoryOutput();
|
||||
const logger = createDiscordBridgeLogger({
|
||||
component: "test-bridge",
|
||||
logLevel: "warn",
|
||||
now: () => new Date("2026-05-12T04:22:00.123Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
logger.debug("hidden.debug");
|
||||
logger.info("hidden.info");
|
||||
logger.warn("visible.warn");
|
||||
logger.error("visible.error");
|
||||
|
||||
expect(output.text.trim().split("\n").map((line) => JSON.parse(line).event))
|
||||
.toEqual(["visible.warn", "visible.error"]);
|
||||
});
|
||||
|
||||
test("pretty prints structured json logs and plain process output", () => {
|
||||
const structured = formatPrettyLogLine(
|
||||
JSON.stringify({
|
||||
time: "2026-05-12T04:22:00.123Z",
|
||||
component: "codex-discord-bridge",
|
||||
level: "info",
|
||||
event: "bridge.started",
|
||||
appServerUrl: "local",
|
||||
localAppServer: true,
|
||||
}),
|
||||
{ color: false },
|
||||
);
|
||||
const plain = formatPrettyLogLine("listening on ws://127.0.0.1:3585", {
|
||||
color: false,
|
||||
name: "codex-remote-control",
|
||||
now: () => new Date("2026-05-12T04:22:01.456Z"),
|
||||
});
|
||||
|
||||
expect(structured).toBe(
|
||||
"[04:22:00.123] INFO codex-discord-bridge bridge.started appServerUrl=local localAppServer=true",
|
||||
);
|
||||
expect(plain).toBe(
|
||||
"[04:22:01.456] INFO codex-remote-control listening on ws://127.0.0.1:3585",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMemoryOutput(): {
|
||||
readonly stream: Pick<NodeJS.WriteStream, "write">;
|
||||
readonly text: string;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
stream: {
|
||||
write: ((chunk: string | Uint8Array) => {
|
||||
chunks.push(
|
||||
typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8"),
|
||||
);
|
||||
return true;
|
||||
}) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
get text() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { JsonFileStateStore } from "../src/state.ts";
|
||||
|
||||
describe("JsonFileStateStore", () => {
|
||||
test("loads per-thread grant metadata and older sessions without grants", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "discord-bridge-state-"));
|
||||
try {
|
||||
const statePath = path.join(dir, "state.json");
|
||||
await writeFile(
|
||||
statePath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
workspace: {
|
||||
homeChannelId: "home-channel",
|
||||
mainThreadId: "codex-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
toolsVersion: 1,
|
||||
delegations: [
|
||||
{
|
||||
id: "delegation-1",
|
||||
codexThreadId: "codex-delegated-thread",
|
||||
title: "Patchbay webhook work",
|
||||
status: "active",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
discordDetailThreadId: "discord-detail-thread",
|
||||
discordTaskThreadId: "discord-task-thread",
|
||||
discordWorkspaceThreadId: "discord-workspace-thread",
|
||||
parentDiscordMessageId: "message-parent",
|
||||
taskMirroredAt: "2026-05-11T00:00:02.500Z",
|
||||
createdAt: "2026-05-11T00:00:01.000Z",
|
||||
updatedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
key: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
cwd: "/workspace/patchbay",
|
||||
title: "patchbay",
|
||||
discordThreadId: "discord-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
delegationIds: ["delegation-1", "", "delegation-1"],
|
||||
createdAt: "2026-05-11T00:00:00.500Z",
|
||||
updatedAt: "2026-05-11T00:00:02.500Z",
|
||||
},
|
||||
],
|
||||
observedThreads: [
|
||||
{
|
||||
threadId: "codex-observed-thread",
|
||||
title: "Observed work",
|
||||
status: "waiting",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
model: "gpt-test",
|
||||
transcriptPath: "/tmp/observed.jsonl",
|
||||
lastTurnId: "turn-observed",
|
||||
lastHookEventName: "PermissionRequest",
|
||||
promptPreview: "Observed prompt",
|
||||
permissionDescription: "Needs approval",
|
||||
firstSeenAt: "2026-05-11T00:00:01.000Z",
|
||||
lastSeenAt: "2026-05-11T00:00:04.000Z",
|
||||
updatedAt: "2026-05-11T00:00:04.000Z",
|
||||
},
|
||||
],
|
||||
pendingWakes: [
|
||||
{
|
||||
id: "wake-1",
|
||||
kind: "group",
|
||||
delegationIds: ["delegation-1"],
|
||||
groupId: "patchbay",
|
||||
reason: "Group patchbay completed.",
|
||||
createdAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
],
|
||||
processedStopHookEventIds: [
|
||||
"stop-1",
|
||||
"",
|
||||
"stop-1",
|
||||
"stop-2",
|
||||
],
|
||||
processedHookEventIds: ["hook-1", "", "hook-1"],
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
discordThreadId: "discord-thread-1",
|
||||
parentChannelId: "parent-channel",
|
||||
surfaceKey: "org-a",
|
||||
sourceMessageId: "message-start-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
title: "Granted thread",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
ownerUserId: "user-1",
|
||||
participantUserIds: ["user-2", "", "user-2", "user-3"],
|
||||
cwd: "/workspace/project",
|
||||
mode: "workspace",
|
||||
statusMessageId: "message-status-1",
|
||||
},
|
||||
{
|
||||
discordThreadId: "discord-thread-2",
|
||||
parentChannelId: "parent-channel",
|
||||
codexThreadId: "codex-thread-2",
|
||||
title: "Older thread",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
queue: [],
|
||||
activeTurns: [
|
||||
{
|
||||
turnId: "turn-active-1",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
origin: "external",
|
||||
startedAt: "2026-05-11T00:00:01.000Z",
|
||||
observedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
turnId: "turn-active-2",
|
||||
discordThreadId: "discord-thread-2",
|
||||
codexThreadId: "codex-thread-2",
|
||||
origin: "unknown",
|
||||
queueItemId: "queue-1",
|
||||
observedAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
],
|
||||
processedMessageIds: [],
|
||||
deliveries: [],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const state = await new JsonFileStateStore(statePath).load();
|
||||
|
||||
expect(state.workspace).toEqual({
|
||||
homeChannelId: "home-channel",
|
||||
mainThreadId: "codex-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
toolsVersion: 1,
|
||||
delegations: [
|
||||
{
|
||||
id: "delegation-1",
|
||||
codexThreadId: "codex-delegated-thread",
|
||||
title: "Patchbay webhook work",
|
||||
status: "active",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
discordDetailThreadId: "discord-detail-thread",
|
||||
discordTaskThreadId: "discord-task-thread",
|
||||
discordWorkspaceThreadId: "discord-workspace-thread",
|
||||
parentDiscordMessageId: "message-parent",
|
||||
taskMirroredAt: "2026-05-11T00:00:02.500Z",
|
||||
createdAt: "2026-05-11T00:00:01.000Z",
|
||||
updatedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
key: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
cwd: "/workspace/patchbay",
|
||||
title: "patchbay",
|
||||
discordThreadId: "discord-workspace-thread",
|
||||
statusMessageId: "message-workspace-status",
|
||||
delegationIds: ["delegation-1"],
|
||||
createdAt: "2026-05-11T00:00:00.500Z",
|
||||
updatedAt: "2026-05-11T00:00:02.500Z",
|
||||
},
|
||||
],
|
||||
observedThreads: [
|
||||
{
|
||||
threadId: "codex-observed-thread",
|
||||
title: "Observed work",
|
||||
status: "waiting",
|
||||
cwd: "/workspace/patchbay",
|
||||
workspaceKey: "workspace-patchbay",
|
||||
surfaceKey: "org-a",
|
||||
model: "gpt-test",
|
||||
transcriptPath: "/tmp/observed.jsonl",
|
||||
lastTurnId: "turn-observed",
|
||||
lastHookEventName: "PermissionRequest",
|
||||
source: undefined,
|
||||
promptPreview: "Observed prompt",
|
||||
assistantPreview: undefined,
|
||||
toolName: undefined,
|
||||
toolUseId: undefined,
|
||||
toolInputPreview: undefined,
|
||||
toolResponsePreview: undefined,
|
||||
permissionDescription: "Needs approval",
|
||||
firstSeenAt: "2026-05-11T00:00:01.000Z",
|
||||
lastSeenAt: "2026-05-11T00:00:04.000Z",
|
||||
updatedAt: "2026-05-11T00:00:04.000Z",
|
||||
},
|
||||
],
|
||||
pendingWakes: [
|
||||
{
|
||||
id: "wake-1",
|
||||
kind: "group",
|
||||
delegationIds: ["delegation-1"],
|
||||
groupId: "patchbay",
|
||||
reason: "Group patchbay completed.",
|
||||
createdAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
],
|
||||
processedHookEventIds: ["hook-1", "stop-1", "stop-2"],
|
||||
processedStopHookEventIds: ["stop-1", "stop-2"],
|
||||
});
|
||||
expect(state.sessions).toHaveLength(2);
|
||||
expect(state.sessions[0]?.ownerUserId).toBe("user-1");
|
||||
expect(state.sessions[0]?.sourceMessageId).toBe("message-start-1");
|
||||
expect(state.sessions[0]?.surfaceKey).toBe("org-a");
|
||||
expect(state.sessions[0]?.participantUserIds).toEqual([
|
||||
"user-2",
|
||||
"user-3",
|
||||
]);
|
||||
expect(state.sessions[0]?.cwd).toBe("/workspace/project");
|
||||
expect(state.sessions[0]?.mode).toBe("workspace");
|
||||
expect(state.sessions[0]?.statusMessageId).toBe("message-status-1");
|
||||
expect(state.sessions[1]?.ownerUserId).toBeUndefined();
|
||||
expect(state.sessions[1]?.sourceMessageId).toBeUndefined();
|
||||
expect(state.sessions[1]?.participantUserIds).toBeUndefined();
|
||||
expect(state.sessions[1]?.cwd).toBeUndefined();
|
||||
expect(state.sessions[1]?.mode).toBeUndefined();
|
||||
expect(state.sessions[1]?.statusMessageId).toBeUndefined();
|
||||
expect(state.activeTurns).toEqual([
|
||||
{
|
||||
turnId: "turn-active-1",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
origin: "external",
|
||||
startedAt: "2026-05-11T00:00:01.000Z",
|
||||
observedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
turnId: "turn-active-2",
|
||||
discordThreadId: "discord-thread-2",
|
||||
codexThreadId: "codex-thread-2",
|
||||
origin: "external",
|
||||
queueItemId: "queue-1",
|
||||
observedAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
archiveStopHookSpoolFile,
|
||||
readPendingStopHookSpoolFiles,
|
||||
writeStopHookSpoolEvent,
|
||||
} from "../src/stop-hook-spool.ts";
|
||||
|
||||
describe("stop hook spool", () => {
|
||||
test("writes stable Stop events into the pending spool", async () => {
|
||||
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "stop-hook-spool-"));
|
||||
try {
|
||||
const input = {
|
||||
hook_event_name: "Stop",
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
cwd: "/workspace",
|
||||
transcript_path: "/tmp/session.jsonl",
|
||||
last_assistant_message: "Finished.",
|
||||
stop_hook_active: false,
|
||||
};
|
||||
|
||||
const first = await writeStopHookSpoolEvent(input, {
|
||||
spoolDir,
|
||||
now: () => new Date("2026-05-14T12:00:00.000Z"),
|
||||
});
|
||||
const second = await writeStopHookSpoolEvent(input, {
|
||||
spoolDir,
|
||||
now: () => new Date("2026-05-14T12:01:00.000Z"),
|
||||
});
|
||||
const third = await writeStopHookSpoolEvent(
|
||||
{ ...input, last_assistant_message: "Finished again." },
|
||||
{
|
||||
spoolDir,
|
||||
now: () => new Date("2026-05-14T12:02:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(third.id).toBe(first.id);
|
||||
const pending = await readPendingStopHookSpoolFiles(spoolDir);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
id: first.id,
|
||||
eventName: "Stop",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
lastAssistantMessage: "Finished again.",
|
||||
stopHookActive: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(spoolDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writes passive lifecycle hook events with previews", async () => {
|
||||
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "hook-spool-"));
|
||||
try {
|
||||
const event = await writeStopHookSpoolEvent(
|
||||
{
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
session_id: "session-observed",
|
||||
turn_id: "turn-observed",
|
||||
cwd: "/workspace/observed",
|
||||
transcript_path: "/tmp/session-observed.jsonl",
|
||||
model: "gpt-test",
|
||||
prompt: "Inspect the observed workspace without routing through Discord.",
|
||||
},
|
||||
{
|
||||
spoolDir,
|
||||
now: () => new Date("2026-05-14T12:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(event).toEqual(
|
||||
expect.objectContaining({
|
||||
eventName: "UserPromptSubmit",
|
||||
sessionId: "session-observed",
|
||||
turnId: "turn-observed",
|
||||
cwd: "/workspace/observed",
|
||||
model: "gpt-test",
|
||||
promptPreview:
|
||||
"Inspect the observed workspace without routing through Discord.",
|
||||
}),
|
||||
);
|
||||
const pending = await readPendingStopHookSpoolFiles(spoolDir);
|
||||
expect(pending[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
id: event.id,
|
||||
eventName: "UserPromptSubmit",
|
||||
promptPreview:
|
||||
"Inspect the observed workspace without routing through Discord.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(spoolDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("archives processed files out of pending", async () => {
|
||||
const spoolDir = await mkdtemp(path.join(os.tmpdir(), "stop-hook-spool-"));
|
||||
try {
|
||||
await writeStopHookSpoolEvent(
|
||||
{
|
||||
hook_event_name: "Stop",
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
},
|
||||
{ spoolDir },
|
||||
);
|
||||
const [file] = await readPendingStopHookSpoolFiles(spoolDir);
|
||||
expect(file).toBeDefined();
|
||||
await archiveStopHookSpoolFile(file!, spoolDir, "processed");
|
||||
|
||||
expect(await readPendingStopHookSpoolFiles(spoolDir)).toEqual([]);
|
||||
} finally {
|
||||
await rm(spoolDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
|
||||
"@peezy.tech/codex-flows/browser": ["../../packages/codex-client/src/browser.ts"],
|
||||
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
|
||||
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
|
||||
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],
|
||||
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# @peezy.tech/codex-workspace-voice-gateway
|
||||
|
||||
Broadcast-only Discord voice gateway for Codex workspace backend updates.
|
||||
|
||||
```bash
|
||||
pnpm add @peezy.tech/codex-workspace-voice-gateway
|
||||
```
|
||||
|
||||
Run it beside a workspace backend and a TTS worker:
|
||||
|
||||
```bash
|
||||
codex-workspace-backend-local serve --local-app-server
|
||||
codex-workspace-voice-gateway \
|
||||
--workspace-backend-url ws://127.0.0.1:3586 \
|
||||
--tts-worker-url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
This package is a channel-specific gateway. It depends on
|
||||
`@peezy.tech/codex-flows` for the workspace backend protocol and keeps Discord
|
||||
voice/TTS dependencies outside the core package.
|
||||
|
||||
Full reference docs live in
|
||||
`docs/pages/reference/workspace-voice-gateway.md` in the source repository.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"name": "@peezy.tech/codex-workspace-voice-gateway",
|
||||
"version": "0.132.6",
|
||||
"description": "Broadcast-only Discord voice gateway for Codex workspace backend updates.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/peezy-tech/codex-flows.git",
|
||||
"directory": "apps/workspace-voice-gateway"
|
||||
},
|
||||
"keywords": [
|
||||
"codex",
|
||||
"discord",
|
||||
"voice",
|
||||
"gateway"
|
||||
],
|
||||
"bin": {
|
||||
"codex-workspace-voice-gateway": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp run clean && tsx scripts/build-package.ts",
|
||||
"check:types": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"pack:dry-run": "npm pack --dry-run --json",
|
||||
"prepack": "vp run build",
|
||||
"release:check": "vp run test && vp run check:types && vp run build && vp run smoke:bin && vp run pack:dry-run",
|
||||
"smoke:bin": "tsx scripts/smoke-bin.ts",
|
||||
"start": "tsx ./src/index.ts",
|
||||
"test": "vp test run --root ../.. apps/workspace-voice-gateway/test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@peezy.tech/codex-flows": "workspace:*",
|
||||
"discord.js": "^14.22.1",
|
||||
"opusscript": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const outDir = path.join(appRoot, "dist");
|
||||
const packOutDir = path.join(outDir, ".pack");
|
||||
const outfile = path.join(outDir, "index.js");
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const proc = spawn("vp", [
|
||||
"pack",
|
||||
"src/index.ts",
|
||||
"--platform=node",
|
||||
"--format=esm",
|
||||
"--target=node24",
|
||||
"--out-dir",
|
||||
packOutDir,
|
||||
"--deps.never-bundle",
|
||||
"@peezy.tech/codex-flows",
|
||||
"--deps.never-bundle",
|
||||
"@peezy.tech/codex-flows/*",
|
||||
"--deps.never-bundle",
|
||||
"@discordjs/*",
|
||||
"--deps.never-bundle",
|
||||
"discord.js",
|
||||
"--deps.never-bundle",
|
||||
"ffmpeg-static",
|
||||
"--deps.never-bundle",
|
||||
"opusscript",
|
||||
], {
|
||||
cwd: appRoot,
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
collectText(proc.stdout),
|
||||
collectText(proc.stderr),
|
||||
exitCodeFor(proc),
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.stderr.write(stderr);
|
||||
process.stderr.write(stdout);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
await movePackOutput(packOutDir, outfile);
|
||||
await rm(packOutDir, { recursive: true, force: true });
|
||||
await chmod(outfile, 0o755);
|
||||
process.stderr.write(`built ${path.relative(appRoot, outfile)}\n`);
|
||||
|
||||
async function movePackOutput(packDir: string, entryOutfile: string): Promise<void> {
|
||||
await rename(path.join(packDir, "index.mjs"), entryOutfile);
|
||||
for (const entry of await readdir(packDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
await rename(path.join(packDir, entry.name), path.join(outDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
if (!stream) {
|
||||
resolve(output);
|
||||
return;
|
||||
}
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk: string) => {
|
||||
output += chunk;
|
||||
});
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(output));
|
||||
});
|
||||
}
|
||||
|
||||
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const binPath = path.join(appRoot, "dist", "index.js");
|
||||
|
||||
await access(binPath);
|
||||
|
||||
const proc = spawn(process.execPath, [binPath, "--help"], {
|
||||
cwd: appRoot,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
collectText(proc.stdout),
|
||||
collectText(proc.stderr),
|
||||
exitCodeFor(proc),
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.stderr.write(stderr);
|
||||
process.stderr.write(stdout);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
if (!stdout.includes("codex-workspace-voice-gateway")) {
|
||||
throw new Error(
|
||||
"codex-workspace-voice-gateway --help did not mention codex-workspace-voice-gateway",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("bin smoke test passed");
|
||||
|
||||
function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
if (!stream) {
|
||||
resolve(output);
|
||||
return;
|
||||
}
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk: string) => {
|
||||
output += chunk;
|
||||
});
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve(output));
|
||||
});
|
||||
}
|
||||
|
||||
function exitCodeFor(child: ReturnType<typeof spawn>): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => resolve(code));
|
||||
});
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
import type { v2 } from "@peezy.tech/codex-flows/generated";
|
||||
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
|
||||
import type { WorkspaceBackendEvent } from "@peezy.tech/codex-flows/workspace-backend";
|
||||
import { cleanForSpeech, record, stringValue } from "./text.ts";
|
||||
import type { AnnouncementPriority, VoiceAnnouncement } from "./types.ts";
|
||||
|
||||
export type AnnouncementPolicy = {
|
||||
announceBackendConnected: boolean;
|
||||
announceTurnStarted: boolean;
|
||||
ignoredThreadIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type TurnCompletionContext = {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
status: v2.TurnStatus;
|
||||
durationMs: number | null;
|
||||
finalText: string;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
export type AnnouncementDraft = VoiceAnnouncement & {
|
||||
kind:
|
||||
| "backend.connected"
|
||||
| "backend.error"
|
||||
| "backend.closed"
|
||||
| "turn.started"
|
||||
| "turn.completed"
|
||||
| "hook.completed"
|
||||
| "warning"
|
||||
| "error";
|
||||
turnCompletion?: TurnCompletionContext;
|
||||
};
|
||||
|
||||
export function draftFromWorkspaceEvent(
|
||||
event: WorkspaceBackendEvent,
|
||||
policy: AnnouncementPolicy,
|
||||
): AnnouncementDraft | undefined {
|
||||
if (event.type === "connected") {
|
||||
if (!policy.announceBackendConnected) {
|
||||
return undefined;
|
||||
}
|
||||
return draft({
|
||||
id: `backend:connected:${event.at}`,
|
||||
kind: "backend.connected",
|
||||
source: "workspace-backend",
|
||||
priority: "low",
|
||||
text: "Workspace backend connected.",
|
||||
policy,
|
||||
});
|
||||
}
|
||||
if (event.type === "appServer.connected") {
|
||||
return draft({
|
||||
id: `app-server:connected:${event.at}`,
|
||||
kind: "backend.connected",
|
||||
source: "workspace-backend",
|
||||
priority: "low",
|
||||
text: "Codex app server connected.",
|
||||
policy,
|
||||
});
|
||||
}
|
||||
if (event.type === "appServer.closed") {
|
||||
const reason = event.reason ? ` Reason: ${event.reason}.` : "";
|
||||
return draft({
|
||||
id: `app-server:closed:${event.at}`,
|
||||
kind: "backend.closed",
|
||||
source: "workspace-backend",
|
||||
priority: "high",
|
||||
text: `Codex app server disconnected.${reason}`,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
if (event.type === "appServer.error") {
|
||||
return draft({
|
||||
id: `app-server:error:${event.at}:${event.message}`,
|
||||
kind: "backend.error",
|
||||
source: "workspace-backend",
|
||||
priority: "high",
|
||||
text: `Codex app server error: ${event.message}`,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
if (event.type === "unsupportedWorkspaceBackendMethod") {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function draftFromNotification(
|
||||
message: JsonRpcNotification,
|
||||
policy: AnnouncementPolicy,
|
||||
): AnnouncementDraft | undefined {
|
||||
const params = record(message.params);
|
||||
const threadId = stringValue(params.threadId);
|
||||
if (threadId && policy.ignoredThreadIds?.has(threadId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (message.method === "turn/started") {
|
||||
if (!policy.announceTurnStarted) {
|
||||
return undefined;
|
||||
}
|
||||
const turn = record(params.turn);
|
||||
const turnId = stringValue(turn.id);
|
||||
if (!threadId || !turnId) {
|
||||
return undefined;
|
||||
}
|
||||
return draft({
|
||||
id: `turn:started:${threadId}:${turnId}`,
|
||||
kind: "turn.started",
|
||||
source: "app-server",
|
||||
priority: "low",
|
||||
text: "Workspace turn started.",
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.method === "turn/completed") {
|
||||
const context = turnCompletionContext(message);
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
const statusText = context.status === "completed"
|
||||
? "Workspace turn completed."
|
||||
: `Workspace turn ${context.status}.`;
|
||||
const detail = context.errorMessage ?? context.finalText;
|
||||
const text = detail ? `${statusText} ${detail}` : statusText;
|
||||
return {
|
||||
...draft({
|
||||
id: `turn:completed:${context.threadId}:${context.turnId}:${context.status}`,
|
||||
kind: "turn.completed",
|
||||
source: "app-server",
|
||||
priority: context.status === "completed" ? "normal" : "high",
|
||||
text,
|
||||
policy,
|
||||
}),
|
||||
turnCompletion: context,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.method === "hook/completed") {
|
||||
const run = record(params.run);
|
||||
const status = stringValue(run.status);
|
||||
if (!threadId || !status || status === "completed") {
|
||||
return undefined;
|
||||
}
|
||||
const eventName = stringValue(run.eventName) ?? "hook";
|
||||
const statusMessage = stringValue(run.statusMessage);
|
||||
return draft({
|
||||
id: `hook:${threadId}:${stringValue(run.id) ?? eventName}:${status}`,
|
||||
kind: "hook.completed",
|
||||
source: "app-server",
|
||||
priority: "high",
|
||||
text: `Codex ${eventName} hook ${status}.${statusMessage ? ` ${statusMessage}` : ""}`,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.method === "error") {
|
||||
const error = record(params.error);
|
||||
const messageText = stringValue(error.message) ?? "Unknown error.";
|
||||
const turnId = stringValue(params.turnId) ?? "unknown";
|
||||
return draft({
|
||||
id: `error:${threadId ?? "global"}:${turnId}:${messageText}`,
|
||||
kind: "error",
|
||||
source: "app-server",
|
||||
priority: "high",
|
||||
text: `Codex turn error: ${messageText}`,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
message.method === "warning" ||
|
||||
message.method === "configWarning" ||
|
||||
message.method === "guardianWarning"
|
||||
) {
|
||||
const messageText = stringValue(params.message);
|
||||
if (!messageText) {
|
||||
return undefined;
|
||||
}
|
||||
return draft({
|
||||
id: `warning:${threadId ?? "global"}:${message.method}:${messageText}`,
|
||||
kind: "warning",
|
||||
source: "app-server",
|
||||
priority: "normal",
|
||||
text: messageText,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function turnCompletionContext(
|
||||
message: JsonRpcNotification,
|
||||
): TurnCompletionContext | undefined {
|
||||
if (message.method !== "turn/completed") {
|
||||
return undefined;
|
||||
}
|
||||
const params = record(message.params);
|
||||
const threadId = stringValue(params.threadId);
|
||||
const turn = record(params.turn);
|
||||
const turnId = stringValue(turn.id);
|
||||
const status = turnStatusValue(turn.status);
|
||||
if (!threadId || !turnId || !status) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
threadId,
|
||||
turnId,
|
||||
status,
|
||||
durationMs: numberValue(turn.durationMs),
|
||||
finalText: finalTextFromTurn(turn),
|
||||
errorMessage: turnErrorMessage(turn),
|
||||
};
|
||||
}
|
||||
|
||||
export function finalTextFromTurn(turn: Partial<v2.Turn> | Record<string, unknown>): string {
|
||||
const items = Array.isArray(turn.items) ? turn.items : [];
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = record(items[index]);
|
||||
if (item.type !== "agentMessage") {
|
||||
continue;
|
||||
}
|
||||
if (item.phase === "commentary") {
|
||||
continue;
|
||||
}
|
||||
const text = stringValue(item.text);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function draft(input: {
|
||||
id: string;
|
||||
kind: AnnouncementDraft["kind"];
|
||||
source: string;
|
||||
priority: AnnouncementPriority;
|
||||
text: string;
|
||||
policy: AnnouncementPolicy;
|
||||
}): AnnouncementDraft {
|
||||
return {
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
source: input.source,
|
||||
priority: input.priority,
|
||||
text: cleanForSpeech(input.text),
|
||||
};
|
||||
}
|
||||
|
||||
function turnStatusValue(value: unknown): v2.TurnStatus | undefined {
|
||||
if (
|
||||
value === "completed" ||
|
||||
value === "interrupted" ||
|
||||
value === "failed" ||
|
||||
value === "inProgress"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function turnErrorMessage(turn: Record<string, unknown>): string | null {
|
||||
const error = record(turn.error);
|
||||
return stringValue(error.message) ?? null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
import type { ReasoningEffort, v2 } from "@peezy.tech/codex-flows/generated";
|
||||
import {
|
||||
CodexWorkspaceBackendClient,
|
||||
type CodexWorkspaceBackendClientOptions,
|
||||
} from "@peezy.tech/codex-flows/workspace-backend";
|
||||
import {
|
||||
finalTextFromTurn,
|
||||
type TurnCompletionContext,
|
||||
} from "./announcements.ts";
|
||||
import { cleanForSpeech, errorMessage, record, stringValue } from "./text.ts";
|
||||
import type { AnnouncementPriority, Logger } from "./types.ts";
|
||||
|
||||
export type AnnouncerDecision = {
|
||||
speak: boolean;
|
||||
text: string;
|
||||
priority: AnnouncementPriority;
|
||||
};
|
||||
|
||||
export type TurnAnnouncer = {
|
||||
polish(context: TurnCompletionContext): Promise<AnnouncerDecision>;
|
||||
ignoredThreadIds?(): Iterable<string>;
|
||||
close?(): void;
|
||||
};
|
||||
|
||||
export type CodexTurnAnnouncerOptions = {
|
||||
workspaceBackendUrl: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
timeoutMs: number;
|
||||
maxPhraseChars: number;
|
||||
cwd?: string | null;
|
||||
logger: Logger;
|
||||
clientOptions?: Partial<CodexWorkspaceBackendClientOptions>;
|
||||
};
|
||||
|
||||
const announcerInstructions = [
|
||||
"You turn Codex workspace turn-completion data into one concise spoken announcement.",
|
||||
"Return only JSON matching the provided schema.",
|
||||
"Do not use markdown, bullets, code fences, URLs, stack traces, raw ids, or raw command logs.",
|
||||
"Prefer the practical outcome and next risk over implementation detail.",
|
||||
"Set speak=false when the turn has no meaningful user-facing update.",
|
||||
].join("\n");
|
||||
|
||||
export class TemplateTurnAnnouncer implements TurnAnnouncer {
|
||||
async polish(context: TurnCompletionContext): Promise<AnnouncerDecision> {
|
||||
const prefix = context.status === "completed"
|
||||
? "Workspace turn completed."
|
||||
: `Workspace turn ${context.status}.`;
|
||||
const detail = context.errorMessage ?? context.finalText;
|
||||
return {
|
||||
speak: Boolean(detail || context.status !== "completed"),
|
||||
priority: context.status === "completed" ? "normal" : "high",
|
||||
text: cleanForSpeech(detail ? `${prefix} ${detail}` : prefix),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexTurnAnnouncer implements TurnAnnouncer {
|
||||
#workspaceBackendUrl: string;
|
||||
#model: string;
|
||||
#reasoningEffort: ReasoningEffort;
|
||||
#timeoutMs: number;
|
||||
#maxPhraseChars: number;
|
||||
#cwd: string | null;
|
||||
#logger: Logger;
|
||||
#clientOptions: Partial<CodexWorkspaceBackendClientOptions>;
|
||||
#client?: CodexWorkspaceBackendClient;
|
||||
#threadId?: string;
|
||||
|
||||
constructor(options: CodexTurnAnnouncerOptions) {
|
||||
this.#workspaceBackendUrl = options.workspaceBackendUrl;
|
||||
this.#model = options.model;
|
||||
this.#reasoningEffort = options.reasoningEffort;
|
||||
this.#timeoutMs = options.timeoutMs;
|
||||
this.#maxPhraseChars = options.maxPhraseChars;
|
||||
this.#cwd = options.cwd ?? null;
|
||||
this.#logger = options.logger;
|
||||
this.#clientOptions = options.clientOptions ?? {};
|
||||
}
|
||||
|
||||
ignoredThreadIds(): Iterable<string> {
|
||||
return this.#threadId ? [this.#threadId] : [];
|
||||
}
|
||||
|
||||
async polish(context: TurnCompletionContext): Promise<AnnouncerDecision> {
|
||||
const client = await this.#ensureClient();
|
||||
const threadId = await this.#ensureThread();
|
||||
const prompt = JSON.stringify({
|
||||
task: "polish-workspace-voice-announcement",
|
||||
maxCharacters: this.#maxPhraseChars,
|
||||
turn: context,
|
||||
});
|
||||
const started = await client.startTurn({
|
||||
threadId,
|
||||
input: [{ type: "text", text: prompt, text_elements: [] }],
|
||||
model: this.#model,
|
||||
cwd: this.#cwd,
|
||||
approvalPolicy: "never",
|
||||
sandboxPolicy: { type: "readOnly", networkAccess: false },
|
||||
effort: this.#reasoningEffort,
|
||||
outputSchema: announcerOutputSchema(this.#maxPhraseChars),
|
||||
});
|
||||
const completedTurn = await waitForTurn(client, {
|
||||
threadId,
|
||||
turnId: started.turn.id,
|
||||
timeoutMs: this.#timeoutMs,
|
||||
});
|
||||
const text = finalTextFromTurn(completedTurn);
|
||||
const decision = parseAnnouncerDecision(text);
|
||||
if (!decision) {
|
||||
this.#logger.warn("announcer.invalidOutput", {
|
||||
threadId,
|
||||
turnId: started.turn.id,
|
||||
});
|
||||
return new TemplateTurnAnnouncer().polish(context);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#client?.close();
|
||||
}
|
||||
|
||||
async #ensureClient(): Promise<CodexWorkspaceBackendClient> {
|
||||
if (this.#client) {
|
||||
return this.#client;
|
||||
}
|
||||
this.#client = new CodexWorkspaceBackendClient({
|
||||
...this.#clientOptions,
|
||||
webSocketTransportOptions: {
|
||||
url: this.#workspaceBackendUrl,
|
||||
requestTimeoutMs: this.#timeoutMs,
|
||||
...this.#clientOptions.webSocketTransportOptions,
|
||||
},
|
||||
clientName: "codex-workspace-voice-announcer",
|
||||
clientTitle: "Codex Workspace Voice Announcer",
|
||||
clientVersion: "0.1.0",
|
||||
});
|
||||
await this.#client.connect();
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
async #ensureThread(): Promise<string> {
|
||||
await this.#ensureClient();
|
||||
if (this.#threadId) {
|
||||
return this.#threadId;
|
||||
}
|
||||
if (!this.#client) {
|
||||
throw new Error("Announcer client is not initialized");
|
||||
}
|
||||
const response = await this.#client.startThread({
|
||||
model: this.#model,
|
||||
cwd: this.#cwd,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "read-only",
|
||||
baseInstructions: announcerInstructions,
|
||||
ephemeral: true,
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: false,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
this.#threadId = response.thread.id;
|
||||
return this.#threadId;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAnnouncerDecision(
|
||||
rawText: string,
|
||||
): AnnouncerDecision | undefined {
|
||||
const parsed = parseJsonObject(rawText);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const speak = parsed.speak;
|
||||
const text = stringValue(parsed.text);
|
||||
if (typeof speak !== "boolean" || !text) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
speak,
|
||||
text: cleanForSpeech(text),
|
||||
priority: priorityValue(parsed.priority) ?? "normal",
|
||||
};
|
||||
}
|
||||
|
||||
export function fallbackAnnouncerDecision(
|
||||
context: TurnCompletionContext,
|
||||
error: unknown,
|
||||
logger: Logger,
|
||||
): AnnouncerDecision {
|
||||
logger.warn("announcer.failed", { error: errorMessage(error) });
|
||||
return {
|
||||
speak: true,
|
||||
priority: context.status === "completed" ? "normal" : "high",
|
||||
text: cleanForSpeech(
|
||||
context.errorMessage ||
|
||||
context.finalText ||
|
||||
`Workspace turn ${context.status}.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function announcerOutputSchema(
|
||||
maxPhraseChars: number,
|
||||
): NonNullable<v2.TurnStartParams["outputSchema"]> {
|
||||
return {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["speak", "text"],
|
||||
properties: {
|
||||
speak: { type: "boolean" },
|
||||
priority: { type: "string", enum: ["low", "normal", "high"] },
|
||||
text: { type: "string", maxLength: maxPhraseChars },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTurn(
|
||||
client: CodexWorkspaceBackendClient,
|
||||
options: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<v2.Turn> {
|
||||
const startedAt = Date.now();
|
||||
while (true) {
|
||||
const response = await client.readThread({
|
||||
threadId: options.threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
const turn = response.thread.turns.find((entry) => entry.id === options.turnId);
|
||||
if (!turn) {
|
||||
throw new Error(`Announcer turn ${options.turnId} was not found`);
|
||||
}
|
||||
if (turn.status !== "inProgress") {
|
||||
if (turn.status === "failed") {
|
||||
throw new Error(turn.error?.message ?? `Announcer turn ${options.turnId} failed`);
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
if (Date.now() - startedAt >= options.timeoutMs) {
|
||||
throw new Error(`Timed out waiting for announcer turn ${options.turnId}`);
|
||||
}
|
||||
await delay(Math.min(1000, Math.max(0, options.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseJsonObject(rawText: string): Record<string, unknown> | undefined {
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return record(JSON.parse(trimmed));
|
||||
} catch {
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return record(JSON.parse(trimmed.slice(start, end + 1)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function priorityValue(value: unknown): AnnouncementPriority | undefined {
|
||||
if (value === "low" || value === "normal" || value === "high") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
import type { ReasoningEffort } from "@peezy.tech/codex-flows/generated";
|
||||
|
||||
export type VoiceGatewayConfig = {
|
||||
workspaceBackendUrl: string;
|
||||
ttsWorkerUrl: string;
|
||||
dryRun: boolean;
|
||||
maxPhraseChars: number;
|
||||
maxQueuedAnnouncements: number;
|
||||
announceBackendConnected: boolean;
|
||||
announceTurnStarted: boolean;
|
||||
hookSpool: {
|
||||
enabled: boolean;
|
||||
dir: string;
|
||||
};
|
||||
discord: {
|
||||
token: string | null;
|
||||
guildId: string | null;
|
||||
voiceChannelId: string | null;
|
||||
};
|
||||
tts: {
|
||||
referenceAudioPath: string | null;
|
||||
referenceText: string | null;
|
||||
referenceTextPath: string | null;
|
||||
};
|
||||
announcer: {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
timeoutMs: number;
|
||||
cwd: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type CliParseResult =
|
||||
| { type: "config"; config: VoiceGatewayConfig }
|
||||
| { type: "help"; text: string };
|
||||
|
||||
const defaultWorkspaceBackendUrl = "ws://127.0.0.1:3586";
|
||||
const defaultTtsWorkerUrl = "http://127.0.0.1:8000";
|
||||
|
||||
export function parseCli(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): CliParseResult {
|
||||
const flags = parseFlags(argv);
|
||||
if (flags.has("help") || flags.has("h")) {
|
||||
return { type: "help", text: helpText() };
|
||||
}
|
||||
|
||||
const dryRun = booleanValue(flags, env, {
|
||||
flag: "dry-run",
|
||||
env: "CODEX_VOICE_DRY_RUN",
|
||||
defaultValue: false,
|
||||
});
|
||||
const announcerEnabled = booleanValue(flags, env, {
|
||||
flag: "announcer",
|
||||
negativeFlag: "no-announcer",
|
||||
env: "CODEX_VOICE_ANNOUNCER_ENABLED",
|
||||
defaultValue: false,
|
||||
});
|
||||
const config: VoiceGatewayConfig = {
|
||||
workspaceBackendUrl: normalizeWorkspaceBackendUrl(
|
||||
stringOption(flags, env, [
|
||||
"workspace-backend-url",
|
||||
"workspace-url",
|
||||
], [
|
||||
"CODEX_VOICE_WORKSPACE_BACKEND_WS_URL",
|
||||
"CODEX_WORKSPACE_BACKEND_WS_URL",
|
||||
"CODEX_GATEWAY_BACKEND_URL",
|
||||
]) ?? defaultWorkspaceBackendUrl,
|
||||
),
|
||||
ttsWorkerUrl: stripTrailingSlash(
|
||||
stringOption(flags, env, ["tts-worker-url"], [
|
||||
"CODEX_VOICE_TTS_WORKER_URL",
|
||||
"DISCORD_TTS_WORKER_URL",
|
||||
]) ?? defaultTtsWorkerUrl,
|
||||
),
|
||||
dryRun,
|
||||
maxPhraseChars: numberOption(flags, env, "max-phrase-chars", "CODEX_VOICE_MAX_PHRASE_CHARS", 260),
|
||||
maxQueuedAnnouncements: numberOption(flags, env, "max-queued-announcements", "CODEX_VOICE_MAX_QUEUE", 20),
|
||||
announceBackendConnected: booleanValue(flags, env, {
|
||||
flag: "announce-backend-connected",
|
||||
negativeFlag: "no-announce-backend-connected",
|
||||
env: "CODEX_VOICE_ANNOUNCE_BACKEND_CONNECTED",
|
||||
defaultValue: true,
|
||||
}),
|
||||
announceTurnStarted: booleanValue(flags, env, {
|
||||
flag: "announce-turn-started",
|
||||
negativeFlag: "no-announce-turn-started",
|
||||
env: "CODEX_VOICE_ANNOUNCE_TURN_STARTED",
|
||||
defaultValue: false,
|
||||
}),
|
||||
hookSpool: {
|
||||
enabled: booleanValue(flags, env, {
|
||||
flag: "observe-hook-spool",
|
||||
negativeFlag: "no-observe-hook-spool",
|
||||
env: "CODEX_VOICE_OBSERVE_HOOK_SPOOL",
|
||||
defaultValue: true,
|
||||
}),
|
||||
dir: stringOption(flags, env, ["hook-spool-dir"], [
|
||||
"CODEX_VOICE_HOOK_SPOOL_DIR",
|
||||
"CODEX_DISCORD_HOOK_SPOOL_DIR",
|
||||
]) ?? "~/.codex/discord-bridge/stop-hooks",
|
||||
},
|
||||
discord: {
|
||||
token: stringOption(flags, env, ["discord-token"], [
|
||||
"CODEX_VOICE_DISCORD_BOT_TOKEN",
|
||||
"CODEX_DISCORD_BOT_TOKEN",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]) ?? null,
|
||||
guildId: stringOption(flags, env, ["discord-guild-id"], [
|
||||
"CODEX_VOICE_DISCORD_GUILD_ID",
|
||||
"CODEX_DISCORD_GUILD_ID",
|
||||
"DISCORD_GUILD_ID",
|
||||
]) ?? null,
|
||||
voiceChannelId: stringOption(flags, env, [
|
||||
"discord-voice-channel-id",
|
||||
"discord-channel-id",
|
||||
], [
|
||||
"CODEX_VOICE_DISCORD_VOICE_CHANNEL_ID",
|
||||
"CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID",
|
||||
"DISCORD_VOICE_CHANNEL_ID",
|
||||
]) ?? null,
|
||||
},
|
||||
tts: {
|
||||
referenceAudioPath: stringOption(flags, env, ["reference-audio-path"], [
|
||||
"CODEX_VOICE_TTS_REFERENCE_AUDIO_PATH",
|
||||
"DISCORD_TTS_REFERENCE_AUDIO_PATH",
|
||||
]) ?? null,
|
||||
referenceText: stringOption(flags, env, ["reference-text"], [
|
||||
"CODEX_VOICE_TTS_REFERENCE_TEXT",
|
||||
"DISCORD_TTS_REFERENCE_TEXT",
|
||||
]) ?? null,
|
||||
referenceTextPath: stringOption(flags, env, ["reference-text-path"], [
|
||||
"CODEX_VOICE_TTS_REFERENCE_TEXT_PATH",
|
||||
"DISCORD_TTS_REFERENCE_TEXT_PATH",
|
||||
]) ?? null,
|
||||
},
|
||||
announcer: {
|
||||
enabled: announcerEnabled,
|
||||
model: stringOption(flags, env, ["announcer-model"], [
|
||||
"CODEX_VOICE_ANNOUNCER_MODEL",
|
||||
]) ?? "gpt-5.3-codex-spark",
|
||||
reasoningEffort: reasoningEffortValue(
|
||||
stringOption(flags, env, ["announcer-reasoning-effort"], [
|
||||
"CODEX_VOICE_ANNOUNCER_REASONING_EFFORT",
|
||||
]) ?? "low",
|
||||
),
|
||||
timeoutMs: numberOption(flags, env, "announcer-timeout-ms", "CODEX_VOICE_ANNOUNCER_TIMEOUT_MS", 90_000),
|
||||
cwd: stringOption(flags, env, ["announcer-cwd"], [
|
||||
"CODEX_VOICE_ANNOUNCER_CWD",
|
||||
]) ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
validateConfig(config);
|
||||
return { type: "config", config };
|
||||
}
|
||||
|
||||
export function helpText(): string {
|
||||
return `Usage: codex-workspace-voice-gateway [options]
|
||||
|
||||
Broadcast selected Codex workspace backend updates into one Discord voice channel.
|
||||
|
||||
Options:
|
||||
--workspace-backend-url <url> Workspace backend WebSocket URL.
|
||||
--tts-worker-url <url> TTS worker HTTP URL.
|
||||
--discord-token <token> Discord bot token.
|
||||
--discord-guild-id <id> Discord guild id.
|
||||
--discord-voice-channel-id <id> Discord voice channel id.
|
||||
--reference-audio-path <path> TTS reference voice audio path.
|
||||
--reference-text <text> TTS reference transcript.
|
||||
--reference-text-path <path> TTS reference transcript path.
|
||||
--announcer Enable model-polished turn-end phrases.
|
||||
--announcer-model <model> Announcer model override.
|
||||
--announcer-reasoning-effort <e> Announcer reasoning effort. Defaults to low.
|
||||
--max-phrase-chars <n> Announcer phrase target. Defaults to 260.
|
||||
--hook-spool-dir <path> Codex hook event spool directory.
|
||||
--no-observe-hook-spool Do not watch external Codex hook events.
|
||||
--dry-run Log announcements instead of joining Discord.
|
||||
--help Show this help.
|
||||
`;
|
||||
}
|
||||
|
||||
function validateConfig(config: VoiceGatewayConfig): void {
|
||||
if (!config.dryRun) {
|
||||
const missing = [
|
||||
["Discord bot token", config.discord.token],
|
||||
["Discord voice channel id", config.discord.voiceChannelId],
|
||||
].filter(([, value]) => !value);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required voice gateway config: ${
|
||||
missing.map(([name]) => name).join(", ")
|
||||
}. Use --dry-run to skip Discord voice output.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): Map<string, string | true> {
|
||||
const flags = new Map<string, string | true>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg?.startsWith("--")) {
|
||||
throw new Error(`Unexpected positional argument: ${arg ?? ""}`);
|
||||
}
|
||||
const raw = arg.slice(2);
|
||||
const equalsIndex = raw.indexOf("=");
|
||||
if (equalsIndex >= 0) {
|
||||
flags.set(raw.slice(0, equalsIndex), raw.slice(equalsIndex + 1));
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
flags.set(raw, next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
flags.set(raw, true);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function stringOption(
|
||||
flags: Map<string, string | true>,
|
||||
env: Record<string, string | undefined>,
|
||||
names: string[],
|
||||
envNames: string[],
|
||||
): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = flags.get(name);
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
for (const envName of envNames) {
|
||||
const value = env[envName];
|
||||
if (value?.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberOption(
|
||||
flags: Map<string, string | true>,
|
||||
env: Record<string, string | undefined>,
|
||||
flagName: string,
|
||||
envName: string,
|
||||
defaultValue: number,
|
||||
): number {
|
||||
const raw = flags.get(flagName) ?? env[envName];
|
||||
if (raw === undefined || raw === true || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`Expected a non-negative number for ${flagName}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(
|
||||
flags: Map<string, string | true>,
|
||||
env: Record<string, string | undefined>,
|
||||
options: {
|
||||
flag: string;
|
||||
negativeFlag?: string;
|
||||
env: string;
|
||||
defaultValue: boolean;
|
||||
},
|
||||
): boolean {
|
||||
if (options.negativeFlag && flags.has(options.negativeFlag)) {
|
||||
return false;
|
||||
}
|
||||
if (flags.has(options.flag)) {
|
||||
const value = flags.get(options.flag);
|
||||
return value === true ? true : value === "1" || value === "true";
|
||||
}
|
||||
const envValue = env[options.env];
|
||||
if (envValue === undefined) {
|
||||
return options.defaultValue;
|
||||
}
|
||||
return envValue === "1" || envValue.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeWorkspaceBackendUrl(value: string): string {
|
||||
if (value.startsWith("http://")) {
|
||||
return `ws://${value.slice("http://".length).replace(/\/+$/, "")}`;
|
||||
}
|
||||
if (value.startsWith("https://")) {
|
||||
return `wss://${value.slice("https://".length).replace(/\/+$/, "")}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reasoningEffortValue(value: string): ReasoningEffort {
|
||||
if (
|
||||
value === "none" ||
|
||||
value === "minimal" ||
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high" ||
|
||||
value === "xhigh"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported announcer reasoning effort: ${value}`);
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
import { once } from "node:events";
|
||||
import { readFile, unlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import {
|
||||
AudioPlayerStatus,
|
||||
StreamType,
|
||||
VoiceConnectionStatus,
|
||||
createAudioPlayer,
|
||||
createAudioResource,
|
||||
entersState,
|
||||
joinVoiceChannel,
|
||||
type AudioPlayer,
|
||||
type VoiceConnection,
|
||||
} from "@discordjs/voice";
|
||||
import {
|
||||
ChannelType,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
type Guild,
|
||||
type VoiceBasedChannel,
|
||||
} from "discord.js";
|
||||
import type { TtsAudioStream, TtsWorkerClient } from "./tts-client.ts";
|
||||
import { errorMessage } from "./text.ts";
|
||||
import type { Logger, Speaker } from "./types.ts";
|
||||
|
||||
export type DiscordVoiceSpeakerOptions = {
|
||||
token: string;
|
||||
guildId?: string | null;
|
||||
voiceChannelId: string;
|
||||
tts: TtsWorkerClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
type ActivePlayback = {
|
||||
cleanup(): void;
|
||||
pumpTask: Promise<void>;
|
||||
resource: ReturnType<typeof createAudioResource>;
|
||||
};
|
||||
|
||||
export class DiscordVoiceSpeaker implements Speaker {
|
||||
#token: string;
|
||||
#guildId: string | null;
|
||||
#voiceChannelId: string;
|
||||
#tts: TtsWorkerClient;
|
||||
#logger: Logger;
|
||||
#client?: Client;
|
||||
#connection?: VoiceConnection;
|
||||
#player?: AudioPlayer;
|
||||
#activePlayback: ActivePlayback | null = null;
|
||||
#playbackResolver: (() => void) | null = null;
|
||||
#closed = false;
|
||||
#rejoining = false;
|
||||
|
||||
constructor(options: DiscordVoiceSpeakerOptions) {
|
||||
this.#token = options.token;
|
||||
this.#guildId = options.guildId ?? null;
|
||||
this.#voiceChannelId = options.voiceChannelId;
|
||||
this.#tts = options.tts;
|
||||
this.#logger = options.logger;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#closed = false;
|
||||
await this.#tts.ensureHealthy();
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
|
||||
});
|
||||
this.#client = client;
|
||||
client.on("error", (error) => {
|
||||
this.#logger.error("discord.client.error", { error: errorMessage(error) });
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fail = (error: unknown) => reject(error);
|
||||
client.once("error", fail);
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
const cachedGuild = this.#guildId
|
||||
? readyClient.guilds.cache.get(this.#guildId) ?? null
|
||||
: null;
|
||||
void this.#join(cachedGuild)
|
||||
.then(resolve, reject)
|
||||
.finally(() => client.off("error", fail));
|
||||
});
|
||||
void client.login(this.#token).catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
async speak(text: string): Promise<void> {
|
||||
const player = this.#player;
|
||||
if (!player) {
|
||||
throw new Error("Discord voice speaker is not started");
|
||||
}
|
||||
const outputPath = path.join(os.tmpdir(), `codex-voice-${randomUUID()}.wav`);
|
||||
let filePath = outputPath;
|
||||
try {
|
||||
const file = await this.#tts.synthesizeFile(text, outputPath);
|
||||
filePath = file.outputPath;
|
||||
const playback = await createWavFileResource(filePath);
|
||||
const playbackFinished = new Promise<void>((resolve) => {
|
||||
this.#playbackResolver = resolve;
|
||||
});
|
||||
this.#activePlayback = playback;
|
||||
player.play(playback.resource);
|
||||
await playbackFinished;
|
||||
await playback.pumpTask.catch(() => undefined);
|
||||
} finally {
|
||||
void unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#activePlayback?.cleanup();
|
||||
this.#connection?.destroy();
|
||||
await this.#client?.destroy();
|
||||
}
|
||||
|
||||
async #join(cachedGuild: Guild | null): Promise<void> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord client is not initialized");
|
||||
}
|
||||
if (!this.#guildId) {
|
||||
const channel = await client.channels.fetch(this.#voiceChannelId);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
||||
throw new Error(`Discord channel ${this.#voiceChannelId} is not a guild voice channel`);
|
||||
}
|
||||
await this.#joinVoiceChannel(channel as VoiceBasedChannel);
|
||||
return;
|
||||
}
|
||||
const guild = cachedGuild ?? await client.guilds.fetch(this.#guildId);
|
||||
const channel = await guild.channels.fetch(this.#voiceChannelId);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
||||
throw new Error(`Discord channel ${this.#voiceChannelId} is not a guild voice channel`);
|
||||
}
|
||||
await this.#joinVoiceChannel(channel as VoiceBasedChannel);
|
||||
}
|
||||
|
||||
async #joinVoiceChannel(voiceChannel: VoiceBasedChannel): Promise<void> {
|
||||
const guild = voiceChannel.guild;
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: voiceChannel.id,
|
||||
guildId: guild.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
selfDeaf: true,
|
||||
selfMute: false,
|
||||
});
|
||||
this.#connection = connection;
|
||||
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
||||
void this.#recoverConnection(connection);
|
||||
});
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
|
||||
const player = createAudioPlayer();
|
||||
player.on(AudioPlayerStatus.Idle, () => this.#resolvePlayback());
|
||||
player.on("error", (error) => {
|
||||
this.#logger.error("discord.player.error", { error: errorMessage(error) });
|
||||
this.#resolvePlayback();
|
||||
});
|
||||
connection.subscribe(player);
|
||||
this.#player = player;
|
||||
this.#logger.info("discord.voice.connected", {
|
||||
guild: guild.name,
|
||||
channel: voiceChannel.name,
|
||||
});
|
||||
}
|
||||
|
||||
async #recoverConnection(connection: VoiceConnection): Promise<void> {
|
||||
if (this.#closed || this.#rejoining) {
|
||||
return;
|
||||
}
|
||||
this.#rejoining = true;
|
||||
this.#logger.warn("discord.voice.disconnected");
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||
]);
|
||||
} catch {
|
||||
if (this.#connection === connection) {
|
||||
connection.destroy();
|
||||
this.#connection = undefined;
|
||||
try {
|
||||
await this.#join(null);
|
||||
} catch (error) {
|
||||
this.#logger.error("discord.voice.rejoinFailed", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#rejoining = false;
|
||||
}
|
||||
}
|
||||
|
||||
#resolvePlayback(): void {
|
||||
const playback = this.#activePlayback;
|
||||
this.#activePlayback = null;
|
||||
playback?.cleanup();
|
||||
const resolve = this.#playbackResolver;
|
||||
this.#playbackResolver = null;
|
||||
resolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWavFileResource(filePath: string): Promise<ActivePlayback> {
|
||||
const wav = await readFile(filePath);
|
||||
const pcm = wavToDiscordPcm(wav);
|
||||
const source = Readable.from([pcm]);
|
||||
return {
|
||||
resource: createAudioResource(source, { inputType: StreamType.Raw }),
|
||||
cleanup() {
|
||||
source.destroy();
|
||||
},
|
||||
pumpTask: Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createStreamingPcmResource(
|
||||
stream: TtsAudioStream,
|
||||
prerollMs: number,
|
||||
): Promise<ActivePlayback> {
|
||||
if (stream.sampleRateHz !== 48000 || stream.channels !== 2) {
|
||||
throw new Error(
|
||||
`Expected Discord-ready PCM stream (48000 Hz stereo), got ${
|
||||
stream.sampleRateHz
|
||||
} Hz / ${stream.channels} channels`,
|
||||
);
|
||||
}
|
||||
const bytesPerSecond = stream.sampleRateHz * stream.channels * 2;
|
||||
const prerollBytes = Math.max(
|
||||
Math.floor((bytesPerSecond * Math.max(prerollMs, 0)) / 1000),
|
||||
0,
|
||||
);
|
||||
const reader = stream.body.getReader();
|
||||
const initialChunks: Buffer[] = [];
|
||||
let initialBytes = 0;
|
||||
while (initialBytes < prerollBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value || value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
const chunk = Buffer.from(value);
|
||||
initialChunks.push(chunk);
|
||||
initialBytes += chunk.byteLength;
|
||||
}
|
||||
const source = new PassThrough({
|
||||
highWaterMark: Math.max(prerollBytes, 256 * 1024),
|
||||
});
|
||||
for (const chunk of initialChunks) {
|
||||
source.write(chunk);
|
||||
}
|
||||
let stopped = false;
|
||||
const pumpTask = (async () => {
|
||||
try {
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value || value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!source.write(Buffer.from(value))) {
|
||||
await once(source, "drain");
|
||||
}
|
||||
}
|
||||
source.end();
|
||||
} catch (error) {
|
||||
if (!stopped) {
|
||||
source.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
})();
|
||||
return {
|
||||
resource: createAudioResource(source, { inputType: StreamType.Raw }),
|
||||
cleanup() {
|
||||
stopped = true;
|
||||
void reader.cancel().catch(() => undefined);
|
||||
source.destroy();
|
||||
},
|
||||
pumpTask,
|
||||
};
|
||||
}
|
||||
|
||||
export function wavToDiscordPcm(wav: Buffer): Buffer {
|
||||
const parsed = parsePcmWav(wav);
|
||||
const targetSampleRateHz = 48000;
|
||||
const targetChannels = 2;
|
||||
const frameCount = parsed.data.length / (parsed.channels * 2);
|
||||
const targetFrameCount = Math.max(
|
||||
1,
|
||||
Math.ceil((frameCount * targetSampleRateHz) / parsed.sampleRateHz),
|
||||
);
|
||||
const output = Buffer.alloc(targetFrameCount * targetChannels * 2);
|
||||
for (let targetFrame = 0; targetFrame < targetFrameCount; targetFrame += 1) {
|
||||
const sourceFrame = Math.min(
|
||||
Math.floor((targetFrame * parsed.sampleRateHz) / targetSampleRateHz),
|
||||
frameCount - 1,
|
||||
);
|
||||
const sourceOffset = sourceFrame * parsed.channels * 2;
|
||||
const left = parsed.data.readInt16LE(sourceOffset);
|
||||
const right = parsed.channels > 1
|
||||
? parsed.data.readInt16LE(sourceOffset + 2)
|
||||
: left;
|
||||
const targetOffset = targetFrame * targetChannels * 2;
|
||||
output.writeInt16LE(left, targetOffset);
|
||||
output.writeInt16LE(right, targetOffset + 2);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parsePcmWav(wav: Buffer): {
|
||||
channels: number;
|
||||
sampleRateHz: number;
|
||||
data: Buffer;
|
||||
} {
|
||||
if (
|
||||
wav.length < 44 ||
|
||||
wav.toString("ascii", 0, 4) !== "RIFF" ||
|
||||
wav.toString("ascii", 8, 12) !== "WAVE"
|
||||
) {
|
||||
throw new Error("Expected a RIFF/WAVE file from TTS worker");
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let channels: number | undefined;
|
||||
let sampleRateHz: number | undefined;
|
||||
let bitsPerSample: number | undefined;
|
||||
let audioFormat: number | undefined;
|
||||
let data: Buffer | undefined;
|
||||
|
||||
while (offset + 8 <= wav.length) {
|
||||
const chunkId = wav.toString("ascii", offset, offset + 4);
|
||||
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||
const chunkStart = offset + 8;
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
if (chunkEnd > wav.length) {
|
||||
throw new Error("Malformed WAV chunk from TTS worker");
|
||||
}
|
||||
if (chunkId === "fmt ") {
|
||||
audioFormat = wav.readUInt16LE(chunkStart);
|
||||
channels = wav.readUInt16LE(chunkStart + 2);
|
||||
sampleRateHz = wav.readUInt32LE(chunkStart + 4);
|
||||
bitsPerSample = wav.readUInt16LE(chunkStart + 14);
|
||||
} else if (chunkId === "data") {
|
||||
data = wav.subarray(chunkStart, chunkEnd);
|
||||
}
|
||||
offset = chunkEnd + (chunkSize % 2);
|
||||
}
|
||||
|
||||
if (audioFormat !== 1 || bitsPerSample !== 16 || !channels || !sampleRateHz || !data) {
|
||||
throw new Error("Expected 16-bit PCM WAV audio from TTS worker");
|
||||
}
|
||||
return { channels, sampleRateHz, data };
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
|
||||
import {
|
||||
CodexWorkspaceBackendClient,
|
||||
type WorkspaceBackendEvent,
|
||||
} from "@peezy.tech/codex-flows/workspace-backend";
|
||||
import {
|
||||
draftFromNotification,
|
||||
draftFromWorkspaceEvent,
|
||||
type AnnouncementDraft,
|
||||
type AnnouncementPolicy,
|
||||
} from "./announcements.ts";
|
||||
import {
|
||||
fallbackAnnouncerDecision,
|
||||
type TurnAnnouncer,
|
||||
} from "./announcer.ts";
|
||||
import { HookSpoolObserver } from "./hook-spool.ts";
|
||||
import { SpeechQueue } from "./speech-queue.ts";
|
||||
import { errorMessage } from "./text.ts";
|
||||
import type { Logger, Speaker, VoiceAnnouncement } from "./types.ts";
|
||||
|
||||
export type WorkspaceVoiceGatewayOptions = {
|
||||
workspaceBackendUrl: string;
|
||||
workspaceClient?: CodexWorkspaceBackendClient;
|
||||
speaker: Speaker;
|
||||
logger: Logger;
|
||||
maxQueuedAnnouncements: number;
|
||||
announceBackendConnected: boolean;
|
||||
announceTurnStarted: boolean;
|
||||
hookSpool?: {
|
||||
enabled: boolean;
|
||||
dir: string;
|
||||
};
|
||||
announcer?: TurnAnnouncer;
|
||||
};
|
||||
|
||||
export class WorkspaceVoiceGateway {
|
||||
#client: CodexWorkspaceBackendClient;
|
||||
#queue: SpeechQueue;
|
||||
#logger: Logger;
|
||||
#policy: AnnouncementPolicy;
|
||||
#announcer?: TurnAnnouncer;
|
||||
#hookSpool?: HookSpoolObserver;
|
||||
|
||||
constructor(options: WorkspaceVoiceGatewayOptions) {
|
||||
this.#logger = options.logger;
|
||||
this.#announcer = options.announcer;
|
||||
this.#client = options.workspaceClient ??
|
||||
new CodexWorkspaceBackendClient({
|
||||
webSocketTransportOptions: {
|
||||
url: options.workspaceBackendUrl,
|
||||
requestTimeoutMs: 90_000,
|
||||
},
|
||||
clientName: "codex-workspace-voice-gateway",
|
||||
clientTitle: "Codex Workspace Voice Gateway",
|
||||
clientVersion: "0.1.0",
|
||||
});
|
||||
this.#queue = new SpeechQueue({
|
||||
speaker: options.speaker,
|
||||
logger: options.logger,
|
||||
maxQueuedAnnouncements: options.maxQueuedAnnouncements,
|
||||
});
|
||||
this.#policy = {
|
||||
announceBackendConnected: options.announceBackendConnected,
|
||||
announceTurnStarted: options.announceTurnStarted,
|
||||
ignoredThreadIds: this.#ignoredThreadIds(),
|
||||
};
|
||||
if (options.hookSpool?.enabled) {
|
||||
this.#hookSpool = new HookSpoolObserver({
|
||||
spoolDir: options.hookSpool.dir,
|
||||
logger: options.logger,
|
||||
onAnnouncement: (announcement) => this.#enqueue(announcement),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#client.on("workspaceBackendEvent", (event) =>
|
||||
this.#handleWorkspaceBackendEvent(event as WorkspaceBackendEvent)
|
||||
);
|
||||
this.#client.on("notification", (message) =>
|
||||
this.#handleNotification(message as JsonRpcNotification)
|
||||
);
|
||||
this.#client.on("error", (error) => {
|
||||
this.#logger.error("workspaceBackend.error", { error: errorMessage(error) });
|
||||
});
|
||||
this.#client.on("close", (code, reason) => {
|
||||
this.#logger.warn("workspaceBackend.closed", {
|
||||
code: typeof code === "number" ? code : null,
|
||||
reason: typeof reason === "string" ? reason : null,
|
||||
});
|
||||
});
|
||||
await this.#queue.speaker.start?.();
|
||||
await this.#hookSpool?.start();
|
||||
await this.#client.connect();
|
||||
this.#logger.info("gateway.started");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#hookSpool?.close();
|
||||
this.#client.close();
|
||||
this.#announcer?.close?.();
|
||||
await this.#queue.close();
|
||||
}
|
||||
|
||||
#handleWorkspaceBackendEvent(event: WorkspaceBackendEvent): void {
|
||||
const draft = draftFromWorkspaceEvent(event, this.#currentPolicy());
|
||||
if (draft) {
|
||||
this.#enqueue(draft);
|
||||
}
|
||||
}
|
||||
|
||||
#handleNotification(message: JsonRpcNotification): void {
|
||||
const draft = draftFromNotification(message, this.#currentPolicy());
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
if (draft.turnCompletion && this.#announcer) {
|
||||
void this.#polishAndEnqueue(draft);
|
||||
return;
|
||||
}
|
||||
this.#enqueue(draft);
|
||||
}
|
||||
|
||||
async #polishAndEnqueue(draft: AnnouncementDraft): Promise<void> {
|
||||
const context = draft.turnCompletion;
|
||||
if (!context || !this.#announcer) {
|
||||
this.#enqueue(draft);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decision = await this.#announcer.polish(context);
|
||||
if (!decision.speak) {
|
||||
this.#logger.debug?.("announcement.skippedByAnnouncer", {
|
||||
threadId: context.threadId,
|
||||
turnId: context.turnId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#enqueue({
|
||||
id: `${draft.id}:announcer`,
|
||||
text: decision.text,
|
||||
priority: decision.priority,
|
||||
source: "announcer",
|
||||
});
|
||||
} catch (error) {
|
||||
const decision = fallbackAnnouncerDecision(
|
||||
context,
|
||||
error,
|
||||
this.#logger,
|
||||
);
|
||||
this.#enqueue({
|
||||
id: `${draft.id}:fallback`,
|
||||
text: decision.text,
|
||||
priority: decision.priority,
|
||||
source: "announcer-fallback",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#enqueue(announcement: VoiceAnnouncement): void {
|
||||
this.#queue.enqueue(announcement);
|
||||
}
|
||||
|
||||
#currentPolicy(): AnnouncementPolicy {
|
||||
return {
|
||||
...this.#policy,
|
||||
ignoredThreadIds: this.#ignoredThreadIds(),
|
||||
};
|
||||
}
|
||||
|
||||
#ignoredThreadIds(): ReadonlySet<string> {
|
||||
return new Set(this.#announcer?.ignoredThreadIds?.() ?? []);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { cleanForSpeech, errorMessage, record, stringValue } from "./text.ts";
|
||||
import type { Logger, VoiceAnnouncement } from "./types.ts";
|
||||
|
||||
export type HookSpoolObserverOptions = {
|
||||
spoolDir: string;
|
||||
logger: Logger;
|
||||
onAnnouncement(announcement: VoiceAnnouncement): void;
|
||||
sinceMs?: number;
|
||||
};
|
||||
|
||||
type HookSpoolEvent = {
|
||||
id: string;
|
||||
eventName: string;
|
||||
sessionId: string;
|
||||
turnId?: string;
|
||||
cwd?: string;
|
||||
lastAssistantMessage?: string;
|
||||
stopHookActive?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const scanDebounceMs = 150;
|
||||
|
||||
export class HookSpoolObserver {
|
||||
#spoolDir: string;
|
||||
#pendingDir: string;
|
||||
#logger: Logger;
|
||||
#onAnnouncement: (announcement: VoiceAnnouncement) => void;
|
||||
#sinceMs: number;
|
||||
#seenFiles = new Set<string>();
|
||||
#seenEvents = new Set<string>();
|
||||
#watcher?: FSWatcher;
|
||||
#timer?: ReturnType<typeof setTimeout>;
|
||||
#closed = false;
|
||||
#scanning = false;
|
||||
|
||||
constructor(options: HookSpoolObserverOptions) {
|
||||
this.#spoolDir = expandHome(options.spoolDir);
|
||||
this.#pendingDir = path.join(this.#spoolDir, "pending");
|
||||
this.#logger = options.logger;
|
||||
this.#onAnnouncement = options.onAnnouncement;
|
||||
this.#sinceMs = options.sinceMs ?? Date.now();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await mkdir(this.#pendingDir, { recursive: true });
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#watcher = watch(this.#pendingDir, { persistent: false }, () => {
|
||||
this.#scheduleScan();
|
||||
});
|
||||
this.#watcher.on("error", (error) => {
|
||||
this.#logger.warn("hookSpool.watch.failed", { error: errorMessage(error) });
|
||||
});
|
||||
this.#scheduleScan(0);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
if (this.#timer) {
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = undefined;
|
||||
}
|
||||
this.#watcher?.close();
|
||||
this.#watcher = undefined;
|
||||
}
|
||||
|
||||
async scan(): Promise<void> {
|
||||
if (this.#closed || this.#scanning) {
|
||||
return;
|
||||
}
|
||||
this.#scanning = true;
|
||||
try {
|
||||
const files = (await readdir(this.#pendingDir))
|
||||
.filter((fileName) => fileName.endsWith(".json"))
|
||||
.sort();
|
||||
for (const fileName of files) {
|
||||
await this.#processFile(fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.warn("hookSpool.scan.failed", { error: errorMessage(error) });
|
||||
} finally {
|
||||
this.#scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleScan(delayMs = scanDebounceMs): void {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
if (this.#timer) {
|
||||
clearTimeout(this.#timer);
|
||||
}
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#timer = undefined;
|
||||
void this.scan();
|
||||
}, delayMs);
|
||||
this.#timer.unref?.();
|
||||
}
|
||||
|
||||
async #processFile(fileName: string): Promise<void> {
|
||||
if (this.#seenFiles.has(fileName)) {
|
||||
return;
|
||||
}
|
||||
const filePath = path.join(this.#pendingDir, fileName);
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (info.mtimeMs < this.#sinceMs) {
|
||||
this.#seenFiles.add(fileName);
|
||||
return;
|
||||
}
|
||||
const event = parseHookSpoolEvent(JSON.parse(await readFile(filePath, "utf8")));
|
||||
this.#seenFiles.add(fileName);
|
||||
if (!event || this.#seenEvents.has(event.id)) {
|
||||
return;
|
||||
}
|
||||
this.#seenEvents.add(event.id);
|
||||
const announcement = announcementFromHookEvent(event);
|
||||
if (announcement) {
|
||||
this.#onAnnouncement(announcement);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
this.#seenFiles.add(fileName);
|
||||
return;
|
||||
}
|
||||
this.#logger.debug?.("hookSpool.file.skipped", {
|
||||
fileName,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function announcementFromHookEvent(
|
||||
event: HookSpoolEvent,
|
||||
): VoiceAnnouncement | undefined {
|
||||
if (event.eventName !== "Stop" || event.stopHookActive === true) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: `hook-stop:${event.id}`,
|
||||
source: "codex-hook-spool",
|
||||
priority: "normal",
|
||||
text: cleanForSpeech(hookAnnouncementText(event)),
|
||||
};
|
||||
}
|
||||
|
||||
function hookAnnouncementText(event: HookSpoolEvent): string {
|
||||
const workspace = event.cwd ? path.basename(event.cwd) : "that workspace";
|
||||
const detail = event.lastAssistantMessage?.trim();
|
||||
if (!detail) {
|
||||
return `Hey, about ${workspace}. I just finished that turn.`;
|
||||
}
|
||||
return `Hey, about ${workspace}. I just finished: ${detail}`;
|
||||
}
|
||||
|
||||
function parseHookSpoolEvent(input: unknown): HookSpoolEvent | undefined {
|
||||
const parsed = record(input);
|
||||
if (parsed.version !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const id = stringValue(parsed.id);
|
||||
const eventName = stringValue(parsed.eventName);
|
||||
const sessionId = stringValue(parsed.sessionId);
|
||||
if (!id || !eventName || !sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
eventName,
|
||||
sessionId,
|
||||
turnId: stringValue(parsed.turnId),
|
||||
cwd: stringValue(parsed.cwd),
|
||||
lastAssistantMessage: stringValue(parsed.lastAssistantMessage),
|
||||
stopHookActive: typeof parsed.stopHookActive === "boolean"
|
||||
? parsed.stopHookActive
|
||||
: undefined,
|
||||
createdAt: stringValue(parsed.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isEnoent(error: unknown): boolean {
|
||||
return error instanceof Error &&
|
||||
"code" in error &&
|
||||
String((error as NodeJS.ErrnoException).code) === "ENOENT";
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { CodexTurnAnnouncer } from "./announcer.ts";
|
||||
import { parseCli } from "./config.ts";
|
||||
import { DiscordVoiceSpeaker } from "./discord-voice.ts";
|
||||
import { WorkspaceVoiceGateway } from "./gateway.ts";
|
||||
import { ConsoleSpeaker } from "./speech-queue.ts";
|
||||
import { errorMessage } from "./text.ts";
|
||||
import { TtsWorkerClient } from "./tts-client.ts";
|
||||
import { consoleLogger } from "./types.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseCli(process.argv.slice(2), process.env);
|
||||
if (parsed.type === "help") {
|
||||
process.stdout.write(parsed.text);
|
||||
return;
|
||||
}
|
||||
const config = parsed.config;
|
||||
const logger = consoleLogger;
|
||||
const tts = new TtsWorkerClient({
|
||||
workerUrl: config.ttsWorkerUrl,
|
||||
referenceAudioPath: config.tts.referenceAudioPath,
|
||||
referenceText: config.tts.referenceText,
|
||||
referenceTextPath: config.tts.referenceTextPath,
|
||||
});
|
||||
const speaker = config.dryRun
|
||||
? new ConsoleSpeaker(logger)
|
||||
: new DiscordVoiceSpeaker({
|
||||
token: requireConfig(config.discord.token, "Discord token"),
|
||||
guildId: config.discord.guildId,
|
||||
voiceChannelId: requireConfig(
|
||||
config.discord.voiceChannelId,
|
||||
"Discord voice channel id",
|
||||
),
|
||||
tts,
|
||||
logger,
|
||||
});
|
||||
const announcer = config.announcer.enabled
|
||||
? new CodexTurnAnnouncer({
|
||||
workspaceBackendUrl: config.workspaceBackendUrl,
|
||||
model: config.announcer.model,
|
||||
reasoningEffort: config.announcer.reasoningEffort,
|
||||
timeoutMs: config.announcer.timeoutMs,
|
||||
maxPhraseChars: config.maxPhraseChars,
|
||||
cwd: config.announcer.cwd,
|
||||
logger,
|
||||
})
|
||||
: undefined;
|
||||
const gateway = new WorkspaceVoiceGateway({
|
||||
workspaceBackendUrl: config.workspaceBackendUrl,
|
||||
speaker,
|
||||
logger,
|
||||
maxQueuedAnnouncements: config.maxQueuedAnnouncements,
|
||||
announceBackendConnected: config.announceBackendConnected,
|
||||
announceTurnStarted: config.announceTurnStarted,
|
||||
hookSpool: config.hookSpool,
|
||||
announcer,
|
||||
});
|
||||
await gateway.start();
|
||||
await waitForShutdown(gateway);
|
||||
}
|
||||
|
||||
function requireConfig(value: string | null, label: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function waitForShutdown(gateway: WorkspaceVoiceGateway): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const shutdown = () => {
|
||||
process.off("SIGINT", shutdown);
|
||||
process.off("SIGTERM", shutdown);
|
||||
void gateway.close().finally(resolve);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
});
|
||||
}
|
||||
|
||||
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
|
||||
if (isDirectRun) {
|
||||
main().catch((error) => {
|
||||
console.error(errorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { errorMessage } from "./text.ts";
|
||||
import type { Logger, Speaker, VoiceAnnouncement } from "./types.ts";
|
||||
|
||||
export type SpeechQueueOptions = {
|
||||
speaker: Speaker;
|
||||
logger: Logger;
|
||||
maxQueuedAnnouncements: number;
|
||||
maxSeenAnnouncements?: number;
|
||||
};
|
||||
|
||||
export class SpeechQueue {
|
||||
readonly speaker: Speaker;
|
||||
readonly logger: Logger;
|
||||
#queue: VoiceAnnouncement[] = [];
|
||||
#seen = new Set<string>();
|
||||
#seenOrder: string[] = [];
|
||||
#draining = false;
|
||||
#closed = false;
|
||||
#maxQueuedAnnouncements: number;
|
||||
#maxSeenAnnouncements: number;
|
||||
|
||||
constructor(options: SpeechQueueOptions) {
|
||||
this.speaker = options.speaker;
|
||||
this.logger = options.logger;
|
||||
this.#maxQueuedAnnouncements = options.maxQueuedAnnouncements;
|
||||
this.#maxSeenAnnouncements = options.maxSeenAnnouncements ?? 1000;
|
||||
}
|
||||
|
||||
enqueue(announcement: VoiceAnnouncement): boolean {
|
||||
if (this.#closed || !announcement.text.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (this.#seen.has(announcement.id)) {
|
||||
this.logger.debug?.("announcement.deduped", { id: announcement.id });
|
||||
return false;
|
||||
}
|
||||
this.#remember(announcement.id);
|
||||
if (this.#queue.length >= this.#maxQueuedAnnouncements) {
|
||||
const dropped = this.#queue.shift();
|
||||
this.logger.warn("announcement.dropped", {
|
||||
id: dropped?.id,
|
||||
reason: "queue-full",
|
||||
});
|
||||
}
|
||||
if (announcement.priority === "high") {
|
||||
this.#queue.unshift(announcement);
|
||||
} else {
|
||||
this.#queue.push(announcement);
|
||||
}
|
||||
this.#drain();
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#queue = [];
|
||||
await this.speaker.close?.();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#queue.length;
|
||||
}
|
||||
|
||||
#drain(): void {
|
||||
if (this.#draining) {
|
||||
return;
|
||||
}
|
||||
this.#draining = true;
|
||||
void (async () => {
|
||||
while (!this.#closed) {
|
||||
const next = this.#queue.shift();
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
this.logger.info("announcement.speak", {
|
||||
id: next.id,
|
||||
source: next.source,
|
||||
priority: next.priority,
|
||||
});
|
||||
await this.speaker.speak(next.text);
|
||||
} catch (error) {
|
||||
this.logger.error("announcement.failed", {
|
||||
id: next.id,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
this.#draining = false;
|
||||
if (this.#queue.length > 0 && !this.#closed) {
|
||||
this.#drain();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
#remember(id: string): void {
|
||||
this.#seen.add(id);
|
||||
this.#seenOrder.push(id);
|
||||
while (this.#seenOrder.length > this.#maxSeenAnnouncements) {
|
||||
const expired = this.#seenOrder.shift();
|
||||
if (expired) {
|
||||
this.#seen.delete(expired);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ConsoleSpeaker implements Speaker {
|
||||
readonly logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async speak(text: string): Promise<void> {
|
||||
this.logger.info("dry-run.speech", { text });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
export function cleanForSpeech(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, " code block ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
|
||||
.replace(/https?:\/\/\S+/g, "")
|
||||
.replace(/<#[0-9]+>/g, "channel")
|
||||
.replace(/<@!?[0-9]+>/g, "user")
|
||||
.replace(/<@&[0-9]+>/g, "role")
|
||||
.replace(/[*_~>#|]/g, " ")
|
||||
.replace(/\b([0-9a-f]{7})[0-9a-f]{6,}\b/gi, "$1")
|
||||
.replace(/[ \t\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
export type TtsWorkerClientOptions = {
|
||||
workerUrl: string;
|
||||
referenceAudioPath?: string | null;
|
||||
referenceText?: string | null;
|
||||
referenceTextPath?: string | null;
|
||||
};
|
||||
|
||||
export type TtsAudioStream = {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
requestId: string | null;
|
||||
sampleRateHz: number;
|
||||
channels: number;
|
||||
};
|
||||
|
||||
export type TtsAudioFile = {
|
||||
requestId: string;
|
||||
outputPath: string;
|
||||
sampleRateHz: number;
|
||||
durationSeconds: number;
|
||||
};
|
||||
|
||||
export class TtsWorkerClient {
|
||||
#workerUrl: string;
|
||||
#referenceAudioPath: string | null;
|
||||
#referenceText: string | null;
|
||||
#referenceTextPath: string | null;
|
||||
|
||||
constructor(options: TtsWorkerClientOptions) {
|
||||
this.#workerUrl = options.workerUrl.replace(/\/+$/, "");
|
||||
this.#referenceAudioPath = options.referenceAudioPath ?? null;
|
||||
this.#referenceText = options.referenceText ?? null;
|
||||
this.#referenceTextPath = options.referenceTextPath ?? null;
|
||||
}
|
||||
|
||||
async ensureHealthy(): Promise<void> {
|
||||
const response = await fetch(`${this.#workerUrl}/health`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`TTS worker health check failed with ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async synthesizeStream(text: string): Promise<TtsAudioStream> {
|
||||
const response = await fetch(`${this.#workerUrl}/v1/synthesize/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
voice: "default",
|
||||
reference_audio_path: this.#referenceAudioPath,
|
||||
reference_text: this.#referenceText,
|
||||
reference_text_path: this.#referenceTextPath,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`TTS stream synthesis failed with ${response.status}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("TTS worker did not return streaming audio");
|
||||
}
|
||||
return {
|
||||
body: response.body,
|
||||
requestId: response.headers.get("x-request-id"),
|
||||
sampleRateHz: Number(response.headers.get("x-sample-rate-hz") ?? "24000"),
|
||||
channels: Number(response.headers.get("x-channels") ?? "1"),
|
||||
};
|
||||
}
|
||||
|
||||
async synthesizeFile(text: string, outputPath: string): Promise<TtsAudioFile> {
|
||||
const response = await fetch(`${this.#workerUrl}/v1/synthesize`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
voice: "default",
|
||||
output_path: outputPath,
|
||||
reference_audio_path: this.#referenceAudioPath,
|
||||
reference_text: this.#referenceText,
|
||||
reference_text_path: this.#referenceTextPath,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`TTS file synthesis failed with ${response.status}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
const body = await response.json() as Record<string, unknown>;
|
||||
return {
|
||||
requestId: stringValue(body.request_id) ?? "",
|
||||
outputPath: requiredString(body.output_path, "output_path"),
|
||||
sampleRateHz: numberValue(body.sample_rate_hz) ?? 24000,
|
||||
durationSeconds: numberValue(body.duration_seconds) ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const parsed = stringValue(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`TTS worker response is missing ${label}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
export type Logger = {
|
||||
info(message: string, details?: Record<string, unknown>): void;
|
||||
warn(message: string, details?: Record<string, unknown>): void;
|
||||
error(message: string, details?: Record<string, unknown>): void;
|
||||
debug?(message: string, details?: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export const consoleLogger: Logger = {
|
||||
info(message, details) {
|
||||
log("info", message, details);
|
||||
},
|
||||
warn(message, details) {
|
||||
log("warn", message, details);
|
||||
},
|
||||
error(message, details) {
|
||||
log("error", message, details);
|
||||
},
|
||||
debug(message, details) {
|
||||
if (process.env.CODEX_VOICE_DEBUG === "1") {
|
||||
log("debug", message, details);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export type AnnouncementPriority = "low" | "normal" | "high";
|
||||
|
||||
export type VoiceAnnouncement = {
|
||||
id: string;
|
||||
text: string;
|
||||
priority: AnnouncementPriority;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type Speaker = {
|
||||
start?(): Promise<void>;
|
||||
speak(text: string): Promise<void>;
|
||||
close?(): void | Promise<void>;
|
||||
};
|
||||
|
||||
function log(
|
||||
level: "info" | "warn" | "error" | "debug",
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
const suffix = details ? ` ${JSON.stringify(details)}` : "";
|
||||
const line = `[workspace-voice-gateway] ${level}: ${message}${suffix}`;
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
return;
|
||||
}
|
||||
if (level === "warn") {
|
||||
console.warn(line);
|
||||
return;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
|
||||
import {
|
||||
draftFromNotification,
|
||||
draftFromWorkspaceEvent,
|
||||
finalTextFromTurn,
|
||||
} from "../src/announcements.ts";
|
||||
|
||||
const policy = {
|
||||
announceBackendConnected: true,
|
||||
announceTurnStarted: false,
|
||||
};
|
||||
|
||||
describe("announcement extraction", () => {
|
||||
test("announces backend connection when enabled", () => {
|
||||
const draft = draftFromWorkspaceEvent({
|
||||
type: "connected",
|
||||
at: "2026-05-16T00:00:00.000Z",
|
||||
}, policy);
|
||||
expect(draft?.text).toBe("Workspace backend connected.");
|
||||
expect(draft?.priority).toBe("low");
|
||||
});
|
||||
|
||||
test("extracts final turn text and removes speech-hostile formatting", () => {
|
||||
const message: JsonRpcNotification = {
|
||||
jsonrpc: "2.0",
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
durationMs: 1200,
|
||||
error: null,
|
||||
items: [
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "m1",
|
||||
phase: "commentary",
|
||||
text: "Still working",
|
||||
},
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "m2",
|
||||
phase: "final_answer",
|
||||
text: "Implemented `voice gateway`. See https://example.com.\n```txt\nlogs\n```",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const draft = draftFromNotification(message, policy);
|
||||
expect(draft?.kind).toBe("turn.completed");
|
||||
expect(draft?.turnCompletion?.finalText).toContain("Implemented");
|
||||
expect(draft?.text).toBe("Workspace turn completed. Implemented voice gateway. See code block");
|
||||
});
|
||||
|
||||
test("ignores announcer thread notifications", () => {
|
||||
const draft = draftFromNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "turn/started",
|
||||
params: { threadId: "announcer", turn: { id: "turn-1" } },
|
||||
}, {
|
||||
...policy,
|
||||
announceTurnStarted: true,
|
||||
ignoredThreadIds: new Set(["announcer"]),
|
||||
});
|
||||
expect(draft).toBeUndefined();
|
||||
});
|
||||
|
||||
test("announces failed hooks and turn errors", () => {
|
||||
const hook = draftFromNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "hook/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
run: {
|
||||
id: "hook-1",
|
||||
eventName: "turn-completed",
|
||||
status: "failed",
|
||||
statusMessage: "post hook failed",
|
||||
},
|
||||
},
|
||||
}, policy);
|
||||
expect(hook?.priority).toBe("high");
|
||||
expect(hook?.text).toBe("Codex turn-completed hook failed. post hook failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalTextFromTurn", () => {
|
||||
test("uses the last non-commentary agent message", () => {
|
||||
expect(finalTextFromTurn({
|
||||
items: [
|
||||
{ type: "agentMessage", id: "one", phase: "final_answer", text: "first" },
|
||||
{ type: "agentMessage", id: "two", phase: "commentary", text: "progress" },
|
||||
{ type: "agentMessage", id: "three", phase: null, text: "last" },
|
||||
],
|
||||
})).toBe("last");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
TemplateTurnAnnouncer,
|
||||
parseAnnouncerDecision,
|
||||
} from "../src/announcer.ts";
|
||||
|
||||
describe("parseAnnouncerDecision", () => {
|
||||
test("accepts strict JSON and cleans text", () => {
|
||||
expect(parseAnnouncerDecision(JSON.stringify({
|
||||
speak: true,
|
||||
priority: "high",
|
||||
text: "Release check failed. See https://example.com for logs.",
|
||||
}))).toEqual({
|
||||
speak: true,
|
||||
priority: "high",
|
||||
text: "Release check failed. See for logs.",
|
||||
});
|
||||
});
|
||||
|
||||
test("finds JSON inside model wrapper text", () => {
|
||||
const decision = parseAnnouncerDecision("```json\n{\"speak\":false,\"text\":\"skip\"}\n```");
|
||||
expect(decision).toEqual({
|
||||
speak: false,
|
||||
priority: "normal",
|
||||
text: "skip",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateTurnAnnouncer", () => {
|
||||
test("speaks failures even without final text", async () => {
|
||||
const announcer = new TemplateTurnAnnouncer();
|
||||
const decision = await announcer.polish({
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
status: "failed",
|
||||
durationMs: null,
|
||||
finalText: "",
|
||||
errorMessage: "Tests failed.",
|
||||
});
|
||||
expect(decision).toMatchObject({
|
||||
speak: true,
|
||||
priority: "high",
|
||||
text: "Workspace turn failed. Tests failed.",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mechanically truncate long fallback text", async () => {
|
||||
const announcer = new TemplateTurnAnnouncer();
|
||||
const finalText = "Completed. ".repeat(80);
|
||||
const decision = await announcer.polish({
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
status: "completed",
|
||||
durationMs: null,
|
||||
finalText,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(decision.text).toContain(finalText.trim());
|
||||
expect(decision.text).not.toContain("...");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { parseCli } from "../src/config.ts";
|
||||
|
||||
describe("parseCli", () => {
|
||||
test("supports dry-run without Discord credentials", () => {
|
||||
const parsed = parseCli(["--dry-run"], {});
|
||||
expect(parsed.type).toBe("config");
|
||||
if (parsed.type === "config") {
|
||||
expect(parsed.config.dryRun).toBe(true);
|
||||
expect(parsed.config.workspaceBackendUrl).toBe("ws://127.0.0.1:3586");
|
||||
expect(parsed.config.ttsWorkerUrl).toBe("http://127.0.0.1:8000");
|
||||
expect(parsed.config.hookSpool.enabled).toBe(true);
|
||||
expect(parsed.config.hookSpool.dir).toBe("~/.codex/discord-bridge/stop-hooks");
|
||||
}
|
||||
});
|
||||
|
||||
test("requires Discord credentials when not in dry-run mode", () => {
|
||||
expect(() => parseCli([], {})).toThrow("Missing required voice gateway config");
|
||||
});
|
||||
|
||||
test("does not require a guild id when a voice channel id is provided", () => {
|
||||
const parsed = parseCli([], {
|
||||
CODEX_DISCORD_BOT_TOKEN: "token",
|
||||
CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID: "voice",
|
||||
});
|
||||
expect(parsed.type).toBe("config");
|
||||
if (parsed.type === "config") {
|
||||
expect(parsed.config.discord.guildId).toBeNull();
|
||||
expect(parsed.config.discord.voiceChannelId).toBe("voice");
|
||||
}
|
||||
});
|
||||
|
||||
test("reads workspace, Discord, TTS, and announcer config from env and flags", () => {
|
||||
const parsed = parseCli([
|
||||
"--announcer",
|
||||
"--announcer-model",
|
||||
"gpt-test",
|
||||
"--max-phrase-chars",
|
||||
"120",
|
||||
], {
|
||||
CODEX_GATEWAY_BACKEND_URL: "http://workspace.example/",
|
||||
DISCORD_TTS_WORKER_URL: "http://127.0.0.1:8000/",
|
||||
DISCORD_BOT_TOKEN: "token",
|
||||
DISCORD_GUILD_ID: "guild",
|
||||
DISCORD_VOICE_CHANNEL_ID: "voice",
|
||||
DISCORD_TTS_REFERENCE_AUDIO_PATH: "references/jo.wav",
|
||||
DISCORD_TTS_REFERENCE_TEXT_PATH: "references/jo.txt",
|
||||
CODEX_VOICE_HOOK_SPOOL_DIR: "/tmp/hooks",
|
||||
});
|
||||
expect(parsed.type).toBe("config");
|
||||
if (parsed.type === "config") {
|
||||
expect(parsed.config.workspaceBackendUrl).toBe("ws://workspace.example");
|
||||
expect(parsed.config.ttsWorkerUrl).toBe("http://127.0.0.1:8000");
|
||||
expect(parsed.config.discord.token).toBe("token");
|
||||
expect(parsed.config.tts.referenceAudioPath).toBe("references/jo.wav");
|
||||
expect(parsed.config.hookSpool.dir).toBe("/tmp/hooks");
|
||||
expect(parsed.config.announcer.enabled).toBe(true);
|
||||
expect(parsed.config.announcer.model).toBe("gpt-test");
|
||||
expect(parsed.config.maxPhraseChars).toBe(120);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { CodexEventEmitter } from "@peezy.tech/codex-flows";
|
||||
import type { JsonRpcNotification } from "@peezy.tech/codex-flows/rpc";
|
||||
import {
|
||||
APP_SERVER_NOTIFICATION_METHOD,
|
||||
CodexWorkspaceBackendClient,
|
||||
WORKSPACE_BACKEND_EVENT_METHOD,
|
||||
type WorkspaceBackendInitializeResponse,
|
||||
} from "@peezy.tech/codex-flows/workspace-backend";
|
||||
import { WorkspaceVoiceGateway } from "../src/gateway.ts";
|
||||
import type { Logger, Speaker } from "../src/types.ts";
|
||||
|
||||
const silentLogger: Logger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
describe("WorkspaceVoiceGateway", () => {
|
||||
test("observes workspace backend events and completed turns", async () => {
|
||||
const transport = new FakeWorkspaceTransport();
|
||||
const spoken: string[] = [];
|
||||
const speaker: Speaker = {
|
||||
async speak(text) {
|
||||
spoken.push(text);
|
||||
},
|
||||
};
|
||||
const gateway = new WorkspaceVoiceGateway({
|
||||
workspaceBackendUrl: "ws://unused",
|
||||
workspaceClient: new CodexWorkspaceBackendClient({ transport }),
|
||||
speaker,
|
||||
logger: silentLogger,
|
||||
maxQueuedAnnouncements: 10,
|
||||
announceBackendConnected: true,
|
||||
announceTurnStarted: false,
|
||||
});
|
||||
|
||||
await gateway.start();
|
||||
transport.emitWorkspaceEvent({
|
||||
type: "connected",
|
||||
at: "2026-05-16T00:00:00.000Z",
|
||||
});
|
||||
transport.emitAppNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
error: null,
|
||||
durationMs: 1000,
|
||||
items: [
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "final",
|
||||
phase: "final_answer",
|
||||
text: "Packed the voice gateway and verified tests.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => spoken.length === 2);
|
||||
expect(spoken).toEqual([
|
||||
"Workspace backend connected.",
|
||||
"Workspace turn completed. Packed the voice gateway and verified tests.",
|
||||
]);
|
||||
await gateway.close();
|
||||
expect(transport.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorkspaceTransport extends CodexEventEmitter {
|
||||
readonly requestTimeoutMs = 1000;
|
||||
closed = false;
|
||||
|
||||
start(): void {}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
async request<T = unknown>(method: string, _params?: unknown): Promise<T> {
|
||||
if (method !== "workspace.initialize") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
serverInfo: { name: "fake", version: "0.1.0" },
|
||||
capabilities: {
|
||||
appServerPassThrough: true,
|
||||
workspaceMethods: [],
|
||||
},
|
||||
} satisfies WorkspaceBackendInitializeResponse as T;
|
||||
}
|
||||
|
||||
notify(_method: string, _params?: unknown): void {}
|
||||
|
||||
emitWorkspaceEvent(event: unknown): void {
|
||||
this.emit("notification", {
|
||||
jsonrpc: "2.0",
|
||||
method: WORKSPACE_BACKEND_EVENT_METHOD,
|
||||
params: { event },
|
||||
});
|
||||
}
|
||||
|
||||
emitAppNotification(message: JsonRpcNotification): void {
|
||||
this.emit("notification", {
|
||||
jsonrpc: "2.0",
|
||||
method: APP_SERVER_NOTIFICATION_METHOD,
|
||||
params: { message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error("Timed out waiting for predicate");
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
announcementFromHookEvent,
|
||||
HookSpoolObserver,
|
||||
} from "../src/hook-spool.ts";
|
||||
import type { VoiceAnnouncement } from "../src/types.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("hook spool announcements", () => {
|
||||
test("announces external Stop hook events without archiving files", async () => {
|
||||
const root = await mkTempDir();
|
||||
const pending = path.join(root, "pending");
|
||||
await mkdir(pending, { recursive: true });
|
||||
const filePath = path.join(pending, "stop-1.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
id: "stop-1",
|
||||
eventName: "Stop",
|
||||
sessionId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
cwd: "/home/peezy/meta-workspace/codex-flows",
|
||||
lastAssistantMessage: "Done. `vp test` passed.",
|
||||
createdAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const announcements: VoiceAnnouncement[] = [];
|
||||
const observer = new HookSpoolObserver({
|
||||
spoolDir: root,
|
||||
logger: testLogger,
|
||||
sinceMs: 0,
|
||||
onAnnouncement: (announcement) => announcements.push(announcement),
|
||||
});
|
||||
|
||||
await observer.start();
|
||||
await observer.scan();
|
||||
observer.close();
|
||||
|
||||
expect(announcements).toHaveLength(1);
|
||||
expect(announcements[0]?.source).toBe("codex-hook-spool");
|
||||
expect(announcements[0]?.text).toBe(
|
||||
"Hey, about codex-flows. I just finished: Done. vp test passed.",
|
||||
);
|
||||
expect(await exists(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses a conversational fallback when there is no final text", () => {
|
||||
const announcement = announcementFromHookEvent({
|
||||
id: "stop-1",
|
||||
eventName: "Stop",
|
||||
sessionId: "thread-1",
|
||||
cwd: "/home/peezy/meta-workspace",
|
||||
});
|
||||
expect(announcement?.text).toBe(
|
||||
"Hey, about meta-workspace. I just finished that turn.",
|
||||
);
|
||||
});
|
||||
|
||||
test("skips non-Stop and active recursive Stop events", () => {
|
||||
expect(announcementFromHookEvent({
|
||||
id: "hook-1",
|
||||
eventName: "PreToolUse",
|
||||
sessionId: "thread-1",
|
||||
})).toBeUndefined();
|
||||
expect(announcementFromHookEvent({
|
||||
id: "stop-1",
|
||||
eventName: "Stop",
|
||||
sessionId: "thread-1",
|
||||
stopHookActive: true,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
async function mkTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "voice-hook-spool-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const testLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
|
||||
import { SpeechQueue } from "../src/speech-queue.ts";
|
||||
import type { Logger, Speaker } from "../src/types.ts";
|
||||
|
||||
const silentLogger: Logger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
describe("SpeechQueue", () => {
|
||||
test("dedupes announcements by id", async () => {
|
||||
const spoken: string[] = [];
|
||||
const speaker: Speaker = {
|
||||
async speak(text) {
|
||||
spoken.push(text);
|
||||
},
|
||||
};
|
||||
const queue = new SpeechQueue({
|
||||
speaker,
|
||||
logger: silentLogger,
|
||||
maxQueuedAnnouncements: 10,
|
||||
});
|
||||
expect(queue.enqueue({
|
||||
id: "same",
|
||||
text: "hello",
|
||||
priority: "normal",
|
||||
source: "test",
|
||||
})).toBe(true);
|
||||
expect(queue.enqueue({
|
||||
id: "same",
|
||||
text: "hello again",
|
||||
priority: "normal",
|
||||
source: "test",
|
||||
})).toBe(false);
|
||||
await waitFor(() => spoken.length === 1);
|
||||
expect(spoken).toEqual(["hello"]);
|
||||
await queue.close();
|
||||
});
|
||||
|
||||
test("prioritizes high priority queued items after current playback", async () => {
|
||||
const spoken: string[] = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const speaker: Speaker = {
|
||||
async speak(text) {
|
||||
spoken.push(text);
|
||||
if (text === "first") {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
const queue = new SpeechQueue({
|
||||
speaker,
|
||||
logger: silentLogger,
|
||||
maxQueuedAnnouncements: 10,
|
||||
});
|
||||
queue.enqueue({ id: "1", text: "first", priority: "normal", source: "test" });
|
||||
queue.enqueue({ id: "2", text: "second", priority: "normal", source: "test" });
|
||||
queue.enqueue({ id: "3", text: "urgent", priority: "high", source: "test" });
|
||||
await waitFor(() => spoken.length === 1);
|
||||
releaseFirst?.();
|
||||
await waitFor(() => spoken.length === 3);
|
||||
expect(spoken).toEqual(["first", "urgent", "second"]);
|
||||
await queue.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error("Timed out waiting for predicate");
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import http from "node:http";
|
||||
|
||||
import { createStreamingPcmResource, wavToDiscordPcm } from "../src/discord-voice.ts";
|
||||
import { TtsWorkerClient } from "../src/tts-client.ts";
|
||||
|
||||
describe("TtsWorkerClient", () => {
|
||||
test("checks health and requests streaming synthesis with reference voice config", async () => {
|
||||
let receivedPayload: Record<string, unknown> | undefined;
|
||||
const server = await createHttpTestServer(
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") {
|
||||
return Response.json({ status: "ok", engine: "test" });
|
||||
}
|
||||
if (url.pathname === "/v1/synthesize/stream") {
|
||||
receivedPayload = await request.json() as Record<string, unknown>;
|
||||
return new Response(pcmStream(), {
|
||||
headers: {
|
||||
"x-request-id": "request-1",
|
||||
"x-sample-rate-hz": "48000",
|
||||
"x-channels": "2",
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
);
|
||||
try {
|
||||
const client = new TtsWorkerClient({
|
||||
workerUrl: server.url,
|
||||
referenceAudioPath: "references/jo.wav",
|
||||
referenceTextPath: "references/jo.txt",
|
||||
});
|
||||
await client.ensureHealthy();
|
||||
const stream = await client.synthesizeStream("hello workspace");
|
||||
expect(stream.requestId).toBe("request-1");
|
||||
expect(stream.sampleRateHz).toBe(48000);
|
||||
expect(stream.channels).toBe(2);
|
||||
expect(receivedPayload).toMatchObject({
|
||||
text: "hello workspace",
|
||||
reference_audio_path: "references/jo.wav",
|
||||
reference_text_path: "references/jo.txt",
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("requests file synthesis with an explicit output path", async () => {
|
||||
let receivedPayload: Record<string, unknown> | undefined;
|
||||
const server = await createHttpTestServer(
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") {
|
||||
return Response.json({ status: "ok", engine: "test" });
|
||||
}
|
||||
if (url.pathname === "/v1/synthesize") {
|
||||
receivedPayload = await request.json() as Record<string, unknown>;
|
||||
return Response.json({
|
||||
request_id: "file-request-1",
|
||||
engine: "NeuTTS",
|
||||
voice: "default",
|
||||
output_path: "/tmp/codex-voice.wav",
|
||||
format: "wav",
|
||||
sample_rate_hz: 24000,
|
||||
duration_seconds: 1.25,
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
);
|
||||
try {
|
||||
const client = new TtsWorkerClient({
|
||||
workerUrl: server.url,
|
||||
referenceAudioPath: "references/jo.wav",
|
||||
referenceTextPath: "references/jo.txt",
|
||||
});
|
||||
const file = await client.synthesizeFile("hello workspace", "/tmp/codex-voice.wav");
|
||||
expect(file).toEqual({
|
||||
requestId: "file-request-1",
|
||||
outputPath: "/tmp/codex-voice.wav",
|
||||
sampleRateHz: 24000,
|
||||
durationSeconds: 1.25,
|
||||
});
|
||||
expect(receivedPayload).toMatchObject({
|
||||
text: "hello workspace",
|
||||
output_path: "/tmp/codex-voice.wav",
|
||||
reference_audio_path: "references/jo.wav",
|
||||
reference_text_path: "references/jo.txt",
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStreamingPcmResource", () => {
|
||||
test("accepts Discord-ready PCM streams", async () => {
|
||||
const playback = await createStreamingPcmResource({
|
||||
body: pcmStream(),
|
||||
requestId: "request-1",
|
||||
sampleRateHz: 48000,
|
||||
channels: 2,
|
||||
}, 0);
|
||||
playback.cleanup();
|
||||
await playback.pumpTask.catch(() => undefined);
|
||||
expect(playback.resource).toBeDefined();
|
||||
});
|
||||
|
||||
test("rejects non-Discord PCM streams", async () => {
|
||||
await expect(createStreamingPcmResource({
|
||||
body: pcmStream(),
|
||||
requestId: "request-1",
|
||||
sampleRateHz: 24000,
|
||||
channels: 1,
|
||||
}, 0)).rejects.toThrow("Expected Discord-ready PCM stream");
|
||||
});
|
||||
|
||||
test("converts 24 kHz mono WAV to Discord-ready PCM", () => {
|
||||
const wav = pcmWav({
|
||||
sampleRateHz: 24000,
|
||||
channels: 1,
|
||||
samples: [1000, -1000],
|
||||
});
|
||||
const pcm = wavToDiscordPcm(wav);
|
||||
expect(pcm.length).toBe(16);
|
||||
expect(pcm.readInt16LE(0)).toBe(1000);
|
||||
expect(pcm.readInt16LE(2)).toBe(1000);
|
||||
expect(pcm.readInt16LE(4)).toBe(1000);
|
||||
expect(pcm.readInt16LE(6)).toBe(1000);
|
||||
expect(pcm.readInt16LE(8)).toBe(-1000);
|
||||
expect(pcm.readInt16LE(10)).toBe(-1000);
|
||||
});
|
||||
});
|
||||
|
||||
function pcmStream(): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(3840));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createHttpTestServer(
|
||||
handler: (request: Request) => Response | Promise<Response>,
|
||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = http.createServer((request, response) => {
|
||||
void (async () => {
|
||||
const webResponse = await handler(await toWebRequest(request));
|
||||
response.statusCode = webResponse.status;
|
||||
for (const [name, value] of webResponse.headers) {
|
||||
response.setHeader(name, value);
|
||||
}
|
||||
response.end(Buffer.from(await webResponse.arrayBuffer()));
|
||||
})().catch((error: unknown) => {
|
||||
response.statusCode = 500;
|
||||
response.end(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || !address) {
|
||||
throw new Error("test server did not start");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function toWebRequest(request: http.IncomingMessage): Promise<Request> {
|
||||
const host = request.headers.host ?? "127.0.0.1";
|
||||
const url = new URL(request.url ?? "/", `http://${host}`);
|
||||
const body = request.method === "GET" || request.method === "HEAD"
|
||||
? undefined
|
||||
: await collectBody(request);
|
||||
return new Request(url, {
|
||||
method: request.method,
|
||||
headers: nodeHeaders(request.headers),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
function nodeHeaders(headers: http.IncomingHttpHeaders): Headers {
|
||||
const result = new Headers();
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
result.append(name, entry);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectBody(request: http.IncomingMessage): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function pcmWav(input: {
|
||||
sampleRateHz: number;
|
||||
channels: number;
|
||||
samples: number[];
|
||||
}): Buffer {
|
||||
const data = Buffer.alloc(input.samples.length * 2);
|
||||
for (const [index, sample] of input.samples.entries()) {
|
||||
data.writeInt16LE(sample, index * 2);
|
||||
}
|
||||
const header = Buffer.alloc(44);
|
||||
header.write("RIFF", 0, "ascii");
|
||||
header.writeUInt32LE(36 + data.length, 4);
|
||||
header.write("WAVE", 8, "ascii");
|
||||
header.write("fmt ", 12, "ascii");
|
||||
header.writeUInt32LE(16, 16);
|
||||
header.writeUInt16LE(1, 20);
|
||||
header.writeUInt16LE(input.channels, 22);
|
||||
header.writeUInt32LE(input.sampleRateHz, 24);
|
||||
header.writeUInt32LE(input.sampleRateHz * input.channels * 2, 28);
|
||||
header.writeUInt16LE(input.channels * 2, 32);
|
||||
header.writeUInt16LE(16, 34);
|
||||
header.write("data", 36, "ascii");
|
||||
header.writeUInt32LE(data.length, 40);
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
|
||||
"@peezy.tech/codex-flows/generated": ["../../packages/codex-client/src/app-server/generated/index.ts"],
|
||||
"@peezy.tech/codex-flows/generated/*": ["../../packages/codex-client/src/app-server/generated/*"],
|
||||
"@peezy.tech/codex-flows/rpc": ["../../packages/codex-client/src/app-server/rpc.ts"],
|
||||
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ node "${PLUGIN_ROOT}/hooks/hook-event.mjs"
|
|||
Codex expands `${PLUGIN_ROOT}` from the installed plugin bundle. The command is
|
||||
self-contained and writes lifecycle events into the hook spool used by
|
||||
workspace surfaces. Override the spool with `CODEX_FLOWS_HOOK_SPOOL_DIR`, or
|
||||
with `CODEX_DISCORD_HOOK_SPOOL_DIR` for the existing Discord bridge and voice
|
||||
with `CODEX_DISCORD_HOOK_SPOOL_DIR` for external Discord bridge and voice
|
||||
gateway consumers.
|
||||
|
||||
## Local backend setup
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
procs:
|
||||
workspace-backend:
|
||||
shell: |
|
||||
bash -lc 'set -o pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
set +a
|
||||
backend_url="${CODEX_VOICE_WORKSPACE_BACKEND_WS_URL:-${CODEX_WORKSPACE_BACKEND_WS_URL:-${CODEX_GATEWAY_BACKEND_URL:-ws://127.0.0.1:3586}}}"
|
||||
backend_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$backend_url")"
|
||||
vp run workspace:backend --local-app-server --port "$backend_port"'
|
||||
autostart: true
|
||||
autorestart: true
|
||||
|
||||
tts-worker:
|
||||
shell: |
|
||||
bash -lc 'set -o pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
cd ../tts
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
set +a
|
||||
export TTS_WORKER_BACKBONE_REPO="${TTS_WORKER_BACKBONE_REPO:-neuphonic/neutts-nano-q4-gguf}"
|
||||
export TTS_WORKER_BACKBONE_DEVICE="${TTS_WORKER_BACKBONE_DEVICE:-cpu}"
|
||||
export TTS_WORKER_CODEC_REPO="${TTS_WORKER_CODEC_REPO:-neuphonic/neucodec}"
|
||||
export TTS_WORKER_CODEC_DEVICE="${TTS_WORKER_CODEC_DEVICE:-cpu}"
|
||||
export TTS_WORKER_DEFAULT_REFERENCE_AUDIO_PATH="${TTS_WORKER_DEFAULT_REFERENCE_AUDIO_PATH:-${DISCORD_TTS_REFERENCE_AUDIO_PATH:-references/jo.wav}}"
|
||||
export TTS_WORKER_DEFAULT_REFERENCE_TEXT_PATH="${TTS_WORKER_DEFAULT_REFERENCE_TEXT_PATH:-${DISCORD_TTS_REFERENCE_TEXT_PATH:-references/jo.txt}}"
|
||||
uv run tts-worker'
|
||||
autostart: true
|
||||
autorestart: true
|
||||
|
||||
workspace-voice-gateway:
|
||||
shell: |
|
||||
bash -lc 'set -o pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
set +a
|
||||
backend_url="${CODEX_VOICE_WORKSPACE_BACKEND_WS_URL:-${CODEX_WORKSPACE_BACKEND_WS_URL:-${CODEX_GATEWAY_BACKEND_URL:-ws://127.0.0.1:3586}}}"
|
||||
tts_url="${CODEX_VOICE_TTS_WORKER_URL:-${DISCORD_TTS_WORKER_URL:-http://127.0.0.1:8000}}"
|
||||
voice_channel_id="${CODEX_VOICE_DISCORD_VOICE_CHANNEL_ID:-${CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID:-${DISCORD_VOICE_CHANNEL_ID:-}}}"
|
||||
if [ -z "$voice_channel_id" ]; then
|
||||
echo "Set CODEX_GATEWAY_DISCORD_VOICE_CHANNEL_ID in .env before starting the voice gateway." >&2
|
||||
exit 1
|
||||
fi
|
||||
backend_host="$(node -e "const url = new URL(process.argv[1]); console.log(url.hostname);" "$backend_url")"
|
||||
backend_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$backend_url")"
|
||||
until (echo >"/dev/tcp/$backend_host/$backend_port") >/dev/null 2>&1; do
|
||||
echo "waiting for workspace backend at $backend_url"
|
||||
sleep 2
|
||||
done
|
||||
until curl -fsS "$tts_url/health" >/dev/null 2>&1; do
|
||||
echo "waiting for TTS worker at $tts_url"
|
||||
sleep 2
|
||||
done
|
||||
tsx ./apps/workspace-voice-gateway/src/index.ts \
|
||||
--workspace-backend-url "$backend_url" \
|
||||
--tts-worker-url "$tts_url" \
|
||||
--discord-voice-channel-id "$voice_channel_id"'
|
||||
autostart: true
|
||||
autorestart: true
|
||||
42
mprocs.yaml
42
mprocs.yaml
|
|
@ -1,42 +0,0 @@
|
|||
procs:
|
||||
codex-remote-control:
|
||||
shell: |
|
||||
bash -lc 'set -o pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
set +a
|
||||
app_server_url="${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
|
||||
codex app-server \
|
||||
--listen "$app_server_url" \
|
||||
--enable apps \
|
||||
--enable hooks \
|
||||
--enable remote_control \
|
||||
2>&1 | tsx ./apps/discord-bridge/src/pretty-log.ts --name codex-remote-control'
|
||||
autostart: true
|
||||
autorestart: false
|
||||
|
||||
discord-bridge:
|
||||
shell: |
|
||||
bash -lc 'set -o pipefail
|
||||
set -a
|
||||
[ ! -f .env ] || source .env
|
||||
[ ! -f .env.local ] || source .env.local
|
||||
set +a
|
||||
app_server_url="${CODEX_WORKSPACE_APP_SERVER_WS_URL:-ws://127.0.0.1:3585}"
|
||||
app_server_host="$(node -e "const url = new URL(process.argv[1]); console.log(url.hostname);" "$app_server_url")"
|
||||
app_server_port="$(node -e "const url = new URL(process.argv[1]); console.log(url.port || (url.protocol === \"https:\" || url.protocol === \"wss:\" ? \"443\" : \"80\"));" "$app_server_url")"
|
||||
until (echo >"/dev/tcp/$app_server_host/$app_server_port") >/dev/null 2>&1; do
|
||||
echo "waiting for codex app-server at $app_server_url" >&2
|
||||
sleep 1
|
||||
done
|
||||
tsx ./apps/discord-bridge/src/index.ts \
|
||||
--log-level "${CODEX_DISCORD_LOG_LEVEL:-warn}" \
|
||||
--console-output messages \
|
||||
--approval-policy never \
|
||||
--sandbox danger-full-access \
|
||||
--progress-mode commentary \
|
||||
--app-server-url "$app_server_url" \
|
||||
2> >(tsx ./apps/discord-bridge/src/pretty-log.ts --name discord-bridge >&2)'
|
||||
autostart: true
|
||||
autorestart: false
|
||||
411
pnpm-lock.yaml
generated
411
pnpm-lock.yaml
generated
|
|
@ -106,25 +106,6 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
apps/discord-bridge:
|
||||
dependencies:
|
||||
'@peezy.tech/codex-flows':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codex-client
|
||||
discord.js:
|
||||
specifier: ^14.22.1
|
||||
version: 14.26.4
|
||||
smol-toml:
|
||||
specifier: 'catalog:'
|
||||
version: 1.6.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.12.4
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@peezy.tech/codex-flows':
|
||||
|
|
@ -187,28 +168,6 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
apps/workspace-voice-gateway:
|
||||
dependencies:
|
||||
'@discordjs/voice':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.1.1)
|
||||
'@peezy.tech/codex-flows':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codex-client
|
||||
discord.js:
|
||||
specifier: ^14.22.1
|
||||
version: 14.26.4
|
||||
opusscript:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.12.4
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
docs:
|
||||
devDependencies:
|
||||
'@tomehq/cli':
|
||||
|
|
@ -524,38 +483,6 @@ packages:
|
|||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@1.5.3':
|
||||
resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@2.1.1':
|
||||
resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/formatters@0.6.2':
|
||||
resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/rest@2.6.1':
|
||||
resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/util@1.2.0':
|
||||
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/voice@0.19.2':
|
||||
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@dotenvx/dotenvx@1.66.0':
|
||||
resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==}
|
||||
hasBin: true
|
||||
|
|
@ -1412,22 +1339,6 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sapphire/async-queue@1.5.5':
|
||||
resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
|
||||
engines: {node: '>=v16'}
|
||||
|
||||
'@sapphire/snowflake@3.5.3':
|
||||
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sapphire/snowflake@3.5.5':
|
||||
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
|
|
@ -1472,97 +1383,6 @@ packages:
|
|||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.11':
|
||||
resolution: {integrity: sha512-T1RYbNYKN6tLOcGIDKJd8OI6FBSEemwL7DOYdTMmhqfhhMr3YVN8WOhfoxGg63OcnpTN2e2c5tdY2bAx25RmQQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.11':
|
||||
resolution: {integrity: sha512-ksJn/x2VU8h6w9eku1HT96ugSRZ7lKVkKNKbFleaFN+U99DJaPM+gMu2YvnFU4V54HR06ZBnRihnVG6VLXQpDw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.11':
|
||||
resolution: {integrity: sha512-E1d7PbaaVMO3Lj9EiAPqOVbuV0xg5+PsHzHH097DDXiD1+zUDXvJaTnUWsnm5z50pJniHpi4GtaYmk+ieB/guA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.11':
|
||||
resolution: {integrity: sha512-Tl4TI/LTmgJZepgbgVMYDi8RqlAkPtPg1OEBPl7a9Tn3AwR36Vs6lyIT1cs/lGy/ds/+B+mKI4rPObN1cyILTw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.11':
|
||||
resolution: {integrity: sha512-T8Iw9FXkuI1T+YBAFzh9v/TXf9IOTOSqnd/BFpTRTrlW72PR2lhIidzSmg027VxO7r5pX47iFwiOkb9I/NU/EA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.11':
|
||||
resolution: {integrity: sha512-1Txj+8pqA8uq/OGtaUaBFWAPnNMQzFgIywj0iA7EI4xZl+mab48/pv+YZ1pNb/suC6ynsW44oB9efiXSdcUAgA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.11':
|
||||
resolution: {integrity: sha512-ERzF5nM/IYW1BcN3wLXpEwBCGLFf0kGJUVhaV6yfiInz0tkU8UmvrrgpaMaACfMjIhfWdq5CcX+aTkXo/saNcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.11':
|
||||
resolution: {integrity: sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.11':
|
||||
resolution: {integrity: sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.11':
|
||||
resolution: {integrity: sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.11':
|
||||
resolution: {integrity: sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.11':
|
||||
resolution: {integrity: sha512-5fptJU4tX901m3mj0SHiBljMrPT4ZEsynbBhR7bK1yn9TY1jjyhN8EFi7QF5IWtUEni+0mia2BCMHZ5ZkmFZqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.11':
|
||||
resolution: {integrity: sha512-ualexn8SeLsiMHhWfzVrzRcjHgcBapg++FPaVgJJxoh2S/jCRiklXOu3luqIZdJdNKvhe2V9SwO/cImPeIIBKw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.11':
|
||||
resolution: {integrity: sha512-muNhc8UKXtknzsH/w4AIkbPR2I8BuvApn0pDXar0IEvY8PCjqU/M8MPbOOEYwQVvQRMwVTgExtxzrkBPSXB4nA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey@0.1.11':
|
||||
resolution: {integrity: sha512-oBN+msHzPnm1M5DDx3wVD7iBwpNXFUtkh2MrAbUJu0OhKjliLChi28hq++mu1+qdMpAVQO5JKAvQQxYVbyneiw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
|
|
@ -1779,10 +1599,6 @@ packages:
|
|||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7':
|
||||
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@voidzero-dev/vite-plus-core@0.1.22':
|
||||
resolution: {integrity: sha512-OC7tChagbJCoY7YKzD5MuyxJO1km5IF42B3ltZoQ9Twc8UuPrMuWZrVoP984tJKYd/gFJuQFM/lrbNtBm9kyDg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -2242,13 +2058,6 @@ packages:
|
|||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
discord-api-types@0.38.47:
|
||||
resolution: {integrity: sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==}
|
||||
|
||||
discord.js@14.26.4:
|
||||
resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
dompurify@3.4.5:
|
||||
resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
|
||||
|
||||
|
|
@ -2884,12 +2693,6 @@ packages:
|
|||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2909,9 +2712,6 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
magic-bytes.js@1.13.0:
|
||||
resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
|
|
@ -3227,9 +3027,6 @@ packages:
|
|||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
opusscript@0.1.1:
|
||||
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
|
||||
|
||||
ora@8.2.0:
|
||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3341,23 +3138,6 @@ packages:
|
|||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
prism-media@1.3.5:
|
||||
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
|
||||
peerDependencies:
|
||||
'@discordjs/opus': '>=0.8.0 <1.0.0'
|
||||
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
|
||||
node-opus: ^0.3.3
|
||||
opusscript: ^0.0.8
|
||||
peerDependenciesMeta:
|
||||
'@discordjs/opus':
|
||||
optional: true
|
||||
ffmpeg-static:
|
||||
optional: true
|
||||
node-opus:
|
||||
optional: true
|
||||
opusscript:
|
||||
optional: true
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -3731,9 +3511,6 @@ packages:
|
|||
trough@2.2.0:
|
||||
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
|
||||
|
||||
ts-mixer@6.0.4:
|
||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||
|
||||
ts-morph@26.0.0:
|
||||
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
|
||||
|
||||
|
|
@ -3776,10 +3553,6 @@ packages:
|
|||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
undici@6.24.1:
|
||||
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.25.0:
|
||||
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
|
@ -4284,73 +4057,6 @@ snapshots:
|
|||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/shapeshift': 4.0.0
|
||||
discord-api-types: 0.38.47
|
||||
fast-deep-equal: 3.1.3
|
||||
ts-mixer: 6.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@discordjs/collection@1.5.3': {}
|
||||
|
||||
'@discordjs/collection@2.1.1': {}
|
||||
|
||||
'@discordjs/formatters@0.6.2':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.47
|
||||
|
||||
'@discordjs/rest@2.6.1':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@sapphire/snowflake': 3.5.5
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.47
|
||||
magic-bytes.js: 1.13.0
|
||||
tslib: 2.8.1
|
||||
undici: 6.24.1
|
||||
|
||||
'@discordjs/util@1.2.0':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.47
|
||||
|
||||
'@discordjs/voice@0.19.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.1.1)':
|
||||
dependencies:
|
||||
'@snazzah/davey': 0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@types/ws': 8.18.1
|
||||
discord-api-types: 0.38.47
|
||||
prism-media: 1.3.5(opusscript@0.1.1)
|
||||
tslib: 2.8.1
|
||||
ws: 8.20.1
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- bufferutil
|
||||
- ffmpeg-static
|
||||
- node-opus
|
||||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/rest': 2.6.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@types/ws': 8.18.1
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.47
|
||||
tslib: 2.8.1
|
||||
ws: 8.20.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@dotenvx/dotenvx@1.66.0':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
|
|
@ -4940,17 +4646,6 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.60.4':
|
||||
optional: true
|
||||
|
||||
'@sapphire/async-queue@1.5.5': {}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash: 4.18.1
|
||||
|
||||
'@sapphire/snowflake@3.5.3': {}
|
||||
|
||||
'@sapphire/snowflake@3.5.5': {}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
|
|
@ -5004,73 +4699,6 @@ snapshots:
|
|||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.11':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey@0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
optionalDependencies:
|
||||
'@snazzah/davey-android-arm-eabi': 0.1.11
|
||||
'@snazzah/davey-android-arm64': 0.1.11
|
||||
'@snazzah/davey-darwin-arm64': 0.1.11
|
||||
'@snazzah/davey-darwin-x64': 0.1.11
|
||||
'@snazzah/davey-freebsd-x64': 0.1.11
|
||||
'@snazzah/davey-linux-arm-gnueabihf': 0.1.11
|
||||
'@snazzah/davey-linux-arm64-gnu': 0.1.11
|
||||
'@snazzah/davey-linux-arm64-musl': 0.1.11
|
||||
'@snazzah/davey-linux-x64-gnu': 0.1.11
|
||||
'@snazzah/davey-linux-x64-musl': 0.1.11
|
||||
'@snazzah/davey-wasm32-wasi': 0.1.11(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@snazzah/davey-win32-arm64-msvc': 0.1.11
|
||||
'@snazzah/davey-win32-ia32-msvc': 0.1.11
|
||||
'@snazzah/davey-win32-x64-msvc': 0.1.11
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
|
|
@ -5314,8 +4942,6 @@ snapshots:
|
|||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7': {}
|
||||
|
||||
'@voidzero-dev/vite-plus-core@0.1.22(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0)':
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.129.0
|
||||
|
|
@ -5643,27 +5269,6 @@ snapshots:
|
|||
|
||||
diff@8.0.4: {}
|
||||
|
||||
discord-api-types@0.38.47: {}
|
||||
|
||||
discord.js@14.26.4:
|
||||
dependencies:
|
||||
'@discordjs/builders': 1.14.1
|
||||
'@discordjs/collection': 1.5.3
|
||||
'@discordjs/formatters': 0.6.2
|
||||
'@discordjs/rest': 2.6.1
|
||||
'@discordjs/util': 1.2.0
|
||||
'@discordjs/ws': 1.2.3
|
||||
'@sapphire/snowflake': 3.5.3
|
||||
discord-api-types: 0.38.47
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash.snakecase: 4.1.1
|
||||
magic-bytes.js: 1.13.0
|
||||
tslib: 2.8.1
|
||||
undici: 6.24.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dompurify@3.4.5:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
|
@ -6386,10 +5991,6 @@ snapshots:
|
|||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
|
|
@ -6407,8 +6008,6 @@ snapshots:
|
|||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
magic-bytes.js@1.13.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
|
@ -6994,8 +6593,6 @@ snapshots:
|
|||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
opusscript@0.1.1: {}
|
||||
|
||||
ora@8.2.0:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
|
|
@ -7145,10 +6742,6 @@ snapshots:
|
|||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
prism-media@1.3.5(opusscript@0.1.1):
|
||||
optionalDependencies:
|
||||
opusscript: 0.1.1
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
|
|
@ -7664,8 +7257,6 @@ snapshots:
|
|||
|
||||
trough@2.2.0: {}
|
||||
|
||||
ts-mixer@6.0.4: {}
|
||||
|
||||
ts-morph@26.0.0:
|
||||
dependencies:
|
||||
'@ts-morph/common': 0.27.0
|
||||
|
|
@ -7711,8 +7302,6 @@ snapshots:
|
|||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
|
||||
undici@7.25.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
packages:
|
||||
- apps/*
|
||||
- apps/cli
|
||||
- apps/web
|
||||
- apps/workspace-backend
|
||||
- packages/*
|
||||
- docs
|
||||
|
||||
|
|
@ -9,7 +11,6 @@ allowBuilds:
|
|||
|
||||
catalog:
|
||||
"@base-ui/react": ^1.4.1
|
||||
"@discordjs/voice": ^0.19.2
|
||||
"@tailwindcss/vite": ^4.1.18
|
||||
"@types/node": ^24.12.4
|
||||
"@types/react": ^19.2.10
|
||||
|
|
@ -18,9 +19,7 @@ catalog:
|
|||
"@vitejs/plugin-react": ^6.0.2
|
||||
class-variance-authority: ^0.7.1
|
||||
clsx: ^2.1.1
|
||||
discord.js: ^14.22.1
|
||||
lucide-react: ^1.14.0
|
||||
opusscript: ^0.1.1
|
||||
react: ^19.2.4
|
||||
react-dom: ^19.2.4
|
||||
shadcn: ^4.7.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue