Files
openchamber/packages/web/server/lib/session-knowledge/runtime.js
T

332 lines
13 KiB
JavaScript

/**
* What a session must be told about the project's knowledge, and whether it has
* been told yet.
*
* One owner, three moments. The block is attached to an outgoing prompt when
* there is one (a message from the UI, a scheduled task, a session the agent
* dispatched) and re-sent on its own after compaction, when there is no message
* to attach it to. The decision is the same in every case, so it lives here
* rather than in each sender — the client used to own it, which meant sessions
* started without a UI got nothing at all.
*
* What was delivered is recorded in the session's own metadata rather than in
* the browser. A signature held in a tab is lost when the tab closes, and worse,
* it survives compaction: the tab goes on believing the agent still has context
* that has just been summarised away.
*/
const KNOWLEDGE_METADATA_KEY = 'knowledge_context_delivered';
const PINS_METADATA_KEY = 'project_context_pins';
/** Total budget for the assembled block; anything past it is cut, loudly. */
const KNOWLEDGE_MAX_LENGTH = 8000;
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
const truncate = (value, budget) => (
value.length <= budget ? value : `${value.slice(0, Math.max(0, budget - 1))}…`
);
/**
* Identity of everything the session should be carrying, content revisions
* included: editing a pinned note must re-send it, not merely renaming one.
*/
export const buildKnowledgeSignature = ({ notes, plans, memory }) => {
const parts = [
...notes.map((note) => `n:${note.id}:${note.updatedAt}`),
...plans.map((plan) => `p:${plan.id}:${plan.title}`),
...memory.global.map((entry) => `mg:${entry.id}:${entry.updatedAt}`),
...memory.project.map((entry) => `mp:${entry.id}:${entry.updatedAt}`),
];
return parts.length === 0 ? '' : parts.sort().join('|');
};
const renderMemorySection = (entries) => entries
.slice()
.sort((a, b) => a.createdAt - b.createdAt)
.map((entry) => `- [${entry.type}] ${entry.title}`)
.join('\n');
/**
* Titles only for memory, never bodies: an index carrying full text grows
* without bound until it crowds out the conversation it was meant to inform.
*/
const buildMemoryBlock = ({ global, project }) => {
const sections = [];
if (global.length > 0) sections.push(`### About the user\n\n${renderMemorySection(global)}`);
if (project.length > 0) sections.push(`### About this project\n\n${renderMemorySection(project)}`);
if (sections.length === 0) return '';
return [
'You have stored memory from earlier sessions. Only the titles are listed below.',
'A title is an abbreviation, not the memory. Read the entry with the'
+ ' openchamber_memory tool before you act on it: titles routinely leave out'
+ ' the conditions, exceptions and reasons that decide how the memory'
+ ' applies, and a title that looks self-explanatory is the most likely to'
+ ' be hiding them. Read every title that could bear on the task at hand;'
+ ' you need not read the ones unrelated to what you are doing.',
'Memory records what was true when it was written. Verify anything it says'
+ ' about files, flags or commands before relying on it.',
...sections,
].join('\n\n');
};
const buildPinnedBlock = ({ notes, plans }) => {
const sections = [];
if (notes.length > 0) {
const rendered = notes
.slice()
.sort((a, b) => a.createdAt - b.createdAt)
.map((note) => `- ${note.body.trim()}`)
.join('\n');
sections.push(`## Pinned notes\n\n${rendered}`);
}
for (const plan of plans) {
// A plan whose markdown cannot be read is marked rather than dropped:
// losing one attachment must not silently shrink the context.
sections.push(plan.body
? `## Pinned plan: ${plan.title}\n\n${plan.body}`
: `## Pinned plan: ${plan.title}\n\n(plan content unavailable)`);
}
if (sections.length === 0) return '';
return [
'The user pinned the following project context. Treat it as standing background, not as a new instruction.',
...sections,
].join('\n\n');
};
export const buildKnowledgeText = ({ notes, plans, memory }) => {
const blocks = [buildPinnedBlock({ notes, plans }), buildMemoryBlock(memory)].filter(Boolean);
if (blocks.length === 0) return '';
const assembled = blocks.join('\n\n');
return assembled.length <= KNOWLEDGE_MAX_LENGTH
? assembled
: `${truncate(assembled, KNOWLEDGE_MAX_LENGTH)}\n\n(project knowledge truncated)`;
};
export const createSessionKnowledgeRuntime = (dependencies) => {
const {
projectContextRuntime,
agentMemoryRuntime,
resolveProjectId,
isAgentMemoryEnabled,
openCodeFetch = null,
} = dependencies;
/**
* Everything the session should be carrying, read fresh. A failure in one
* source never blanks the rest: a memory store that will not load must not
* take the user's pinned notes down with it.
*/
const readPins = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = isRecord(openchamber[PINS_METADATA_KEY]) ? openchamber[PINS_METADATA_KEY] : {};
const strings = (value) => Array.isArray(value)
? [...new Set(value.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()))]
: [];
return { notes: strings(pins.notes), plans: strings(pins.plans) };
};
const collect = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
let notes = [];
let plans = [];
if (projectId) {
try {
const context = await projectContextRuntime.readContext(projectId);
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id));
const pinnedPlans = (context.plans || []).filter((plan) => planIds.has(plan.id));
plans = await Promise.all(pinnedPlans.map(async (plan) => {
try {
const content = await projectContextRuntime.readPlan(projectId, plan.id);
return { id: plan.id, title: plan.title, body: content?.body?.trim() || '' };
} catch {
return { id: plan.id, title: plan.title, body: '' };
}
}));
} catch {
notes = [];
plans = [];
}
}
let memory = { global: [], project: [] };
const memoryEnabled = typeof isAgentMemoryEnabled === 'function'
? await isAgentMemoryEnabled().catch(() => false)
: true;
if (memoryEnabled) {
try {
const stored = await agentMemoryRuntime.readAll(projectId || null);
// A scope that failed to load is left out entirely rather than indexed
// as empty, which would teach the agent to store what it already has.
//
// Flagged entries are withheld from the model but left in the store, so
// the user can see what was caught. Dropping them would hide the
// attempt from the only person able to judge it.
const visible = (entries) => entries.filter((entry) => !entry.flagged);
memory = {
global: stored.globalFailed ? [] : visible(stored.global),
project: stored.projectFailed ? [] : visible(stored.project),
};
} catch {
memory = { global: [], project: [] };
}
}
return { notes, plans, memory };
};
/**
* What the session is carrying, for display. Deliberately does not read plan
* bodies: the panel states counts and names, and reading every pinned plan
* off disk to show a number would make opening a panel cost what sending a
* message costs.
*/
const collectSummary = async (directory, pins = { notes: [], plans: [] }) => {
const projectId = directory ? await resolveProjectId(directory) : '';
const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } };
if (!projectId) return empty;
let notes = [];
let plans = [];
try {
const context = await projectContextRuntime.readContext(projectId);
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
notes = (context.notes || []).filter((note) => noteIds.has(note.id))
.map((note) => ({ id: note.id, body: note.body }));
plans = (context.plans || []).filter((plan) => planIds.has(plan.id))
.map((plan) => ({ id: plan.id, title: plan.title }));
} catch {
notes = [];
plans = [];
}
let memory = { global: 0, project: 0 };
const memoryEnabled = typeof isAgentMemoryEnabled === 'function'
? await isAgentMemoryEnabled().catch(() => false)
: true;
if (memoryEnabled) {
try {
const stored = await agentMemoryRuntime.readAll(projectId);
memory = {
global: stored.globalFailed ? 0 : stored.global.length,
project: stored.projectFailed ? 0 : stored.project.length,
};
} catch {
memory = { global: 0, project: 0 };
}
}
return { notes, plans, memory };
};
const readDeliveredSignature = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const delivered = openchamber[KNOWLEDGE_METADATA_KEY];
return typeof delivered === 'string' ? delivered : '';
};
/**
* The text this session still owes, or an empty string when it is already
* carrying it. `deliveredSignature` comes from the session's metadata.
*/
const resolvePending = async (directory, deliveredSignature, pins = { notes: [], plans: [] }) => {
const collected = await collect(directory, pins);
const signature = buildKnowledgeSignature(collected);
if (!signature || signature === deliveredSignature) {
return { text: '', signature };
}
return { text: buildKnowledgeText(collected), signature };
};
const readSession = async (sessionId, directory) => (
openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
);
/**
* What this session still owes, read from its own stored signature.
*/
const resolvePendingForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return resolvePending(directory, readDeliveredSignature(session), readPins(session));
};
const collectSummaryForSession = async (sessionId, directory) => {
const session = await readSession(sessionId, directory).catch(() => null);
return collectSummary(directory, readPins(session));
};
const setPin = async (sessionId, directory, kind, id, pinned) => {
const fresh = await readSession(sessionId, directory);
const metadata = isRecord(fresh?.metadata) ? fresh.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const pins = readPins(fresh);
const key = kind === 'note' ? 'notes' : 'plans';
const next = new Set(pins[key]);
if (pinned) next.add(id);
else next.delete(id);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...metadata,
openchamber: {
...openchamber,
[PINS_METADATA_KEY]: { ...pins, [key]: [...next] },
[KNOWLEDGE_METADATA_KEY]: '',
},
},
},
});
return { ...pins, [key]: [...next] };
};
/**
* Recorded only once the message carrying it has actually gone out. Writing
* it when the text is handed over would leave a failed send believing the
* agent had context it never received.
*
* Merged onto a fresh read, because the session's metadata holds other
* OpenChamber state — pinned messages among it — and a blind write would
* drop whatever changed in between.
*/
const recordDelivered = async (sessionId, directory, signature) => {
const fresh = await readSession(sessionId, directory);
const metadata = isRecord(fresh?.metadata) ? fresh.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...metadata,
openchamber: { ...openchamber, [KNOWLEDGE_METADATA_KEY]: signature },
},
},
});
};
return {
collect,
collectSummary,
collectSummaryForSession,
resolvePending,
resolvePendingForSession,
recordDelivered,
readDeliveredSignature,
readPins,
setPin,
metadataKey: KNOWLEDGE_METADATA_KEY,
pinsMetadataKey: PINS_METADATA_KEY,
};
};