openvibers/openvibe.codes Commits

Remember each session; the next run reads Previously first (#411)

OP OpenVibers committed 2 days ago · 9cec0d2bb3 · 3 files, +85 −9

Changes

ddiff --git a/server/domain/knowledge.js b/server/domain/knowledge.js
iindex 0ae79be..39fd01e 100644
---- a/server/domain/knowledge.js
++++ b/server/domain/knowledge.js
@@@ -18,7 +18,8 @@
* remember({ … }) add one row (redacted, deduped), or raise the one it repeats
* edit/pin/remove/share/unshare the owner's changes
* captureTask(row) what a finished run taught: its Learned: lines, a failure, a failed command
-- * recall({ subject, projectId, … }) the few rows the next run's prompt gets
++ * captureSummary(row, ending) the session summary: one rewritten row per repository ("Previously")
++ * recall({ subject, projectId, … }) the few rows the next run's prompt gets, with the previous session first
* block(rows) those rows as the prompt's own section
*
* A store that cannot be reached gives no rows, never an error: a run must not fail because memory is unavailable.
@@@ -99,6 +100,23 @@ function dedupeKey({ kind, text, repo }) {
const pathsOf = (paths) => [...new Set((Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean))].slice(0, 12);
++/** One row per person and repository: what the last session there was asked, and how it ended. The fixed key means
++ * every task rewrites the same row instead of adding one (the upsert keeps the first text, so the writer removes
++ * the old row first and remembers the new one). */
++function summaryKey(repo) {
++ return `summary:${repoOf(repo) || 'workspace'}`.slice(0, 200);
++}
++
++/** The one-line session summary. The ending's state in plain words, with its first line of why. */
++function summaryText({ repo, prompt, ending } = {}) {
++ const where = repoOf(repo) || 'this workspace';
++ const asked = clean(String(prompt || '').split(/\r?\n/)[0], 120) || 'a task';
++ const state = String((ending && ending.state) || 'unfinished');
++ const word = { done: 'finished', succeeded: 'finished', failed: 'failed', cancelled: 'cancelled', expired: 'expired', stopped: 'stopped' }[state] || state;
++ const why = ending && (ending.error || ending.note) ? ` — ${clean(String(ending.error || ending.note).split(/\r?\n/)[0], 160)}` : '';
++ return `Previously on ${where}: ${asked} — ${word}${why}.`;
++}
++
/** The paths a text names, the way the portal's lessons store reads them (a small, useful signal). */
function pathsIn(text) {
const value = String(text || '');
@@@ -249,9 +267,11 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
);
}
-- /** Add one row, or raise the one it repeats. → { row, created, redacted }. */
-- async function remember({ subject, kind = 'lesson', text, repo = null, paths = [], sourceTask = null, sourceJob = null, sourceAgent = null, teamId = null, pinned = false, visibility = null } = {}) {
++ /** Add one row, or raise the one it repeats. → { row, created, redacted }. A fixed `key` names the row instead
++ * of the text hash: the same key rewritten is the same row (the session summary), never a second one. */
++ async function remember({ subject, kind = 'lesson', text, repo = null, paths = [], sourceTask = null, sourceJob = null, sourceAgent = null, teamId = null, pinned = false, visibility = null, key = null } = {}) {
const k = KINDS.includes(kind) ? kind : 'lesson';
++ const fixed = key == null ? null : String(key).slice(0, 200).trim() || null;
const cleaned = scrub(clean(text));
if (!cleaned.text || cleaned.text === '[redacted]') {
throw Object.assign(new Error('there is nothing to remember after removing the secrets'), { status: 422, code: 'knowledge.empty' });
@@@ -276,7 +296,7 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
RETURNING *, (xmax = 0) AS created`,
[id, subject, share.team_id, share.visibility, k, cleaned.text, repository, JSON.stringify(allPaths),
sourceTask ? String(sourceTask) : null, sourceJob ? String(sourceJob) : null, sourceAgent ? String(sourceAgent) : null,
-- dedupeKey({ kind: k, text: cleaned.text, repo: repository }), !!pinned, cleaned.redacted, at],
++ fixed || dedupeKey({ kind: k, text: cleaned.text, repo: repository }), !!pinned, cleaned.redacted, at],
);
return { row, created: !!row.created, redacted: cleaned.redacted };
}
@@@ -385,6 +405,27 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
return out;
}
++ /** Write this task as the repository's session summary: what it was asked, how its last attempt ended. */
++ async function captureSummary(row, ending = null) {
++ if (!row || !row.id || !row.subject) return null;
++ const key = summaryKey(row.repo);
++ const text = summaryText({ repo: row.repo, prompt: row.prompt, ending });
++ const agent = (row.result && typeof row.result === 'object' && Array.isArray(row.result.agents) && row.result.agents[0])
++ || row.agent || null;
++ try {
++ await db.exec('DELETE FROM codes_knowledge WHERE subject = $1 AND kind = $2 AND dedupe_key = $3',
++ [row.subject, 'fact', key]);
++ const saved = await remember({
++ subject: row.subject, kind: 'fact', text, repo: row.repo, key,
++ sourceTask: row.id, sourceJob: row.job_id || null, sourceAgent: agent,
++ });
++ return saved.row;
++ } catch (e) {
++ if (!e || e.code !== 'knowledge.empty') log.warn(`[Codes] knowledge summary for ${row.id}: ${String((e && e.message) || e).slice(0, 160)}`);
++ return null;
++ }
++ }
++
/** Rank the rows a next run should see: pinned first, then this repository, then the words it shares with the brief. */
function rank(rows, { repo, prompt } = {}) {
const words = new Set(String(prompt || '').toLowerCase().match(/[a-z0-9_./-]{4,}/g) || []);
@@@ -412,16 +453,23 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
async function recall({ subject, projectId = null, repo = null, prompt = '', limit = 6 } = {}) {
const teams = projectId && String(projectId).startsWith('prj_') ? [projectId] : [];
const rows = await list({ subject, teamIds: teams, limit: 200 });
-- const picked = rank(rows, { repo: repoOf(repo), prompt }).slice(0, Math.max(1, Math.min(8, limit)));
-- return { rows: picked, text: block(picked, { limit: picked.length }) };
++ // The session summary stands apart from the ranked rows: it is the last session, not a lesson, and the next
++ // run reads it first. It is keyed by repository, so a summary from another repository never leaks in.
++ const key = summaryKey(repo);
++ const previous = rows.find((r) => r.kind === 'fact' && r.dedupe_key === key) || null;
++ const rest = previous ? rows.filter((r) => r !== previous) : rows;
++ const picked = rank(rest, { repo: repoOf(repo), prompt }).slice(0, Math.max(1, Math.min(8, limit)));
++ return { rows: picked, previous, previousText: previous ? previous.text : '', text: block(picked, { limit: picked.length }) };
}
return {
-- projectsOf, teamIdsOf, list, get, remember, edit, pin, remove, share, unshare, captureTask, recall, rank,
++ projectsOf, teamIdsOf, list, get, remember, edit, pin, remove, share, unshare, captureTask, captureSummary,
++ summaryKey, summaryText, recall, rank,
block, present: (row, opts) => present(row, opts), scrub, repoOf, learnedLines, factLines, KINDS,
};
}
module.exports = {
createKnowledge, block, scrub, repoOf, learnedLines, factLines, dedupeKey, pathsIn, present, KINDS, MAX_TEXT,
++ summaryKey, summaryText,
};
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 430a97c..a8fa357 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -632,7 +632,8 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (knowledge && runArgs.repo && runArgs.repo !== 'bench://') {
try {
const recalled = await knowledge.recall({ subject, projectId: project, repo: runArgs.repo, prompt: runArgs.prompt });
-- const text = knowledge.block(recalled.rows);
++ // Previously first: what the last session on this repository was asked, and how it ended.
++ const text = [recalled.previousText, knowledge.block(recalled.rows)].filter((t) => t && t.trim()).join('\n\n');
if (text) runArgs.knowledge = text;
} catch (e) {
log.warn(`[Codes] task ${id}: knowledge could not be recalled: ${String((e && e.message) || e).slice(0, 200)}`);
@@@ -926,7 +927,10 @@ ${String(verdict.check_output || '').slice(0, 50000)}`;
// repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is
// idempotent per (subject, kind, text), so a redelivered ending adds no second row.
if (knowledge) {
-- try { await knowledge.captureTask(row); } catch (e) {
++ try {
++ await knowledge.captureTask(row);
++ await knowledge.captureSummary(row, ending);
++ } catch (e) {
log.warn(`[Codes] task ${row.id}: knowledge could not be captured: ${String((e && e.message) || e).slice(0, 200)}`);
}
}
ddiff --git a/test/knowledge.test.js b/test/knowledge.test.js
iindex ffe6ad4..3bf8324 100644
---- a/test/knowledge.test.js
++++ b/test/knowledge.test.js
@@@ -242,6 +242,30 @@ function fakeHarness() {
assert.strictEqual(anon.status, 401);
});
++ await check('every task rewrites one session summary, and the next run reads it first', async () => {
++ const { summaryKey, summaryText } = require('../server/domain/knowledge');
++ assert.strictEqual(summaryKey(REPO), 'summary:github.com/OpenVibers/OpenVibe.Codes');
++ assert.match(summaryText({ repo: REPO, prompt: 'Fix the footer.\nSecond line.', ending: { state: 'succeeded' } }),
++ /^Previously on github\.com\/OpenVibers\/OpenVibe\.Codes: Fix the footer\. — finished\./);
++ const summaries = async () => (await knowledgeOf(ada.subject)).filter((r) => r.kind === 'fact' && /^Previously on /.test(r.text));
++ const third = await startTask('Bump the footer year.');
++ await end(third.id, 'succeeded', { ok: true, summary: 'Done.', agents: ['claude-code'], log: [] });
++ assert.strictEqual((await summaries()).length, 1, 'one summary row, rewritten not added');
++ assert.match((await summaries())[0].text, /Bump the footer year.*finished/);
++ const fourth = await startTask('Fix the header link.');
++ await end(fourth.id, 'failed', { ok: false, summary: 'Could not.', agents: ['codex'], log: [] }, 'the tests failed');
++ const again = await summaries();
++ assert.strictEqual(again.length, 1, 'the second task rewrote the same row');
++ assert.match(again[0].text, /Fix the header link.*failed/);
++ assert.strictEqual(again[0].source_task, fourth.id, 'the row names its latest run');
++ const fifth = await startTask('Polish the footer.');
++ assert.match(calls.at(-1).args.knowledge, /^Previously on .*Fix the header link.*failed/, 'the next run reads the last session first');
++ await end(fifth.id, 'succeeded', { ok: true, summary: 'Done.', agents: ['claude-code'], log: [] });
++ const other = await t.ctx.knowledge.recall({ subject: ada.subject, repo: 'https://github.com/OpenVibers/OpenVibe.Bot', prompt: '' });
++ assert.strictEqual(other.previous, null, 'another repository has no summary');
++ assert.strictEqual(other.previousText, '');
++ });
++
await check('the person’s knowledge is exported with the account and erased with it', async () => {
const { TABLES } = require('../server/account-data');
assert.ok(TABLES.some((x) => x.table === 'codes_knowledge' && x.subject === 'subject'), 'the table is covered');