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
50 changes: 50 additions & 0 deletions packages/ai-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# @pairux/ai-core

Provider abstraction for PairUX Pro **AI Session Notes** and **AI Clips**. This is the
compute-plane contract the desktop host calls into: transcript text in, validated
structured output out.

## What it does

- **One interface** — `AiProvider` with `summarizeSession()` and `selectClips()`.
- **Two drivers** — `anthropic` (managed key **or** BYOK) and `ollama` (fully local).
- **Validated output** — every reply is parsed against Zod schemas (`SessionNote`,
`ClipCandidate[]`). Malformed output triggers exactly one auto-repair retry, then
throws `StructuredOutputError` so the caller can fall back to manual mode — the
contract the PRD specifies for clip selection.
- **Text-only boundary** — a provider only ever receives `TranscriptInput` (text +
metadata). Audio and video never cross this package, by construction.

## Usage

```ts
import Anthropic from '@anthropic-ai/sdk';
import { createProvider } from '@pairux/ai-core';

// Managed key or BYOK — the host injects the real SDK client.
const provider = createProvider({ kind: 'anthropic', client: new Anthropic() });

const note = await provider.summarizeSession(transcript, { template: 'pair-programming' });
const clips = await provider.selectClips(transcript, { platform: 'shorts', maxClips: 5 });
```

Fully-local mode (nothing leaves the device):

```ts
const provider = createProvider({ kind: 'ollama', model: 'llama3.1' });
```

## Notes

- The managed default model is **Haiku 4.5** — see `config.ts`. This keeps the
summarize + clip-select pair (two transcript-text-only calls per session) inside
the Pro unit-economics envelope; BYOK callers can override for higher quality.
- `ai-core` deliberately does **not** depend on `@anthropic-ai/sdk`. The host passes a
client that structurally matches `AnthropicClientLike`, keeping this package light
and unit-testable offline.

## Scope

This is the foundation slice for the "Replay" PRD. Recording (P0-1), local Whisper
transcription (P0-2), and the ffmpeg clip renderer (P0-4) live in the desktop app and
depend on this contract; they are not part of this package.
32 changes: 32 additions & 0 deletions packages/ai-core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@pairux/ai-core",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.2.0"
}
}
45 changes: 45 additions & 0 deletions packages/ai-core/src/complete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { completeStructured, type CompleteFn } from './complete.js';
import { StructuredOutputError } from './errors.js';

const schema = z.object({ ok: z.boolean() });

describe('completeStructured', () => {
it('returns parsed output when the first reply is valid', async () => {
const complete: CompleteFn = vi.fn(() => Promise.resolve('{"ok": true}'));
await expect(completeStructured(complete, { system: 's', user: 'u' }, schema)).resolves.toEqual(
{ ok: true }
);
expect(complete).toHaveBeenCalledTimes(1);
});

it('retries exactly once and succeeds on the repair', async () => {
const complete = vi
.fn<CompleteFn>()
.mockResolvedValueOnce('sorry, here: not json')
.mockResolvedValueOnce('{"ok": false}');
const result = await completeStructured(complete, { system: 's', user: 'u' }, schema);
expect(result).toEqual({ ok: false });
expect(complete).toHaveBeenCalledTimes(2);
});

it('asks for JSON-only on the repair attempt', async () => {
const complete = vi
.fn<CompleteFn>()
.mockResolvedValueOnce('nope')
.mockResolvedValueOnce('{"ok": true}');
await completeStructured(complete, { system: 's', user: 'u' }, schema);
const secondCall = complete.mock.calls[1]?.[0];
expect(secondCall?.user).toContain('ONLY the JSON');
expect(secondCall?.system).toBe('s');
});

it('rethrows StructuredOutputError after a second failure (caller falls back to manual mode)', async () => {
const complete: CompleteFn = vi.fn(() => Promise.resolve('still not json'));
await expect(
completeStructured(complete, { system: 's', user: 'u' }, schema)
).rejects.toBeInstanceOf(StructuredOutputError);
expect(complete).toHaveBeenCalledTimes(2);
});
});
38 changes: 38 additions & 0 deletions packages/ai-core/src/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ZodType } from 'zod';
import { StructuredOutputError } from './errors.js';
import { parseStructured } from './parse.js';

export interface Prompt {
system: string;
user: string;
}

/** A minimal chat completion: text in, text out. Every provider reduces to this. */
export type CompleteFn = (prompt: Prompt) => Promise<string>;

/**
* Run a prompt and validate the reply against `schema`.
*
* Implements the PRD's contract: malformed output triggers exactly one
* auto-repair retry (re-asking for JSON only); a second failure rethrows the
* {@link StructuredOutputError} so the caller can fall back to manual mode.
*/
export async function completeStructured<T>(
complete: CompleteFn,
prompt: Prompt,
schema: ZodType<T>
): Promise<T> {
const first = await complete(prompt);
try {
return parseStructured(first, schema);
} catch (error) {
if (!(error instanceof StructuredOutputError)) {
throw error;
}
const repaired = await complete({
system: prompt.system,
user: `${prompt.user}\n\nYour previous reply could not be parsed. Reply again with ONLY the JSON value — no prose, no explanation, no code fences.`,
});
return parseStructured(repaired, schema);
}
}
14 changes: 14 additions & 0 deletions packages/ai-core/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Provider defaults.
*
* The managed-key default is deliberately Haiku 4.5. Summarize + clip-select is
* ~2 transcript-text-only calls per session; at Haiku pricing that keeps the
* managed path inside the Pro unit-economics envelope (review finding F3 — the
* "<$0.40/user/mo" guardrail is only reachable at Haiku tier). BYOK callers can
* override `model` with any Anthropic model for higher-quality summaries.
*/
export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5';
export const DEFAULT_OLLAMA_MODEL = 'llama3.1';
export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
export const DEFAULT_MAX_TOKENS = 2048;
export const DEFAULT_MAX_CLIPS = 6;
15 changes: 15 additions & 0 deletions packages/ai-core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Thrown when a provider's response cannot be parsed into the required structured
* shape. Callers catch this to fall back to manual mode (e.g. the clip-review UI
* lets the host cut clips by hand when selection fails).
*/
export class StructuredOutputError extends Error {
/** The raw model text that failed to parse, preserved for logging and retry. */
readonly raw: string;

constructor(message: string, raw: string, options?: ErrorOptions) {
super(message, options);
this.name = 'StructuredOutputError';
this.raw = raw;
}
}
20 changes: 20 additions & 0 deletions packages/ai-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @pairux/ai-core — provider abstraction for PairUX Pro AI Session Notes + Clips.
*
* One interface ({@link AiProvider}), two drivers (Anthropic for managed/BYOK,
* Ollama for fully-local), Zod-validated structured output, and a one-shot
* auto-repair on malformed replies. Only transcript text ever crosses a provider
* boundary.
*/
export * from './types.js';
export * from './schemas.js';
export * from './templates.js';
export * from './config.js';
export * from './errors.js';
export * from './parse.js';
export * from './transcript.js';
export * from './complete.js';
export * from './prompts.js';
export * from './providers/anthropic.js';
export * from './providers/ollama.js';
export * from './providers/factory.js';
52 changes: 52 additions & 0 deletions packages/ai-core/src/parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { StructuredOutputError } from './errors.js';
import { extractJson, parseStructured } from './parse.js';

const schema = z.object({ ok: z.boolean() });

describe('extractJson', () => {
it('unwraps a ```json fence', () => {
expect(extractJson('```json\n{"ok": true}\n```')).toBe('{"ok": true}');
});

it('unwraps a bare ``` fence', () => {
expect(extractJson('```\n[1, 2]\n```')).toBe('[1, 2]');
});

it('slices JSON out of surrounding prose', () => {
expect(extractJson('Sure! {"ok": true} hope that helps')).toBe('{"ok": true}');
});

it('slices an array out of prose', () => {
expect(extractJson('here you go: [{"a":1}] done')).toBe('[{"a":1}]');
});

it('returns trimmed input when no JSON is present', () => {
expect(extractJson(' no json here ')).toBe('no json here');
});
});

describe('parseStructured', () => {
it('parses and validates', () => {
expect(parseStructured('{"ok": true}', schema)).toEqual({ ok: true });
});

it('throws StructuredOutputError on invalid JSON', () => {
expect(() => parseStructured('{not json', schema)).toThrow(StructuredOutputError);
});

it('throws StructuredOutputError on schema mismatch', () => {
expect(() => parseStructured('{"ok": "yes"}', schema)).toThrow(StructuredOutputError);
});

it('preserves the raw output on the error', () => {
try {
parseStructured('garbage', schema);
expect.unreachable('should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(StructuredOutputError);
expect((error as StructuredOutputError).raw).toBe('garbage');
}
});
});
88 changes: 88 additions & 0 deletions packages/ai-core/src/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ZodType } from 'zod';
import { StructuredOutputError } from './errors.js';

const FENCE = '```';

/** True if `text` is a short run of ASCII letters (a code-fence language tag like "json"). */
function isLanguageTag(text: string): boolean {
if (text.length === 0 || text.length > 16) {
return false;
}
for (const char of text) {
const isLetter = (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z');
if (!isLetter) {
return false;
}
}
return true;
}

/** Index of the first `{` or `[` in `raw`, or -1. Linear scan (no regex, no backtracking). */
function firstBracket(raw: string): number {
for (let i = 0; i < raw.length; i += 1) {
const char = raw[i];
if (char === '{' || char === '[') {
return i;
}
}
return -1;
}

/**
* Pull the first JSON value out of a model reply that may wrap it in prose or a
* ```json fence. Uses index scans only (no regex) so it cannot backtrack on
* adversarial input. Best-effort: returns the most plausible JSON substring,
* which `parseStructured` then validates.
*/
export function extractJson(raw: string): string {
const fenceStart = raw.indexOf(FENCE);
if (fenceStart !== -1) {
const afterOpen = fenceStart + FENCE.length;
const fenceEnd = raw.indexOf(FENCE, afterOpen);
if (fenceEnd !== -1) {
let body = raw.slice(afterOpen, fenceEnd);
const newline = body.indexOf('\n');
if (newline !== -1 && isLanguageTag(body.slice(0, newline).trim())) {
body = body.slice(newline + 1);
}
return body.trim();
}
}

const start = firstBracket(raw);
if (start === -1) {
return raw.trim();
}

const opener = raw[start];
const closer = opener === '[' ? ']' : '}';
const end = raw.lastIndexOf(closer);
if (end > start) {
return raw.slice(start, end + 1).trim();
}
return raw.slice(start).trim();
}

/** Extract + JSON.parse + schema-validate. Throws {@link StructuredOutputError} on any failure. */
export function parseStructured<T>(raw: string, schema: ZodType<T>): T {
const json = extractJson(raw);

let parsed: unknown;
try {
parsed = JSON.parse(json) as unknown;
} catch (cause) {
throw new StructuredOutputError('model output was not valid JSON', raw, { cause });
}

const result = schema.safeParse(parsed);
if (!result.success) {
throw new StructuredOutputError(
`model output failed schema validation: ${result.error.message}`,
raw,
{
cause: result.error,
}
);
}
return result.data;
}
Loading
Loading