brain backup 2026-09-14
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
COLANODE_BASE_URL=https://colanode.reynafamily.com/client
|
||||
COLANODE_SOCKET_BASE_URL=wss://colanode.reynafamily.com/client
|
||||
COLANODE_WORKSPACE_NAME=FamReynaWorkspace
|
||||
COLANODE_TOKEN=
|
||||
COLANODE_EMAIL=
|
||||
COLANODE_PASSWORD=
|
||||
STATE_FILE=
|
||||
HERMES_BIN=hermes
|
||||
HERMES_TIMEOUT_MS=60000
|
||||
HERMES_MAX_OUTPUT_BYTES=12000
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
*.log
|
||||
@@ -0,0 +1,39 @@
|
||||
# Colanode Hermes Bridge
|
||||
|
||||
Standalone Node 22 TypeScript service that listens to Colanode synchronizer events and replies through Hermes only when a new Colanode `message` node explicitly mentions the bot user.
|
||||
|
||||
## Safety
|
||||
|
||||
- Reads secrets from `$HOME/.hermes/secrets/colanode-family-bot.env`.
|
||||
- Requires that secret file to be mode `0600`.
|
||||
- Uses Colanode client HTTP/WebSocket APIs only.
|
||||
- Does not write to Postgres, Hermes config, Caddy, Colanode deployment files, or Colanode data outside the documented mutation API.
|
||||
- Tests never send Colanode replies.
|
||||
|
||||
## Secret File
|
||||
|
||||
Expected variables:
|
||||
|
||||
```sh
|
||||
COLANODE_TOKEN=...
|
||||
# Optional fallback only if a stored token is unavailable:
|
||||
COLANODE_EMAIL=bot@example.com
|
||||
COLANODE_PASSWORD=...
|
||||
```
|
||||
|
||||
Optional overrides are shown in `.env.example`.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
Run foreground service after build:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "colanode-hermes-bridge",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "colanode-hermes-bridge",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.7",
|
||||
"ws": "^8.18.0",
|
||||
"yjs": "^13.6.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
|
||||
"integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.32",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz",
|
||||
"integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "colanode-hermes-bridge",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "npm run build && node --test dist/test/*.test.js",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.7",
|
||||
"ws": "^8.18.0",
|
||||
"yjs": "^13.6.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import WebSocket from 'ws';
|
||||
import type { AccountSyncOutput, SynchronizerInput, SynchronizerInputMessage, WorkspaceOutput } from './types.js';
|
||||
|
||||
export class ColanodeClient {
|
||||
private token: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly httpBaseUrl: string,
|
||||
private readonly socketBaseUrl: string,
|
||||
token?: string
|
||||
) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async authenticate(email?: string, password?: string): Promise<AccountSyncOutput> {
|
||||
if (!this.token) {
|
||||
if (!email || !password) throw new Error('COLANODE_TOKEN is required unless COLANODE_EMAIL and COLANODE_PASSWORD are provided');
|
||||
const login = await this.postJson<{ type: string; token?: string }>('v1/auth/email/login', { email, password }, false);
|
||||
if (login.type !== 'success' || !login.token) throw new Error('Email login did not return a usable token');
|
||||
this.token = login.token;
|
||||
}
|
||||
const sync = await this.postJson<AccountSyncOutput>('v1/accounts/sync', {});
|
||||
if (sync.token) this.token = sync.token;
|
||||
return sync;
|
||||
}
|
||||
|
||||
async createSocket(): Promise<WebSocket> {
|
||||
const output = await this.postJson<{ id: string }>('v1/sockets', {});
|
||||
return new WebSocket(`${this.socketBaseUrl}/v1/sockets/${output.id}`, {
|
||||
headers: this.authHeader()
|
||||
});
|
||||
}
|
||||
|
||||
async createMessage(workspaceId: string, mutation: unknown): Promise<void> {
|
||||
const output = await this.postJson<{ results: { id: string; status: number }[] }>(
|
||||
`v1/workspaces/${workspaceId}/mutations`,
|
||||
{ mutations: [mutation] }
|
||||
);
|
||||
const result = output.results[0];
|
||||
if (!result || (result.status !== 200 && result.status !== 201)) {
|
||||
throw new Error(`Colanode mutation failed with status ${result?.status ?? 'missing'}`);
|
||||
}
|
||||
}
|
||||
|
||||
sendSynchronizer(socket: WebSocket, userId: string, input: SynchronizerInput, cursor: string): string {
|
||||
const id = synchronizerId(userId, input);
|
||||
const message: SynchronizerInputMessage = { type: 'synchronizer.input', id, userId, input, cursor };
|
||||
socket.send(JSON.stringify(message));
|
||||
return id;
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown, useAuth = true): Promise<T> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (useAuth) Object.assign(headers, this.authHeader());
|
||||
const response = await fetch(`${this.httpBaseUrl}/${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) throw new Error(`Colanode ${path} returned HTTP ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private authHeader(): Record<string, string> {
|
||||
if (!this.token) throw new Error('Missing Colanode token');
|
||||
return { authorization: `Bearer ${this.token}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function requireWorkspace(sync: AccountSyncOutput, name: string): WorkspaceOutput {
|
||||
const workspace = sync.workspaces.find((item) => item.name === name);
|
||||
if (!workspace) throw new Error(`Required workspace membership not found: ${name}`);
|
||||
console.log(`Colanode account: ${sync.account.name}`);
|
||||
console.log(`Workspace: ${workspace.name}`);
|
||||
console.log(`Role: ${workspace.user.role}`);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export function synchronizerId(userId: string, input: SynchronizerInput): string {
|
||||
return createHash('sha256').update(JSON.stringify({ userId, input })).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type Config = {
|
||||
httpBaseUrl: string;
|
||||
socketBaseUrl: string;
|
||||
workspaceName: string;
|
||||
token?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
stateFile: string;
|
||||
hermesBin: string;
|
||||
hermesTimeoutMs: number;
|
||||
hermesMaxOutputBytes: number;
|
||||
};
|
||||
|
||||
const SECRET_PATH = join(homedir(), '.hermes/secrets/colanode-family-bot.env');
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
const secrets = await readSecretEnv(SECRET_PATH);
|
||||
const env = { ...process.env, ...secrets };
|
||||
return {
|
||||
httpBaseUrl: stripTrailingSlash(env.COLANODE_BASE_URL ?? 'https://colanode.reynafamily.com/client'),
|
||||
socketBaseUrl: stripTrailingSlash(env.COLANODE_SOCKET_BASE_URL ?? 'wss://colanode.reynafamily.com/client'),
|
||||
workspaceName: env.COLANODE_WORKSPACE_NAME ?? 'FamReynaWorkspace',
|
||||
token: emptyToUndefined(env.COLANODE_TOKEN ?? env.COLANODE_BOT_TOKEN),
|
||||
email: emptyToUndefined(env.COLANODE_EMAIL ?? env.COLANODE_BOT_EMAIL),
|
||||
password: emptyToUndefined(env.COLANODE_PASSWORD ?? env.COLANODE_BOT_PASSWORD),
|
||||
stateFile: env.STATE_FILE ?? join(homedir(), '.hermes/colanode-family-bot-state.json'),
|
||||
hermesBin: env.HERMES_BIN ?? 'hermes',
|
||||
hermesTimeoutMs: Number(env.HERMES_TIMEOUT_MS ?? 60_000),
|
||||
hermesMaxOutputBytes: Number(env.HERMES_MAX_OUTPUT_BYTES ?? 12_000)
|
||||
};
|
||||
}
|
||||
|
||||
async function readSecretEnv(path: string): Promise<Record<string, string>> {
|
||||
const info = await stat(path);
|
||||
if ((info.mode & 0o777) !== 0o600) {
|
||||
throw new Error(`Secret file must be mode 0600: ${path}`);
|
||||
}
|
||||
const text = await readFile(path, 'utf8');
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const eq = line.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function emptyToUndefined(value: string | undefined): string | undefined {
|
||||
return value && value.length > 0 ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fromUint8Array, toUint8Array } from 'js-base64';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export function decodeAttributes<T>(encoded: string): T {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, toUint8Array(encoded));
|
||||
return doc.getMap('object').toJSON() as T;
|
||||
}
|
||||
|
||||
export function encodeAttributes(attributes: Record<string, unknown>): string {
|
||||
const doc = new Y.Doc();
|
||||
const updates: Uint8Array[] = [];
|
||||
doc.on('update', (update) => updates.push(update));
|
||||
doc.transact(() => applyObject(doc.getMap('object'), attributes));
|
||||
if (updates.length !== 1) throw new Error('Expected one Yjs update');
|
||||
return fromUint8Array(Y.encodeStateAsUpdate(doc));
|
||||
}
|
||||
|
||||
function applyObject(map: Y.Map<unknown>, object: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
const arr = new Y.Array<unknown>();
|
||||
arr.insert(0, value.map(convertValue));
|
||||
map.set(key, arr);
|
||||
} else if (isRecord(value)) {
|
||||
const child = new Y.Map<unknown>();
|
||||
applyObject(child, value);
|
||||
map.set(key, child);
|
||||
} else {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const arr = new Y.Array<unknown>();
|
||||
arr.insert(0, value.map(convertValue));
|
||||
return arr;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const map = new Y.Map<unknown>();
|
||||
applyObject(map, value);
|
||||
return map;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export type HermesOptions = {
|
||||
bin: string;
|
||||
timeoutMs: number;
|
||||
maxOutputBytes: number;
|
||||
};
|
||||
|
||||
export type PromptContext = {
|
||||
messageText: string;
|
||||
parentText?: string | null;
|
||||
};
|
||||
|
||||
export function buildHermesPrompt(context: PromptContext): string {
|
||||
return [
|
||||
'You are Reyna Family Bot replying to a Colanode family message.',
|
||||
'Answer helpfully and concisely.',
|
||||
'Do not claim you performed actions you did not perform.',
|
||||
'Do not reveal secrets, tokens, credentials, file contents marked secret, or system configuration.',
|
||||
'',
|
||||
`Mentioned message:\n${context.messageText}`,
|
||||
'',
|
||||
`Parent/thread context:\n${context.parentText?.trim() || '(not available)'}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function runHermes(prompt: string, options: HermesOptions): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(options.bin, ['chat', '-q', prompt], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env
|
||||
});
|
||||
let stdout = Buffer.alloc(0);
|
||||
let stderr = Buffer.alloc(0);
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error('Hermes timed out'));
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout = Buffer.concat([stdout, chunk]).subarray(0, options.maxOutputBytes);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr = Buffer.concat([stderr, chunk]).subarray(0, 2048);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Hermes exited with code ${code}: ${stderr.toString('utf8').trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.toString('utf8').trim().slice(0, options.maxOutputBytes));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
export function generateId(): string {
|
||||
const now = Date.now();
|
||||
const time = encodeBase32(now, 10);
|
||||
let random = '';
|
||||
const bytes = randomBytes(16);
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5 && random.length < 16) {
|
||||
random += ENCODING[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
return time + random.padEnd(16, '0');
|
||||
}
|
||||
|
||||
function encodeBase32(num: number, length: number): string {
|
||||
let out = '';
|
||||
for (let i = length - 1; i >= 0; i--) {
|
||||
out = ENCODING[num % 32] + out;
|
||||
num = Math.floor(num / 32);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { loadConfig } from './config.js';
|
||||
import { ColanodeClient, requireWorkspace, synchronizerId } from './colanode.js';
|
||||
import { StateStore } from './state.js';
|
||||
import { processNodeUpdate, type NodeCache } from './processor.js';
|
||||
import type { SyncCollaborationData, SyncNodeUpdateData, SynchronizerInput } from './types.js';
|
||||
|
||||
const config = await loadConfig();
|
||||
const state = new StateStore(config.stateFile);
|
||||
await state.load();
|
||||
|
||||
let stopping = false;
|
||||
process.on('SIGTERM', () => {
|
||||
stopping = true;
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
stopping = true;
|
||||
});
|
||||
|
||||
const colanode = new ColanodeClient(config.httpBaseUrl, config.socketBaseUrl, config.token);
|
||||
const sync = await colanode.authenticate(config.email, config.password);
|
||||
const workspace = requireWorkspace(sync, config.workspaceName);
|
||||
const botUserId = workspace.user.id;
|
||||
const activeRoots = new Set<string>();
|
||||
const cache: NodeCache = new Map();
|
||||
|
||||
let backoff = 1000;
|
||||
while (!stopping) {
|
||||
try {
|
||||
await runSocketLoop();
|
||||
backoff = 1000;
|
||||
} catch (error) {
|
||||
if (!stopping) {
|
||||
console.error(`Bridge loop error: ${(error as Error).message}`);
|
||||
await delay(backoff);
|
||||
backoff = Math.min(backoff * 2, 30_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runSocketLoop(): Promise<void> {
|
||||
const socket = await colanode.createSocket();
|
||||
const ids = new Map<string, { key: string; input: SynchronizerInput }>();
|
||||
const subscribe = (input: SynchronizerInput) => {
|
||||
const key = cursorKey(input);
|
||||
const id = colanode.sendSynchronizer(socket, botUserId, input, state.getCursor(key));
|
||||
ids.set(id, { key, input });
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ping = setInterval(() => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
for (const item of ids.values()) colanode.sendSynchronizer(socket, botUserId, item.input, state.getCursor(item.key));
|
||||
}, 60_000);
|
||||
|
||||
socket.on('open', () => {
|
||||
subscribe({ type: 'users' });
|
||||
subscribe({ type: 'collaborations' });
|
||||
for (const rootId of activeRoots) subscribe({ type: 'node.updates', rootId });
|
||||
});
|
||||
socket.on('message', (raw) => {
|
||||
void handleMessage(raw.toString(), ids, subscribe).catch((error) => console.error(`Event handling error: ${error.message}`));
|
||||
});
|
||||
socket.on('close', () => {
|
||||
clearInterval(ping);
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
clearInterval(ping);
|
||||
reject(error);
|
||||
});
|
||||
const stopTimer = setInterval(() => {
|
||||
if (stopping) {
|
||||
clearInterval(stopTimer);
|
||||
socket.close();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMessage(
|
||||
raw: string,
|
||||
ids: Map<string, { key: string; input: SynchronizerInput }>,
|
||||
subscribe: (input: SynchronizerInput) => void
|
||||
): Promise<void> {
|
||||
let message: { type?: string; id?: string; items?: { cursor: string; data: unknown }[] };
|
||||
try {
|
||||
message = JSON.parse(raw) as typeof message;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type !== 'synchronizer.output' || !message.id || !Array.isArray(message.items)) return;
|
||||
const registration = ids.get(message.id);
|
||||
if (!registration) return;
|
||||
let lastCursor: string | null = null;
|
||||
for (const item of message.items) {
|
||||
if (registration.input.type === 'collaborations') {
|
||||
const data = item.data as SyncCollaborationData;
|
||||
if (!data.deletedAt && !activeRoots.has(data.nodeId)) {
|
||||
activeRoots.add(data.nodeId);
|
||||
subscribe({ type: 'node.updates', rootId: data.nodeId });
|
||||
}
|
||||
} else if (registration.input.type === 'node.updates') {
|
||||
await processNodeUpdate({
|
||||
update: item.data as SyncNodeUpdateData,
|
||||
botUserId,
|
||||
workspaceId: workspace.id,
|
||||
cache,
|
||||
state,
|
||||
hermes: { bin: config.hermesBin, timeoutMs: config.hermesTimeoutMs, maxOutputBytes: config.hermesMaxOutputBytes },
|
||||
colanode
|
||||
});
|
||||
}
|
||||
lastCursor = item.cursor;
|
||||
}
|
||||
if (lastCursor) await state.setCursor(registration.key, lastCursor);
|
||||
}
|
||||
|
||||
function cursorKey(input: SynchronizerInput): string {
|
||||
return input.type === 'node.updates' ? `${input.rootId}.node.updates` : input.type;
|
||||
}
|
||||
|
||||
void synchronizerId;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { encodeAttributes } from './crdt.js';
|
||||
import { generateId } from './ids.js';
|
||||
import { makePlainMessageAttributes } from './text.js';
|
||||
|
||||
export function createReplyMutation(parentId: string, text: string, now = new Date()): { mutation: unknown; nodeId: string } {
|
||||
const nodeId = generateId();
|
||||
const updateId = generateId();
|
||||
const createdAt = now.toISOString();
|
||||
const attributes = makePlainMessageAttributes(nodeId, parentId, text);
|
||||
return {
|
||||
nodeId,
|
||||
mutation: {
|
||||
id: generateId(),
|
||||
type: 'node.create',
|
||||
createdAt,
|
||||
data: {
|
||||
nodeId,
|
||||
updateId,
|
||||
createdAt,
|
||||
data: encodeAttributes(attributes as unknown as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MessageAttributes, SyncNodeUpdateData } from './types.js';
|
||||
import { decodeAttributes } from './crdt.js';
|
||||
import { extractMentionTargets, plainTextFromMessage } from './text.js';
|
||||
import { buildHermesPrompt, runHermes, type HermesOptions } from './hermes.js';
|
||||
import { createReplyMutation } from './mutations.js';
|
||||
import type { ColanodeClient } from './colanode.js';
|
||||
import type { StateStore } from './state.js';
|
||||
|
||||
export type NodeCache = Map<string, { id: string; attrs: MessageAttributes; createdBy: string }>;
|
||||
export type HermesRunner = (prompt: string, options: HermesOptions) => Promise<string>;
|
||||
|
||||
export async function processNodeUpdate(input: {
|
||||
update: SyncNodeUpdateData;
|
||||
botUserId: string;
|
||||
workspaceId: string;
|
||||
cache: NodeCache;
|
||||
state: StateStore;
|
||||
hermes: HermesOptions;
|
||||
colanode: ColanodeClient;
|
||||
runHermesFn?: HermesRunner;
|
||||
}): Promise<void> {
|
||||
const attrs = decodeAttributes<Partial<MessageAttributes>>(input.update.data);
|
||||
if (attrs.type !== 'message' || !attrs.parentId) return;
|
||||
const messageAttrs = attrs as MessageAttributes;
|
||||
input.cache.set(input.update.nodeId, { id: input.update.nodeId, attrs: messageAttrs, createdBy: input.update.createdBy });
|
||||
|
||||
if (input.update.createdBy === input.botUserId) return;
|
||||
if (input.state.hasProcessed(input.update.nodeId)) return;
|
||||
if (!extractMentionTargets(input.update.nodeId, messageAttrs.content).includes(input.botUserId)) return;
|
||||
|
||||
const messageText = plainTextFromMessage(input.update.nodeId, messageAttrs);
|
||||
const parentText = input.cache.get(messageAttrs.parentId)?.attrs
|
||||
? plainTextFromMessage(messageAttrs.parentId, input.cache.get(messageAttrs.parentId)!.attrs)
|
||||
: null;
|
||||
const prompt = buildHermesPrompt({ messageText, parentText });
|
||||
const reply = await (input.runHermesFn ?? runHermes)(prompt, input.hermes);
|
||||
if (!reply) throw new Error('Hermes returned an empty reply');
|
||||
const { mutation, nodeId } = createReplyMutation(messageAttrs.parentId, reply);
|
||||
await input.colanode.createMessage(input.workspaceId, mutation);
|
||||
await input.state.markProcessed(input.update.nodeId, nodeId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdir, open, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export type BridgeState = {
|
||||
cursors: Record<string, string>;
|
||||
processedMessages: Record<string, { repliedNodeId: string; at: string }>;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BridgeState = { cursors: {}, processedMessages: {} };
|
||||
|
||||
export class StateStore {
|
||||
private state: BridgeState = structuredClone(EMPTY_STATE);
|
||||
constructor(private readonly path: string) {}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
const text = await readFile(this.path, 'utf8');
|
||||
this.state = { ...structuredClone(EMPTY_STATE), ...JSON.parse(text) };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
getCursor(key: string): string {
|
||||
return this.state.cursors[key] ?? '0';
|
||||
}
|
||||
|
||||
async setCursor(key: string, cursor: string): Promise<void> {
|
||||
this.state.cursors[key] = cursor;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
hasProcessed(messageId: string): boolean {
|
||||
return Boolean(this.state.processedMessages[messageId]);
|
||||
}
|
||||
|
||||
async markProcessed(messageId: string, repliedNodeId: string): Promise<void> {
|
||||
this.state.processedMessages[messageId] = { repliedNodeId, at: new Date().toISOString() };
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
const handle = await open(this.path, 'w', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(this.state, null, 2)}\n`, 'utf8');
|
||||
await handle.chmod(0o600);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Block, MessageAttributes } from './types.js';
|
||||
import { generateId } from './ids.js';
|
||||
|
||||
export function extractBlockText(nodeId: string, blocks: Record<string, Block> | null | undefined): string | null {
|
||||
if (!blocks) return null;
|
||||
const text = collectText(nodeId, blocks).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
export function extractMentionTargets(nodeId: string, blocks: Record<string, Block> | null | undefined): string[] {
|
||||
if (!blocks) return [];
|
||||
return collectMentions(nodeId, blocks);
|
||||
}
|
||||
|
||||
export function plainTextFromMessage(id: string, attrs: MessageAttributes): string {
|
||||
return extractBlockText(id, attrs.content) ?? '';
|
||||
}
|
||||
|
||||
export function makePlainMessageAttributes(nodeId: string, parentId: string, text: string): MessageAttributes {
|
||||
const blockId = generateId();
|
||||
return {
|
||||
type: 'message',
|
||||
subtype: 'standard',
|
||||
name: 'Reyna Family Bot',
|
||||
parentId,
|
||||
content: {
|
||||
[blockId]: {
|
||||
id: blockId,
|
||||
type: 'paragraph',
|
||||
parentId: nodeId,
|
||||
index: 'a0',
|
||||
content: [{ type: 'text', text }]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function collectText(blockId: string, blocks: Record<string, Block>): string {
|
||||
const texts: string[] = [];
|
||||
const block = blocks[blockId];
|
||||
if (block?.content) {
|
||||
texts.push(block.content.map((leaf) => leaf.text ?? '').join(''));
|
||||
}
|
||||
for (const child of children(blockId, blocks)) texts.push(collectText(child.id, blocks));
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
function collectMentions(blockId: string, blocks: Record<string, Block>): string[] {
|
||||
const mentions: string[] = [];
|
||||
const block = blocks[blockId];
|
||||
if (block?.content) {
|
||||
for (const leaf of block.content) {
|
||||
if (leaf.type === 'mention' && typeof leaf.attrs?.target === 'string') mentions.push(leaf.attrs.target);
|
||||
}
|
||||
}
|
||||
for (const child of children(blockId, blocks)) mentions.push(...collectMentions(child.id, blocks));
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function children(blockId: string, blocks: Record<string, Block>): Block[] {
|
||||
return Object.values(blocks)
|
||||
.filter((child) => child.parentId === blockId)
|
||||
.sort((a, b) => a.index.localeCompare(b.index));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type WorkspaceRole = 'owner' | 'admin' | 'collaborator' | 'guest' | 'none';
|
||||
|
||||
export type WorkspaceOutput = {
|
||||
id: string;
|
||||
name: string;
|
||||
user: { id: string; accountId: string; role: WorkspaceRole };
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type AccountSyncOutput = {
|
||||
account: { id: string; email: string; name: string; avatar?: string | null };
|
||||
workspaces: WorkspaceOutput[];
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type SynchronizerInput =
|
||||
| { type: 'users' }
|
||||
| { type: 'collaborations' }
|
||||
| { type: 'node.updates'; rootId: string };
|
||||
|
||||
export type SynchronizerInputMessage = {
|
||||
type: 'synchronizer.input';
|
||||
id: string;
|
||||
userId: string;
|
||||
input: SynchronizerInput;
|
||||
cursor: string;
|
||||
};
|
||||
|
||||
export type SyncItem<T> = { cursor: string; data: T };
|
||||
|
||||
export type SynchronizerOutputMessage<T = unknown> = {
|
||||
type: 'synchronizer.output';
|
||||
userId: string;
|
||||
id: string;
|
||||
items: SyncItem<T>[];
|
||||
};
|
||||
|
||||
export type SyncCollaborationData = {
|
||||
collaboratorId: string;
|
||||
nodeId: string;
|
||||
workspaceId: string;
|
||||
role: string;
|
||||
deletedAt?: string | null;
|
||||
};
|
||||
|
||||
export type SyncNodeUpdateData = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
rootId: string;
|
||||
workspaceId: string;
|
||||
revision: string;
|
||||
data: string;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
};
|
||||
|
||||
export type BlockLeaf = {
|
||||
type: string;
|
||||
text?: string | null;
|
||||
attrs?: Record<string, unknown> | null;
|
||||
marks?: unknown[] | null;
|
||||
};
|
||||
|
||||
export type Block = {
|
||||
id: string;
|
||||
type: string;
|
||||
parentId: string;
|
||||
content?: BlockLeaf[] | null;
|
||||
attrs?: Record<string, unknown> | null;
|
||||
index: string;
|
||||
};
|
||||
|
||||
export type MessageAttributes = {
|
||||
type: 'message';
|
||||
subtype: 'standard' | 'question' | 'answer';
|
||||
name?: string;
|
||||
parentId: string;
|
||||
referenceId?: string | null;
|
||||
content?: Record<string, Block> | null;
|
||||
selectedContextNodeIds?: string[] | null;
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { encodeAttributes, decodeAttributes } from '../src/crdt.js';
|
||||
import { buildHermesPrompt } from '../src/hermes.js';
|
||||
import { createReplyMutation } from '../src/mutations.js';
|
||||
import { processNodeUpdate, type NodeCache } from '../src/processor.js';
|
||||
import { StateStore } from '../src/state.js';
|
||||
import type { BlockLeaf, MessageAttributes, SyncNodeUpdateData } from '../src/types.js';
|
||||
|
||||
const botUserId = 'bot-user';
|
||||
|
||||
test('mention extraction gates replies and ignores non-mentioned messages', async () => {
|
||||
const env = await setupProcessor();
|
||||
await processNodeUpdate({
|
||||
...env,
|
||||
update: makeUpdate('msg1', 'human', messageAttrs('msg1', 'parent', [{ type: 'text', text: 'hello' }]))
|
||||
});
|
||||
assert.equal(env.created.length, 0);
|
||||
});
|
||||
|
||||
test('mentioned messages invoke Hermes and dedupe by source message id', async () => {
|
||||
const env = await setupProcessor();
|
||||
const update = makeUpdate('msg2', 'human', messageAttrs('msg2', 'parent', [
|
||||
{ type: 'mention', text: '@bot', attrs: { id: 'm1', target: botUserId } },
|
||||
{ type: 'text', text: ' please summarize this' }
|
||||
]));
|
||||
await processNodeUpdate({ ...env, update });
|
||||
await processNodeUpdate({ ...env, update });
|
||||
assert.equal(env.prompts.length, 1);
|
||||
assert.equal(env.created.length, 1);
|
||||
});
|
||||
|
||||
test('messages created by the bot are ignored even when they mention the bot', async () => {
|
||||
const env = await setupProcessor();
|
||||
await processNodeUpdate({
|
||||
...env,
|
||||
update: makeUpdate('msg3', botUserId, messageAttrs('msg3', 'parent', [
|
||||
{ type: 'mention', attrs: { id: 'm1', target: botUserId } }
|
||||
]))
|
||||
});
|
||||
assert.equal(env.created.length, 0);
|
||||
});
|
||||
|
||||
test('prompt includes safety instruction, mentioned message, and parent context', () => {
|
||||
const prompt = buildHermesPrompt({ messageText: 'Can you help?', parentText: 'Family logistics thread' });
|
||||
assert.match(prompt, /Colanode family message/);
|
||||
assert.match(prompt, /Do not reveal secrets/);
|
||||
assert.match(prompt, /Can you help\?/);
|
||||
assert.match(prompt, /Family logistics thread/);
|
||||
});
|
||||
|
||||
test('reply mutation encodes a decodable Colanode message attributes document', () => {
|
||||
const { mutation, nodeId } = createReplyMutation('parent-id', 'A helpful reply.', new Date('2026-01-01T00:00:00.000Z'));
|
||||
const data = mutationData(mutation).data;
|
||||
const attrs = decodeAttributes<MessageAttributes>(data);
|
||||
assert.equal(attrs.type, 'message');
|
||||
assert.equal(attrs.subtype, 'standard');
|
||||
assert.equal(attrs.name, 'Reyna Family Bot');
|
||||
assert.equal(attrs.parentId, 'parent-id');
|
||||
assert.equal(Object.values(attrs.content ?? {})[0]?.content?.[0]?.text, 'A helpful reply.');
|
||||
});
|
||||
|
||||
async function setupProcessor() {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'bridge-test-'));
|
||||
const state = new StateStore(join(dir, 'state.json'));
|
||||
await state.load();
|
||||
test.after(async () => rm(dir, { recursive: true, force: true }));
|
||||
const prompts: string[] = [];
|
||||
const created: unknown[] = [];
|
||||
const cache: NodeCache = new Map([
|
||||
['parent', { id: 'parent', createdBy: 'human', attrs: messageAttrs('parent', 'thread', [{ type: 'text', text: 'Parent context' }]) }]
|
||||
]);
|
||||
return {
|
||||
botUserId,
|
||||
workspaceId: 'workspace',
|
||||
cache,
|
||||
state,
|
||||
hermes: { bin: 'hermes', timeoutMs: 1000, maxOutputBytes: 1000 },
|
||||
colanode: { createMessage: async (_workspaceId: string, mutation: unknown) => { created.push(mutation); } } as never,
|
||||
runHermesFn: async (prompt: string) => {
|
||||
prompts.push(prompt);
|
||||
return 'Hermes reply';
|
||||
},
|
||||
prompts,
|
||||
created
|
||||
};
|
||||
}
|
||||
|
||||
function makeUpdate(nodeId: string, createdBy: string, attrs: MessageAttributes): SyncNodeUpdateData {
|
||||
return {
|
||||
id: `${nodeId}-update`,
|
||||
nodeId,
|
||||
rootId: 'root',
|
||||
workspaceId: 'workspace',
|
||||
revision: '1',
|
||||
data: encodeAttributes(attrs as unknown as Record<string, unknown>),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy
|
||||
};
|
||||
}
|
||||
|
||||
function messageAttrs(id: string, parentId: string, content: BlockLeaf[]): MessageAttributes {
|
||||
const blockId = `${id}-block`;
|
||||
return {
|
||||
type: 'message',
|
||||
subtype: 'standard',
|
||||
parentId,
|
||||
content: {
|
||||
[blockId]: { id: blockId, type: 'paragraph', parentId: id, index: 'a0', content }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mutationData(mutation: unknown): { data: string } {
|
||||
const outer = mutation as { data: { data: string } };
|
||||
return outer.data;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user