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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ shape, relative to the project root.
| `audience` | `learner` \| `candidate` | Default `learner`. `candidate` is for assessments: unless you set them, the assistant model is pinned and the temperature is lowered to `0.2`, and `assistant.allowCustomInstructions` is forced off. Cosmo behaves the same for both audiences: a neutral assistant that does not coach. |
| `scenarioType` | `compose_new` \| `reply` \| `reply_chain` | Drives composer prefill. |
| `learner` | `{ displayName, email, avatar }` | Who the learner is in the thread. Optional `avatar` is `0`–`12`; **`0` is the empty face and the default for You**. |
| `characters` | object[] | People the learner may put on To / Cc. `{ id, name, email }` required; optional `avatar` (`0`–`12`). Optional `persona` (free-form object or string) and/or `prompt` make the character **live** — they reply via `cosmo-mail-character`. Live characters reply as ordinary people (busy, self-interested, incomplete) unless the persona explicitly says they coach, mentor, or manage the learner's development. `responds: true/false` overrides live vs directory-only. Directory-only people (no persona, `responds` omitted) can be emailed but never write back. |
| `characters` | object[] | People the learner may put on To / Cc. `{ id, name, email }` required; optional `avatar` (`0`–`12`). Optional `persona` (free-form object or string) and/or `prompt` make the character **live** — they reply via `cosmo-mail-character`. Live characters reply as ordinary people (busy, self-interested, incomplete) unless the persona explicitly says they coach, mentor, or manage the learner's development. They can also add directory people to Cc (for example, "CC Victoria and introduce her") without the learner putting them on the thread first; newly looped-in live people then get a chance to write back. `responds: true/false` overrides live vs directory-only. Directory-only people (no persona, `responds` omitted) can be emailed but never write back. |
| `world` | string \| `{ summary }` | Shared in-world facts every live character already knows. Cosmo does **not** see this. |
| `seed.inbox` | object \| string | Inline `{ threads }` by default. Optionally a path to a JSON file of the same shape for large inboxes. Threads may set `"mailbox": "spam"` to land in Spam; otherwise they start in Inbox. Sent is filled automatically when the learner sends. |
| `seed.activeThreadId` | string | Which mailbox to open on load (the folder that contains this thread). |
Expand Down
6 changes: 4 additions & 2 deletions agents/cosmo-mail-character/prompts/character-reply.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ unless that is who you are.
This email arrived as text only. You did not receive any attached file. Do not
ask them to send a document; if you need more, ask them to put it in the email.

Write the email body only (no subject, no headers, no commentary). Then on the
last line write [[continue]] or [[done]].
Write the email body only (no subject, no headers, no commentary). If you are
adding someone new to Cc, write [[cc: name-or-email]] on its own line after
the body (one person per line). Then on the last line write [[continue]] or
[[done]].
14 changes: 12 additions & 2 deletions agents/cosmo-mail-character/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,16 @@ How you write
- Keep length and tone realistic for email, following your style of communication
when one is given. Sign off as {{CHARACTER_NAME}} when a sign-off is natural.
- Reply-all is in effect: other people on the thread may see this. Write
accordingly.
accordingly. Reply-all already keeps everyone currently on the thread —
do not re-list them.
- You may add someone new to Cc only when your character card says to
introduce or loop them in, or when you would naturally bring them into
this thread. After the body, write one [[cc: name-or-email]] line per
person, using only people from the list below. Do not invent addresses.
Do not mention these tags in the email body.

People you can add to Cc:
{{PEOPLE}}

═══════════════════════════════════════════════════════════════════════
When you are finished
Expand All @@ -91,7 +100,8 @@ still send a short email in your voice — never go silent. A closing note such
"I have nothing left to say" (in your own voice) is correct. Do not reopen a
settled point, and do not keep the thread alive to teach.

After the email body, on its own last line, write exactly one of:
After the email body, you may write one [[cc: name-or-email]] line per
person you are newly adding to Cc. Then on the last line write exactly one of:
[[continue]] — you would keep this correspondence going
[[done]] — your goal is met, or you have nothing left to say

Expand Down
6 changes: 6 additions & 0 deletions agents/cosmo-mail-character/protocol.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ input:
optional: true
default: "You"
description: The name of the person this character is emailing with.
PEOPLE:
type: string
optional: true
default: ""
description: Directory of people this character may add to Cc (name <email>).

triggers:
character-reply:
Expand Down Expand Up @@ -76,6 +81,7 @@ agent:
CHARACTER_CARD: CHARACTER_CARD
WORLD: WORLD
LEARNER_NAME: LEARNER_NAME
PEOPLE: PEOPLE
temperature: TEMPERATURE
thinking: THINKING
webSearch: false
Expand Down
135 changes: 125 additions & 10 deletions lib/character-replies.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* omitted or false) can be emailed but never generate a reply.
*/

import { emailsFromAddresses, uniqueEmails } from './characters.js';
import { characterByEmail, emailsFromAddresses, uniqueEmails } from './characters.js';
import { replySubject } from './mailboxes.js';

const PERSONA_LABELS = {
Expand All @@ -24,6 +24,11 @@ const PERSONA_LABELS = {

const DONE_TAG = /\[\[\s*done\s*\]\]/i;
const CONTINUE_TAG = /\[\[\s*continue\s*\]\]/i;
const CC_LINE = /^\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\]$/i;
const CC_TAG_LINE = /^[ \t]*\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\][ \t]*\n?/gim;
const CC_TAG = /\[\[\s*cc\s*:\s*([^\]]+?)\s*\]\]/gi;
const EMAIL_IN_ANGLE = /<([^<>@\s]+@[^<>@\s]+)>/;
const LOOKS_LIKE_EMAIL = /^[^\s@]+@[^\s@]+$/;
const NOTHING_LEFT_RE =
/nothing left to say|don'?t have anything (else|more) to (say|add)|nothing more to add|i have nothing else/i;

Expand Down Expand Up @@ -183,6 +188,7 @@ export function selectResponders(email, characters, { doneIds = [], random = Mat
export function parseCharacterReply(raw) {
let text = String(raw || '').replace(/\r\n/g, '\n').trim();
let done = false;
const cc = [];
const lines = text.split('\n');
while (lines.length) {
const last = lines[lines.length - 1].trim();
Expand All @@ -199,17 +205,98 @@ export function parseCharacterReply(raw) {
lines.pop();
continue;
}
const ccMatch = last.match(CC_LINE);
if (ccMatch) {
cc.unshift(ccMatch[1].trim());
lines.pop();
continue;
}
break;
}
text = lines.join('\n').trim();
CC_TAG_LINE.lastIndex = 0;
CC_TAG.lastIndex = 0;
text = text
.replace(CC_TAG_LINE, (_, value) => {
cc.push(String(value || '').trim());
return '';
})
.replace(CC_TAG, (_, value) => {
cc.push(String(value || '').trim());
return '';
})
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (DONE_TAG.test(text)) {
done = true;
text = text.replace(DONE_TAG, '').trim();
}
if (!done && text.length > 0 && text.length < 400 && NOTHING_LEFT_RE.test(text)) {
done = true;
}
return { body: text, done };
return { body: text, done, cc: cc.filter(Boolean) };
}

function matchDirectoryPerson(raw, characters) {
const text = String(raw || '').trim();
if (!text) return null;
const angled = text.match(EMAIL_IN_ANGLE);
const email = (angled ? angled[1] : LOOKS_LIKE_EMAIL.test(text) ? text : '').trim();
if (email) return characterByEmail(characters, email);

const lower = text.toLowerCase();
const exact = (characters || []).filter(
(character) => String(character.name || '').trim().toLowerCase() === lower,
);
if (exact.length === 1) return exact[0];

const firstNameHits = (characters || []).filter((character) => {
const first = String(character.name || '').trim().split(/\s+/).find((part) => part.length >= 2);
return first && first.length >= 3 && first.toLowerCase() === lower;
});
return firstNameHits.length === 1 ? firstNameHits[0] : null;
}

/**
* Map agent [[cc: …]] values onto directory emails. Unknown names, the
* sender, the learner, and anyone already on To are dropped.
*/
export function resolveCcAdds(rawAdds, characters, { excludeEmails = [] } = {}) {
const skip = new Set(
(excludeEmails || []).map((email) => String(email || '').trim().toLowerCase()).filter(Boolean),
);
const resolved = [];
for (const raw of rawAdds || []) {
const character = matchDirectoryPerson(raw, characters);
if (!character) continue;
const key = character.email.toLowerCase();
if (skip.has(key)) continue;
skip.add(key);
resolved.push(character.email);
}
return resolved;
}

export function formatPeopleDirectory(characters, { excludeEmails = [], learner } = {}) {
const skip = new Set(
(excludeEmails || []).map((email) => String(email || '').trim().toLowerCase()).filter(Boolean),
);
const lines = [];
const learnerEmail = String(learner?.email || '').trim();
if (learnerEmail && !skip.has(learnerEmail.toLowerCase())) {
const name = String(learner.displayName || learner.name || 'You').trim() || 'You';
lines.push(`- ${name} <${learnerEmail}>`);
skip.add(learnerEmail.toLowerCase());
}
for (const character of characters || []) {
const email = String(character.email || '').trim();
if (!email || skip.has(email.toLowerCase())) continue;
const role = String(character.role || '').trim();
lines.push(`- ${character.name} <${email}>${role ? ` — ${role}` : ''}`);
skip.add(email.toLowerCase());
}
return lines.join('\n');
}

function addressEmail(addr) {
Expand All @@ -218,12 +305,18 @@ function addressEmail(addr) {
}

/**
* Reply-all headers for a character answering the learner's outbound email:
* From the character, To the learner plus remaining To, Cc everyone else.
* Reply-all headers for a character answering an email:
* From the character, To the learner plus the prior sender and remaining To,
* Cc everyone else plus any directory people the character asked to add.
*/
export function buildCharacterReplyHeaders(sentEmail, character, { learnerEmail = '', subjectFallback = '' } = {}) {
export function buildCharacterReplyHeaders(
sentEmail,
character,
{ learnerEmail = '', subjectFallback = '', ccAdds = [] } = {},
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const self = String(character?.email || '').toLowerCase();
const learner = String(learnerEmail || '').toLowerCase();
const originalFrom = addressEmail(sentEmail?.from);
const originalTo = uniqueEmails(emailsFromAddresses(sentEmail?.to));
const originalCc = uniqueEmails(emailsFromAddresses(sentEmail?.cc));
const notSelfOrLearner = (email) => {
Expand All @@ -233,18 +326,40 @@ export function buildCharacterReplyHeaders(sentEmail, character, { learnerEmail

const to = uniqueEmails([
learnerEmail,
...originalTo.filter(notSelfOrLearner),
...[originalFrom, ...originalTo].filter(notSelfOrLearner),
].filter(Boolean));

const cc = uniqueEmails(originalCc.filter((email) => {
const key = email.toLowerCase();
return notSelfOrLearner(email) && !to.some((item) => item.toLowerCase() === key);
}));
const keepOnCc = (email) =>
notSelfOrLearner(email) && !to.some((item) => item.toLowerCase() === email.toLowerCase());

const cc = uniqueEmails([...originalCc, ...ccAdds].filter(keepOnCc));

const subject = replySubject(sentEmail?.subject || subjectFallback);
return { to, cc, subject };
}

/**
* Live people this reply newly put on To/Cc — they were just looped in and
* should get a chance to write back.
*/
export function selectIntroducedResponders(replyEmail, priorEmail, characters) {
const prior = new Set(
uniqueEmails([
...emailsFromAddresses(priorEmail?.to),
...emailsFromAddresses(priorEmail?.cc),
addressEmail(priorEmail?.from),
]).map((email) => email.toLowerCase()),
);
const introduced = uniqueEmails([
...emailsFromAddresses(replyEmail?.to),
...emailsFromAddresses(replyEmail?.cc),
]).filter((email) => !prior.has(email.toLowerCase()));
if (!introduced.length) return [];
return (characters || []).filter(
(character) => characterIsLive(character) && onList(character, introduced),
);
}

export function formatRecipientsContext(email) {
const from = addressEmail(email?.from);
const to = uniqueEmails(emailsFromAddresses(email?.to)).join(', ') || '(none)';
Expand Down
9 changes: 6 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2910,9 +2910,9 @@ async function finalizeCharacterReply(characterId, threadId, replyText, inReplyT
}),
});
if (!res.ok) return null;
const { thread, email } = await res.json();
const { thread, email, responders } = await res.json();
refreshMailAfterInbound(thread, email);
return email;
return { email, responders };
}

async function ensureCharacterSession(characterId) {
Expand Down Expand Up @@ -2970,7 +2970,10 @@ async function runCharacterReply(character, sentEmail, threadId) {

const replyText = assistantTextFromMessages(chat.messages);
if (!replyText.trim()) return;
await finalizeCharacterReply(character.id, threadId, replyText, sentEmail?.id);
const recorded = await finalizeCharacterReply(character.id, threadId, replyText, sentEmail?.id);
if (recorded?.responders?.length) {
await collectCharacterReplies(recorded.responders, recorded.email, threadId);
}
}

// ── Assistant (Cosmo) ─────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions scenario.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
"Do not invent discounts, legal terms, or dates that are not in knowledge"
]
}
},
{
"id": "lee",
"name": "Lee Park",
"email": "lee.park@acme-vendor.com",
"role": "Deal desk",
"persona": {
"goal": "Review commercial exceptions when Dana loops you in.",
"constraints": ["Do not engage until Dana introduces you."]
}
}
],

Expand Down
20 changes: 17 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
compileCharacterCard,
parseCharacterReply,
buildCharacterReplyHeaders,
resolveCcAdds,
selectIntroducedResponders,
formatPeopleDirectory,
} from './lib/character-replies.js';
import {
buildCapabilityInstructions,
Expand Down Expand Up @@ -359,6 +362,10 @@ function buildCharacterSessionInput(config, character) {
input.CHARACTER_CARD = compileCharacterCard(character);
if (config.world) input.WORLD = config.world;
input.LEARNER_NAME = config.learner?.displayName || 'You';
input.PEOPLE = formatPeopleDirectory(config.characters, {
excludeEmails: [character?.email],
learner: config.learner,
});
return input;
}

Expand Down Expand Up @@ -771,7 +778,8 @@ app.post('/api/character/trigger', async (req, res) => {
});

// POST /api/character/complete — append a finished character reply as inbound
// mail (reply-all) and record whether that character is done.
// mail (reply-all, plus any [[cc:]] adds), record whether that character is
// done, and list live people they just looped in so the client can ask them.
app.post('/api/character/complete', async (req, res) => {
const { sessionId, threadId, characterId, reply, inReplyToId } = req.body;
if (!sessionId || !characterId) {
Expand All @@ -786,6 +794,7 @@ app.post('/api/character/complete', async (req, res) => {
let email;
let thread;
let done;
let sent;
const record = await updateSessionById(sessionId, (fresh) => {
thread = fresh.threads.find((t) => t.id === threadId) ?? fresh.threads[0];
if (!thread) {
Expand All @@ -794,15 +803,19 @@ app.post('/api/character/complete', async (req, res) => {
throw err;
}

const sent =
sent =
thread.emails.find((entry) => entry.id === inReplyToId) ??
[...thread.emails].reverse().find((entry) => entry.outbound);
const parsed = parseCharacterReply(reply);
done = parsed.done;
const learnerEmail = config.learner?.email || 'you@example.com';
const ccAdds = resolveCcAdds(parsed.cc, characters, {
excludeEmails: [character.email, learnerEmail],
});
const headers = buildCharacterReplyHeaders(sent, character, {
learnerEmail,
subjectFallback: thread.subject,
ccAdds,
});

email = {
Expand All @@ -826,7 +839,8 @@ app.post('/api/character/complete', async (req, res) => {
});
if (!record) return res.status(404).json({ error: 'Session not found' });

res.json({ email, thread, done });
const responders = selectIntroducedResponders(email, sent, characters).map(publicResponder);
res.json({ email, thread, done, responders });
} catch (err) {
if (err?.status === 404) return res.status(404).json({ error: err.message });
console.error('[character/complete] Error:', err);
Expand Down
Loading
Loading