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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public/app.bundle.js
public/hljs-*.css
sessions.json
scenario.json
rubric.json
report.md
.agent-build
dist
Expand Down
21 changes: 13 additions & 8 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,18 @@ of the same shape.
"hideAssistant": false,
"hideHistory": false,
"strings": {}
},

"rubricHints": {
"notes": "Reward: acknowledges proposal, counters price with rationale, proposes 12-month term, professional tone."
}
}
```

Grading notes live in a sidecar `rubric.json` (not in the scenario), e.g.:

```json
{
"notes": "Reward: acknowledges proposal, counters price with rationale, proposes 12-month term, professional tone."
}
```

### 6.2 Seed email shape (illustrative)

This object lives in `seed.inbox`. For a large mailbox, the same JSON may live
Expand Down Expand Up @@ -286,7 +290,8 @@ in a separate file and `seed.inbox` can be a path to it.
- Control generation (model, temperature, thinking, language) and attachments.
- Configure the submission label/limit and UI/i18n overrides (reusing ChatCPT's
string-override + i18n approach).
- Provide optional `rubricHints` metadata for the external tutor/assessment.
- Provide optional grading notes in a sidecar `rubric.json` (never in the scenario
runtime or learner/Cosmo surface) for the external tutor/assessment.

---

Expand Down Expand Up @@ -389,9 +394,9 @@ draft revisions, the assistant chat transcript, and the final submission.
- the **assistant chat transcript** (including any search/Q&A the learner did),
- a combined **report** for the tutor/rubric.
- Modes along the lines of `--mode full|submission|thread|assistant|report`,
plus `--latest`, `--output`, `--print-settings`.
- **Used by:** the external AI tutor and assessment rubrics (the `rubricHints`
block in the scenario config can inform grading).
plus `--latest`, `--output`, `--print-settings`, `--rubric`.
- **Used by:** the external AI tutor and assessment rubrics (sidecar `rubric.json`
can inform grading; it is not part of the scenario the learner runs).

---

Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,25 @@ shape, relative to the project root.
| `attachments` | `{ enabled, allowedTypes }` | Outbound attachment support (learner can attach files in the composer when enabled). |
| Seed email `attachments[]` | `{ name, text? }` | Optional on seeded inbound emails. `name` is shown in the UI. Author-provided `text` is readable by Cosmo (Q&A / summarize / extract); omit `text` when contents should stay unknown. Not real file parsing. |
| `ui` | `{ hideHistory, strings }` | UI overrides + i18n strings. |
| `rubricHints` | object \| string | Notes surfaced in the extraction report. |

Relative date tokens in seed strings (resolved once at load): `${today}`,
`${tomorrow}`, `${yesterday}`, `${next week}`, `${last week}`,
`${3 days from today}`, `${in 2 days}`, `${5 days ago}`. Use them in
`email.date`, bodies, subjects, and attachment `text`. Bare date fields like
`"${today}"` become ISO datetimes (`…T12:00:00Z`); prose keeps `YYYY-MM-DD`.

### Rubric sidecar (not part of the scenario)

Grading notes do **not** belong in `scenario.json`. Put them in a sidecar file
next to the scenario:

| Scenario file | Rubric file |
| --- | --- |
| `scenario.json` | `rubric.json` |
| `foo.scenario.json` | `foo.rubric.json` |

`npm run report` includes the sidecar when present. Override with
`--rubric <file>`. Cosmo and `/api/config` never see rubric content.

### Example scenarios

Expand All @@ -189,7 +207,9 @@ one to `scenario.json` to try it:
prospects; Ryan is the right call).

```bash
cp scenario-examples/04-simulated-recipient-support.scenario.json scenario.json && npm run dev
cp scenario-examples/04-simulated-recipient-support.scenario.json scenario.json && \
cp scenario-examples/04-simulated-recipient-support.rubric.json rubric.json && \
npm run dev
```

See [`scenario-examples/README.md`](scenario-examples/README.md) for details.
Expand Down
2 changes: 1 addition & 1 deletion build-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ Octavus presigned uploads.
- [x] `extract-conversations.js` reading `sessions.json`, with modes:
`full`, `submission`, `thread`, `assistant`, `report`; options `--latest`,
`--output <file>`, `--print-settings`, `--help`. `npm run extract`/`report`.
- [x] Output email bodies as Markdown; include `rubricHints` in the report header.
- [x] Output email bodies as Markdown; include sidecar `rubric.json` hints in the report header.

**Verify**
- [x] Each mode prints/writes correct, readable output for a sample session.
Expand Down
39 changes: 37 additions & 2 deletions extract-conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@
* writes to report.md by default; other modes already
* print to stdout unless --output is given).
* --print-settings Include the scenario's key settings in the heading.
* --rubric <file> Rubric sidecar JSON for report mode (default: rubric.json
* next to the scenario, or foo.rubric.json beside
* foo.scenario.json).
* -h, --help Show this help message.
*/

import { readFileSync, writeFileSync } from 'fs';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { characterIsLive, normalizeWorld } from './lib/character-replies.js';
import { defaultRubricPath, rubricLines } from './lib/rubric.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSIONS_FILE = process.env.SESSIONS_FILE || join(__dirname, 'sessions.json');
Expand Down Expand Up @@ -86,11 +90,15 @@ Options:
--output <file> Write output to this file instead of the default
--stdout Print to stdout instead of writing report.md (report mode)
--print-settings Include a human-readable World/Characters summary
--rubric <file> Rubric sidecar for report mode (default beside scenario)
-h, --help Show this help message

The assistant (Cosmo) conversation is only included when the scenario's
"assistant.enabled" is true (the default); otherwise the report notes that
the assistant was disabled for the scenario.`);
the assistant was disabled for the scenario.

Grading notes belong in a sidecar rubric file (not scenario.json) and are
included in report mode when that file is present.`);
process.exit(0);
}

Expand Down Expand Up @@ -122,6 +130,8 @@ const explicitOutputFile = outputIdx !== -1 && args[outputIdx + 1] ? args[output
// Report mode is meant to be handed to a human grader, so it writes a
// report.md file by default instead of dumping to stdout.
const outputFile = explicitOutputFile ?? (mode === 'report' && !stdout ? DEFAULT_REPORT_FILE : null);
const rubricIdx = args.indexOf('--rubric');
const explicitRubricFile = rubricIdx !== -1 && args[rubricIdx + 1] ? args[rubricIdx + 1] : null;

// ── Load ──────────────────────────────────────────────────────
// No sessions.json yet means the participant hasn't opened the scenario at
Expand All @@ -147,6 +157,23 @@ try {
scenario = {};
}

const rubricPath = explicitRubricFile || defaultRubricPath(SCENARIO_FILE);
let rubric = null;
if (mode === 'report') {
if (explicitRubricFile && !existsSync(rubricPath)) {
console.log(`Error: rubric file not found: ${rubricPath}`);
process.exit(1);
}
if (existsSync(rubricPath)) {
try {
rubric = JSON.parse(readFileSync(rubricPath, 'utf8'));
} catch (err) {
console.log(`Could not read ${rubricPath}: ${err.message}`);
process.exit(1);
}
}
}

// Defaults to true, matching lib/scenario.js's default for `assistant.enabled`.
const assistantEnabled = scenario.assistant?.enabled !== false;

Expand Down Expand Up @@ -209,6 +236,10 @@ function overviewLines() {
return lines;
}

function rubricSectionLines() {
return rubricLines(rubric);
}

function emailLines(email) {
const dir = email.outbound ? 'Outbound (participant)' : 'Inbound';
const lines = [
Expand Down Expand Up @@ -466,6 +497,10 @@ if (mode === 'report') {
lines.push('# CosmoMail Session Report', '');
lines.push(`*Generated on ${formatDate(new Date().toISOString())}*`, '', '---', '');
lines.push(...overviewLines());
const rubricBlock = rubricSectionLines();
if (rubricBlock.length) {
lines.push(...rubricBlock, '---', '');
}
if (reportSessions.length === 0) {
lines.push(NO_SESSIONS_MESSAGE, '');
} else {
Expand Down
18 changes: 11 additions & 7 deletions features.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
## Features
- Support dates relative to today in seeds. Instead of saying "By July 15th", I should be able to say something like "By ${today}" or "By ${next week}" or "By ${3 days from today}" etc
- The scenario should not know anything about the grading rubric
- The assistant should use a SplitPanel so it can be re-sized
- We need some way of knowing that Cosmo is "thinking". A spinner, or some sort of animation, etc.
- Clicking a seeded inbound attachment chip should open a small read-only preview when author-provided `text` is present (name-only chips stay non-interactive; no real file parsing).
- ~~Support dates relative to today in seeds. Instead of saying "By July 15th", I should be able to say something like "By ${today}" or "By ${next week}" or "By ${3 days from today}" etc~~
- ~~The scenario should not know anything about the grading rubric~~
- ~~The assistant should use a SplitPanel so it can be re-sized~~
- ~~We need some way of knowing that Cosmo is "thinking". A spinner, or some sort of animation, etc.~~
- ~~Clicking a seeded inbound attachment chip should open a small read-only preview when author-provided `text` is present (name-only chips stay non-interactive; no real file parsing).~~

## Bugs
- No auth / session-ownership checks on mutating APIs (e.g. `POST /api/assistant/clear`, send, session save). Any caller who knows a `sessionId` can modify that session. Needs a real ownership model if this ever leaves a single-learner local harness. (not a bug. This is not a real production app and only runs in a sandbox for one user)
- Session store (`sessions.json`) has no serialized read-modify-write: overlapping `/api/session/save`, `/api/session/events`, send, and draft saves can overwrite each other’s updates. Needs a shared per-file (or per-session) write queue if concurrent writers matter.
- ~~No auth / session-ownership checks on mutating APIs (e.g. `POST /api/assistant/clear`, send, session save). Any caller who knows a `sessionId` can modify that session. Needs a real ownership model if this ever leaves a single-learner local harness.~~ (not a bug. This is not a real production app and only runs in a sandbox for one user)
- ~~Session store (`sessions.json`) has no serialized read-modify-write: overlapping `/api/session/save`, `/api/session/events`, send, and draft saves can overwrite each other’s updates. Needs a shared per-file (or per-session) write queue if concurrent writers matter.~~ (fixed via `withSessionsWrite` / `updateSessionById`)
- `sendAssistant` only guards on `chat.status === 'streaming'`, so Enter can still submit while a quick action is busy. Use `assistantIsBusy()` (keep the missing-chat guard).

## A11y

Parked until after the resizable shell. Track remediation in
[a11y_audits/findings-checklist.md](a11y_audits/findings-checklist.md)
(start with C1/C2, then S1–S5). SplitPanel unblocks S3 reflow work.
7 changes: 6 additions & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
"Awaiting reply…": "Awaiting reply…",
"New email": "New email",
"Dismiss": "Dismiss",
"Discard": "Discard"
"Discard": "Discard",
"Thinking…": "Thinking…",
"Working…": "Working…",
"Resize Cosmo panel": "Resize Cosmo panel",
"Preview attachment": "Preview attachment",
"Attachment": "Attachment"
}
}
136 changes: 136 additions & 0 deletions lib/relative-dates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Expand ${relative date} tokens in scenario seed strings.
*
* Tokens resolve at scenario load time (not per request) so seeded sessions
* keep stable absolute timestamps for the life of the process.
*
* Supported expressions (case-insensitive):
* today | tomorrow | yesterday
* next week | last week
* N days from today | N day from today
* in N days
* N days ago | N day ago
*
* Calendar-day results are YYYY-MM-DD. When a token stands alone as an
* email.date value, a midday UTC time is appended (T12:00:00Z) so ISO parsing
* stays unambiguous. Authors may also write `${today}T14:02:00Z`.
*/

const TOKEN_RE = /\$\{([^}]+)\}/g;

function startOfUtcDay(date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

function addUtcDays(date, days) {
const next = new Date(date.getTime());
next.setUTCDate(next.getUTCDate() + days);
return next;
}

function formatUtcDate(date) {
return date.toISOString().slice(0, 10);
}

/**
* Parse a relative-date expression into a UTC calendar day, or null if unknown.
* @param {string} expression
* @param {Date} [now]
* @returns {Date|null}
*/
export function resolveRelativeDateExpression(expression, now = new Date()) {
const expr = String(expression || '').trim().toLowerCase().replace(/\s+/g, ' ');
if (!expr) return null;

const today = startOfUtcDay(now);

if (expr === 'today') return today;
if (expr === 'tomorrow') return addUtcDays(today, 1);
if (expr === 'yesterday') return addUtcDays(today, -1);
if (expr === 'next week') return addUtcDays(today, 7);
if (expr === 'last week') return addUtcDays(today, -7);

let match = expr.match(/^(\d+)\s+days?\s+from\s+today$/);
if (match) return addUtcDays(today, Number(match[1]));

match = expr.match(/^in\s+(\d+)\s+days?$/);
if (match) return addUtcDays(today, Number(match[1]));

match = expr.match(/^(\d+)\s+days?\s+ago$/);
if (match) return addUtcDays(today, -Number(match[1]));

return null;
}

/**
* Expand ${...} tokens inside a string. Unknown tokens are left unchanged.
* @param {string} value
* @param {Date} [now]
* @returns {string}
*/
export function expandRelativeDatesInString(value, now = new Date()) {
if (typeof value !== 'string' || !value.includes('${')) return value;
return value.replace(TOKEN_RE, (full, expr) => {
const resolved = resolveRelativeDateExpression(expr, now);
return resolved ? formatUtcDate(resolved) : full;
});
}

/**
* Expand tokens in an email.date field. Bare `${today}` becomes an ISO
* datetime; mixed strings (e.g. body prose) use expandRelativeDatesInString.
* @param {string} value
* @param {Date} [now]
* @returns {string}
*/
export function expandRelativeDateField(value, now = new Date()) {
if (typeof value !== 'string' || !value.includes('${')) return value;

const trimmed = value.trim();
const bare = trimmed.match(/^\$\{([^}]+)\}$/);
if (bare) {
const resolved = resolveRelativeDateExpression(bare[1], now);
if (resolved) return `${formatUtcDate(resolved)}T12:00:00Z`;
}

// `${today}T14:02:00Z` → `2026-09-30T14:02:00Z`
const withTime = trimmed.match(/^\$\{([^}]+)\}(T[\d:.]+Z?)$/i);
if (withTime) {
const resolved = resolveRelativeDateExpression(withTime[1], now);
if (resolved) return `${formatUtcDate(resolved)}${withTime[2]}`;
}

return expandRelativeDatesInString(value, now);
}

function expandValue(value, now, { asDateField = false } = {}) {
if (typeof value === 'string') {
return asDateField
? expandRelativeDateField(value, now)
: expandRelativeDatesInString(value, now);
}
if (Array.isArray(value)) {
return value.map((entry) => expandValue(entry, now));
}
if (value && typeof value === 'object') {
const out = {};
for (const [key, child] of Object.entries(value)) {
out[key] = expandValue(child, now, { asDateField: key === 'date' });
}
return out;
}
return value;
}

/**
* Deep-expand relative date tokens across a resolved inbox `{ threads }`.
* @param {{ threads?: unknown[] }|null|undefined} inbox
* @param {Date} [now]
* @returns {{ threads: unknown[] }}
*/
export function expandRelativeDatesInInbox(inbox, now = new Date()) {
const threads = Array.isArray(inbox?.threads) ? inbox.threads : [];
return {
threads: threads.map((thread) => expandValue(thread, now)),
};
}
Loading
Loading