// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "context" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) // RemoteSpeechOutput is a reader-only observation ticket. Capture it when local // output is admitted, then retain it through delivery. It cannot follow a lease // replacement or authorize output queued before consent. No child RPC exposes it. type RemoteSpeechOutput struct { session *Session lease tentaclewire.RemoteLease ctx context.Context } func (manager *Manager) CaptureRemoteSpeechOutput() *RemoteSpeechOutput { manager.remoteMutex.Lock() session := manager.remoteOwner manager.remoteMutex.Unlock() if session == nil { return nil } session.mutex.Lock() defer session.mutex.Unlock() access := session.remote if access == nil { return nil } lease := tentaclewire.RemoteLease{Lease: access.lease, Epoch: access.epoch} if session.remote_access_locked(lease, "output-observe") == nil || access.role != "controlled" { return nil } return &RemoteSpeechOutput{session: session, lease: lease, ctx: access.ctx} } // PublishText exports one resolved backend utterance. The caller must establish // local origin and exclude sensitive/status output. Conservative UTF-8 chunks // fit even six-byte JSON escaping; only the final frame ends the utterance. // The complete set is reserved before any frame becomes visible to the child. func (output *RemoteSpeechOutput) PublishText(text string, characters bool) error { if output == nil || output.ctx.Err() != nil { return ErrPermission } const chunkBytes = 768 if text == "" { return nil } if len(text) > tentaclewire.RemoteSpeechQueueBytes || !utf8.ValidString(text) { return output.publish(nil, true) } var speeches []tentaclewire.RemoteSpeech for len(text) != 0 { if len(speeches) == tentaclewire.RemoteSpeechQueueSize { return output.publish(nil, true) } end := min(len(text), chunkBytes) for end < len(text) && !utf8.RuneStart(text[end]) { end-- } parts := []tentaclewire.RemoteSpeechPart{{Text: text[:end], Characters: characters}} text = text[end:] if text == "" { parts = append(parts, tentaclewire.RemoteSpeechPart{End: true}) } speeches = append(speeches, tentaclewire.RemoteSpeech{Kind: "speak", Parts: parts}) } return output.publish(speeches, false) } func (output *RemoteSpeechOutput) Cancel() error { if output == nil { return ErrPermission } return output.publish([]tentaclewire.RemoteSpeech{{Kind: "cancel"}}, false) } func (output *RemoteSpeechOutput) publish(speeches []tentaclewire.RemoteSpeech, overflow bool) error { session := output.session session.mutex.Lock() defer session.mutex.Unlock() // Serialize production admission with lock/handoff cancellation. Policy // cancellation never takes a session lock, so this follows consent's order. if policy := session.manager.remotePolicy; policy != nil { policy.mutex.Lock() defer policy.mutex.Unlock() } access := session.remote_access_locked(output.lease, "output-observe") if access == nil || access.role != "controlled" || output.ctx.Err() != nil { return ErrPermission } frames := make([]outgoing, 0, len(speeches)) bytes := 0 for index, speech := range speeches { event := tentaclewire.SessionEvent{Sequence: session.eventSequence + uint64(index) + 1, Kind: "remote.output", Lease: output.lease.Lease, Epoch: output.lease.Epoch, Speech: &speech} frame, err := encode_request("", "session.event", event) if err != nil || !tentaclewire.ValidRemoteSpeech(speech) { overflow = true break } frames = append(frames, outgoing{frame: frame}) bytes += len(frame) } if overflow || len(frames) > cap(session.outbound)-len(session.outbound) || session.queuedBytes+bytes > tentaclewire.SessionQueueBytes { // Losing a cancel or a suffix must never leave an apparently healthy // speech stream. Match the existing observed-output overflow policy. session.fail_locked(ErrLimit) return ErrLimit } for _, frame := range frames { session.outbound <- frame // All producers reserve under session.mutex. session.queuedBytes += len(frame.frame) } session.eventSequence += uint64(len(frames)) return nil }