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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ jobs:

- name: Test
run: npm test

- name: Install Next.js example
working-directory: examples/nextjs-basic
run: npm ci

- name: Build Next.js example
working-directory: examples/nextjs-basic
run: npm run build
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ node_modules/
# build output
dist/
coverage/
.next/
**/.next/

# env files
.env
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# @rlippmann/context-compiler

TypeScript port of the Context Compiler core.
Deterministic control layer for LLM applications.
Compiles explicit user directives into authoritative context state before model calls.
Helps hosts enforce premise and policy guardrails consistently across turns.

Reference implementation (Python):
https://github.com/rlippmann/context-compiler
Expand Down Expand Up @@ -31,6 +34,13 @@ Behavioral conformance is defined by the upstream Python fixture corpus and dire
npm install @rlippmann/context-compiler
```

## Examples

- `examples/nextjs-basic/` — minimal Next.js App Router integration
- compiler-mediated request flow
- `clarify` blocks LLM calls
- per-session state via `exportJson()` / `importJson()`

## Quick Start

```ts
Expand All @@ -55,5 +65,7 @@ if (decision.kind === 'update') {
- `engine.step(input)` -> apply one user input and return a `Decision`.
- `engine.state` -> authoritative current state snapshot.
- `engine.exportJson()` / `engine.importJson(payload)` -> state serialization utilities.
- Note: in 0.5.x, export/import serialize authoritative state only (`premise`, `policies`), not pending clarify/confirm interaction state.
- For stateless HTTP integrations, hosts must persist pending clarification context separately when needed; a checkpoint-style full-resume API is planned for 0.6.
- `compile_transcript(messages)` -> replay user messages and return `state` or `confirm`.
- `getPremiseValue(state)` / `getPolicyItems(state, value?)` -> read helpers for state.
9 changes: 3 additions & 6 deletions examples/06_transcript_replay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { compile_transcript, createEngine, type TranscriptResult } from '../src/index.js';
import { compile_transcript, createEngine, getPolicyItems, type TranscriptResult } from '../src/index.js';

declare const process: { argv: string[] };

Expand Down Expand Up @@ -51,11 +51,8 @@ export function runExample06(): {
const currentReplay = applyTranscriptOnCurrentEngine(engine, transcript);

const freshPolicies =
freshReplay.kind === 'state' ? Object.keys(freshReplay.state.policies).sort((a, b) => a.localeCompare(b)) : [];
const currentPolicies =
currentReplay.kind === 'state'
? Object.keys(currentReplay.state.policies).sort((a, b) => a.localeCompare(b))
: [];
freshReplay.kind === 'state' ? getPolicyItems(freshReplay.state) : [];
const currentPolicies = currentReplay.kind === 'state' ? getPolicyItems(currentReplay.state) : [];

return {
freshReplayKind: freshReplay.kind,
Expand Down
6 changes: 4 additions & 2 deletions examples/07_single_policy_correction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEngine } from '../src/index.js';
import { createEngine, getPolicyItems } from '../src/index.js';

declare const process: { argv: string[] };

Expand All @@ -11,10 +11,12 @@ export function runExample07(): {
const decision1 = engine.step('prohibit peanuts');
const decision2 = engine.step('remove policy peanuts');
const decision3 = engine.step('use peanuts');
const useItems = getPolicyItems(engine.state, 'use');
const prohibitItems = getPolicyItems(engine.state, 'prohibit');

return {
stepKinds: [decision1.kind, decision2.kind, decision3.kind],
finalPolicy: engine.state.policies.peanuts ?? null
finalPolicy: useItems.includes('peanuts') ? 'use' : prohibitItems.includes('peanuts') ? 'prohibit' : null
};
}

Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
TypeScript examples showing host-side usage of the Context Compiler core API.

These examples target Python 0.5 semantic compatibility and only use core APIs.
In 0.5.x, `exportJson()` / `importJson()` persist authoritative state only (`premise`, `policies`) and do not persist pending clarify/confirm interaction state.
For stateless HTTP hosts, pending clarification context must be persisted separately if you need request-to-request clarify/confirm continuity.

## 01_persistent_guardrails.ts

Expand Down
8 changes: 8 additions & 0 deletions examples/nextjs-basic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Next.js Basic Integration

This example uses `engine.exportJson()` / `engine.importJson()` for per-session persistence.
In 0.5.x, that payload stores authoritative state only (`premise` and `policies`), not pending clarify/confirm interaction state.
In stateless HTTP flows, a clarify on request N can lose pending confirmation context on request N+1 after restore.
That means follow-ups like `maybe` may not re-trigger the same clarify prompt unless the host persists pending interaction state separately.
A checkpoint-style resume API is planned for 0.6 to support full conversation resume.

125 changes: 125 additions & 0 deletions examples/nextjs-basic/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createEngine, getPolicyItems, getPremiseValue, type EngineState } from '@rlippmann/context-compiler';
import { loadSessionState, saveSessionState } from '../../../lib/context-sessions';

type ChatBody = {
sessionId: string;
input: string;
history?: Array<{ role: string; content: unknown }>;
};

type ChatResponse =
| { kind: 'clarify'; prompt_to_user: string | null }
| { kind: 'continue'; output: string };

function stateToSystemPrompt(state: EngineState): string {
const useItems = new Set(getPolicyItems(state, 'use'));
const policies = getPolicyItems(state)
.map((item) => `- ${useItems.has(item) ? 'USE' : 'PROHIBIT'}: ${item}`)
.join('\n');

return [
'You are an assistant operating under compiled context.',
'',
'PREMISE:',
getPremiseValue(state) ?? '(none)',
'',
'POLICIES:',
policies || '(none)',
'',
'Follow these constraints strictly.'
].join('\n');
}

function minimalRecentContext(history: ChatBody['history']) {
if (!history?.length) {
return [];
}

return history
.filter(
(m): m is { role: 'user' | 'assistant'; content: string } =>
(m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string'
)
.slice(-2)
.map((m) => ({ role: m.role, content: m.content }));
}

export async function POST(req: Request): Promise<Response> {
const { sessionId, input, history }: ChatBody = await req.json();

if (!sessionId || !input) {
return Response.json({ error: 'sessionId and input are required' }, { status: 400 });
}

const engine = createEngine();
const saved = loadSessionState(sessionId);

if (saved) {
engine.importJson(saved);
} else if (history?.length) {
for (const m of history) {
if (m.role !== 'user' || typeof m.content !== 'string') {
continue;
}

const d = engine.step(m.content);
if (d.kind === 'clarify') {
saveSessionState(sessionId, engine.exportJson());
const payload: ChatResponse = {
kind: 'clarify',
prompt_to_user: d.prompt_to_user
};
return Response.json(payload);
}
}

saveSessionState(sessionId, engine.exportJson());
}

const decision = engine.step(input);

if (decision.kind === 'clarify') {
saveSessionState(sessionId, engine.exportJson());
const payload: ChatResponse = {
kind: 'clarify',
prompt_to_user: decision.prompt_to_user
};
return Response.json(payload);
}

saveSessionState(sessionId, engine.exportJson());

const usedReplay = !saved && !!history?.length;
const messages = [
{ role: 'system', content: stateToSystemPrompt(engine.state) },
...(usedReplay ? [] : minimalRecentContext(history)),
{ role: 'user', content: input }
];

const llmRes = await fetch(`${process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL ?? 'gpt-4o-mini',
messages,
temperature: 0.2
})
});

if (!llmRes.ok) {
const text = await llmRes.text();
return Response.json({ error: 'llm_failed', details: text }, { status: 502 });
}

const data = await llmRes.json();
const output = data?.choices?.[0]?.message?.content ?? '';

const payload: ChatResponse = {
kind: 'continue',
output
};
return Response.json(payload);
}
9 changes: 9 additions & 0 deletions examples/nextjs-basic/lib/context-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const sessionState = new Map<string, string>();

export function loadSessionState(sessionId: string): string | null {
return sessionState.get(sessionId) ?? null;
}

export function saveSessionState(sessionId: string, exportedState: string): void {
sessionState.set(sessionId, exportedState);
}
6 changes: 6 additions & 0 deletions examples/nextjs-basic/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Loading
Loading