brain backup 2026-09-14

This commit is contained in:
Adolfo Reyna
2026-09-14 22:38:47 -04:00
parent ca346cc6e7
commit f0ead4f6fa
147 changed files with 73451 additions and 8 deletions
@@ -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
+39
View File
@@ -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
View File
@@ -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"]
}