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.d/1847-memory-write-bounds.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Project memory box now refuses what the Project Assistant's own writer always refused: an empty entry is answered with *A Project memory entry needs text* and one over 600 characters with *A Project memory entry is at most 600 characters*, in the interface language in the panel, and neither writes a row or touches the Project. Before this the endpoint stored a blank entry, or cut a long one to 600 characters, and still reported success ([#1847](https://github.com/ThinkFlowLab/FlyBot/issues/1847)).
7 changes: 5 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1488,10 +1488,13 @@ var HOST_ERRORS = [
[/^Memory cannot change now: [\s\S]*$/, "现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队"],
// The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in
// Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner
// gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is
// last and takes one token, so it can never claim a longer "Unknown Project …" sentence.
// gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 —
// the memory box's two bounds. The id rule is last and takes one token, so it can never claim a
// longer "Unknown Project …" sentence.
[/^Only the owner of this Project may change it$/, "只有这个项目的主人可以修改"],
[/^A binding needs the member id$/, "绑定需要 member id"],
[/^A Project memory entry needs text$/, "项目记忆条目需要文字"],
[/^A Project memory entry is at most (\d+) characters$/, "项目记忆条目最多 $1 个字符"],
[/^Unknown Project (\S+)$/, "找不到项目 $1"]
];
var HOST_ZH = {
Expand Down
7 changes: 5 additions & 2 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1444,10 +1444,13 @@ const HOST_ERRORS = [
[/^Memory cannot change now: [\s\S]*$/, '现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队'],
// The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in
// Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner
// gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is
// last and takes one token, so it can never claim a longer "Unknown Project …" sentence.
// gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 —
// the memory box's two bounds. The id rule is last and takes one token, so it can never claim a
// longer "Unknown Project …" sentence.
[/^Only the owner of this Project may change it$/, '只有这个项目的主人可以修改'],
[/^A binding needs the member id$/, '绑定需要 member id'],
[/^A Project memory entry needs text$/, '项目记忆条目需要文字'],
[/^A Project memory entry is at most (\d+) characters$/, '项目记忆条目最多 $1 个字符'],
[/^Unknown Project (\S+)$/, '找不到项目 $1'],
]
const HOST_ZH = {
Expand Down
3 changes: 3 additions & 0 deletions core/src/host/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const OCR_CACHE_MAX = 500
export const MAX_AVATAR_IMAGE = 160_000
export const ROUTE = '/api/bot'
export const MAX_HOPS = 6
// The bound on one Project memory entry (#1847): one value, shared by the assistant writer and the
// store's `appendProjectMemory`, so no writer can store a blank entry or silently cut a long one.
export const MAX_ENTRY = 600
// Bot requests a Bot's own chat may hold unread; past this the Bot is most likely
// stuck, and more messages only pile up behind it.
export const MAX_UNREAD_REQUESTS = 10
Expand Down
5 changes: 4 additions & 1 deletion core/src/host/project-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// on the assistant Session through the host's schedule path, and `project-delete` removes it with
// the Project. A missed beat is DSH's, and it posts nothing.
import { defineTool } from './tool-schema.js'
import { MAX_ENTRY } from './constants.js'
import { listProjectState } from './project-state.js'
import { output } from './tools.js'
import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js'
Expand All @@ -24,7 +25,9 @@ export const DIGEST_ZONE = 'Asia/Shanghai'
export const DIGEST_TIME = '09:00'
export const DIGEST_TITLE = '09:00 Project digest'
export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.'
export const MAX_ENTRY = 600
// The bound itself is in `constants.js` (#1847), beside the other limits host modules share,
// because the store's write door enforces it too; this re-export keeps the assistant's own name.
export { MAX_ENTRY }
export const MEMORY_KINDS = ['decision', 'question']
// At most this many cards that need one owner's decision in one digest (#1733): a digest is a
// short list of things to decide, not the board. One constant, not a setting.
Expand Down
11 changes: 9 additions & 2 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
import { constants, mkdirSync, statSync } from 'node:fs'
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
import { homeLock } from './home-lock.js'
import { openJournal } from './journal.js'
import { currentColor, normalize, unquote } from './text.js'
Expand Down Expand Up @@ -512,11 +512,18 @@ export function install(rt) {
const appendProjectMemory = (id, kind, entry = {}) => {
const project = requireProject(id)
if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`)
// #1847: this is the one door into a Project's memory, so the bound the assistant writer states
// is enforced here too. Before this, an endpoint caller could store an empty entry or have a
// long one cut to `MAX_ENTRY` without being told; a refusal leaves the table and `updatedAt` as
// they were.
const entryText = projectText(entry.text)
if (entryText === '') throw projectRefusal('A Project memory entry needs text')
if (entryText.length > MAX_ENTRY) throw projectRefusal(`A Project memory entry is at most ${MAX_ENTRY} characters`)
const table = memoryTable(project)
const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project)
const record = ownerRow(table, ownerId, project)
const list = kind === 'decision' ? record.decisions : record.questions
list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) })
list.push({ text: entryText, at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) })
mirrorOwner(table, project, ownerId, record)
project.updatedAt = Math.max(project.updatedAt, Date.now())
return ownerView(record)
Expand Down
62 changes: 62 additions & 0 deletions core/test/project-board-panels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,68 @@ test('a memory kind the host does not know writes nothing, and the panel quotes
}
})

// #1847: the panel is not the only writer of a Project's memory. The endpoint used to store a
// blank entry and silently cut one over 600 characters, while the assistant writer refused both.
// The bound now lives at `appendProjectMemory`, the one door every writer goes through.
test('#1847: the memory write door refuses empty and over-long text and stores 600 exactly', async (t) => {
const rt = await startHost(t)
const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload)
const before = (await call('project-read', { id: 'project-1' })).project

await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: '' }),
error => error.code === 'bot/invalid' && error.message === 'A Project memory entry needs text')
await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: 'x'.repeat(601) }),
error => error.code === 'bot/invalid' && error.message === 'A Project memory entry is at most 600 characters')

let memory = (await call('project-memory', { id: 'project-1' })).memory
assert.deepEqual(memory.decisions, [], 'a refused write leaves no entry behind')
assert.deepEqual(memory.questions, [], 'and it does not land in the other list either')
const after = (await call('project-read', { id: 'project-1' })).project
assert.equal(after.updatedAt, before.updatedAt, 'a refused write does not touch the Project')

const exact = 'x'.repeat(600)
await call('project-memory-write', { id: 'project-1', kind: 'decision', text: exact })
memory = (await call('project-memory', { id: 'project-1' })).memory
assert.equal(memory.decisions.length, 1, 'the entry at the cap is accepted')
assert.equal(memory.decisions[0].text.length, 600, 'and stored whole, not cut')
assert.equal(memory.decisions[0].text, exact, 'the text is what was sent')
})

// The other half of #1847: the panel quotes the host's new refusals in Chinese, so the reader sees
// why Save did nothing instead of a success line, and no row is added.
test('#1847: the panel shows the Chinese refusal for an empty or over-long memory box', async (t) => {
const rt = await startHost(t)
const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload)
const off = installLocale(fakeLocale('zh'))
try {
// The hook slots outlive a render, so the box starts where the previous test left it; empty it
// explicitly before pressing Save.
let tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
all(tree).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: '' } })
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
byAction(tree, 'project-memory-save').props.onClick()
await settle()
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
let status = all(tree).find(node => node.props?.role === 'status')
assert.equal(text(status), '项目记忆条目需要文字')
assert.ok(!text(status).includes('Memory saved.'), 'a refusal is never painted as a success')

const note = all(tree).find(node => node.props?.id === 'bt-project-note')
note.props.onChange({ target: { value: 'x'.repeat(601) } })
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
byAction(tree, 'project-memory-save').props.onClick()
await settle()
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
status = all(tree).find(node => node.props?.role === 'status')
assert.equal(text(status), '项目记忆条目最多 600 个字符')

const memory = (await call('project-memory', { id: 'project-1' })).memory
assert.deepEqual(memory.decisions, [], 'neither refusal adds a row')
} finally {
off()
}
})

test("the settings panel shows the host's refusal instead of pretending the save landed", async () => {
resetSlots()
const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' })
Expand Down
8 changes: 6 additions & 2 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,12 @@ box reads the caller's own `assistantMine.off` from `project-read` (#1845), neve
and a Save that changes nothing leaves the stored value and the 09:00 task alone. Its memory box sends one of
the two kinds the host knows: the free text is recorded as a `decision`, never as a third kind
`project-memory-write` would refuse, so the success line (`Memory saved.`) is reachable. The status line
quotes a refusal through `hostText`, so a zh reader reads the host's own sentence in Chinese. Tests:
`core/test/project-board-panels.test.js`.
quotes a refusal through `hostText`, so a zh reader reads the host's own sentence in Chinese: since
#1847 the host refuses an empty entry and one over 600 characters at that write door, and `i18n.js`
carries both sentences — the exact key for `A Project memory entry needs text` and the number-carrying
`[/^A Project memory entry is at most (\d+) characters$/, …]` — so a zh box that was empty or over the
cap reads 项目记忆条目需要文字 / 项目记忆条目最多 600 个字符 instead of `Memory saved.`, with no row
added. Tests: `core/test/project-board-panels.test.js`.

The **Members** panel lists every owner and every member the board names, and since #1859 also the
Project's own Chief as its own row (`data-member-role="chief"`), read from `chief` on the `project-read`
Expand Down
Loading
Loading