openvibers/openvibe.codes Commits

Load the small memory index into every prompt (#408)

OP OpenVibers committed 2 days ago · aa935bf870 · 4 files, +194 −1

Changes

ddiff --git a/engine/index.js b/engine/index.js
iindex ac52d18..996438e 100644
---- a/engine/index.js
++++ b/engine/index.js
@@@ -12,7 +12,8 @@
* engine.runerror what a failed run really said
* engine.adapters every harness Codes can run, with the portal's argv and environment
* engine.jobrunner one attempt of one harness, the way the portal runs one
-- * engine.prompt the prompt every job gets (the rules, the lessons, the digest, the brief)
++ * engine.prompt the prompt every job gets (the rules, the lessons, the memory, the digest, the brief)
++ * engine.memory the small memory index: MEMORY.md always, topics by search
* engine.repomap the repo digest a job gets instead of a directory tree
* engine.joblines the lines a task's page shows while it runs
* engine.logins what this machine has: each CLI, signed in or not, and its limit windows
@@@ -37,6 +38,7 @@ const runerror = require('./runerror');
const adapters = require('./adapters');
const jobrunner = require('./jobrunner');
const prompt = require('./prompt');
++const memory = require('./memory');
const repomap = require('./repomap');
const joblines = require('./joblines');
const logins = require('./logins');
@@@ -60,6 +62,7 @@ module.exports = {
adapters,
jobrunner,
prompt,
++ memory,
repomap,
joblines,
logins,
ddiff --git a/engine/memory.js b/engine/memory.js
nnew file mode 100644
iindex 0000000..ec8213b
---- /dev/null
++++ b/engine/memory.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * The small memory index: what the job remembers, from files beside the work.
++ *
++ * A repository keeps `MEMORY.md` (the index, always loaded) and `memory/<topic>.md` (one file per topic, loaded by
++ * search). The index is cheap and stable, so every prompt carries it; a topic costs tokens, so only the topics the
++ * brief's words point at ride along, most overlapping first. Everything is capped: an index is a pointer, not a dump.
++ *
++ * A folder with neither file gets nothing, never an error: memory is optional, and a job must not fail for lack of
++ * remembering. The nightly dream pass (dedupe and correction against transcripts) and the receipt's cache-hit share
++ * live on the Know side; this module is what Codes loads and what the dream pass reads back.
++ *
++ * indexOf(dir) the index text, capped, or ''
++ * topics(dir) every topic file: [{ topic, text }]
++ * search(dir, query) topics by word overlap, best first, capped
++ * block({ dir, brief }) { text, indexChars, topics } — the prompt block
++ * INDEX_CHARS, MAX_TOPICS, TOPIC_CHARS, INDEX_FILE, TOPICS_DIR
++ */
++
++const fs = require('fs');
++const path = require('path');
++
++const INDEX_FILE = 'MEMORY.md';
++const TOPICS_DIR = 'memory';
++const INDEX_CHARS = 4000;
++const MAX_TOPICS = 2;
++const TOPIC_CHARS = 2000;
++
++/** The file's text, capped, or '' when it is not there or not readable. */
++function readCapped(file, max) {
++ try {
++ const text = fs.readFileSync(file, 'utf8');
++ if (!text || !text.trim()) return '';
++ return text.length > max ? text.slice(0, max) : text;
++ } catch { return ''; }
++}
++
++/** The index: the repository's MEMORY.md, always loaded, capped. */
++function indexOf(dir) {
++ if (!dir) return '';
++ return readCapped(path.join(String(dir), INDEX_FILE), INDEX_CHARS);
++}
++
++/** Every topic file under the repository's memory folder. */
++function topics(dir) {
++ if (!dir) return [];
++ let names;
++ try { names = fs.readdirSync(path.join(String(dir), TOPICS_DIR)); } catch { return []; }
++ const out = [];
++ for (const name of names.sort()) {
++ if (!name.endsWith('.md') || name.startsWith('.')) continue;
++ const text = readCapped(path.join(String(dir), TOPICS_DIR, name), TOPIC_CHARS);
++ if (text) out.push({ topic: name.replace(/\.md$/, ''), text });
++ }
++ return out;
++}
++
++/** The brief's words worth searching for: lowercased, short and common ones dropped. */
++function wordsOf(query) {
++ const stop = new Set(('the,a,an,and,or,to,of,in,on,for,with,from,that,this,what,when,how,are,was,were,is,it,as,at,be,by,do,does,did,you,your,into,over,under,than,then,than,not,but,all,any,can,will,just,make,fix,ship,use,add,new,old,way,our,its,their,them,they,we,my,me,i').split(','));
++ return [...new Set(String(query || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !stop.has(w)))];
++}
++
++/**
++ * The topics the query points at: word overlap between the brief and the topic, most overlapping first, capped at
++ * MAX_TOPICS. A topic no brief word reaches is not loaded, however short; ties break by topic name, so the choice
++ * is stable for the same brief.
++ */
++function search(dir, query) {
++ const words = wordsOf(query);
++ if (!words.length) return [];
++ const scored = [];
++ for (const t of topics(dir)) {
++ const hay = (t.topic + '\n' + t.text).toLowerCase();
++ let score = 0;
++ for (const w of words) if (hay.includes(w)) score++;
++ if (score > 0) scored.push({ ...t, score });
++ }
++ scored.sort((a, b) => b.score - a.score || (a.topic < b.topic ? -1 : 1));
++ return scored.slice(0, MAX_TOPICS);
++}
++
++/** The prompt block: the index always, then the topics the brief points at. Empty when there is no memory. */
++function block({ dir, brief } = {}) {
++ const index = indexOf(dir);
++ const found = search(dir, brief);
++ const parts = [];
++ if (index) parts.push('# Memory (index, always loaded)\n' + index);
++ for (const t of found) parts.push(`# Memory: ${t.topic}\n` + t.text);
++ return {
++ text: parts.join('\n\n'),
++ indexChars: index.length,
++ topics: found.map((t) => ({ topic: t.topic, score: t.score, chars: t.text.length })),
++ };
++}
++
++module.exports = {
++ INDEX_FILE, TOPICS_DIR, INDEX_CHARS, MAX_TOPICS, TOPIC_CHARS,
++ indexOf, topics, search, block,
++};
ddiff --git a/engine/prompt.js b/engine/prompt.js
iindex 1f362f1..32a3c75 100644
---- a/engine/prompt.js
++++ b/engine/prompt.js
@@@ -13,6 +13,7 @@
const fs = require('fs');
const path = require('path');
const { repomap } = require('./repomap');
++const { block: memoryBlock } = require('./memory');
const HERE = __dirname;
const FRAGMENTS = path.join(HERE, 'fragments');
@@@ -63,12 +64,15 @@ function assemble({ brief, dir, env = process.env, know } = {}) {
const houseRules = fragment('house-rules.md');
const repo = repomap(dir);
const learned = lessons({ file: know, repo: repo.repo, brief, env });
++ // The small memory index: MEMORY.md always, the topics the brief points at (engine/memory.js).
++ const memory = memoryBlock({ dir, brief });
// Every prompt section is inspectable as a versioned layer. Stable layers stay first;
// the request is always last so changing the task leaves the cached prefix byte-identical.
const parts = [
{ name: 'git-rule', layer: 'platform', source: 'engine/fragments/git-rule.md', version: 1, text: gitRule },
{ name: 'house-rules', layer: 'product', source: 'engine/fragments/house-rules.md', version: 1, text: houseRules },
{ name: 'lessons', layer: 'role', source: 'Know recall', version: 1, text: learned },
++ { name: 'memory', layer: 'role', source: 'engine/memory.js', version: 1, text: memory.text },
{ name: 'repo-digest', layer: 'role', source: 'engine/repomap.js', version: 1, text: repo.text },
{ name: 'brief', layer: 'task', source: 'launch request', version: 1, text: String(brief || '') },
].filter((p) => p.text && p.text.trim());
ddiff --git a/test/engine-memory.test.js b/test/engine-memory.test.js
nnew file mode 100644
iindex 0000000..565c404
---- /dev/null
++++ b/test/engine-memory.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * Release 2 item 8 (Codes slice): the small memory index.
++ *
++ * MEMORY.md is always loaded; memory/<topic>.md files are loaded by search — the brief's words pick them, most
++ * overlap first, at most MAX_TOPICS. Everything is capped, and a folder with no memory gives nothing, never an
++ * error. The assembled prompt carries the block as its memory part.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const memory = require('../engine/memory');
++const { assemble } = require('../engine/prompt');
++const { check, done } = require('./helpers/boot');
++
++const mem = (dir) => {
++ fs.writeFileSync(path.join(dir, 'MEMORY.md'), '# Index\nAlways remember the deploy freeze on Fridays.\n');
++ fs.mkdirSync(path.join(dir, 'memory'));
++ fs.writeFileSync(path.join(dir, 'memory', 'deploy.md'), '# Deploy\nThe deploy freeze runs Friday UTC. Release trains ship Thursdays.\n');
++ fs.writeFileSync(path.join(dir, 'memory', 'tipping.md'), '# Tipping\nTips settle in Billing on Mondays.\n');
++ fs.writeFileSync(path.join(dir, 'memory', 'unrelated.md'), '# Gardening\nRoses need pruning in March.\n');
++};
++
++(async () => {
++ try {
++ await check('the index is always loaded and capped', () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-idx-'));
++ mem(dir);
++ const b = memory.block({ dir, brief: 'something else entirely' });
++ assert.ok(b.text.includes('deploy freeze'), 'the index rides along whatever the brief');
++ assert.strictEqual(b.topics.length, 0, 'no topic matches, none is loaded');
++ fs.writeFileSync(path.join(dir, 'MEMORY.md'), 'x'.repeat(9000));
++ assert.ok(memory.indexOf(dir).length <= memory.INDEX_CHARS, 'the index is capped');
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++
++ await check('topics load by search, most overlap first, at most two', () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-top-'));
++ mem(dir);
++ const found = memory.search(dir, 'when is the deploy freeze and do release trains ship?');
++ assert.deepStrictEqual(found.map((t) => t.topic), ['deploy'], 'only the matching topic loads');
++ const both = memory.search(dir, 'deploy freeze tips settle billing monday');
++ assert.deepStrictEqual(both.map((t) => t.topic), ['tipping', 'deploy'], 'most overlap first');
++ assert.deepStrictEqual(memory.search(dir, 'roses pruning')[0].topic, 'unrelated');
++ assert.deepStrictEqual(memory.search(dir, 'quantum entanglement'), [], 'nothing matching, nothing loaded');
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++
++ await check('no memory is nothing, never an error', () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-none-'));
++ assert.strictEqual(memory.indexOf(dir), '');
++ assert.deepStrictEqual(memory.topics(dir), []);
++ assert.deepStrictEqual(memory.search(dir, 'deploy'), []);
++ assert.strictEqual(memory.block({ dir, brief: 'deploy' }).text, '');
++ assert.strictEqual(memory.block({}).text, '');
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++
++ await check('the assembled prompt carries the memory between the lessons and the digest', () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-asm-'));
++ require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: dir });
++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');
++ mem(dir);
++ const built = assemble({ brief: 'when is the deploy freeze?', dir });
++ const names = built.parts.map((p) => p.name);
++ assert.deepStrictEqual(names, ['git-rule', 'house-rules', 'memory', 'repo-digest', 'brief']);
++ const part = built.parts.find((p) => p.name === 'memory');
++ assert.ok(part.text.includes('deploy freeze'), 'the index is in the prompt');
++ assert.ok(part.text.includes('Release trains ship Thursdays'), 'the matching topic is in the prompt');
++ assert.ok(!part.text.includes('Roses need pruning'), 'the unmatching topic stays out');
++ assert.strictEqual(part.layer, 'role');
++ const bare = assemble({ brief: 'x', dir: fs.mkdtempSync(path.join(os.tmpdir(), 'mem-bare-')) });
++ assert.ok(!bare.parts.some((p) => p.name === 'memory'), 'no memory files, no part');
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++
++ console.log('memory: the index always, the topics the brief points at, capped');
++ } catch (e) {
++ console.error(e);
++ process.exit(1);
++ }
++ done();
++})();