Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 6 additions & 18 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies from the unpublished SDK source
- name: Install locked dependencies
run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@latest @opencode-ai/plugin@latest
- run: npm run lint
- run: npm run typecheck
Expand All @@ -34,21 +28,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies from the unpublished SDK source
- name: Install locked dependencies
run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest
- run: bash scripts/smoke-loader.sh
13 changes: 3 additions & 10 deletions .github/workflows/live-channels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,15 @@ jobs:

steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies from the unpublished SDK source
- name: Install locked dependencies
run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest

Expand All @@ -82,7 +76,6 @@ jobs:
- name: Boot the AUT gateway (${{ matrix.mode }})
env:
MODE: ${{ matrix.mode }}
INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript
AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }}
AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }}
INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }}
Expand Down
13 changes: 3 additions & 10 deletions .github/workflows/live-external-events.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,28 +38,21 @@ jobs:

steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies from the unpublished SDK source
- name: Install locked dependencies
run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest

- name: Boot the AUT gateway (real model, external events on)
env:
MODE: real
INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript
AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }}
AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }}
INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }}
Expand Down
13 changes: 3 additions & 10 deletions .github/workflows/live-voice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,28 +57,21 @@ jobs:

steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install dependencies from the unpublished SDK source
- name: Install locked dependencies
run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest

- name: Boot the AUT gateway (voice; ${{ matrix.scenario }})
env:
MODE: real
INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript
AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }}
AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }}
INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }}
Expand Down
34 changes: 8 additions & 26 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
Expand All @@ -23,10 +18,9 @@ jobs:
with:
python-version: "3.12"
- run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: npm run lint
- run: npm run typecheck
Expand All @@ -40,24 +34,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@latest @opencode-ai/plugin@latest
- run: npm run typecheck
- run: npx vitest run tests/contract
Expand All @@ -66,20 +54,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
repository: inkbox-ai/inkbox
ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e
path: .ci/inkbox
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: |
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript
npm run build --prefix .ci/inkbox/sdk/typescript
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci
bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \
./.ci/inkbox/sdk/typescript \
@inkbox/sdk@0.5.9 \
@opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18
- run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest
- run: INKBOX_SDK_PATH="$PWD/.ci/inkbox/sdk/typescript" bash scripts/smoke-loader.sh
- run: bash scripts/smoke-loader.sh
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Adds negotiated 16 kHz PCM call audio with streaming resampling and legacy call compatibility.
- Uses the published, pinned SDK in CI and gives periodic progress delivery time to finish before the live task ends.
- Retries connection failures during live identity discovery without repeating task submissions.

## 0.2.9 (unreleased)

- Adds a resumable, non-interactive `inkbox-opencode bootstrap` command for existing identities, hosted Voice AI, explicit signing-key rotation, and background gateway startup.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,9 @@ inbound events. What it does:
notifies OpenCode after it ends; the two local stacks keep the call attached
to the OpenCode gateway. Realtime uses `INKBOX_REALTIME_API_KEY` (or
`OPENAI_API_KEY` as the backstop) and runs the call as a live raw-audio
conversation with in-call actions; otherwise Inkbox handles speech-to-text
conversation with in-call actions. Call media negotiates mono 16-bit PCM at
16 kHz, with streaming conversion to and from the realtime connection’s
24 kHz PCM. Older call streams retain 8 kHz compatibility. Otherwise Inkbox handles speech-to-text
and text-to-speech. Opt out with `INKBOX_VOICE_ENABLED=false` (stop answering)
or `INKBOX_REALTIME_ENABLED=false` (force Inkbox STT/TTS).
`inkbox_place_call` dials out with a purpose loaded into the call.
Expand Down
2 changes: 2 additions & 0 deletions src/gateway/hosted-call-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export function createHostedCallCompletion(deps: HostedCallCompletionDeps) {
"Do not execute any non-SMS post-call action in this correction turn.",
"Do not delegate this send to another session or agent.",
`Exact open SMS commitment:\n${escapePromptData(smsCommitment ?? "")}`,
"If the caller specified an exact message body, copy it verbatim from the action or transcript. Do not replace it with an acknowledgement or summary.",
`Call inkbox_send_sms exactly once with to="${escapePromptData(remote)}". Do not use conversationId, send to another number, or make a second attempt. Plain-text replies are suppressed.`,
].join("\n\n")
: [
Expand Down Expand Up @@ -239,6 +240,7 @@ export function createHostedCallCompletion(deps: HostedCallCompletionDeps) {
? `A promised SMS must use inkbox_send_sms exactly once with to="${escapePromptData(remote)}". Do not use conversationId, a contact-derived number, or delegate the send to another session or agent. Count it complete only when the tool reports success; do not retry inside this turn.`
: undefined,
"Complete every still-open commitment once. Do not repeat work already completed during the call. If nothing remains, return [SILENT]; plain text is suppressed.",
"If the caller specified an exact message body, copy it verbatim from the action or transcript. Do not replace it with an acknowledgement or summary.",
]
.filter(Boolean)
.join("\n\n");
Expand Down
10 changes: 7 additions & 3 deletions src/gateway/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
query: { directory: deps.directory },
});
const statuses = (statusRes as any)?.data ?? statusRes;
if (statuses?.[turn.sessionID]?.type === "busy") {
const status = statuses?.[turn.sessionID]?.type;
if (status && status !== "idle") {
await delay(POLL_MS);
continue;
}
Expand All @@ -300,7 +301,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
if (last?.info?.error) {
throw new Error(`OpenCode turn failed: ${JSON.stringify(last.info.error).slice(0, 300)}`);
}
if (last?.info?.time?.completed || last?.info?.finish) return extractText(last);
// A completed assistant step can still be followed by tool work or a
// model retry. Keep the turn's side-effect guards until the final answer.
const finish = last?.info?.finish;
if (finish && finish !== "tool-calls" && finish !== "unknown") return extractText(last);
await delay(POLL_MS);
}
throw new HostedCaptureDeferredError();
Expand Down Expand Up @@ -589,7 +593,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
});
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Progress summary timed out.")), 10_000);
timer = setTimeout(() => reject(new Error("Progress summary timed out.")), 20_000);
timer.unref?.();
});
try {
Expand Down
74 changes: 74 additions & 0 deletions src/gateway/voice/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Pcm16Resampler } from "./pcm-resampler.js";

export type CallAudioFormat = "pcm_s16le_16000" | "pcmu_8000";

export function callAudioFormat(start: unknown): CallAudioFormat {
const media = (start as { media_format?: Record<string, unknown> } | undefined)?.media_format;
if (!media) return "pcmu_8000";
if (media.channels === 1 && media.encoding === "L16" && media.sample_rate === 16000) {
return "pcm_s16le_16000";
}
if (media.channels === 1 && media.encoding === "PCMU" && media.sample_rate === 8000) {
return "pcmu_8000";
}
throw new Error("Unsupported call audio format");
}

function decodeUlaw(audio: Buffer): Buffer {
const pcm = Buffer.alloc(audio.length * 2);
for (let i = 0; i < audio.length; i++) {
const value = ~audio[i] & 255;
const magnitude = (((value & 15) << 3) + 132) << ((value >> 4) & 7);
pcm.writeInt16LE(value & 128 ? 132 - magnitude : magnitude - 132, i * 2);
}
return pcm;
}

function encodeUlaw(pcm: Buffer): Buffer {
const audio = Buffer.alloc(pcm.length / 2);
for (let i = 0; i < audio.length; i++) {
const sample = pcm.readInt16LE(i * 2);
const sign = sample < 0 ? 128 : 0;
const magnitude = Math.min(32635, Math.abs(sample)) + 132;
let exponent = 7;
for (let mask = 0x4000; exponent > 0 && !(magnitude & mask); mask >>= 1) exponent--;
const mantissa = (magnitude >> (exponent + 3)) & 15;
audio[i] = ~(sign | (exponent << 4) | mantissa) & 255;
}
return audio;
}

/** Each call owns separate continuous input and response-scoped output state. */
export class CallAudio {
private input: Pcm16Resampler;
private output: Pcm16Resampler;

constructor(private readonly format: CallAudioFormat = "pcm_s16le_16000") {
const rate = format === "pcmu_8000" ? 8000 : 16000;
this.input = new Pcm16Resampler(rate, 24000);
this.output = new Pcm16Resampler(24000, rate);
}

toRealtime(base64: string): string {
const audio = Buffer.from(base64, "base64");
return this.input
.process(this.format === "pcmu_8000" ? decodeUlaw(audio) : audio)
.toString("base64");
}

fromRealtime(base64: string): string {
return this.encodeOutput(this.output.process(Buffer.from(base64, "base64")));
}

finishOutput(): string {
return this.encodeOutput(this.output.flush());
}

interrupt(): void {
this.output.reset();
}

private encodeOutput(pcm: Buffer): string {
return (this.format === "pcmu_8000" ? encodeUlaw(pcm) : pcm).toString("base64");
}
}
Loading