Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions ops/agent-phone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -357,18 +357,50 @@ function writeFloor(pin, agent, text, forMs) {
let prev = {};
try { prev = JSON.parse(readFileSync(file, 'utf8')); } catch {}
const now = Date.now();
const said = { ...(prev.said && typeof prev.said === 'object' ? prev.said : {}) };
// Carry the old single-slot shape forward, so a leg already on a call is not
// suddenly a stranger to one that restarts.
if (prev.agent && prev.text && !said[prev.agent]) said[prev.agent] = { at: Number(prev.at) || now, text: String(prev.text) };
if (text.trim()) said[agent] = { at: now, text: text.slice(0, 600) };
for (const [who, entry] of Object.entries(said)) {
if (now - (Number(entry?.at) || 0) > CO_HOST_MEMORY_MS) delete said[who];
}
const said = mergeSaid(prev, agent, text, now);
const temp = `${file}.${process.pid}.${now}.tmp`;
writeFileSync(temp, JSON.stringify({ agent, at: now, until: now + forMs, text: text.slice(0, 600), said }));
renameSync(temp, file);
}
/** At most this many recent lines per agent: a turn is two chunks, and one may be re-claimed. */
const SAID_PER_AGENT = 4;
/**
* Add a line to what `agent` has said lately, keeping the ones before it.
*
* A turn is split so playback can start before the whole line is rendered, and
* `playNextChunk` re-claims the floor with the chunk it is playing. Keeping only
* the latest claim therefore forgot the first half of the sentence while its
* transcription was still arriving - which came back as the guest. Keep the last
* few lines per agent and match a heard line against each of them.
*/
function mergeSaid(prev, agent, text, now = Date.now()) {
const said = {};
const from = prev?.said && typeof prev.said === 'object' ? prev.said : {};
for (const [who, entry] of Object.entries(from)) said[who] = normaliseSaid(entry);
// Carry the old single-slot shape forward, so a leg already on a call is not
// suddenly a stranger to one that restarts.
if (prev?.agent && prev?.text && !said[prev.agent]) said[prev.agent] = [{ at: Number(prev.at) || now, text: String(prev.text) }];
const line = String(text ?? '').trim();
if (line) {
const mine = (said[agent] ?? []).filter((e) => e.text !== line.slice(0, 600));
said[agent] = [{ at: now, text: line.slice(0, 600) }, ...mine].slice(0, SAID_PER_AGENT);
}
for (const [who, lines] of Object.entries(said)) {
const fresh = lines.filter((e) => now - e.at < CO_HOST_MEMORY_MS);
if (fresh.length) said[who] = fresh;
else delete said[who];
}
return said;
}
/** One agent's lines, newest first, from either the list shape or the single-entry one. */
function normaliseSaid(entry) {
const list = Array.isArray(entry) ? entry : entry && typeof entry === 'object' ? [entry] : [];
return list
.map((e) => ({ at: Number(e?.at) || 0, text: String(e?.text || '') }))
.filter((e) => e.text.trim())
.sort((a, b) => b.at - a.at)
.slice(0, SAID_PER_AGENT);
}
/**
* Record what this agent is saying without claiming the floor, for the case where
* the claim was refused or lost a race: the co-host still has to recognise these
Expand All @@ -382,11 +414,7 @@ export function noteSaid(pin, agent, text) {
let prev = {};
try { prev = JSON.parse(readFileSync(file, 'utf8')); } catch {}
const now = Date.now();
const said = { ...(prev.said && typeof prev.said === 'object' ? prev.said : {}) };
said[agent] = { at: now, text: String(text).slice(0, 600) };
for (const [who, entry] of Object.entries(said)) {
if (now - (Number(entry?.at) || 0) > CO_HOST_MEMORY_MS) delete said[who];
}
const said = mergeSaid(prev, agent, text, now);
const temp = `${file}.${process.pid}.${now}.tmp`;
writeFileSync(temp, JSON.stringify({ ...prev, said }));
renameSync(temp, file);
Expand All @@ -405,13 +433,15 @@ const CO_HOST_MEMORY_MS = 30_000;
export function coHostSpeech(pin, agent) {
try {
const f = JSON.parse(readFileSync(floorFile(pin), 'utf8'));
const said = f.said && typeof f.said === 'object' ? { ...f.said } : {};
const said = {};
for (const [who, entry] of Object.entries(f.said && typeof f.said === 'object' ? f.said : {})) said[who] = normaliseSaid(entry);
// Old single-slot files, and a leg writing one while this one reads.
if (f.agent && f.text && !said[f.agent]) said[f.agent] = { at: Number(f.at) || 0, text: String(f.text) };
if (f.agent && f.text && !said[f.agent]) said[f.agent] = [{ at: Number(f.at) || 0, text: String(f.text) }];
const now = Date.now();
return Object.entries(said)
.filter(([who, entry]) => who !== agent && String(entry?.text || '').trim() && Date.now() - (Number(entry?.at) || 0) < CO_HOST_MEMORY_MS)
.sort((a, b) => Number(b[1].at) - Number(a[1].at))
.map(([who, entry]) => ({ agent: who, text: String(entry.text) }));
.filter(([who]) => who !== agent)
.flatMap(([who, lines]) => lines.filter((e) => now - e.at < CO_HOST_MEMORY_MS).map((e) => ({ agent: who, text: e.text, at: e.at })))
.sort((a, b) => b.at - a.at);
} catch {
return [];
}
Expand Down
33 changes: 32 additions & 1 deletion test/agent-phone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,44 @@ test('a co-host line goes stale, so an old question never shadows the guest', ()
assert.deepEqual(worker.coHostSpeech(pin, 'cha0spunk').map((s) => s.agent), ['riotcoder']);
const file = join(testHome, '.local/share/agent-phone/calls', `floor-${pin}.json`);
const aged = JSON.parse(readFileSync(file, 'utf8'));
aged.said.riotcoder.at = Date.now() - 120_000;
for (const line of aged.said.riotcoder) line.at = Date.now() - 120_000;
aged.text = '';
writeFileSync(file, JSON.stringify(aged));
assert.deepEqual(worker.coHostSpeech(pin, 'cha0spunk'), []);
worker.releaseFloor(pin, 'riotcoder');
});

test('a chunked line is still the host\'s own while its first half is transcribed', () => {
// A turn is split so playback starts before the whole line renders, and
// playNextChunk re-claims the floor with the chunk it is playing. Keeping only
// the newest claim forgot the first half of the sentence while its
// transcription was still arriving, so it came back as the guest: on the
// rehearsal, "And this is a rehearsal, ChaosPunk" was logged as `room:`.
const pin = `floor-chunks-${process.pid}`;
assert.equal(worker.takeFloor(pin, 'riotcoder', "I'm RiotCoder, an AI assistant and co-host, and this is a rehearsal, ChaosPunk.", 60_000), true);
assert.equal(worker.takeFloor(pin, 'riotcoder', "How's the line?", 20_000), true);
const heard = worker.coHostSpeech(pin, 'cha0spunk');
assert.deepEqual(heard.map((s) => s.agent), ['riotcoder', 'riotcoder']);
// Both halves of the turn are recognised as the co-host, not the guest.
assert.equal(heard.some((s) => worker.echoesFloor('And this is a rehearsal chaos punk.', s.text)), true);
assert.equal(heard.some((s) => worker.echoesFloor("How's the line", s.text)), true);
// And the guest still gets through either way.
assert.equal(heard.some((s) => worker.echoesFloor('Can you hear me?', s.text)), false);
worker.releaseFloor(pin, 'riotcoder');
});

test('each agent keeps a few recent lines, not the whole call', () => {
const pin = `floor-cap-${process.pid}`;
for (const line of ['one thing entirely', 'second separate matter', 'third distinct subject', 'fourth unrelated topic', 'fifth other business']) {
assert.equal(worker.takeFloor(pin, 'riotcoder', line, 30_000), true);
}
const heard = worker.coHostSpeech(pin, 'cha0spunk');
assert.equal(heard.length, 4);
assert.equal(heard[0].text, 'fifth other business');
assert.equal(heard.some((s) => s.text === 'one thing entirely'), false);
worker.releaseFloor(pin, 'riotcoder');
});

test('a model that says PASS is saying nothing, not reading the word out', () => {
assert.deepEqual(worker.parseTurn('SAY: PASS'), { kind: 'pass', text: '' });
assert.deepEqual(worker.parseTurn('PASS'), { kind: 'pass', text: '' });
Expand Down
Loading