openvibers/openvibe.codes Commits

Recheck Codes knowledge against task transcripts nightly (#414)

OP OpenVibers committed 2 days ago · 632ac12f0f · 5 files, +108 −14

* Recheck Codes knowledge against task transcripts nightly

* Keep knowledge dream replay idempotent across tasks

* Skip transcript checks when no task result remains

* Show why dream-suppressed knowledge needs review

Changes

ddiff --git a/migrations/0040_knowledge_dream.sql b/migrations/0040_knowledge_dream.sql
nnew file mode 100644
iindex 0000000..2d3f81b
---- /dev/null
++++ b/migrations/0040_knowledge_dream.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- The nightly dream pass rechecks auto-captured knowledge against its source task's saved transcript.
++-- Unsupported rows remain visible to their owner but are omitted from the next agent's prompt.
++ALTER TABLE codes_knowledge ADD COLUMN IF NOT EXISTS dream_status text COLLATE "C" NOT NULL DEFAULT 'active'
++ CHECK (dream_status IN ('active', 'unsupported'));
++
++CREATE TABLE IF NOT EXISTS codes_knowledge_dream_state (
++ name text COLLATE "C" PRIMARY KEY,
++ last_at bigint NOT NULL,
++ cursor text COLLATE "C" NOT NULL DEFAULT ''
++);
ddiff --git a/server/domain/knowledge.js b/server/domain/knowledge.js
iindex 39fd01e..6db6baa 100644
---- a/server/domain/knowledge.js
++++ b/server/domain/knowledge.js
@@@ -153,6 +153,7 @@ function present(row, { subject } = {}) {
source: row.source_task ? { task: row.source_task, job: row.source_job || null, agent: row.source_agent || null } : null,
seen: Number(row.seen) || 1,
redacted: !!row.redacted,
++ dream_status: row.dream_status || 'active',
created_at: iso(row.created_at),
updated_at: iso(row.updated_at),
last_seen_at: iso(row.last_seen_at),
@@@ -269,7 +270,7 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
/** 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 } = {}) {
++ async function remember({ subject, kind = 'lesson', text, repo = null, paths = [], sourceTask = null, sourceJob = null, sourceAgent = null, teamId = null, pinned = false, visibility = null, key = null, replay = false } = {}) {
const k = KINDS.includes(kind) ? kind : 'lesson';
const fixed = key == null ? null : String(key).slice(0, 200).trim() || null;
const cleaned = scrub(clean(text));
@@@ -287,16 +288,20 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
dedupe_key, seen, pinned, redacted, created_at, updated_at, last_seen_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, 1, $13, $14, $15, $15, $15)
ON CONFLICT (subject, kind, dedupe_key) WHERE dedupe_key IS NOT NULL
-- DO UPDATE SET seen = codes_knowledge.seen + 1,
-- last_seen_at = EXCLUDED.last_seen_at,
-- updated_at = EXCLUDED.updated_at,
-- source_task = COALESCE(EXCLUDED.source_task, codes_knowledge.source_task),
-- source_job = COALESCE(EXCLUDED.source_job, codes_knowledge.source_job),
-- source_agent = COALESCE(EXCLUDED.source_agent, codes_knowledge.source_agent)
++ DO UPDATE SET seen = CASE WHEN $16::boolean THEN codes_knowledge.seen
++ WHEN EXCLUDED.source_task IS NOT NULL AND codes_knowledge.source_task = EXCLUDED.source_task
++ THEN codes_knowledge.seen ELSE codes_knowledge.seen + 1 END,
++ last_seen_at = CASE WHEN $16::boolean THEN codes_knowledge.last_seen_at ELSE EXCLUDED.last_seen_at END,
++ updated_at = CASE WHEN $16::boolean THEN codes_knowledge.updated_at ELSE EXCLUDED.updated_at END,
++ dream_status = CASE WHEN $16::boolean AND codes_knowledge.source_task IS DISTINCT FROM EXCLUDED.source_task
++ THEN codes_knowledge.dream_status ELSE 'active' END,
++ source_task = CASE WHEN $16::boolean THEN codes_knowledge.source_task ELSE COALESCE(EXCLUDED.source_task, codes_knowledge.source_task) END,
++ source_job = CASE WHEN $16::boolean THEN codes_knowledge.source_job ELSE COALESCE(EXCLUDED.source_job, codes_knowledge.source_job) END,
++ source_agent = CASE WHEN $16::boolean THEN codes_knowledge.source_agent ELSE COALESCE(EXCLUDED.source_agent, codes_knowledge.source_agent) END
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,
-- fixed || 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, !!replay],
);
return { row, created: !!row.created, redacted: cleaned.redacted };
}
@@@ -313,6 +318,7 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
const repository = repoOf(row.repo);
return await db.one(
`UPDATE codes_knowledge SET text = $2, kind = $3, dedupe_key = $4, redacted = $5,
++ source_task = NULL, source_job = NULL, dream_status = 'active',
pinned = COALESCE($6::boolean, pinned), updated_at = $7
WHERE id = $1 RETURNING *`,
[row.id, cleaned.text, k, dedupeKey({ kind: k, text: cleaned.text, repo: repository }), cleaned.redacted,
@@@ -347,7 +353,7 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
* What one finished run taught, captured idempotently: the agent's Learned: and Fact: lines, a failure with its
* reason, the command that failed, and the commands the repository's runs use. Never the whole transcript.
*/
-- async function captureTask(row) {
++ async function captureTask(row, { replay = false } = {}) {
if (!row || !row.id || !row.subject) return [];
const result = row.result && typeof row.result === 'object' ? row.result : {};
const logLines = Array.isArray(result.log) ? result.log : [];
@@@ -395,10 +401,11 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
try {
const saved = await remember({
subject: row.subject, kind: entry.kind, text: entry.text, repo, paths: pathsIn(entry.text),
-- sourceTask: row.id, sourceJob: row.job_id || null, sourceAgent: agent,
++ sourceTask: row.id, sourceJob: row.job_id || null, sourceAgent: agent, replay,
});
out.push(saved.row);
} catch (e) {
++ if (replay && (!e || e.code !== 'knowledge.empty')) throw e;
if (!e || e.code !== 'knowledge.empty') log.warn(`[Codes] knowledge capture for ${row.id}: ${String((e && e.message) || e).slice(0, 160)}`);
}
}
@@@ -426,6 +433,44 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
}
}
++ /** Recheck a bounded batch of auto-captured rows against the saved task transcript, once per day.
++ * Re-capture restores missed observations through remember's exact dedupe key. A row the transcript no longer
++ * supports stays visible to its owner but cannot steer a later run. Owner edits become manual rows and are never
++ * touched by this pass. The cursor carries a large backlog across nights. */
++ async function dream({ force = false, limit = 50 } = {}) {
++ const at = now();
++ const state = await db.maybe('SELECT last_at, cursor FROM codes_knowledge_dream_state WHERE name = $1', ['nightly']);
++ if (!force && state && at - Number(state.last_at) < 86400e3) return { skipped: true, processed: 0, unsupported: 0 };
++ const batch = Math.max(1, Math.min(100, Number(limit) || 50));
++ const tasks = await db.many(`SELECT t.id, t.subject, t.repo, t.job_id, t.agent, t.state, t.error,
++ t.result - 'patch' - 'files' AS result
++ FROM run_tasks t
++ WHERE t.id > $1 AND t.result IS NOT NULL AND t.state IN ('succeeded', 'failed', 'cancelled', 'expired')
++ AND EXISTS (SELECT 1 FROM codes_knowledge k WHERE k.source_task = t.id AND k.subject = t.subject
++ AND COALESCE(k.dedupe_key, '') NOT LIKE 'summary:%')
++ ORDER BY t.id LIMIT $2`, [state && state.cursor || '', batch]);
++ let unsupported = 0;
++ for (const task of tasks) {
++ const before = await db.many(`SELECT id FROM codes_knowledge
++ WHERE source_task = $1 AND subject = $2 AND pinned = false AND visibility = 'private'
++ AND updated_at = last_seen_at AND COALESCE(dedupe_key, '') NOT LIKE 'summary:%'`, [task.id, task.subject]);
++ const supported = new Set((await captureTask(task, { replay: true })).map((row) => row.id));
++ for (const row of before) {
++ if (supported.has(row.id)) continue;
++ await db.exec(`UPDATE codes_knowledge SET dream_status = 'unsupported'
++ WHERE id = $1 AND source_task = $2 AND subject = $3 AND pinned = false AND visibility = 'private'
++ AND updated_at = last_seen_at`,
++ [row.id, task.id, task.subject]);
++ unsupported++;
++ }
++ }
++ const cursor = tasks.length === batch ? tasks[tasks.length - 1].id : '';
++ await db.exec(`INSERT INTO codes_knowledge_dream_state (name, last_at, cursor) VALUES ($1, $2, $3)
++ ON CONFLICT (name) DO UPDATE SET last_at = EXCLUDED.last_at, cursor = EXCLUDED.cursor`,
++ ['nightly', at, cursor]);
++ return { skipped: false, processed: tasks.length, unsupported, cursor };
++ }
++
/** 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) || []);
@@@ -457,13 +502,13 @@ function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log
// 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 rest = rows.filter((r) => r !== previous && (r.dream_status || 'active') === 'active');
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, captureSummary,
++ projectsOf, teamIdsOf, list, get, remember, edit, pin, remove, share, unshare, captureTask, captureSummary, dream,
summaryKey, summaryText, recall, rank,
block, present: (row, opts) => present(row, opts), scrub, repoOf, learnedLines, factLines, KINDS,
};
ddiff --git a/server/http/knowledge.js b/server/http/knowledge.js
iindex 3aa9eb0..1ddc26d 100644
---- a/server/http/knowledge.js
++++ b/server/http/knowledge.js
@@@ -120,9 +120,9 @@ ${row.visibility === 'team' ? `<form method="post" action="/knowledge/${esc(row.
<details class="c4-more kn-delete"><summary>Delete</summary><div class="c4-more-body"><p class="kn-confirm">Delete this ${row.kind === 'fact' ? 'fact' : 'lesson'}? Runs stop recalling it, and it cannot be brought back.</p><form method="post" action="/knowledge/${esc(row.id)}/delete"><input type="hidden" name="tab" value="${esc(tab)}"><button class="c4-button c4-button-danger" type="submit">Delete it</button></form></div></details>
</div>` : `<p class="kn-team-note">${row.visibility === 'team' ? 'Shared with your team.' : ''} Only ${mine ? 'you' : 'its owner'} can edit it.</p>`;
return `<article class="c4-card kn-row${row.pinned ? ' is-pinned' : ''}">
--<div class="kn-row-head">${pill(row.kind === 'fact' ? 'Fact' : 'Lesson')}${row.pinned ? pill('Pinned', 'pinned') : ''}${shared}</div>
++<div class="kn-row-head">${pill(row.kind === 'fact' ? 'Fact' : 'Lesson')}${row.pinned ? pill('Pinned', 'pinned') : ''}${row.dream_status === 'unsupported' ? pill('Needs review') : ''}${shared}</div>
<p class="kn-text">${esc(row.text)}</p>
--<p class="kn-meta">${esc(dayOf(row.last_seen_at))}${row.seen > 1 ? ` · seen ${row.seen} times` : ''}${row.redacted ? ' · secrets removed' : ''}</p>
++<p class="kn-meta">${esc(dayOf(row.last_seen_at))}${row.seen > 1 ? ` · seen ${row.seen} times` : ''}${row.redacted ? ' · secrets removed' : ''}${row.dream_status === 'unsupported' ? ' · not sent to the next run' : ''}</p>
${source}${actions}
</article>`;
}
ddiff --git a/server/index.js b/server/index.js
iindex f98301b..3536f3e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -23,6 +23,7 @@ function createLifecycle({ server, ctx, exit, signals }) {
() => { if (ctx.publisher) return ctx.publisher.stop(); return undefined; },
() => { if (ctx.sessionRetention) ctx.sessionRetention.stop(); },
() => { if (ctx.githubLearningJob) ctx.githubLearningJob(); },
++ () => { if (ctx.knowledgeDreamJob) ctx.knowledgeDreamJob(); },
() => ctx.keys.client.stop(),
async () => { if (ctx.telemetryRetention) await ctx.telemetryRetention.stop(); await telemetry.stop(); },
() => ctx.store.close(),
@@@ -35,6 +36,8 @@ async function start() {
const { config } = ctx;
// GitHub research is due based: the first boot catches an overdue day, then checks once an hour.
ctx.githubLearningJob = every('codes-github-learning', 60 * 60_000, () => ctx.learning.runOnce(), { initialDelayMs: 10_000, jitterMs: 30_000, log: console });
++ if (!config.drill) ctx.knowledgeDreamJob = every('codes-knowledge-dream', 60 * 60_000,
++ () => ctx.knowledge.dream(), { initialDelayMs: 30_000, jitterMs: 30_000, log: console });
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
// queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.
ddiff --git a/test/knowledge.test.js b/test/knowledge.test.js
iindex 3bf8324..252404c 100644
---- a/test/knowledge.test.js
++++ b/test/knowledge.test.js
@@@ -266,6 +266,41 @@ function fakeHarness() {
assert.strictEqual(other.previousText, '');
});
++ await check('the nightly dream rechecks captured rows against the saved transcript without repeating a lesson', async () => {
++ const task = await startTask('Check the release tests.');
++ await end(task.id, 'succeeded', { ok: true, summary: 'Done.\n\nLearned:\n- npm test covers the release.\n- The release uses CI.', agents: ['codex'], log: [] });
++ const valid = (await knowledgeOf(ada.subject)).find((r) => r.source_task === task.id && /covers the release/.test(r.text));
++ assert.ok(valid);
++ const ghost = await t.ctx.knowledge.remember({ subject: ada.subject, kind: 'lesson',
++ text: 'The moon is green.', repo: REPO, sourceTask: task.id });
++ const pass = await t.ctx.knowledge.dream({ limit: 100 });
++ assert.strictEqual(pass.skipped, false);
++ assert.ok(pass.processed > 0);
++ assert.strictEqual((await db.one('SELECT seen FROM codes_knowledge WHERE id = $1', [valid.id])).seen, valid.seen,
++ 'a nightly replay of the same task is not a second sighting');
++ const stale = await db.one('SELECT * FROM codes_knowledge WHERE id = $1', [ghost.row.id]);
++ assert.strictEqual(stale.dream_status, 'unsupported', 'the saved task does not support this claim');
++ const recalled = await t.ctx.knowledge.recall({ subject: ada.subject, repo: REPO, prompt: 'moon green' });
++ assert.ok(!recalled.rows.some((r) => r.id === stale.id), 'unsupported text cannot steer another run');
++ assert.strictEqual((await t.ctx.knowledge.list({ subject: ada.subject })).find((r) => r.id === stale.id).dream_status,
++ 'unsupported', 'the owner can still inspect it');
++ assert.match((await t.get('/knowledge', { as: ada })).text, /Needs review.*The moon is green\..*not sent to the next run/s,
++ 'the owner sees why the row is no longer recalled');
++ const manual = await t.ctx.knowledge.edit({ subject: ada.subject, id: stale.id, text: 'The moon is blue.' });
++ assert.strictEqual(manual.source_task, null, 'an owner edit becomes manual knowledge');
++ assert.strictEqual(manual.dream_status, 'active');
++ assert.strictEqual((await t.ctx.knowledge.dream()).skipped, true, 'the due check runs once a day');
++ const next = await startTask('Check the next release.');
++ await end(next.id, 'succeeded', { ok: true, summary: 'Done.\n\nLearned:\n- npm test covers the release.', agents: ['codex'], log: [] });
++ const repeated = await db.one('SELECT seen, source_task FROM codes_knowledge WHERE id = $1', [valid.id]);
++ assert.strictEqual(repeated.source_task, next.id);
++ assert.strictEqual(repeated.seen, valid.seen + 1, 'a new task is a second sighting');
++ await t.ctx.knowledge.dream({ force: true, limit: 100 });
++ const replayed = await db.one('SELECT seen, source_task FROM codes_knowledge WHERE id = $1', [valid.id]);
++ assert.strictEqual(replayed.seen, repeated.seen, 'replaying an older task does not inflate sightings');
++ assert.strictEqual(replayed.source_task, next.id, 'replay does not move attribution backwards');
++ });
++
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');