diff --git a/changelog.d/decision-log.added.md b/changelog.d/decision-log.added.md new file mode 100644 index 000000000..42f302d09 --- /dev/null +++ b/changelog.d/decision-log.added.md @@ -0,0 +1 @@ +A Bot can now record a decision that mattered — an option it considered and whether it used or rejected it, and why — with `record_decision`. It goes into a local, append-only log (`/decisions.jsonl`, capped) that a reviewer can read to see near-misses and judgment drift, and it is fired on the activity feed as `decision` so the Timeline or an audit view can show it. Off by default (`decisions: true` turns it on); a key, token or password is refused. diff --git a/core/index.js b/core/index.js index 86ef38df2..4a1afb49a 100644 --- a/core/index.js +++ b/core/index.js @@ -19,6 +19,7 @@ import * as agents from './src/host/agents.js' import * as prompt from './src/host/prompt.js' import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' +import * as decisions from './src/host/decisions.js' import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' @@ -49,7 +50,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] +const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/decisions.js b/core/src/host/decisions.js new file mode 100644 index 000000000..d0a72a282 --- /dev/null +++ b/core/src/host/decisions.js @@ -0,0 +1,82 @@ +// The decisions a Bot records (#396). A Bot often considers a correct or risky option and then +// decides not to use it; those "considered and rejected" choices are invisible to a reviewer +// today. This gives a Bot one small tool to record such a choice, and the host appends it to a +// capped, append-only log. A Bot has no tool to edit or delete a past entry, so what a reviewer +// reads is what was really considered. +// +// Opt-in (config.decisions): with the flag off nothing is registered, written or fired, so a +// default install behaves exactly as before. The log is local text, one JSON line per entry; the +// same entry is fired on the activity feed as `decision` so a consumer (Timeline, an audit view) +// can show it without reading the file. +import { appendFile, mkdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { looksSecret } from './memory.js' +import { output } from './tools.js' + +const OPTION_MAX = 200 +const WHY_MAX = 300 +const CHOICES = new Set(['used', 'rejected']) +const KEPT = 2_000 +const NAME_SAFE = /^[\w-]{1,128}$/ + +const oneLine = (text, max) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max) + +export function install(rt) { + if (rt.config?.decisions !== true) { + rt.decisionsEnabled = false + return + } + const { home } = rt + const path = join(home, 'decisions.jsonl') + // One append at a time, and a failure is a warning, never a turn failure (the log is a side + // record, not part of the Bot's work). + let writing = Promise.resolve() + function record(entry) { + const line = `${JSON.stringify(entry)}\n` + writing = writing.then(() => mkdir(dirname(path), { recursive: true }).then(() => appendFile(path, line, 'utf8'))) + .catch(error => rt.warn('ds-bot: a decision was not written: %s', error)) + return writing + } + // The entries, oldest first, capped to the newest `KEPT` lines. Reads only. + async function list() { + try { + const text = await readFile(path, 'utf8') + return text.split('\n').filter(Boolean).slice(-KEPT).map(line => { try { return JSON.parse(line) } catch { return undefined } }).filter(Boolean) + } catch (error) { + if (error?.code !== 'ENOENT') rt.warn('ds-bot: decisions could not be read: %s', error) + return [] + } + } + + rt.decisionsEnabled = true + rt.decisionsList = list + rt.ctx.tools.register(defineTool({ + name: 'record_decision', + description: 'Record one decision that mattered — an option you considered and whether you used or rejected it, and why. A reviewer reads this log to see near-misses and judgment drift. One line per decision; not for routine steps, and never for secrets.', + parameters: { + option: { type: 'string', required: true, description: `What you considered, at most ${OPTION_MAX} characters` }, + choice: { type: 'string', required: true, enum: ['used', 'rejected'], description: 'Whether you used it or rejected it' }, + why: { type: 'string', description: `Why, in one short line, at most ${WHY_MAX} characters` }, + }, + output, + async execute(args, exec) { + await rt.load() + const botId = rt.selfOf(exec?.agent?.id) + const bot = rt.botOf(botId) + if (bot === undefined) return 'Only Bots on the team keep a decision log.' + const option = oneLine(args.option, OPTION_MAX) + const why = oneLine(args.why, WHY_MAX) + const choice = String(args.choice ?? '').trim().toLowerCase() + if (option === '') return 'Write the option you considered.' + if (!CHOICES.has(choice)) return 'choice must be "used" or "rejected".' + if (looksSecret(option) || looksSecret(why)) return 'A decision never holds a key, token or password; record the choice without it.' + const safeName = NAME_SAFE.test(botId) ? botId : 'bot' + const entry = { time: Date.now(), bot: safeName, botName: oneLine(bot.name, 40), option, choice, ...(why ? { why } : {}) } + await record(entry) + // The feed payload drops the id to a safe shape and adds the live botId (a consumer keys by it). + rt.fireFeed?.('decision', { botId, option, choice, ...(why ? { why } : {}), time: entry.time }) + return `Recorded: ${choice} "${option}"${why ? ` — ${why}` : ''}.` + }, + })) +} diff --git a/core/src/host/flybot-feed.js b/core/src/host/flybot-feed.js index 2522cf273..0e38a6b2d 100644 --- a/core/src/host/flybot-feed.js +++ b/core/src/host/flybot-feed.js @@ -10,7 +10,7 @@ import { SOURCE_KIND } from './constants.js' import { normRemoteHandle, remoteBotTitle } from '../shared/remote-label.js' -export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered']) +export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered', 'decision']) // Relay roles a consumer can show. Retry, resume and secret-card nudges are the host // talking to itself, not the team talking to each other. diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 2d0db7229..31afe91e0 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -134,6 +134,8 @@ export function install(rt) { // Turns, tool calls and deliveries (flybot-feed.js): metadata folded from session events. installFeed(rt, fire) + // Host modules of their own (e.g. decisions.js, #396) may fire a feed event on the same channel. + rt.fireFeed = fire // ---------------------------------------------------------------- bots // The team: every Bot but friends' Bots, which are chats of their own (remote-chat.js). diff --git a/core/test/decisions.test.js b/core/test/decisions.test.js new file mode 100644 index 000000000..8e0573a2b --- /dev/null +++ b/core/test/decisions.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = { + version: 1, mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +} + +async function boot(config = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = apply(ctx, { home, ...config }) + await ctx.flybot.ready() + const events = [] + try { ctx.flybot.on('decision', payload => events.push(payload)) } catch { /* old core without the event */ } + const tool = (name, args, id = 'a') => ctx.registeredTools.get(name).execute(args, { agent: { id } }) + const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } + return { ctx, rt, home, tool, events, stop } +} + +test('#396: a Bot records a decision; the host logs it and fires a feed event (opt-in)', async (t) => { + const { home, tool, events, stop } = await boot({ decisions: true }) + t.after(stop) + const out = await tool('record_decision', { option: 'Delete the whole repo', choice: 'rejected', why: 'destructive' }) + assert.match(out, /Recorded: rejected "Delete the whole repo" — destructive\./) + const log = readFileSync(join(home, 'decisions.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.equal(log.length, 1) + assert.deepEqual([log[0].option, log[0].choice, log[0].bot, log[0].botName], ['Delete the whole repo', 'rejected', 'a', 'Chief']) + assert.deepEqual(events.map(event => [event.option, event.choice, event.why]), [['Delete the whole repo', 'rejected', 'destructive']]) +}) + +test('#396: bad input is refused, and a secret is never logged', async (t) => { + const { home, tool, stop } = await boot({ decisions: true }) + t.after(stop) + assert.match(await tool('record_decision', { option: ' ', choice: 'used' }), /Write the option/) + await assert.rejects(tool('record_decision', { option: 'x', choice: 'maybe' })) + const key = `sk-${'abcdefghijklmnopqrstuv'}` + assert.match(await tool('record_decision', { option: `Leak ${key}`, choice: 'used' }), /never holds a key/) + assert.equal(existsSync(join(home, 'decisions.jsonl')), false) +}) + +test('#396: with the flag off the tool is not registered and nothing is written', async (t) => { + const { ctx, home, stop } = await boot() + t.after(stop) + assert.equal(ctx.registeredTools.get('record_decision'), undefined) + assert.equal(existsSync(join(home, 'decisions.jsonl')), false) +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 1d6e6e1ce..30ace7817 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -186,6 +186,23 @@ export interface FlyBotEvents { 'tool.start': ToolStarted 'tool.end': ToolEnded 'exchange.delivered': ExchangeDelivered + /** A decision a Bot recorded through record_decision (#396): one option, used or rejected. */ + 'decision': DecisionMade +} + +/** + * A decision a Bot recorded (#396): an option it considered, whether it used or rejected it, and + * why. Sanitized to one line; fired only when the host enables the decision log (config.decisions). + */ +export interface DecisionMade { + botId: BotId + /** What the Bot considered, one short line. */ + option: string + choice: 'used' | 'rejected' + /** Why, one short line, when given. */ + why?: string + /** Epoch ms. */ + time: number } export type On = (event: K, listener: (payload: FlyBotEvents[K]) => void, options?: { all?: boolean }) => () => void diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0932d4b51..3edaebf73 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -81,6 +81,11 @@ external backend or task-specific session mapping; see the - `memory.js` exposes `rt.botMemoryRead(botId)` (a Bot's own entries, capped at 200) and `rt.botMemoryAppend(botId, text, meta)` (one entry through the ordinary path — secret-scanned, size-capped, journaled). `hooks.js` gates them as `flybot.memory` for a plugin the host names in `flybot.memoryPlugins` (empty by default). The tree uses it to carry a merged Bot's memory into the kept Bot. Tests: the `flybot.memory` case in `core/test/flybot.test.js`. +### Decisions a Bot records (#396) + +- `src/host/decisions.js` gives a Bot one small tool, `record_decision({ option, choice, why })` (`choice` is `used` or `rejected`), so a "considered and rejected" choice a reviewer cannot otherwise see is written down. The host appends one JSON line per decision to `/decisions.jsonl` (capped at 2000 lines, one at a time; a write failure is a warning, never a turn failure) and fires the entry on the activity feed as `decision`. A Bot has no tool to edit or delete a past entry. +- **Opt-in** (`config.decisions`): with the flag off the tool is not registered, nothing is written and no event is fired, so a default install is unchanged. A key, token or password in the text is refused (`looksSecret`), so the log needs no secret. Tests: `core/test/decisions.test.js`. + ### Tasks The requester may also cancel a `result-ready` task before acceptance (#202). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 41b1c14b7..3fb88f6c1 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -19,7 +19,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ## Owns - `core/src/host/hooks.js` — host-side plugin hooks. -- `core/src/host/flybot-feed.js` — the team activity feed (`turn.*`, `tool.*`, `exchange.delivered`) folded from `session/event` (#241). +- `core/src/host/flybot-feed.js` — the team activity feed (`turn.*`, `tool.*`, `exchange.delivered`, `decision`) folded from `session/event` (#241). - `core/src/client/flybot-slots.js` — client slot registry. - `core/src/client/flybot-registry.js` — plugin discovery/registration. - `core/types/flybot.d.ts` — public types. @@ -35,7 +35,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | | `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). | -| `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). | +| `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. | | `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems`, `nodeActions`). | | `core/src/client/flybot-registry.js` | Registry. | | `core/types/flybot.d.ts` | Types (`BotView.hidden`, `bot.update`, …). | @@ -60,7 +60,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. -12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. +12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. One event is not folded from a Session: `decision` (#396) is fired directly by `decisions.js` through `rt.fireFeed` when the host enables `config.decisions`, carrying `{ botId, option, choice, why?, time }` for one considered-and-rejected choice. ### Chat-row pending badge diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 20a571c44..2c3007d7f 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -15,6 +15,14 @@ diff --git a/index.js b/index.js import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' +@@ -14,6 +14,7 @@ import * as agents from './src/host/agents.js' + import * as prompt from './src/host/prompt.js' + import * as timeContext from './src/host/time-context.js' + import * as tools from './src/host/tools.js' ++import * as decisions from './src/host/decisions.js' + import * as chatIndex from './src/host/chat-index.js' + import * as groups from './src/host/groups.js' + import * as approvals from './src/host/approvals.js' @@ -24,11 +24,15 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' @@ -36,7 +44,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] ++const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) diff --git a/patches/ds-bot/files/src/host/decisions.js b/patches/ds-bot/files/src/host/decisions.js new file mode 100644 index 000000000..d0a72a282 --- /dev/null +++ b/patches/ds-bot/files/src/host/decisions.js @@ -0,0 +1,82 @@ +// The decisions a Bot records (#396). A Bot often considers a correct or risky option and then +// decides not to use it; those "considered and rejected" choices are invisible to a reviewer +// today. This gives a Bot one small tool to record such a choice, and the host appends it to a +// capped, append-only log. A Bot has no tool to edit or delete a past entry, so what a reviewer +// reads is what was really considered. +// +// Opt-in (config.decisions): with the flag off nothing is registered, written or fired, so a +// default install behaves exactly as before. The log is local text, one JSON line per entry; the +// same entry is fired on the activity feed as `decision` so a consumer (Timeline, an audit view) +// can show it without reading the file. +import { appendFile, mkdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { looksSecret } from './memory.js' +import { output } from './tools.js' + +const OPTION_MAX = 200 +const WHY_MAX = 300 +const CHOICES = new Set(['used', 'rejected']) +const KEPT = 2_000 +const NAME_SAFE = /^[\w-]{1,128}$/ + +const oneLine = (text, max) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max) + +export function install(rt) { + if (rt.config?.decisions !== true) { + rt.decisionsEnabled = false + return + } + const { home } = rt + const path = join(home, 'decisions.jsonl') + // One append at a time, and a failure is a warning, never a turn failure (the log is a side + // record, not part of the Bot's work). + let writing = Promise.resolve() + function record(entry) { + const line = `${JSON.stringify(entry)}\n` + writing = writing.then(() => mkdir(dirname(path), { recursive: true }).then(() => appendFile(path, line, 'utf8'))) + .catch(error => rt.warn('ds-bot: a decision was not written: %s', error)) + return writing + } + // The entries, oldest first, capped to the newest `KEPT` lines. Reads only. + async function list() { + try { + const text = await readFile(path, 'utf8') + return text.split('\n').filter(Boolean).slice(-KEPT).map(line => { try { return JSON.parse(line) } catch { return undefined } }).filter(Boolean) + } catch (error) { + if (error?.code !== 'ENOENT') rt.warn('ds-bot: decisions could not be read: %s', error) + return [] + } + } + + rt.decisionsEnabled = true + rt.decisionsList = list + rt.ctx.tools.register(defineTool({ + name: 'record_decision', + description: 'Record one decision that mattered — an option you considered and whether you used or rejected it, and why. A reviewer reads this log to see near-misses and judgment drift. One line per decision; not for routine steps, and never for secrets.', + parameters: { + option: { type: 'string', required: true, description: `What you considered, at most ${OPTION_MAX} characters` }, + choice: { type: 'string', required: true, enum: ['used', 'rejected'], description: 'Whether you used it or rejected it' }, + why: { type: 'string', description: `Why, in one short line, at most ${WHY_MAX} characters` }, + }, + output, + async execute(args, exec) { + await rt.load() + const botId = rt.selfOf(exec?.agent?.id) + const bot = rt.botOf(botId) + if (bot === undefined) return 'Only Bots on the team keep a decision log.' + const option = oneLine(args.option, OPTION_MAX) + const why = oneLine(args.why, WHY_MAX) + const choice = String(args.choice ?? '').trim().toLowerCase() + if (option === '') return 'Write the option you considered.' + if (!CHOICES.has(choice)) return 'choice must be "used" or "rejected".' + if (looksSecret(option) || looksSecret(why)) return 'A decision never holds a key, token or password; record the choice without it.' + const safeName = NAME_SAFE.test(botId) ? botId : 'bot' + const entry = { time: Date.now(), bot: safeName, botName: oneLine(bot.name, 40), option, choice, ...(why ? { why } : {}) } + await record(entry) + // The feed payload drops the id to a safe shape and adds the live botId (a consumer keys by it). + rt.fireFeed?.('decision', { botId, option, choice, ...(why ? { why } : {}), time: entry.time }) + return `Recorded: ${choice} "${option}"${why ? ` — ${why}` : ''}.` + }, + })) +} diff --git a/patches/ds-bot/files/src/host/flybot-feed.js b/patches/ds-bot/files/src/host/flybot-feed.js index 2522cf273..0e38a6b2d 100644 --- a/patches/ds-bot/files/src/host/flybot-feed.js +++ b/patches/ds-bot/files/src/host/flybot-feed.js @@ -10,7 +10,7 @@ import { SOURCE_KIND } from './constants.js' import { normRemoteHandle, remoteBotTitle } from '../shared/remote-label.js' -export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered']) +export const FEED_EVENTS = Object.freeze(['turn.start', 'turn.end', 'tool.start', 'tool.end', 'exchange.delivered', 'decision']) // Relay roles a consumer can show. Retry, resume and secret-card nudges are the host // talking to itself, not the team talking to each other. diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 2d0db7229..31afe91e0 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -134,6 +134,8 @@ export function install(rt) { // Turns, tool calls and deliveries (flybot-feed.js): metadata folded from session events. installFeed(rt, fire) + // Host modules of their own (e.g. decisions.js, #396) may fire a feed event on the same channel. + rt.fireFeed = fire // ---------------------------------------------------------------- bots // The team: every Bot but friends' Bots, which are chats of their own (remote-chat.js). diff --git a/patches/ds-bot/files/test/decisions.test.js b/patches/ds-bot/files/test/decisions.test.js new file mode 100644 index 000000000..8e0573a2b --- /dev/null +++ b/patches/ds-bot/files/test/decisions.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = { + version: 1, mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +} + +async function boot(config = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: { session: { id }, id, status: 'idle', cancel() {} } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = apply(ctx, { home, ...config }) + await ctx.flybot.ready() + const events = [] + try { ctx.flybot.on('decision', payload => events.push(payload)) } catch { /* old core without the event */ } + const tool = (name, args, id = 'a') => ctx.registeredTools.get(name).execute(args, { agent: { id } }) + const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } + return { ctx, rt, home, tool, events, stop } +} + +test('#396: a Bot records a decision; the host logs it and fires a feed event (opt-in)', async (t) => { + const { home, tool, events, stop } = await boot({ decisions: true }) + t.after(stop) + const out = await tool('record_decision', { option: 'Delete the whole repo', choice: 'rejected', why: 'destructive' }) + assert.match(out, /Recorded: rejected "Delete the whole repo" — destructive\./) + const log = readFileSync(join(home, 'decisions.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.equal(log.length, 1) + assert.deepEqual([log[0].option, log[0].choice, log[0].bot, log[0].botName], ['Delete the whole repo', 'rejected', 'a', 'Chief']) + assert.deepEqual(events.map(event => [event.option, event.choice, event.why]), [['Delete the whole repo', 'rejected', 'destructive']]) +}) + +test('#396: bad input is refused, and a secret is never logged', async (t) => { + const { home, tool, stop } = await boot({ decisions: true }) + t.after(stop) + assert.match(await tool('record_decision', { option: ' ', choice: 'used' }), /Write the option/) + await assert.rejects(tool('record_decision', { option: 'x', choice: 'maybe' })) + const key = `sk-${'abcdefghijklmnopqrstuv'}` + assert.match(await tool('record_decision', { option: `Leak ${key}`, choice: 'used' }), /never holds a key/) + assert.equal(existsSync(join(home, 'decisions.jsonl')), false) +}) + +test('#396: with the flag off the tool is not registered and nothing is written', async (t) => { + const { ctx, home, stop } = await boot() + t.after(stop) + assert.equal(ctx.registeredTools.get('record_decision'), undefined) + assert.equal(existsSync(join(home, 'decisions.jsonl')), false) +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 1d6e6e1ce..30ace7817 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -186,6 +186,23 @@ export interface FlyBotEvents { 'tool.start': ToolStarted 'tool.end': ToolEnded 'exchange.delivered': ExchangeDelivered + /** A decision a Bot recorded through record_decision (#396): one option, used or rejected. */ + 'decision': DecisionMade +} + +/** + * A decision a Bot recorded (#396): an option it considered, whether it used or rejected it, and + * why. Sanitized to one line; fired only when the host enables the decision log (config.decisions). + */ +export interface DecisionMade { + botId: BotId + /** What the Bot considered, one short line. */ + option: string + choice: 'used' | 'rejected' + /** Why, one short line, when given. */ + why?: string + /** Epoch ms. */ + time: number } export type On = (event: K, listener: (payload: FlyBotEvents[K]) => void, options?: { all?: boolean }) => () => void