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: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
6e12c84d059bb4f261a25a27a57c44e58f658bca # prettier, 12 files
4 changes: 2 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 5
Expand All @@ -20,7 +20,7 @@ updates:
update-types: [minor, patch]

- package-ecosystem: github-actions
directory: "/"
directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 3
31 changes: 31 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Build output and vendored trees — formatting these is noise.
node_modules
.next
dist
build
out
coverage
.turbo
.vercel
*.min.js
*.min.css

# Generated during a build, so it is absent locally and present in CI — which
# makes a clean local --check no evidence at all. Contentlayer's output also
# uses import assertions, which prettier's parser rejects outright.
.contentlayer
.astro
.svelte-kit
storybook-static
test-results
playwright-report

# Lockfiles are generated; prettier would rewrite them wholesale.
package-lock.json
pnpm-lock.yaml
yarn.lock

# Markdown is deliberately out of scope for now. Prettier rewraps prose, which
# is where it is most opinionated and least useful, and it would bury the real
# diff. Remove this line when you want docs formatted too.
*.md
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}
8 changes: 4 additions & 4 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// library code, and a bespoke rule set would be a second opinion to maintain
// for no benefit. The floor is "lint runs and can fail", not "lint encodes
// taste".
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default tseslint.config(
{
Expand All @@ -22,4 +22,4 @@ export default tseslint.config(
files: ['test/**/*.js', 'scripts/**/*.{js,mjs}'],
languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } },
},
)
);
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,17 @@
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/*.test.js",
"verify": "npm run lint && npm run typecheck && npm run build && npm test",
"prepare": "npm run build"
"verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test",
"prepare": "npm run build",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/node": "^22.10.2",
"eslint": "^9.39.5",
"globals": "^15.15.0",
"prettier": "3.9.6",
"typescript": "^5.8.2",
"typescript-eslint": "^8.67.0"
}
Expand Down
15 changes: 6 additions & 9 deletions src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ export function whenMentioned(aliases: readonly string[]): RespondPolicy {
// addressed to it.
const patterns = aliases
.filter(Boolean)
.map(a => new RegExp(`(^|\\W)${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\W|$)`, 'i'));
.map((a) => new RegExp(`(^|\\W)${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\W|$)`, 'i'));
return ({ visible, self }) => {
const last = visible[visible.length - 1];
if (!last || last.authorId === self.actorId) return false;
return patterns.some(re => re.test(last.body));
return patterns.some((re) => re.test(last.body));
};
}

Expand All @@ -84,7 +84,7 @@ export function whenMentioned(aliases: readonly string[]): RespondPolicy {
* clinical thread unusable the moment an assistant joined.
*/
export function defaultRespondPolicy(ctx: AiTurnContext): boolean {
const active = ctx.thread.participants.filter(p => !p.leftAt);
const active = ctx.thread.participants.filter((p) => !p.leftAt);
if (active.length <= 2) return afterEveryMessage(ctx);
return whenMentioned([ctx.self.role ?? 'assistant', 'ai'])(ctx);
}
Expand All @@ -98,12 +98,9 @@ function trailingSelfMessages(visible: readonly Message[], actorId: string): num
return n;
}

function renderTranscript(
ctx: AiTurnContext,
label: (p: Participant) => string
): string {
function renderTranscript(ctx: AiTurnContext, label: (p: Participant) => string): string {
return ctx.visible
.map(m => {
.map((m) => {
const author = findParticipant(ctx.thread, m.authorId);
const who = author ? label(author) : 'unknown';
return `${who}: ${m.body}`;
Expand All @@ -124,7 +121,7 @@ function renderTranscript(
export async function runAiTurn(
thread: Thread,
allMessages: readonly Message[],
config: AiParticipantConfig
config: AiParticipantConfig,
): Promise<AiTurnResult> {
const self = findParticipant(thread, config.actorId);
if (!self) return { status: 'skipped', reason: 'not a participant in this thread' };
Expand Down
14 changes: 2 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,5 @@ export { unreadCount, unreadMessages, unreadThreadCount } from './unread.js';

export { compareMessages, isPending, mergeMessages } from './merge.js';

export {
afterEveryMessage,
defaultRespondPolicy,
runAiTurn,
whenMentioned,
} from './ai.js';
export type {
AiParticipantConfig,
AiTurnContext,
AiTurnResult,
RespondPolicy,
} from './ai.js';
export { afterEveryMessage, defaultRespondPolicy, runAiTurn, whenMentioned } from './ai.js';
export type { AiParticipantConfig, AiTurnContext, AiTurnResult, RespondPolicy } from './ai.js';
2 changes: 1 addition & 1 deletion src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function compareMessages(a: Message, b: Message): number {
*/
export function mergeMessages(
existing: readonly Message[],
incoming: readonly Message[]
incoming: readonly Message[],
): Message[] {
const byId = new Map<string, Message>();
const idByClientId = new Map<string, string>();
Expand Down
13 changes: 5 additions & 8 deletions src/participants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ import type { Message, Participant, Thread } from './types.js';
* thread does not have that failure mode.
*/

export function findParticipant(
thread: Thread,
actorId: string
): Participant | undefined {
return thread.participants.find(p => p.actorId === actorId);
export function findParticipant(thread: Thread, actorId: string): Participant | undefined {
return thread.participants.find((p) => p.actorId === actorId);
}

/** Has this actor ever been part of the thread? Necessary, never sufficient — see `canSeeMessage`. */
Expand Down Expand Up @@ -63,11 +60,11 @@ export function canSeeMessage(p: Participant, message: Message): boolean {
export function visibleMessages(
thread: Thread,
actorId: string,
messages: readonly Message[]
messages: readonly Message[],
): Message[] {
const p = findParticipant(thread, actorId);
if (!p) return [];
return messages.filter(m => m.threadId === thread.id && canSeeMessage(p, m));
return messages.filter((m) => m.threadId === thread.id && canSeeMessage(p, m));
}

/**
Expand All @@ -78,7 +75,7 @@ export function visibleMessages(
* access that does not exist.
*/
export function readersOf(thread: Thread, message: Message): Participant[] {
return thread.participants.filter(p => {
return thread.participants.filter((p) => {
if (p.actorId === message.authorId) return false;
if (!canSeeMessage(p, message)) return false;
if (!p.lastReadAt) return false;
Expand Down
14 changes: 5 additions & 9 deletions src/unread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import { findParticipant, canSeeMessage } from './participants.js';
export function unreadMessages(
thread: Thread,
actorId: string,
messages: readonly Message[]
messages: readonly Message[],
): Message[] {
const p = findParticipant(thread, actorId);
if (!p) return [];
const since = p.lastReadAt?.getTime() ?? null;
return messages.filter(m => {
return messages.filter((m) => {
if (m.threadId !== thread.id) return false;
if (m.authorId === actorId) return false;
if (!canSeeMessage(p, m)) return false;
Expand All @@ -30,11 +30,7 @@ export function unreadMessages(
});
}

export function unreadCount(
thread: Thread,
actorId: string,
messages: readonly Message[]
): number {
export function unreadCount(thread: Thread, actorId: string, messages: readonly Message[]): number {
return unreadMessages(thread, actorId, messages).length;
}

Expand All @@ -46,7 +42,7 @@ export function unreadCount(
*/
export function unreadThreadCount(
entries: readonly { thread: Thread; messages: readonly Message[] }[],
actorId: string
actorId: string,
): number {
return entries.filter(e => unreadCount(e.thread, actorId, e.messages) > 0).length;
return entries.filter((e) => unreadCount(e.thread, actorId, e.messages) > 0).length;
}
4 changes: 2 additions & 2 deletions test/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function msg(id, authorId, createdAt, body = 'hello') {
/** Records what the model was shown, so the test can assert on the prompt itself. */
function spyComplete(reply = 'a reply') {
const seen = [];
const complete = async input => {
const complete = async (input) => {
seen.push(input);
return reply;
};
Expand Down Expand Up @@ -47,7 +47,7 @@ test('the AI sees only what it was granted, not the whole transcript', async ()
assert.equal(seen.length, 1);
assert.ok(
!seen[0].prompt.includes('private history'),
'a model added later must not be handed the history it was not granted'
'a model added later must not be handed the history it was not granted',
);
assert.ok(seen[0].prompt.includes('summarise my sleep'));
});
Expand Down
22 changes: 11 additions & 11 deletions test/authorization.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';

import {
canRead,
canWrite,
readersOf,
unreadCount,
visibleMessages,
} from '../dist/index.js';
import { canRead, canWrite, readersOf, unreadCount, visibleMessages } from '../dist/index.js';

const T0 = new Date('2026-01-01T09:00:00Z');
const T1 = new Date('2026-01-01T10:00:00Z');
Expand Down Expand Up @@ -40,7 +34,7 @@ test('a clinician added later does not see what was said before they joined', ()
const t = thread([patient, drA, drB]);
const messages = [msg('m1', 'patient-1', T1), msg('m2', 'dr-a', T3)];

const seen = visibleMessages(t, 'dr-b', messages).map(m => m.id);
const seen = visibleMessages(t, 'dr-b', messages).map((m) => m.id);
assert.deepEqual(seen, ['m2'], 'history before joining is not granted by default');
});

Expand All @@ -55,7 +49,7 @@ test('granting thread-start hands over the whole history, on purpose', () => {
const t = thread([patient, drA, drB]);
const messages = [msg('m1', 'patient-1', T1), msg('m2', 'dr-a', T3)];

const seen = visibleMessages(t, 'dr-b', messages).map(m => m.id);
const seen = visibleMessages(t, 'dr-b', messages).map((m) => m.id);
assert.deepEqual(seen, ['m1', 'm2']);
});

Expand All @@ -70,7 +64,10 @@ test('someone who left keeps their history and stops receiving what comes next',
const t = thread([patient, locum]);
const messages = [msg('m1', 'patient-1', T1), msg('m2', 'patient-1', T3)];

assert.deepEqual(visibleMessages(t, 'locum', messages).map(m => m.id), ['m1']);
assert.deepEqual(
visibleMessages(t, 'locum', messages).map((m) => m.id),
['m1'],
);
assert.equal(canWrite(t, 'locum', T3), false, 'a departed participant loses their voice');
assert.equal(canRead(t, 'locum'), true, 'but not what they already saw');
});
Expand Down Expand Up @@ -112,5 +109,8 @@ test('a read receipt never implies access the reader does not have', () => {
const early = msg('m1', 'patient-1', T1);

// dr-b's clock says they have read up to T3, but m1 predates their window.
assert.deepEqual(readersOf(t, early).map(p => p.actorId), []);
assert.deepEqual(
readersOf(t, early).map((p) => p.actorId),
[],
);
});
9 changes: 6 additions & 3 deletions test/merge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ test('messages sharing a timestamp keep a stable order', () => {
const a = { id: 'b', threadId: 't1', authorId: 'x', body: 'a', createdAt: T1 };
const b = { id: 'a', threadId: 't1', authorId: 'x', body: 'b', createdAt: T1 };

const once = mergeMessages([a, b], []).map(m => m.id);
const twice = mergeMessages([b, a], []).map(m => m.id);
const once = mergeMessages([a, b], []).map((m) => m.id);
const twice = mergeMessages([b, a], []).map((m) => m.id);
assert.deepEqual(once, twice, 'insertion order must not change what the user sees');
assert.deepEqual(once, ['a', 'b']);
});
Expand All @@ -51,7 +51,10 @@ test('merging is ordered by time, not by arrival', () => {
const late = { id: 'm2', threadId: 't1', authorId: 'x', body: 'second', createdAt: T2 };
const early = { id: 'm1', threadId: 't1', authorId: 'x', body: 'first', createdAt: T1 };

assert.deepEqual(mergeMessages([late], [early]).map(m => m.id), ['m1', 'm2']);
assert.deepEqual(
mergeMessages([late], [early]).map((m) => m.id),
['m1', 'm2'],
);
});

test('a pending message is distinguishable from a confirmed one', () => {
Expand Down
Loading
Loading