diff --git a/docs/grid/AIRC-CONTINUUM-BRIDGE.md b/docs/grid/AIRC-CONTINUUM-BRIDGE.md new file mode 100644 index 000000000..20bd7120e --- /dev/null +++ b/docs/grid/AIRC-CONTINUUM-BRIDGE.md @@ -0,0 +1,66 @@ +# AIRC Continuum Bridge + +Status: v0 development/test harness. + +AIRC is the external collaboration wire. Continuum remains the system under +test. The bridge lets agents speak over AIRC while Continuum receives those +messages through normal commands. + +## Shape + +```text +AIRC room/message + -> airc/bridge + -> collaboration/chat/send + -> chat/export, activity/list, rooms, assertions + -> optional airc CLI response +``` + +Normal AIRC messages are mirrored into Continuum chat as: + +```text +[airc:] +``` + +Explicit development directives use `!continuum`: + +```text +!continuum ping +!continuum rooms +!continuum chat --room general "hello from the mesh" +!continuum export --room general --last 20 +!continuum assert seen marker-123 --room general --last 80 +!continuum activity list +``` + +## Why This Exists + +Agents should not need to remember direct `jtag collaboration/chat/send` and +`jtag collaboration/chat/export` calls during collaboration tests. They should +talk over AIRC, and the bridge should materialize the traffic inside Continuum. + +## Boundary + +The bridge is an allowlisted adapter. It does not expose arbitrary +`Commands.execute()` over AIRC. Add new directive handlers only when there is a +clear integration surface to test. + +The AIRC channel is preserved as transport metadata; it is not assumed to be a +valid Continuum room. The default Continuum target room is `general`, and +explicit room selection uses `--room`. + +Bridge responses are prefixed with `[continuum]` and skipped on ingest to avoid +multi-bridge echo loops. + +Heavy data should stay out of AIRC. Use AIRC for manifests, handles, room +markers, artifact hashes, and job ids; use Continuum/Grid data paths for model +weights, LoRA artifacts, voice/video, and high-volume streams. + +## Harness + +For deterministic tests without a live AIRC monitor: + +```bash +printf 'mac-codex: hello from airc\n' | node src/scripts/continuum-airc-bridge.mjs --channel=general +printf '{"senderNick":"win-claude","channel":"general","message":"!continuum ping"}\n' | node src/scripts/continuum-airc-bridge.mjs --mirror-response +``` diff --git a/src/commands/airc/bridge/README.md b/src/commands/airc/bridge/README.md new file mode 100644 index 000000000..5885f087c --- /dev/null +++ b/src/commands/airc/bridge/README.md @@ -0,0 +1,52 @@ +# AIRC Bridge Command + +Ingest one AIRC message into Continuum. + +Normal AIRC text becomes a Continuum chat message. Explicit `!continuum` +directives become bounded development/test commands, so agents can test +Continuum through the same collaboration surface they already use instead of +calling `jtag collaboration/chat/send` and `jtag collaboration/chat/export` +manually. + +## Usage + +```bash +./jtag airc/bridge --senderNick=mac-codex --channel=general --message="hello from airc" +./jtag airc/bridge --senderNick=mac-codex --channel=general --message="!continuum ping" --mirrorResponse=true +./jtag airc/bridge --senderNick=mac-codex --channel=general --message="!continuum export general --last 20" +``` + +## Parameters + +- `message` required: raw AIRC message body. +- `senderNick` optional: AIRC sender nick for attribution. +- `channel` optional: AIRC channel; defaults to `general`. +- `room` optional: Continuum room override; defaults to `general`. +- `commandPrefix` optional: directive prefix; defaults to `!continuum`. +- `dryRun` optional: parse without executing commands. +- `mirrorResponse` optional: send directive responses back through the `airc` CLI. + +## Directives + +- `!continuum ping` +- `!continuum status` +- `!continuum rooms [--limit N]` +- `!continuum chat [--room room] ` +- `!continuum export [--room room] [--last N]` +- `!continuum assert seen [--room room] [--last N]` +- `!continuum activity list [--limit N]` + +## Boundary + +This command is intentionally allowlisted. It does not expose arbitrary +`Commands.execute()` over AIRC. Add new directives deliberately as bridge +integration points become stable. + +Broadcast AIRC messages are attributed to the provided nick for collaboration +visibility, not authentication. Treat bridged chat text as human/agent input, +not as a trusted identity or authorization signal. + +Bridge-origin AIRC replies are prefixed with `[continuum]` and skipped on +ingest to prevent echo loops when more than one bridge is listening. + +Large list/export directives are clamped to a bounded limit. diff --git a/src/commands/airc/bridge/browser/AircBridgeBrowserCommand.ts b/src/commands/airc/bridge/browser/AircBridgeBrowserCommand.ts new file mode 100644 index 000000000..91279df01 --- /dev/null +++ b/src/commands/airc/bridge/browser/AircBridgeBrowserCommand.ts @@ -0,0 +1,14 @@ +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import { AircBridgeCommand } from '../shared/AircBridgeCommand'; +import type { AircBridgeParams, AircBridgeResult } from '../shared/AircBridgeTypes'; + +export class AircBridgeBrowserCommand extends AircBridgeCommand { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super(context, subpath, commander); + } + + protected async executeAircBridge(params: AircBridgeParams): Promise { + return this.remoteExecute(params); + } +} diff --git a/src/commands/airc/bridge/package.json b/src/commands/airc/bridge/package.json new file mode 100644 index 000000000..c29209f8a --- /dev/null +++ b/src/commands/airc/bridge/package.json @@ -0,0 +1,31 @@ +{ + "name": "@jtag-commands/airc/bridge", + "version": "1.0.0", + "description": "Ingest AIRC messages into Continuum chat and bounded development/test commands.", + "main": "server/AircBridgeServerCommand.ts", + "types": "shared/AircBridgeTypes.ts", + "scripts": { + "test": "npm run test:unit", + "test:unit": "npx tsx test/unit/AircBridgeProtocolCheck.ts", + "lint": "npx eslint **/*.ts", + "typecheck": "npx tsc --noEmit" + }, + "peerDependencies": { + "@jtag/core": "*" + }, + "files": [ + "shared/**/*.ts", + "browser/**/*.ts", + "server/**/*.ts", + "test/**/*.ts", + "README.md" + ], + "keywords": [ + "jtag", + "command", + "airc/bridge", + "continuum", + "airc" + ], + "license": "MIT" +} diff --git a/src/commands/airc/bridge/server/AircBridgeServerCommand.ts b/src/commands/airc/bridge/server/AircBridgeServerCommand.ts new file mode 100644 index 000000000..68ec1c11d --- /dev/null +++ b/src/commands/airc/bridge/server/AircBridgeServerCommand.ts @@ -0,0 +1,258 @@ +import { spawn } from 'node:child_process'; +import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import { ValidationError } from '@system/core/types/ErrorTypes'; +import { DataList } from '@commands/data/list/shared/DataListTypes'; +import type { RoomEntity } from '@system/data/entities/RoomEntity'; +import { ChatSend } from '@commands/collaboration/chat/send/shared/ChatSendTypes'; +import { ChatExport } from '@commands/collaboration/chat/export/shared/ChatExportTypes'; +import { ActivityList } from '@commands/collaboration/activity/list/shared/ActivityListTypes'; +import { + formatAircBridgeChatText, + parseAircBridgeMessage, + summarizeBridgeResponse, +} from '@system/airc-bridge/shared/AircBridgeProtocol'; +import type { ParsedAircBridgeMessage } from '@system/airc-bridge/shared/AircBridgeProtocol'; +import { AircBridgeCommand } from '../shared/AircBridgeCommand'; +import type { AircBridgeParams, AircBridgeResult } from '../shared/AircBridgeTypes'; +import { createAircBridgeResultFromParams } from '../shared/AircBridgeTypes'; + +interface BridgeHandlerResult { + responseText: string; + commandResult?: unknown; + mirrorError?: string; +} + +export class AircBridgeServerCommand extends AircBridgeCommand { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super(context, subpath, commander); + } + + protected async executeAircBridge(params: AircBridgeParams): Promise { + this.validateParams(params); + + const parsed = parseAircBridgeMessage(params.message, { + senderNick: params.senderNick, + channel: params.channel, + room: params.room, + commandPrefix: params.commandPrefix, + }); + + if (params.dryRun) return this.dryRun(params, parsed); + + try { + const result = await this.handleParsedMessage(params, parsed); + const mirror = await this.mirrorResponseIfRequested(params, parsed.channel, result.responseText); + return createAircBridgeResultFromParams(params, { + success: true, + handled: true, + parsed, + ...result, + mirrored: mirror.mirrored, + mirrorError: mirror.error, + }); + } catch (error) { + return this.failed(params, parsed, error); + } + } + + private validateParams(params: AircBridgeParams): void { + if (!params.message || params.message.trim() === '') { + throw new ValidationError( + 'message', + 'Missing required parameter message. Pass the raw AIRC message body to ingest.', + ); + } + } + + private dryRun(params: AircBridgeParams, parsed: ParsedAircBridgeMessage): AircBridgeResult { + return createAircBridgeResultFromParams(params, { + success: true, + handled: false, + parsed, + responseText: `dry-run: ${parsed.action} -> ${parsed.room}`, + }); + } + + private failed( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + error: unknown, + ): AircBridgeResult { + const message = error instanceof Error ? error.message : String(error); + return createAircBridgeResultFromParams(params, { + success: false, + handled: false, + parsed, + error: message, + responseText: `airc bridge failed: ${message}`, + }); + } + + private async handleParsedMessage( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const handlers: Record Promise> = { + skip: () => Promise.resolve({ responseText: 'skipped Continuum-origin mirror echo' }), + chat: () => this.handleChat(params, parsed), + ping: () => Promise.resolve({ responseText: `continuum-airc-bridge ok (${parsed.room})`, commandResult: { ok: true } }), + status: () => this.handleStatus(params, parsed), + rooms: () => this.handleRooms(params, parsed), + 'activity-list': () => this.handleActivityList(params, parsed), + export: () => this.handleExport(params, parsed), + 'assert-seen': () => this.handleAssertSeen(params, parsed), + }; + + const handler = handlers[parsed.action]; + if (!handler) { + throw new Error(parsed.error ?? 'unknown AIRC bridge directive'); + } + return handler(); + } + + private async handleChat( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const commandResult = await ChatSend.execute({ + room: parsed.room, + message: formatAircBridgeChatText(parsed), + context: params.context, + sessionId: params.sessionId, + }); + return { + commandResult, + responseText: `bridged chat from ${parsed.senderNick} into ${parsed.room}`, + }; + } + + private async handleStatus( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const rooms = await this.listRooms(parsed.limit ?? 25, params); + return { + commandResult: rooms, + responseText: `continuum-airc-bridge ok; rooms=${rooms.length}; room=${parsed.room}`, + }; + } + + private async handleRooms( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const rooms = await this.listRooms(parsed.limit ?? 50, params); + const labels = rooms.map(room => room.name || room.uniqueId || room.id).join(', '); + return { + commandResult: rooms, + responseText: labels ? `rooms: ${labels}` : 'rooms: none', + }; + } + + private async handleActivityList( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const commandResult = await ActivityList.execute({ + limit: parsed.limit ?? 50, + context: params.context, + sessionId: params.sessionId, + }); + const result = commandResult as { success?: boolean; activities?: Array<{ displayName?: string; id?: string }> }; + return { + commandResult, + responseText: result.success + ? `activities: ${this.formatActivityLabels(result.activities)}` + : 'activity list failed', + }; + } + + private async handleExport( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const commandResult = await ChatExport.execute({ + room: parsed.room, + limit: parsed.limit ?? 50, + context: params.context, + sessionId: params.sessionId, + }); + const result = commandResult as { success?: boolean; markdown?: string; message?: string }; + return { + commandResult, + responseText: result.success + ? summarizeBridgeResponse(result.markdown ?? result.message ?? '') + : `export failed: ${result.message ?? 'unknown error'}`, + }; + } + + private async handleAssertSeen( + params: AircBridgeParams, + parsed: ParsedAircBridgeMessage, + ): Promise { + const commandResult = await ChatExport.execute({ + room: parsed.room, + limit: parsed.limit ?? 50, + includeSystem: true, + includeTests: true, + context: params.context, + sessionId: params.sessionId, + }); + const result = commandResult as { markdown?: string }; + const found = Boolean(parsed.marker && result.markdown?.includes(parsed.marker)); + if (!found) throw new Error(`assert seen failed: ${parsed.marker ?? '(missing marker)'}`); + return { commandResult, responseText: `assert seen ok: ${parsed.marker}` }; + } + + private async listRooms(limit: number, params: AircBridgeParams): Promise { + const result = await DataList.execute({ + collection: 'rooms', + limit, + orderBy: [{ field: 'lastMessageAt', direction: 'desc' }], + context: params.context, + sessionId: params.sessionId, + }); + return result.success ? [...result.items] : []; + } + + private formatActivityLabels(activities?: Array<{ displayName?: string; id?: string }>): string { + const labels = activities?.map(a => a.displayName ?? a.id).filter(Boolean).join(', ') ?? ''; + return labels.length > 0 ? labels : 'none'; + } + + private async mirrorResponseIfRequested( + params: AircBridgeParams, + channel: string, + responseText: string, + ): Promise<{ mirrored: boolean; error?: string }> { + if (!params.mirrorResponse || !responseText.trim()) return { mirrored: false }; + try { + const result = await this.spawnAirc([ + 'msg', + '--channel', + channel, + `[continuum] ${summarizeBridgeResponse(responseText, 1200)}`, + ]); + return result.exitCode === 0 + ? { mirrored: true } + : { mirrored: false, error: result.stderr || `airc exited ${result.exitCode}` }; + } catch (error) { + return { + mirrored: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private spawnAirc(argv: string[]): Promise<{ exitCode: number; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn('airc', argv, { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + child.on('error', reject); + child.on('close', exitCode => resolve({ exitCode: exitCode ?? -1, stderr })); + }); + } +} diff --git a/src/commands/airc/bridge/shared/AircBridgeCommand.ts b/src/commands/airc/bridge/shared/AircBridgeCommand.ts new file mode 100644 index 000000000..ef79b0736 --- /dev/null +++ b/src/commands/airc/bridge/shared/AircBridgeCommand.ts @@ -0,0 +1,15 @@ +import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; +import type { JTAGContext, JTAGPayload } from '@system/core/types/JTAGTypes'; +import type { AircBridgeParams, AircBridgeResult } from './AircBridgeTypes'; + +export abstract class AircBridgeCommand extends CommandBase { + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { + super('airc/bridge', context, subpath, commander); + } + + protected abstract executeAircBridge(params: AircBridgeParams): Promise; + + async execute(params: JTAGPayload): Promise { + return this.executeAircBridge(params as AircBridgeParams); + } +} diff --git a/src/commands/airc/bridge/shared/AircBridgeTypes.ts b/src/commands/airc/bridge/shared/AircBridgeTypes.ts new file mode 100644 index 000000000..352e76e0f --- /dev/null +++ b/src/commands/airc/bridge/shared/AircBridgeTypes.ts @@ -0,0 +1,65 @@ +/** + * AIRC Bridge Command - Shared Types + * + * Ingest one AIRC message into Continuum. Normal messages become chat; + * explicit !continuum directives become bounded development/test commands. + */ + +import type { CommandParams, CommandResult, CommandInput, JTAGContext } from '@system/core/types/JTAGTypes'; +import { createPayload, transformPayload } from '@system/core/types/JTAGTypes'; +import type { UUID } from '@system/core/types/CrossPlatformUUID'; +import { Commands } from '@system/core/shared/Commands'; +import type { ParsedAircBridgeMessage } from '@system/airc-bridge/shared/AircBridgeProtocol'; + +export interface AircBridgeParams extends CommandParams { + /** Raw AIRC message body. Normal text is mirrored to Continuum chat. */ + message: string; + + /** AIRC sender nick, used for attribution in bridged chat text. */ + senderNick?: string; + + /** AIRC channel without or with leading #. Defaults to #general. */ + channel?: string; + + /** Continuum room override. Defaults to general; AIRC channel is preserved separately. */ + room?: string; + + /** Directive prefix for test/control messages. Defaults to !continuum. */ + commandPrefix?: string; + + /** Parse and report intent without executing Continuum commands. */ + dryRun?: boolean; + + /** Send command responses back to AIRC via the airc CLI. */ + mirrorResponse?: boolean; +} + +export interface AircBridgeResult extends CommandResult { + success: boolean; + handled: boolean; + parsed: ParsedAircBridgeMessage; + responseText?: string; + mirrored?: boolean; + mirrorError?: string; + commandResult?: unknown; + error?: string; +} + +export const createAircBridgeParams = ( + context: JTAGContext, + sessionId: UUID, + userId: UUID, + data: Omit, +): AircBridgeParams => createPayload(context, sessionId, { userId, ...data }); + +export const createAircBridgeResultFromParams = ( + params: AircBridgeParams, + differences: Omit, +): AircBridgeResult => transformPayload(params, differences); + +export const AircBridge = { + execute(params: CommandInput): Promise { + return Commands.execute('airc/bridge', params as Partial); + }, + commandName: 'airc/bridge' as const, +} as const; diff --git a/src/commands/airc/bridge/test/unit/AircBridgeProtocolCheck.ts b/src/commands/airc/bridge/test/unit/AircBridgeProtocolCheck.ts new file mode 100644 index 000000000..1e4102b3e --- /dev/null +++ b/src/commands/airc/bridge/test/unit/AircBridgeProtocolCheck.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env tsx + +import { + formatAircBridgeChatText, + parseAircBridgeMessage, + roomFromAircChannel, + summarizeBridgeResponse, +} from '../../../../../system/airc-bridge/shared/AircBridgeProtocol'; + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } + console.log(`ok - ${message}`); +} + +function testNormalChat(): void { + const parsed = parseAircBridgeMessage('hello continuum', { + senderNick: 'mac-codex', + channel: '#cambriantech', + }); + + assert(parsed.action === 'chat', 'normal text maps to chat'); + assert(parsed.channel === 'cambriantech', 'channel preserved separately'); + assert(parsed.room === 'general', 'default room is general, not the AIRC channel'); + assert(parsed.senderNick === 'mac-codex', 'sender preserved'); + assert(formatAircBridgeChatText(parsed) === '[airc:mac-codex] hello continuum', 'chat attribution rendered'); +} + +function testDirectives(): void { + const exp = parseAircBridgeMessage('!continuum export --room cambriantech --last 25', { channel: '#general' }); + const assertion = parseAircBridgeMessage('!continuum assert seen marker-123 --room general --last 80'); + + assert(parseAircBridgeMessage('!continuum ping').action === 'ping', 'ping directive parsed'); + assert(exp.action === 'export', 'export directive parsed'); + assert(exp.room === 'cambriantech', 'export room parsed'); + assert(exp.limit === 25, 'export limit parsed'); + assert(assertion.action === 'assert-seen', 'assert seen directive parsed'); + assert(assertion.marker === 'marker-123', 'assert marker parsed'); + assert(assertion.room === 'general', 'assert room flag parsed'); + assert(assertion.limit === 80, 'assert limit parsed'); +} + +function testQuotedChat(): void { + const parsed = parseAircBridgeMessage('!continuum chat --room general "quoted body with spaces"', { + senderNick: 'win-claude', + }); + + assert(parsed.action === 'chat', 'directive chat parsed'); + assert(parsed.room === 'general', 'directive chat room parsed'); + assert(parsed.message === 'quoted body with spaces', 'quoted message parsed'); +} + +function testSafetyBounds(): void { + const echo = parseAircBridgeMessage('[continuum] bridge reply', { senderNick: 'mac-codex' }); + const ambiguousChat = parseAircBridgeMessage('!continuum chat hello world'); + const hugeExport = parseAircBridgeMessage('!continuum export --last 999999'); + + assert(echo.action === 'skip', 'continuum-origin mirror echoes are skipped'); + assert(ambiguousChat.room === 'general', 'chat directive defaults room without first-token ambiguity'); + assert(ambiguousChat.message === 'hello world', 'chat directive keeps full message body'); + assert(hugeExport.limit === 500, 'directive limits are clamped'); +} + +function testSafetyHelpers(): void { + assert(roomFromAircChannel('#cambriantech') === 'cambriantech', 'room strips #'); + assert(roomFromAircChannel('') === 'general', 'empty channel defaults'); + assert(summarizeBridgeResponse('x'.repeat(2000), 100).length <= 100, 'response summary bounds output'); +} + +testNormalChat(); +testDirectives(); +testQuotedChat(); +testSafetyBounds(); +testSafetyHelpers(); +console.log('AircBridge protocol checks passed'); diff --git a/src/scripts/continuum-airc-bridge.mjs b/src/scripts/continuum-airc-bridge.mjs new file mode 100644 index 000000000..5b35060a2 --- /dev/null +++ b/src/scripts/continuum-airc-bridge.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * continuum-airc-bridge + * + * Development harness for feeding AIRC traffic into Continuum. In stdin mode, + * each input line becomes one airc/bridge command. JSON lines may provide + * senderNick/channel/message; plain lines use CLI defaults. + */ + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const JTAG_PATH = resolve(__dirname, '..', 'jtag'); +const JTAG_CWD = dirname(JTAG_PATH); + +function parseArgs() { + const args = { + senderNick: process.env.AIRC_NICK || 'airc-peer', + channel: 'general', + room: '', + mirrorResponse: false, + dryRun: false, + }; + + for (const arg of process.argv.slice(2)) { + if (arg.startsWith('--senderNick=')) args.senderNick = arg.slice('--senderNick='.length); + else if (arg.startsWith('--channel=')) args.channel = arg.slice('--channel='.length); + else if (arg.startsWith('--room=')) args.room = arg.slice('--room='.length); + else if (arg === '--mirror-response') args.mirrorResponse = true; + else if (arg === '--dry-run') args.dryRun = true; + } + + return args; +} + +function parseLine(line, defaults) { + const trimmed = line.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith('{')) { + const parsed = JSON.parse(trimmed); + if (!parsed.message) throw new Error('JSON bridge line must include message'); + return { + senderNick: parsed.senderNick || defaults.senderNick, + channel: parsed.channel || defaults.channel, + room: parsed.room || defaults.room, + message: parsed.message, + }; + } + + const match = trimmed.match(/^([^:]{1,80}):\s+(.+)$/); + if (!match) { + return { senderNick: defaults.senderNick, channel: defaults.channel, room: defaults.room, message: trimmed }; + } + + return { senderNick: match[1], channel: defaults.channel, room: defaults.room, message: match[2] }; +} + +function runBridge(line, defaults) { + const params = { + senderNick: line.senderNick || defaults.senderNick, + channel: line.channel || defaults.channel, + message: line.message, + }; + + const room = line.room || defaults.room; + if (room) params.room = room; + if (defaults.mirrorResponse) params.mirrorResponse = 'true'; + if (defaults.dryRun) params.dryRun = 'true'; + + const argv = ['airc/bridge', ...Object.entries(params).map(([key, value]) => `--${key}=${value}`)]; + const result = spawnSync(JTAG_PATH, argv, { encoding: 'utf8', cwd: JTAG_CWD, timeout: 30000 }); + + if (result.status !== 0) { + process.stderr.write(`[continuum-airc-bridge] jtag failed (${result.status}): ${result.stderr || result.error?.message || ''}\n`); + return; + } + + process.stdout.write(result.stdout); +} + +const args = parseArgs(); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +process.stderr.write(`[continuum-airc-bridge] stdin mode channel=${args.channel} sender=${args.senderNick}\n`); + +for await (const line of rl) { + try { + const bridgeLine = parseLine(line, args); + if (bridgeLine) runBridge(bridgeLine, args); + } catch (error) { + process.stderr.write(`[continuum-airc-bridge] ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/src/system/airc-bridge/shared/AircBridgeProtocol.ts b/src/system/airc-bridge/shared/AircBridgeProtocol.ts new file mode 100644 index 000000000..04fc77d02 --- /dev/null +++ b/src/system/airc-bridge/shared/AircBridgeProtocol.ts @@ -0,0 +1,262 @@ +/** + * AIRC <-> Continuum bridge protocol. + * + * AIRC carries normal chat text or explicit development directives. This + * parser stays transport-agnostic so it can be tested without a live mesh. + */ + +export type AircBridgeAction = + | 'chat' + | 'ping' + | 'status' + | 'rooms' + | 'export' + | 'assert-seen' + | 'activity-list' + | 'skip' + | 'unknown'; + +export interface ParsedAircBridgeMessage { + action: AircBridgeAction; + originalText: string; + senderNick: string; + channel: string; + room: string; + isDirective: boolean; + message?: string; + marker?: string; + limit?: number; + error?: string; +} + +export interface ParseAircBridgeOptions { + senderNick?: string; + channel?: string; + room?: string; + commandPrefix?: string; + defaultRoom?: string; +} + +interface ParseContext { + originalText: string; + senderNick: string; + channel: string; + room: string; +} + +const DEFAULT_PREFIX = '!continuum'; +const DEFAULT_ROOM = 'general'; +const DEFAULT_SENDER = 'airc-peer'; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 500; + +export function roomFromAircChannel(channel?: string, fallback = DEFAULT_ROOM): string { + const normalized = (channel ?? '').trim().replace(/^#/, ''); + return normalized || fallback; +} + +export function parseAircBridgeMessage( + text: string, + options: ParseAircBridgeOptions = {}, +): ParsedAircBridgeMessage { + const prefix = options.commandPrefix ?? DEFAULT_PREFIX; + const context = createParseContext(text, options); + const trimmed = text.trim(); + + if (trimmed.startsWith('[continuum]')) { + return createParsed(context, 'skip', { + isDirective: false, + message: text, + }); + } + + if (!trimmed.startsWith(prefix)) { + return createParsed(context, 'chat', { isDirective: false, message: text }); + } + + return parseDirective(context, tokenize(trimmed.slice(prefix.length).trim()), prefix); +} + +export function formatAircBridgeChatText(parsed: ParsedAircBridgeMessage): string { + const body = parsed.message ?? parsed.originalText; + return `[airc:${parsed.senderNick}] ${body}`; +} + +export function summarizeBridgeResponse(text: string, maxChars = 1600): string { + const normalized = text.replace(/\r\n/g, '\n').trim(); + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, maxChars - 32).trimEnd()}\n... [truncated]`; +} + +function createParseContext(text: string, options: ParseAircBridgeOptions): ParseContext { + const fallbackRoom = options.defaultRoom ?? DEFAULT_ROOM; + const senderNick = nonEmpty(options.senderNick) ?? DEFAULT_SENDER; + const explicitRoom = nonEmpty(options.room); + return { + originalText: text, + senderNick, + channel: roomFromAircChannel(options.channel, fallbackRoom), + room: explicitRoom ?? fallbackRoom, + }; +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function parseDirective(context: ParseContext, tokens: string[], prefix: string): ParsedAircBridgeMessage { + const verb = (tokens.shift() ?? '').toLowerCase(); + if (!verb) { + return createParsed(context, 'unknown', { error: `Missing directive after ${prefix}` }); + } + + const handlers: Record ParsedAircBridgeMessage> = { + ping: ctx => createParsed(ctx, 'ping'), + status: ctx => createParsed(ctx, 'status'), + rooms: parseRooms, + activity: parseActivity, + export: parseExport, + assert: parseAssert, + chat: parseChat, + }; + + return handlers[verb]?.(context, tokens) ?? createParsed(context, 'unknown', { + error: `Unknown directive: ${verb}`, + }); +} + +function parseRooms(context: ParseContext, tokens: string[]): ParsedAircBridgeMessage { + return createParsed(context, 'rooms', { limit: readIntFlag(tokens, 'limit') ?? DEFAULT_LIMIT }); +} + +function parseActivity(context: ParseContext, tokens: string[]): ParsedAircBridgeMessage { + const subcommand = (tokens.shift() ?? '').toLowerCase(); + if (subcommand !== 'list') { + return createParsed(context, 'unknown', { error: 'Expected: !continuum activity list' }); + } + return createParsed(context, 'activity-list', { limit: readIntFlag(tokens, 'limit') ?? DEFAULT_LIMIT }); +} + +function parseExport(context: ParseContext, tokens: string[]): ParsedAircBridgeMessage { + return createParsed(context, 'export', { + room: readRoomArg(tokens) ?? context.room, + limit: readIntFlag(tokens, 'last') ?? readIntFlag(tokens, 'limit') ?? DEFAULT_LIMIT, + }); +} + +function parseAssert(context: ParseContext, tokens: string[]): ParsedAircBridgeMessage { + const assertion = (tokens.shift() ?? '').toLowerCase(); + const marker = tokens.shift(); + if (assertion !== 'seen' || !marker) { + return createParsed(context, 'unknown', { error: 'Expected: !continuum assert seen ' }); + } + return createParsed(context, 'assert-seen', { + marker, + room: readStringFlag(tokens, 'room') ?? context.room, + limit: readIntFlag(tokens, 'last') ?? readIntFlag(tokens, 'limit') ?? DEFAULT_LIMIT, + }); +} + +function parseChat(context: ParseContext, tokens: string[]): ParsedAircBridgeMessage { + const targetRoom = readStringFlag(tokens, 'room') ?? context.room; + const message = tokens.join(' ').trim(); + if (!message) { + return createParsed(context, 'unknown', { error: 'Expected: !continuum chat [--room room] ' }); + } + return createParsed(context, 'chat', { room: targetRoom, message }); +} + +function createParsed( + context: ParseContext, + action: AircBridgeAction, + overrides: Partial = {}, +): ParsedAircBridgeMessage { + return { + action, + originalText: context.originalText, + senderNick: context.senderNick, + channel: context.channel, + room: context.room, + isDirective: true, + ...overrides, + }; +} + +function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (const char of input) { + const handled = consumeTokenChar({ char, tokens, current, quote, escaping }); + current = handled.current; + quote = handled.quote; + escaping = handled.escaping; + } + + if (current) tokens.push(current); + return tokens; +} + +function consumeTokenChar(state: { + char: string; + tokens: string[]; + current: string; + quote: '"' | "'" | null; + escaping: boolean; +}): { current: string; quote: '"' | "'" | null; escaping: boolean } { + if (state.escaping) return { current: state.current + state.char, quote: state.quote, escaping: false }; + if (state.char === '\\') return { current: state.current, quote: state.quote, escaping: true }; + + if (state.quote) { + return state.char === state.quote + ? { current: state.current, quote: null, escaping: false } + : { current: state.current + state.char, quote: state.quote, escaping: false }; + } + + if (state.char === '"' || state.char === "'") { + return { current: state.current, quote: state.char, escaping: false }; + } + + if (/\s/.test(state.char)) { + if (state.current) state.tokens.push(state.current); + return { current: '', quote: null, escaping: false }; + } + + return { current: state.current + state.char, quote: null, escaping: false }; +} + +function readRoomArg(tokens: string[]): string | undefined { + const roomFlag = readStringFlag(tokens, 'room'); + if (roomFlag) return roomFlag; + if (tokens.length > 0 && !tokens[0].startsWith('--')) return tokens.shift(); + return undefined; +} + +function readStringFlag(tokens: string[], name: string): string | undefined { + const prefix = `--${name}=`; + const inline = tokens.findIndex(token => token.startsWith(prefix)); + if (inline >= 0) { + const [token] = tokens.splice(inline, 1); + return token.slice(prefix.length); + } + + const split = tokens.findIndex(token => token === `--${name}`); + if (split >= 0 && tokens[split + 1]) { + tokens.splice(split, 1); + const [value] = tokens.splice(split, 1); + return value; + } + + return undefined; +} + +function readIntFlag(tokens: string[], name: string): number | undefined { + const raw = readStringFlag(tokens, name); + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return Math.min(parsed, MAX_LIMIT); +}