diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f78236a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,42 @@ +name: Publish + +# Publishing is driven by a version tag, so the released artifact is always +# traceable to a commit. `npm version` creates the tag; pushing it ships. +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # Required for npm provenance — proves on the registry that this tarball + # was built by this workflow from this commit. + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - run: npm ci --ignore-scripts + + # Never publish something that would not have passed CI. + - run: npm run verify + + # Refuse to publish a tag whose version does not match package.json, + # rather than silently shipping the wrong number. + - name: Check tag matches package version + run: | + tag="${GITHUB_REF_NAME#v}" + pkg=$(node -p "require('./package.json').version") + if [ "$tag" != "$pkg" ]; then + echo "Tag v$tag does not match package.json version $pkg" >&2 + exit 1 + fi + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8657ac0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mao Nakamoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e9eb3ce..0fce16a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,26 @@ -# @fleet/ai-forms +# ai-forms -Headless AI form filling and conversational refinement. One implementation, shared by every app in the fleet. +[![npm](https://img.shields.io/npm/v/ai-forms.svg)](https://www.npmjs.com/package/ai-forms) +[![CI](https://github.com/maonakamoto/ai-forms/actions/workflows/ci.yml/badge.svg)](https://github.com/maonakamoto/ai-forms/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -Forms are the worst part of most software. This package makes every form in every project answer to plain language — fill it from a description, then keep talking to it ("shorter", "move the date to next Friday", "actually call it something else") until it is right. +Headless AI form filling and conversational refinement. + +Forms are the worst part of most software. This package makes a form answer to plain language — fill it from a description, then keep talking to it ("shorter", "move the date to next Friday", "actually call it something else") until it is right. ``` -npm install github:maonakamoto/ai-forms#v0.1.0 +npm install ai-forms ``` -No registry auth. Versioned by git tag. `npm ci` builds it from source on install. +No provider SDK, no markup, no styles. You pass in a function that calls whatever model you already use; the package handles prompting, parsing, sanitising, and the merge rules that decide who wins when the model and the user disagree. + +Works with any model (OpenAI, Anthropic, Groq, Gemini, local — anything you can wrap in `(prompt) => Promise`), any framework on the server, and React on the client. The React hook is optional; the core is framework-free. --- -## The standard +## What "done" looks like -An app meets the standard when all five hold: +Five properties, all of which this package holds: 1. **Every form can be filled from prose.** The user types what they want in one box; the form fills in. 2. **Every filled form can be changed by talking to it.** Follow-up instructions apply to what is already there. This is the part almost everyone gets wrong — see "Why refinement silently fails" below. @@ -45,7 +51,7 @@ The same mistake has a sibling: a single minimum input length. A fill descriptio ```ts // src/config/ai-forms.ts -import { defineFields, type FormTarget } from '@fleet/ai-forms'; +import { defineFields, type FormTarget } from 'ai-forms'; export const GOAL_FORM: FormTarget = { key: 'goal', @@ -68,9 +74,8 @@ export const AI_FORMS = [GOAL_FORM /* , ... */]; ```ts // src/app/api/ai/form-assist/route.ts -import { createFormAssistHandler } from '@fleet/ai-forms/server'; +import { createFormAssistHandler } from 'ai-forms/server'; import { AI_FORMS } from '@/config/ai-forms'; -import { callGroqText } from '@/lib/groq'; import { getCurrentUserId } from '@/lib/session'; export const POST = createFormAssistHandler({ @@ -78,12 +83,34 @@ export const POST = createFormAssistHandler({ authorize: async () => (await getCurrentUserId()) ? { ok: true } : { ok: false, status: 401, error: 'Sign in to use the assistant.' }, - complete: ({ system, prompt, maxTokens, temperature }) => - callGroqText(prompt, { systemPrompt: system, maxTokens, temperature }), + + // Any provider. `complete` just has to return the model's text. + complete: async ({ system, prompt, maxTokens, temperature }) => { + const res = await fetch(`${process.env.LLM_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${process.env.LLM_API_KEY}`, + }, + body: JSON.stringify({ + model: process.env.LLM_MODEL, + max_tokens: maxTokens, + temperature, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: prompt }, + ], + }), + }); + const json = await res.json(); + return json.choices[0].message.content; + }, }); ``` -The package never owns API keys, model choice, budgets, or fallback policy — the app passes its own caller. Field specs live on the server, so a client can never widen the set of fields the model may write. +The package never owns API keys, model choice, budgets, or fallback policy — the app passes its own caller. That is deliberate: you already have retry, spend caps, and a fallback chain, and a form library has no business owning any of them. + +Field specs live on the server, so a client can never widen the set of fields the model may write. ### 3. Use the hook in the form @@ -110,7 +137,7 @@ Rendering is yours. The package ships no markup and no classes — each app has ## API -### `@fleet/ai-forms` +### `ai-forms` | Export | Purpose | | --- | --- | @@ -121,11 +148,11 @@ Rendering is yours. The package ships no markup and no classes — each app has | `parseAssistResponse(text)` | Extract JSON from fenced or prose-wrapped completions | | `MIN_INSTRUCTION_LENGTH` | Per-intent input floors | -### `@fleet/ai-forms/server` +### `ai-forms/server` `createFormAssistHandler(config)` → `(Request) => Promise`. Web-standard, so it drops straight into a Next.js App Router route. `authorize` runs before any model call. -### `@fleet/ai-forms/react` +### `ai-forms/react` `useAiForm(options)` → values, `setValue`, `ask` / `fill` / `refine`, `busy`, `error`, `transcript`, `changed`, `isAiTouched`, `undo`, `canUndo`, `reset`. diff --git a/package-lock.json b/package-lock.json index 3b31882..34af425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@fleet/ai-forms", + "name": "ai-forms", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@fleet/ai-forms", + "name": "ai-forms", "version": "0.1.0", "license": "MIT", "devDependencies": { @@ -13,6 +13,9 @@ "@types/react": "^19.0.2", "typescript": "^5.8.2" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "react": ">=18" }, diff --git a/package.json b/package.json index a266e0e..5b7d627 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,36 @@ { - "name": "@fleet/ai-forms", + "name": "ai-forms", "version": "0.1.0", "description": "Headless AI form filling and conversational refinement. Provider-agnostic core, React hook, and a server route factory.", "license": "MIT", + "author": "Mao Nakamoto", + "homepage": "https://github.com/maonakamoto/ai-forms#readme", "repository": { "type": "git", "url": "git+https://github.com/maonakamoto/ai-forms.git" }, + "bugs": { + "url": "https://github.com/maonakamoto/ai-forms/issues" + }, + "keywords": [ + "ai", + "forms", + "form-fill", + "autofill", + "llm", + "react", + "headless", + "natural-language" + ], "type": "module", + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "files": [ "dist" ],