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/2101-memory-blocked-empty.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The memory panel can now tell a folder it could not read from a Bot that has never saved anything: `memory-view` carries a `blocked` entry (scope and the folder that failed) for each memory scope the host refused, and the panel prints one line naming that folder above its empty title instead of telling the user to save something that cannot be written. A folder that is merely empty, or not created yet, still renders exactly the copy it rendered before.
11 changes: 11 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,7 @@ var ZH = {
"The summary could not be written: {reason}": "摘要没能生成:{reason}",
"{name} has not saved anything yet": "{name} 还没有记住任何内容",
"Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.": "Bot 会记住长期的偏好、做过的决定,以及你让它记住的事。可以在下面告诉 {name},也可以在对话里说。",
"The memory folder {path} is not a plain directory, so it cannot be read.": "记忆文件夹 {path} 不是普通目录,里面的内容读不到。",
"Team memory": "团队记忆",
"Every Bot reads it": "所有 Bot 都会读",
"MEMORY.md, which every conversation reads": "MEMORY.md,每次对话都会读",
Expand Down Expand Up @@ -13954,6 +13955,14 @@ function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow:
);
}

// src/client/memory-empty.js
function memoryEmptyNotice(view) {
if ((view?.entries?.length ?? 0) > 0) return null;
const paths = [...new Set((view?.blocked ?? []).map((item) => typeof item?.path === "string" ? item.path : "").filter((path) => path !== ""))];
if (paths.length === 0) return null;
return t("The memory folder {path} is not a plain directory, so it cannot be read.", { path: paths.join(", ") });
}

// src/client/memory-panel.js
var import_react28 = require("react");
var POLL_MS5 = 1500;
Expand Down Expand Up @@ -14026,10 +14035,12 @@ function Skeleton({ label }) {
}
function Summary({ bot, view, regenerate }) {
if (view.entries.length === 0) {
const notice = memoryEmptyNotice(view);
return (0, import_react28.createElement)(
"div",
{ className: "bt-mem-empty" },
(0, import_react28.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react28.createElement)(MemoryIcon)),
notice === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, notice),
(0, import_react28.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
(0, import_react28.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
);
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ const ZH = {
'The summary could not be written: {reason}': '摘要没能生成:{reason}',
'{name} has not saved anything yet': '{name} 还没有记住任何内容',
'Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.': 'Bot 会记住长期的偏好、做过的决定,以及你让它记住的事。可以在下面告诉 {name},也可以在对话里说。',
'The memory folder {path} is not a plain directory, so it cannot be read.': '记忆文件夹 {path} 不是普通目录,里面的内容读不到。',
'Team memory': '团队记忆',
'Every Bot reads it': '所有 Bot 都会读',
'MEMORY.md, which every conversation reads': 'MEMORY.md,每次对话都会读',
Expand Down
15 changes: 15 additions & 0 deletions core/src/client/memory-empty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// The empty state of the memory panel has two causes the view used to spell the same way (#2101): a
// folder with nothing in it yet, and a folder the host refused to read. `entries` is `[]` in both,
// so the view carries `blocked` — one `{ scope, path }` per refused scope — and this turns it into
// one line above the empty title, or `null` for the ordinary empty view. Pure, so it is tested
// without a renderer, like `bot-row-title` and the board rows.
import { t } from './i18n.js'

export function memoryEmptyNotice(view) {
if ((view?.entries?.length ?? 0) > 0) return null
const paths = [...new Set((view?.blocked ?? [])
.map(item => (typeof item?.path === 'string' ? item.path : ''))
.filter(path => path !== ''))]
if (paths.length === 0) return null
return t('The memory folder {path} is not a plain directory, so it cannot be read.', { path: paths.join(', ') })
}
3 changes: 3 additions & 0 deletions core/src/client/memory-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createElement as h, Fragment, useCallback, useEffect, useRef, useState
import { useEscape } from './overlay-hooks.js'
import { ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CloseIcon, DotsIcon, MemoryIcon, TrashIcon } from './icons.js'
import { dateLocale, hostText, t } from './i18n.js'
import { memoryEmptyNotice } from './memory-empty.js'
import { summaryBlocks } from './text.js'
import { useLinger } from './motion.js'

Expand Down Expand Up @@ -66,8 +67,10 @@ function Skeleton({ label }) {

function Summary({ bot, view, regenerate }) {
if (view.entries.length === 0) {
const notice = memoryEmptyNotice(view)
return h('div', { className: 'bt-mem-empty' },
h('div', { className: 'bt-mem-empty-mark' }, h(MemoryIcon)),
notice === null ? null : h('div', { className: 'bt-mem-empty-text', role: 'status' }, notice),
h('div', { className: 'bt-mem-empty-title' }, t('{name} has not saved anything yet', { name: bot.name })),
h('div', { className: 'bt-mem-empty-text' }, t('Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.', { name: bot.name })))
}
Expand Down
9 changes: 9 additions & 0 deletions core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,14 @@ export function install(rt) {
const dir = dirOf(scope)
return readFolder(dir) ? dir : undefined
}
// A refused folder answers exactly what a missing one answers, so the panel cannot tell "nothing
// was ever saved" from "this home could not be read" (#2101). The view says which scopes were
// refused and which folder failed; only `other` counts, because `absent` is the ordinary empty
// home a first-run Bot has.
const blockedScopes = (scopes) => scopes.flatMap((scope) => {
const { kind, at } = folderKind(dirOf(scope))
return kind === 'other' ? [{ scope: scope.kind === 'team' ? 'team' : 'own', path: at }] : []
})

const readText = (path) => {
try {
Expand Down Expand Up @@ -1183,6 +1191,7 @@ export function install(rt) {
limits: MEMORY_LIMITS,
summary: entries.length > 0 && saved !== undefined ? { text: saved.text, at: saved.at } : null,
fresh: entries.length === 0 || saved?.hash === hash,
blocked: blockedScopes([ownScope(bot.id), TEAM]),
summarizing: summarizing.has(bot.id),
error: failed !== undefined && !summarizing.has(bot.id) ? failed.message : null,
}
Expand Down
27 changes: 27 additions & 0 deletions core/test/memory-empty.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { memoryEmptyNotice } from '../src/client/memory-empty.js'

// The suite has no React renderer, so the empty state's decision is a pure helper: it says nothing
// for an ordinary empty view, and names the folder for a view whose scopes were refused (#2101).
test('#2101: a blocked empty view says which folder could not be read', () => {
const view = { entries: [], blocked: [{ scope: 'own', path: '/home/u/.flybot/memory' }] }
assert.equal(memoryEmptyNotice(view),
'The memory folder /home/u/.flybot/memory is not a plain directory, so it cannot be read.')
})

test('#2101: an ordinary empty view adds nothing', () => {
assert.equal(memoryEmptyNotice({ entries: [], blocked: [] }), null)
assert.equal(memoryEmptyNotice({ entries: [] }), null, 'a view from a host that reports no blocked scopes')
assert.equal(memoryEmptyNotice({}), null)
assert.equal(memoryEmptyNotice(undefined), null)
})

test('#2101: one broken folder behind both scopes is named once', () => {
const view = { entries: [], blocked: [{ scope: 'own', path: '/p' }, { scope: 'team', path: '/p' }] }
assert.equal(memoryEmptyNotice(view), 'The memory folder /p is not a plain directory, so it cannot be read.')
})

test('#2101: entries are not the empty state, however the folder was read', () => {
assert.equal(memoryEmptyNotice({ entries: [{ text: 'x' }], blocked: [{ scope: 'team', path: '/p' }] }), null)
})
41 changes: 41 additions & 0 deletions core/test/memory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,46 @@ test('#2068: a linked memory folder does not hand a snapshot to the prompt', asy
assert.ok(warned[0].includes(join(home, 'memory')), `the warning names the folder: ${warned[0]}`)
})

// #2101: a refused folder and a folder that was never written answer the same `entries: []`, so the
// view now carries the scopes it could not read; without it the panel asks the user to save
// something that cannot be saved.
test('#2068: a refused memory folder is named on the memory view, not read as nothing saved (#2101)', async (t) => {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-'))
const outside = mkdtempSync(join(tmpdir(), 'ds-bot-memory-outside-'))
mkdirSync(join(outside, 'bots', 'b2'), { recursive: true })
writeFileSync(join(outside, 'bots', 'b2', 'MEMORY.md'), '# Memory\n\n- an entry from outside the home\n')
t.after(() => rmSync(outside, { recursive: true, force: true }))
symlinkSync(outside, join(home, 'memory'), 'dir')
const folderWarnings = captureWarnings(t)
const { call, stop } = start({ home })
t.after(() => stop())
const view = (await call('memory-view', { botId: 'b2' })).value
assert.deepEqual(view.entries, [], 'a refused folder still reads as no entries')
assert.deepEqual(view.blocked, [
{ scope: 'own', path: join(home, 'memory') },
{ scope: 'team', path: join(home, 'memory') },
], 'both scopes name the folder that failed, which is the link itself')
assert.equal(view.fresh, true)
assert.equal(folderWarnings().length, 1, 'one warning per folder and process, as before')
})

test('#2101: a plain or missing memory folder blocks nothing', async (t) => {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-'))
mkdirSync(join(home, 'memory', 'bots', 'b2'), { recursive: true })
mkdirSync(join(home, 'memory', 'team'), { recursive: true })
const plain = start({ home })
t.after(() => plain.stop())
const empty = (await plain.call('memory-view', { botId: 'b2' })).value
assert.deepEqual(empty.entries, [])
assert.deepEqual(empty.blocked, [], 'an empty folder is not a refused one')
assert.equal(empty.fresh, true)
// A home whose `memory/` was never created is the ordinary first-run state, not a refusal.
const fresh = start()
t.after(() => fresh.stop())
const absent = (await fresh.call('memory-view', { botId: 'b2' })).value
assert.deepEqual(absent.blocked, [])
})

test('#2068: a linked summaries folder is not a saved summary', async (t) => {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-'))
const outside = mkdtempSync(join(tmpdir(), 'ds-bot-memory-outside-'))
Expand Down Expand Up @@ -1743,6 +1783,7 @@ const MEMORY_PROBES = {
readFolder: 'a linked bots folder reads nothing for a Bot, and says so once',
writeFolder: 'a linked team folder refuses an entry write instead of writing into it',
scopeDir: 'a linked bots folder reads nothing for a Bot, and says so once',
blockedScopes: 'a refused memory folder is named on the memory view, not read as nothing saved (#2101)',
loadScope: 'a linked bots folder reads nothing for a Bot, and says so once',
readMainOf: 'a linked bots folder reads nothing for a Bot, and says so once',
writable: 'a linked team folder refuses an entry write instead of writing into it',
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl
| --- | --- |
| `core/src/client/` | Client sources → build to `core/client.js`. |
| `core/src/client/busy-ack.js` / `busy-ack-logic.js` | Busy-send spoken acknowledgment (#1584): `BusyAckBubble` draws one short bot bubble while a human send is accepted during a running turn (host `busyAcks` or local queue/steer pending). Pure helpers in `busy-ack-logic.js`. Tests: `core/test/busy-ack-client.test.js`. |
| `core/src/client/memory-empty.js` | The memory dialog's empty state (#2101), decided by a pure helper because the suite has no renderer: `memoryEmptyNotice(view)` answers one line naming the folder when `entries` is empty and the host reported refused scopes in `view.blocked`, and `null` for the ordinary empty view, so `memory-panel.js` draws the notice above `{name} has not saved anything yet` only when the folder could not be read. A refused folder and a fresh Bot both answer `entries: []`; the host side of that field is in [core-host](core-host.md). Tests: `core/test/memory-empty.test.js`. |
| `core/src/client/exchange.js` | The Bot-exchange sheet: `ExchangeDialog` polls the stored turns between two Bots and `ExchangeLine` draws one. A stored line is read once, by `exchangeText`: a leading `Chief → 助手` delegation trace and an `<untrusted-message …>` fence are routing, not words, so the bubble and the message tools' **Copy** receive the same cleaned string. Unlike a sidebar row it is not folded to one line — a reply's paragraph breaks are the writer's own and stay exactly as stored. Tests: `core/test/exchange-read.test.js`. |
| `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. |
| `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). |
Expand Down
Loading
Loading