Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/ai-client.ts
import { readDiagnostic, visibleDiagnostic, type Diagnostic } from './ai-diagnostics.js';
import { cvm, type CvmServerRef, type JsonObject } from '@napplet/sdk';
import { IMAGE_CHUNK_CHARS, type BodyImage, type VisionCheck } from './vision-contract.js';
import { visionChallenge } from './vision-challenge.js';
import { runtimeHasDomain } from './domain-availability.js';
import { boundedText, isProvider, object, parseReport, PROTOCOL_VERSION, type ProviderId, type Report } from './ai-contract.js';

export interface Connection { id: string; label: string; provider: ProviderId; model: string }
export interface ConnectionSettings { version: 1; connections: Connection[]; activeId: string; bridge: CvmServerRef }
export const CONNECTIONS_KEY = 'body-journal-connections-v1';
export function parseSettings(raw: string): ConnectionSettings {
  if (raw.length > 12_000) throw new Error('Connection settings are too large.');
  const data = object(JSON.parse(raw));
  if (data.version !== 1 || !Array.isArray(data.connections) || data.connections.length > 8) throw new Error('Unsupported connections.');
  const ids = new Set<string>();
  const connections = data.connections.map(value => {
    const c = object(value), id = boundedText(c.id, 80);
    if (!/^[a-z0-9-]+$/.test(id) || ids.has(id) || !isProvider(c.provider)) throw new Error('Invalid connection.');
    ids.add(id);
    return { id, label: boundedText(c.label, 60), provider: c.provider, model: modelId(c.model) };
  });
  const bridge = object(data.bridge);
  const pubkey = boundedText(bridge.pubkey, 64, true);
  if (pubkey && !/^[0-9a-f]{64}$/.test(pubkey)) throw new Error('Invalid bridge key.');
  if (!Array.isArray(bridge.relays) || bridge.relays.length > 3) throw new Error('Invalid relay list.');
  const relays = bridge.relays.map(parseRelay);
  return { version: 1, connections, activeId: ids.has(String(data.activeId)) ? String(data.activeId) : connections[0]?.id || '', bridge: { pubkey, relays } };
}
export function modelId(value: unknown): string {
  const text = boundedText(value, 120);
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(text)) throw new Error('Use the provider’s model ID, without spaces.');
  return text;
}
export function parseRelay(value: unknown): string {
  const url = new URL(boundedText(value, 250));
  if (url.username || url.password || url.hash || url.search || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))) throw new Error('Use a WSS relay URL (loopback WS is allowed for development).');
  return url.href;
}
export function validateBridge(bridge: CvmServerRef): void {
  if (!/^[0-9a-f]{64}$/.test(bridge.pubkey)) throw new Error('Enter the bridge’s 64-character public key.');
  if (!bridge.relays?.length || bridge.relays.length > 3) throw new Error('Enter one to three relay URLs from your bridge.');
  bridge.relays.forEach(parseRelay);
}
export class AiClient {
  private servers = new Map<string, CvmServerRef>();
  private lifetime = new AbortController();
  async call(bridge: CvmServerRef, tool: string, args: JsonObject): Promise<Record<string, unknown>> {
    if (this.lifetime.signal.aborted) throw new Error('Connection session ended.');
    if (!runtimeHasDomain('cvm')) throw new Error('This host has no AI service connection. Your case can still be edited and saved.');
    validateBridge(bridge);
    this.servers.set(bridge.pubkey, bridge);
    let result;
    try { result = await cvm.callTool(bridge, tool, args, { timeoutMs: 12_000, initialize: true, payment: 'deny' }); }
    catch { throw new Error('The bridge could not be reached or the host denied access. Check the public key, relay and host approval. No automatic retry was made.'); }
    if (result.isError) throw new Error('The bridge rejected the request. Check its version and configuration.');
    let data;
    try {
      const content = result.structuredContent ?? JSON.parse(result.content.filter(c => c.type === 'text').map(c => c.text).join(''));
      data = object(content);
    } catch { throw new Error('The bridge returned an unreadable response.'); }
    this.checkError(data);
    return data;
  }
  private checkError(data: Record<string, unknown>): void {
    if (data.ok === false) {
      // Bridge errors are untrusted and can echo secrets. Show only local messages.
      const messages: Record<string, string> = {
        'image-invalid': 'The bridge could not accept an image. No assessment was started.',
        'images-expired': 'Prepared images expired or are incomplete. Review the request and send again.',
        authentication: 'The provider rejected the API key or account access.',
        'rate-limit': 'Provider quota or rate limit reached. Check your account and try later.',
        'model-unavailable': 'The model ID or request options were rejected by the provider.',
        timeout: 'The request timed out. The provider may still charge for work already started.',
        busy: 'The bridge is busy. Wait before trying again.',
        'invalid-report': 'The model returned an unsupported report format. No assessment was accepted.',
        incomplete: 'The model did not finish. No partial assessment was accepted.',
        'invalid-response': 'The provider response could not be read. Open request diagnostics for details.',
        'models-unavailable': 'The provider model list is unavailable. You can still enter a model ID manually.',
        refused: 'The model declined this request.',
        expired: 'This result expired or belongs to a different host session. No new inference was started.',
      };
      throw new Error(messages[String(data.code)] || 'The bridge could not complete the request. Check the connection; no automatic retry was made.');
    }
  }
  async checkBridge(bridge: CvmServerRef): Promise<void> {
    const info = await this.call(bridge, 'body_journal_info', {});
    if (info.protocolVersion !== PROTOCOL_VERSION || info.encryptionRequired !== true || info.input !== 'text-and-images' || info.jobs !== true || info.diagnostics !== true || info.modelDiscovery !== true) throw new Error('Update and restart your Body Journal AI bridge (protocol 4). No key or case was sent.');
  }
  private async job(bridge: CvmServerRef, tool: string, args: JsonObject, signal?: AbortSignal, onProgress?: (d: Diagnostic) => void): Promise<Record<string, unknown>> {
    signal = signal ? AbortSignal.any([signal, this.lifetime.signal]) : this.lifetime.signal;
    const requestId = `request-${Date.now().toString(36)}-${Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('')}`;
    if (signal?.aborted) throw new Error('Stopped waiting.');
    onProgress?.({stage:'queued',elapsedMs:0,output:'',reasoning:'',events:[],requestId});
    await this.call(bridge, tool, { ...args, requestId });
    let diagnostics: Diagnostic | undefined;
    const deadline = Date.now() + 110_000;
    while (Date.now() < deadline) {
      if (signal?.aborted) throw new Error('Stopped waiting.');
      const state = await this.call(bridge, 'body_journal_result', { requestId, outputOffset: diagnostics?.output.length || 0, reasoningOffset: diagnostics?.reasoning.length || 0 });
      if (signal?.aborted) throw new Error('Stopped waiting.');
      let more = false;
      if (state.diagnostics) {
        const parsed = readDiagnostic(state.diagnostics, diagnostics, String(args.apiKey || '')); diagnostics = parsed.diagnostic; more = parsed.more;
        onProgress?.({...visibleDiagnostic(diagnostics,String(args.apiKey || '')),requestId});
      }
      if (state.status === 'complete' && more) continue;
      if (state.status === 'complete') { const result = object(state.result); this.checkError(result); return result; }
      if (state.status !== 'pending') throw new Error('Invalid job status from bridge.');
      await new Promise<void>((resolve, reject) => {
        const stop = () => { clearTimeout(timer); reject(new Error('Stopped waiting.')); };
        const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); resolve(); }, 2500);
        signal?.addEventListener('abort', stop, { once: true });
      });
    }
    throw new Error('The result was not available before the deadline. The provider may still charge for the request.');
  }
  async test(bridge: CvmServerRef, c: Connection, apiKey: string): Promise<VisionCheck> {
    await this.checkBridge(bridge);
    const result = await this.job(bridge, 'body_journal_test', { provider: c.provider, model: c.model, apiKey, challenge:visionChallenge() });
    if (result.ok !== true) throw new Error('The connection test did not succeed.');
    const v=object(result.vision);
    if(!['verified','unsupported','unconfirmed'].includes(String(v.state)))throw new Error('Invalid vision test result.');
    return {state:v.state as VisionCheck['state'],model:boundedText(v.model,120)};
  }
  async analyze(bridge: CvmServerRef, c: Connection, apiKey: string, caseText: string, signal?: AbortSignal, images:BodyImage[]=[], onProgress?: (d: Diagnostic) => void): Promise<{ report: Report; model: string }> {
    onProgress?.({stage:'checking',elapsedMs:0,output:'',reasoning:'',events:[]});
    await this.checkBridge(bridge);
    const refs:{id:string;sha256:string;concernId:number|null;caption:string;kind:'body-map'|'patient-photo';attachmentId?:string}[]=[];
    try {
      if (images.length) onProgress?.({stage:'uploading',elapsedMs:0,output:'',reasoning:'',events:[]});
      for(const image of images){
        if(signal?.aborted || this.lifetime.signal.aborted)throw new Error('Stopped waiting.');
        const sha256=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(image.dataUrl))),b=>b.toString(16).padStart(2,'0')).join('');
        const id=`image-${Array.from(crypto.getRandomValues(new Uint8Array(12)),b=>b.toString(16).padStart(2,'0')).join('')}`;
        refs.push({id,sha256,concernId:image.concernId,caption:image.caption.slice(0,1200),kind:image.kind || 'body-map',...(image.attachmentId ? { attachmentId: image.attachmentId } : {})});
        const total=Math.ceil(image.dataUrl.length/IMAGE_CHUNK_CHARS);
        for(let index=0;index<total;index++){
          if(signal?.aborted || this.lifetime.signal.aborted)throw new Error('Stopped waiting.');
          await this.call(bridge,'body_journal_image',{id,sha256,index,total,chunk:image.dataUrl.slice(index*IMAGE_CHUNK_CHARS,(index+1)*IMAGE_CHUNK_CHARS)});
        }
      }
      const result = await this.job(bridge, 'body_journal_analyze', { provider: c.provider, model: c.model, apiKey, caseText, images:refs }, signal, onProgress);
      if (result.ok !== true) throw new Error('The analysis did not complete.');
      return { report: parseReport(result.report, true), model: boundedText(result.model, 120) };
    } finally {
      if(refs.length) { try { await this.call(bridge,'body_journal_images_clear',{ids:refs.map(r=>r.id)}); } catch { /* The bridge also expires abandoned image chunks. */ } }
    }
  }
  async models(bridge: CvmServerRef, provider: ProviderId, apiKey: string): Promise<{models: string[]; truncated: boolean}> {
    await this.checkBridge(bridge);
    const result = await this.job(bridge,'body_journal_models',{provider,apiKey});
    if (!Array.isArray(result.models) || result.models.length > 300) throw new Error('Invalid model list from bridge.');
    return {models:[...new Set(result.models.map(modelId))],truncated:result.truncated === true};
  }
  dispose(): void {
    this.lifetime.abort();
    for (const ref of this.servers.values()) { try { void cvm.close(ref).catch(() => {}); } catch { /* Host may already be gone. */ } }
    this.servers.clear();
  }
}