Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ New entries are written as one fragment file per change in [changelog.d/](change

### Added

- **Private context stays home in shared rooms.** A Bot only carries its owner's chat brief into a group where another owner sits when the owner shares it (a per-room, revocable share from the Bot's owner — host API `share-brief` now, room UI next); memory reviews in such a room keep entries in the Bot's own memory, never the team's, and read only their own owner as "the user"; `list_bots` no longer discloses another owner's Bot folder or instructions; and referencing a chat you cannot read is refused. Previously received messages are not retroactively unseen. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53))
- **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52))
- **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90))
- **Face-to-face join registers a Bot for that room only** when it has no identity yet. `@flybot/social` calls the same `add_home_member` path used for invites, then joins. The identity is not polled, not listed for friends, and is revoked with `remove_home_member` when you leave the last face room or the room closes. A failed join that created one is revoked too so retry is clean. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#89](https://github.com/ThinkFlowLab/FlyBot/issues/89), [#90](https://github.com/ThinkFlowLab/FlyBot/issues/90))
Expand Down
4 changes: 4 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ export function install(rt) {

if (ep === 'create-bot') return ALLOW

// Sharing a member Bot's brief is the Bot owner's decision, checked on the record
// in room-membership (#53); the endpoint itself only needs a signed-in actor.
if (ep === 'share-brief') return ALLOW

// Anything not in the table and not a known auth/read endpoint is denied.
if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW
if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check'
Expand Down
18 changes: 18 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ export function install(rt) {
// auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write.
const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami'])

// A chat reference chip names a Session whose snapshot rides to every recipient of
// the message, so the author must be able to read what they share (#53).
const unshareableReference = (actor, text) => {
if (!actor || !rt.authzEnforced?.() || typeof text !== 'string') return undefined
for (const match of text.matchAll(/dsh-session:([A-Za-z0-9_-]+)/g)) {
if (!rt.sessionOwnedBy?.(actor, match[1])) return match[1]
}
return undefined
}

function setPrefs(body) {
if (body.theme !== undefined) {
if (typeof body.theme !== 'string' || !/^[a-z][\w-]{0,31}$/.test(body.theme)) throw invalid('Unknown theme')
Expand Down Expand Up @@ -221,6 +231,8 @@ export function install(rt) {
}
case 'room-post': {
await rt.load()
const leaked = unshareableReference(actor, body.text)
if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden')
const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), {
text: body.text,
authorId: body.authorId,
Expand Down Expand Up @@ -329,6 +341,8 @@ export function install(rt) {
const text = String(body.text ?? '').trim()
if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid')
await rt.load()
const leaked = unshareableReference(actor, text)
if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden')
// A Bot id or an earlier part of its own chat means the current part.
const past = rt.pastOwners.get(body.sessionId)
const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId
Expand Down Expand Up @@ -382,6 +396,10 @@ export function install(rt) {
case 'diagnostics-clear':
await rt.diagnostics.clear()
return ok(true)
case 'share-brief':
await rt.load()
await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor)
return ok(true)
case 'auth-whoami':
return ok(actor)
case 'auth-invite': {
Expand Down
20 changes: 19 additions & 1 deletion core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { escapeRegExp, splitRef, textOf } from './text.js'
import { mentionSpans } from '../shared/mentions.js'
import { GROUP_REF, output } from './tools.js'
import { failureLine } from './turns.js'
import { currentParticipants } from './room-membership.js'

const ROOM_LOG_MAX = 60
const ROOM_FIRST_LOOK = 12
Expand Down Expand Up @@ -124,6 +125,23 @@ export function install(rt) {
// A member's group Session, created on its first turn there. A new one starts with
// a fresh look at the log and a brief of the member's own chat; the brief is the
// first message, so it stays fixed and the prefix stays cacheable.
// The brief is the member's owner's private chat with the Bot. In a room where
// another owner sits it stays out unless that owner shared it with this room (#53).
const briefAllowed = (room, member) => {
if (!rt.authzEnforced?.()) return true
const participants = currentParticipants(room)
const owners = new Set([rt.botOf(member.id)?.ownerId])
for (const p of participants) {
if (p.kind === 'human') owners.add(p.id)
// A granted remote Bot speaks for another home; its handle stands for that owner.
else if (p.remote) owners.add(p.handle)
else owners.add(rt.botOf(p.id)?.ownerId)
}
owners.delete(undefined)
if (owners.size <= 1) return true
return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true)
}

async function groupSessionFor(room, member) {
room.sessions ??= {}
const known = room.sessions[member.id]
Expand All @@ -144,7 +162,7 @@ export function install(rt) {
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
await rt.save()
await rt.ensureCapabilities(id)
return { id, brief: await rt.ownChatBrief(member) }
return { id, brief: briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined }
}

// Lines from before the round's opening message are kept apart from this round, so
Expand Down
33 changes: 28 additions & 5 deletions core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn
import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import { currentParticipants } from './room-membership.js'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { splitRef, unquote } from './text.js'
import { output } from './tools.js'
Expand Down Expand Up @@ -488,6 +489,22 @@ export function install(rt) {

const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId)

// Team memory is one team's. A review that reads a room where another owner sits
// keeps entries in the Bot's own memory only (#53); ownership, not the admin role,
// decides whose memory "team" refers to there.
const teamAllowedFrom = (room, bot) => {
if (room === undefined) return true
const owners = new Set([bot.ownerId])
for (const p of currentParticipants(room)) {
if (p.kind === 'human') owners.add(p.id)
// A granted remote Bot speaks for another home; its handle stands for that owner.
else if (p.remote) owners.add(p.handle)
else owners.add(state.bots?.[p.id]?.ownerId)
}
owners.delete(undefined)
return owners.size <= 1
}

// `raw` names a scope: "own", "team", or a Bot. Undefined for the default.
function namedScope(raw, botId) {
const value = unquote(raw)
Expand Down Expand Up @@ -1125,17 +1142,23 @@ export function install(rt) {

// What is new in a group, from its log: the member's Session holds the same
// messages wrapped in turn prompts.
// "The user" of a group review is the member Bot's owner; a line another human
// wrote is a peer's, however it is attributed (#53).
const isUserLine = (bot, entry) => (entry.authorKind === 'human'
? entry.authorId !== undefined && entry.authorId === bot.ownerId
: entry.who === 'User')

function groupBatch(room, bot, since) {
const log = (room.log ?? []).filter(line => line.kind === undefined && typeof line.text === 'string' && line.text.trim() !== '')
// A first review reads back at least to the user's latest line, which a round of
// replies from many members can push out of the last few.
const lastUser = log.findLastIndex(line => line.who === 'User')
const lastUser = log.findLastIndex(line => isUserLine(bot, line))
let start = since === undefined ? Math.max(0, Math.min(log.length - REVIEW_FIRST_LINES, lastUser === -1 ? log.length : lastUser)) : log.findIndex(line => line.seq > since)
const oldest = room.log?.[0]?.seq
if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name)
if (start === -1) start = log.length
const line = (entry) => {
const who = entry.who === 'User' ? 'user' : entry.botId === bot.id ? 'you' : 'bot'
const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot'
return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq }
}
return {
Expand All @@ -1160,15 +1183,15 @@ export function install(rt) {
return { texts: kept, count: kept.length }
}

async function applyReview(bot, sessionId, numbered, changes, source) {
async function applyReview(bot, sessionId, numbered, changes, source, { teamAllowed = true } = {}) {
const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true }
const done = []
let failed
for (const change of changes) {
// A Bot deleted mid-review keeps no more changes, its team ones included.
if (rt.botOf(bot.id) === undefined) return 0
// Its role may change mid-review too, so each change asks again.
const teamWrites = writesTeam(bot.id)
const teamWrites = teamAllowed && writesTeam(bot.id)
const op = String(change?.op ?? '')
const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined
const target = numbered[Number(change?.entry) - 1]
Expand Down Expand Up @@ -1274,7 +1297,7 @@ export function install(rt) {
const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined)
if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`)
if (rt.botOf(bot.id) === undefined) return
const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read))
const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read), { teamAllowed: teamAllowedFrom(live, bot) })
if (rt.botOf(bot.id) === undefined) return
// An answer at the limit may have left some out, so the same lines are read
// again, a few times at most, while each pass still changes something.
Expand Down
16 changes: 16 additions & 0 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,22 @@ export function install(rt) {
return linesVisibleTo(room, actor, state)
}

// The owner of a member Bot lets its brief — their own chat with it — ride into
// this one room, or takes it back (#53). Bot ownership decides, not room admin;
// the flag lives on the participant, so leaving the room drops it.
rt.setBriefShare = function setBriefShare(roomId, botId, shared, actor) {
const room = rt.roomOf(roomId)
if (room === undefined) throw invalid('Unknown group chat')
const bot = state.bots?.[String(botId ?? '')]
if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot')
if (rt.authzEnforced?.() && bot.ownerId !== actor?.memberId) throw forbidden("Only the Bot's owner may share its brief")
const entry = currentParticipants(room).find(p => p.kind === 'bot' && p.id === botId)
if (entry === undefined) throw forbidden('That Bot is not in this room')
if (shared) entry.briefShared = true
else delete entry.briefShared
return rt.save()
}

rt.applyParticipants = function applyParticipants(room, drafts, actor) {
if (!Array.isArray(drafts) || drafts.length === 0) return room
room.participants = []
Expand Down
8 changes: 6 additions & 2 deletions core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ export function install(rt) {
async execute(_args, exec) {
await rt.load()
const self = rt.selfOf(exec.agent?.id)
const selfOwner = rt.botOf(self)?.ownerId
// Another owner's Bot lists by name and role only: its folder and instructions
// are its owner's private setup (#53).
const foreign = bot => rt.authzEnforced?.() && selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner
const lines = Object.values(state.bots).filter(bot => bot.remote === undefined).map(bot => {
const model = bot.appliedModel ?? bot.model
const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
})
if (lines.length === 0) return 'No Bots yet.'
Expand Down
Loading
Loading