feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)
The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
This commit is contained in:
committed by
GitHub
parent
7611076436
commit
34e8a24b20
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAgentToolAction } from './actions.js';
|
||||
|
||||
/**
|
||||
* Both cases here are from one real conversation: the model called `read` and
|
||||
* then `get` on `openchamber_memory`, having dropped the namespace its own tool
|
||||
* name appeared to supply, and gave up after the second bare "unsupported".
|
||||
*/
|
||||
describe('a namespace the tool name already implies', () => {
|
||||
test('resolves a bare action inside the calling tool', () => {
|
||||
expect(resolveAgentToolAction('read', 'openchamber_memory')).toEqual({ action: 'memory.read' });
|
||||
expect(resolveAgentToolAction('save', 'openchamber_memory')).toEqual({ action: 'memory.save' });
|
||||
});
|
||||
|
||||
test('resolves a bare name that is ambiguous only across tools', () => {
|
||||
// `delete` belongs to schedule and to memory; inside one tool it is plain.
|
||||
expect(resolveAgentToolAction('delete', 'openchamber_memory')).toEqual({ action: 'memory.delete' });
|
||||
expect(resolveAgentToolAction('delete', 'openchamber')).toEqual({ action: 'schedule.delete' });
|
||||
});
|
||||
|
||||
test('keeps a fully qualified action as it is', () => {
|
||||
expect(resolveAgentToolAction('memory.read', 'openchamber_memory')).toEqual({ action: 'memory.read' });
|
||||
});
|
||||
|
||||
test('does not reach outside the tool that asked', () => {
|
||||
// The memory tool asking for `open` must fail, not drive the browser.
|
||||
expect(resolveAgentToolAction('open', 'openchamber_memory').action).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unidentified caller', () => {
|
||||
test('still resolves a bare name that means one thing everywhere', () => {
|
||||
expect(resolveAgentToolAction('snapshot', null)).toEqual({ action: 'browser.snapshot' });
|
||||
});
|
||||
|
||||
test('refuses a bare name that several actions share', () => {
|
||||
expect(resolveAgentToolAction('list', null).action).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what an unresolvable action reports', () => {
|
||||
test('names the actions the calling tool actually has', () => {
|
||||
const { error } = resolveAgentToolAction('get', 'openchamber_memory');
|
||||
|
||||
expect(error).toContain('memory.read');
|
||||
expect(error).toContain('memory.save');
|
||||
// Listing every action of every tool would bury the four that apply.
|
||||
expect(error).not.toContain('browser.open');
|
||||
});
|
||||
|
||||
test('reports a missing action rather than resolving to something', () => {
|
||||
const { error, action } = resolveAgentToolAction('', 'openchamber_memory');
|
||||
|
||||
expect(action).toBeUndefined();
|
||||
expect(error).toContain('missing');
|
||||
});
|
||||
|
||||
test('an unknown tool falls back to the full action list', () => {
|
||||
const { error } = resolveAgentToolAction('nonsense', 'openchamber_future');
|
||||
|
||||
expect(error).toContain('memory.read');
|
||||
expect(error).toContain('browser.open');
|
||||
});
|
||||
});
|
||||
@@ -53,8 +53,86 @@ export const OPENCHAMBER_WEB_ACTIONS = Object.freeze(
|
||||
OPENCHAMBER_WEB_ACTION_DEFINITIONS.map(({ action }) => action),
|
||||
);
|
||||
|
||||
/**
|
||||
* Memory is its own tool for the same reason web is: remembering across
|
||||
* sessions is a distinct intent from controlling one, and a shared description
|
||||
* would blur both. It also has to switch off cleanly and completely, which a
|
||||
* shared schema cannot do.
|
||||
*
|
||||
* The session already carries an index of stored titles, so the descriptions
|
||||
* push the model toward reading one entry it can already see rather than
|
||||
* listing everything again — and toward reading it at all, since a title that
|
||||
* reads as a complete fact is exactly the one whose conditions get lost.
|
||||
*/
|
||||
export const OPENCHAMBER_MEMORY_ACTION_DEFINITIONS = Object.freeze([
|
||||
{ action: 'memory.read', title: 'Read a stored memory', description: 'Read the full text of one memory listed in the session index. The index shows titles only, and a title omits the conditions that decide how the memory applies, so read before acting rather than working from the title. Requires title (as the index spells it) or memoryId; scope is optional and both stores are searched without it' },
|
||||
{ action: 'memory.list', title: 'List stored memories', description: 'List stored memory titles when the session index is missing or stale; scope is global, project, or both (default)' },
|
||||
{ action: 'memory.save', title: 'Remember something', description: 'Store a durable fact, preference, or reference; requires title and body, plus scope global (about the user) or project (about this codebase). Restating something already stored updates it. Do not store secrets, one-off task state, or anything the user asked you not to keep' },
|
||||
{ action: 'memory.delete', title: 'Forget a memory', description: 'Delete a memory that turned out to be wrong or obsolete; requires memoryId and scope' },
|
||||
]);
|
||||
|
||||
export const OPENCHAMBER_MEMORY_ACTIONS = Object.freeze(
|
||||
OPENCHAMBER_MEMORY_ACTION_DEFINITIONS.map(({ action }) => action),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which actions each managed tool may ask for.
|
||||
*
|
||||
* The callback needs this because models routinely drop the namespace: asked
|
||||
* for `memory.read` from a tool already called `openchamber_memory`, they send
|
||||
* `read`, since the tool's own name appears to have said "memory" already. The
|
||||
* name is unambiguous inside one tool's action set even when it is not across
|
||||
* all of them (`delete` belongs to both schedule and memory), so resolution
|
||||
* starts from the tool that asked.
|
||||
*/
|
||||
const ACTIONS_BY_TOOL = Object.freeze({
|
||||
openchamber: OPENCHAMBER_AGENT_TOOL_ACTIONS,
|
||||
openchamber_web: OPENCHAMBER_WEB_ACTIONS,
|
||||
openchamber_memory: OPENCHAMBER_MEMORY_ACTIONS,
|
||||
});
|
||||
|
||||
const bareName = (action) => {
|
||||
const separator = action.indexOf('.');
|
||||
return separator === -1 ? action : action.slice(separator + 1);
|
||||
};
|
||||
|
||||
const uniqueMatch = (candidates, requested) => {
|
||||
const matches = candidates.filter((candidate) => bareName(candidate) === requested);
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The canonical action for what a tool asked, or the reason it could not be
|
||||
* resolved. The reason lists what the tool can actually do: an error that only
|
||||
* says "unsupported" leaves the model to guess again, which is how one wrong
|
||||
* name becomes three.
|
||||
*/
|
||||
export const resolveAgentToolAction = (requested, toolName) => {
|
||||
const value = typeof requested === 'string' ? requested.trim() : '';
|
||||
const scoped = ACTIONS_BY_TOOL[toolName] ?? null;
|
||||
const known = scoped ?? OPENCHAMBER_ALL_ACTIONS;
|
||||
|
||||
if (value && known.includes(value)) {
|
||||
return { action: value };
|
||||
}
|
||||
if (value) {
|
||||
const resolved = uniqueMatch(known, value)
|
||||
// A tool that did not identify itself still gets the benefit when the
|
||||
// bare name means only one thing across every action.
|
||||
?? (scoped ? null : uniqueMatch(OPENCHAMBER_ALL_ACTIONS, value));
|
||||
if (resolved) {
|
||||
return { action: resolved };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Unsupported OpenChamber action: ${value || 'missing'}. Use one of: ${known.join(', ')}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Everything the callback route will dispatch, whichever tool asked. */
|
||||
export const OPENCHAMBER_ALL_ACTIONS = Object.freeze([
|
||||
...OPENCHAMBER_CONTROL_ACTIONS,
|
||||
...OPENCHAMBER_WEB_ACTIONS,
|
||||
...OPENCHAMBER_MEMORY_ACTIONS,
|
||||
]);
|
||||
|
||||
@@ -144,6 +144,7 @@ export const createOpenChamberControlService = (dependencies) => {
|
||||
sessionService,
|
||||
scheduledTaskService,
|
||||
browserControl = null,
|
||||
agentMemoryActions = null,
|
||||
createClient = createOpencodeClient,
|
||||
sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)),
|
||||
now = Date.now,
|
||||
@@ -458,6 +459,12 @@ export const createOpenChamberControlService = (dependencies) => {
|
||||
if (!CONTROL_ACTIONS.has(action)) {
|
||||
throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400);
|
||||
}
|
||||
if (action.startsWith('memory.')) {
|
||||
if (!agentMemoryActions) {
|
||||
throw new OpenChamberControlError('Agent memory is not available on this server', 503);
|
||||
}
|
||||
return agentMemoryActions.execute(action, input, contextDirectory);
|
||||
}
|
||||
if (action.startsWith('browser.')) {
|
||||
if (!browserControl) {
|
||||
throw new OpenChamberControlError('The in-app browser is not available on this server', 503);
|
||||
|
||||
Reference in New Issue
Block a user