# @niscorp/signal

Universal LLM abstraction. Stateless, immutable, provider-agnostic. Structured output with Zod, tool calling, zero hard dependencies.

## Install

```bash
pnpm add @niscorp/signal zod
# Plus the provider SDK you want to use:
pnpm add openai  # for OpenAI, Groq, OpenRouter, or any OpenAI-compatible API
```

## Quick Example

```typescript
import { createSignal } from '@niscorp/signal';
import { z } from 'zod';

const signal = createSignal('groq');

// Simple text completion
const { response } = await signal.complete('What is 2+2?');

// Structured output — response is typed
const { response: user } = await signal
  .schema(z.object({ name: z.string(), age: z.number() }))
  .complete('Extract: Alice is 30 years old');
// user.name === 'Alice', user.age === 30
```

## Documentation

- **[DOCS.md](./DOCS.md)** — Full API reference with examples
- **[DESIGN.md](./DESIGN.md)** — Architecture, design decisions, and trade-offs

## API

Every builder method returns a new immutable instance. `complete()`
and `stream()` run the full Signal pipeline (schema, retries, tool
loop). `step()` and `stepStream()` are the low-level primitives — one
adapter call, no auto tool execution — used by orchestrators like
`@niscorp/cortex` that own their own tool loop.

```typescript
// Create
const signal = createSignal('groq');               // known provider
const signal = createSignal('groq', { apiKey, model, systemPrompt, retries });

// Configure (each returns a new instance)
signal.model('qwen/qwen3.8-27b')
signal.systemPrompt('You are helpful.')
signal.schema(zodSchema)           // typed structured output
signal.tools([myTool])             // tool calling
signal.history(messages)           // multi-turn
signal.retries(3)                  // validation retries
signal.options({ temperature: 0 }) // sampling / generation options
signal.apiKey(key)                 // override the env key
signal.describe()                  // provider, model, capabilities — from the registry
signal.onRetry(handler)            // retry hook
signal.onToolCall(handler)         // tool call hook

// Execute — high level
const { response, history, meta } = await signal.complete('user message');

// Execute — streaming
for await (const event of signal.stream('user message')) {
  if (event.type === 'text') process.stdout.write(event.text);
  if (event.type === 'done') console.log(event.meta.usage);
}

// Execute — low level (single adapter call, no tool execution)
const { content, toolCalls, usage, finishReason } = await signal.step({
  messages: [...], tools: [{ name, description, parameters }],
});

// Execute — streaming low level (symmetric with step())
for await (const event of signal.stepStream({ messages, tools })) {
  if (event.type === 'text') process.stdout.write(event.text);
  if (event.type === 'done') {
    // event.result is the aggregated StepResult — same shape as step()
  }
}

// Execute — embedding (separate client, embedding model)
const embedder = createSignal('openai').model('text-embedding-3-small');
const vector = await embedder.embed('wireless headphones');             // number[]
const vectors = await embedder.embed(['shoes', 'boots', 'hat']);       // number[][]
const small = await embedder.embed('text', { dimensions: 256 });       // truncated

// Execute — decisions (typed questions about a state; no text is generated)
const jev = createSignal('typesafe');
const { decisions } = await jev.decide({
  state: { message: 'I was charged twice' },
  questions: {
    intent: { type: 'choice', instructions: 'What do they want?', criteria: { refund: 'Money back', other: 'Anything else' } },
    needsHuman: { type: 'noul', instructions: 'Does this need a person?' },
  },
});
decisions.intent.choice;                                               // 'refund' | 'other'
```

## Providers

| Provider | String | Kind | SDK | Embedding |
|----------|--------|------|-----|-----------|
| Groq | `'groq'` | chat | `openai` | No |
| OpenAI | `'openai'` | chat | `openai` | Yes |
| OpenRouter | `'openrouter'` | chat | `openai` | No |
| Anthropic | `'anthropic'` | chat | stub (use OpenRouter) | No |
| Google | `'google'` | chat | stub (use OpenRouter) | No |
| TypeSafe (Jev) | `'typesafe'` | decisions | none — one `fetch` | — |

A chat provider has every verb; `decide()` runs there by emulation, uncalibrated. A decision provider has `decide()` only.

API keys are read from environment variables (`GROQ_API_KEY`, `OPENAI_API_KEY`, `TYPESAFE_API_KEY`, etc.) or passed via `.apiKey()` / options.

## License

Apache-2.0
