@nytka/plugin-typesafe
Version:
TypeSafe connector for nytka projects. Asks Jev typed questions (noul, choice, score) about a file or an existing dataset and registers the judgements in datasets/ with the model, the questions hash and the usage.
170 lines (154 loc) • 8.99 kB
JavaScript
import {
API_KEY_ENV, DEFAULT_CONCURRENCY, MAX_CONCURRENCY, TypeSafeError, TypeSafeHttpError,
ask, client, credentials, explain, modelFromEnv,
} from './typesafe.mjs'
import { loadQuestions, loadState } from './inputs.mjs'
import { findProjectRoot, loadEnv } from '@nytka/core'
import { plan, run } from './index.mjs'
const USAGE = `
nytka-typesafe — TypeSafe connector. Asks Jev typed questions about material the project
already holds. It generates no text and writes nothing at TypeSafe; it spends.
nytka-typesafe check
One ten-token question. The auth smoke test: prints the resolved model and the usage.
nytka-typesafe ask --questions <file> (--state <file> | --state-json '<json>') [--model M]
One state, every question in the file. Answers as JSON on stdout, usage on stderr.
Registers nothing. Small enough to read — the "hand".
nytka-typesafe classify --input <dataset-id | file.json | file.jsonl> --questions <file>
[--field name] [--key name] [--items key] [--limit N]
[--concurrency N] [--model M] [--snapshot] [--no-register] [--yes]
The same questions over every item. WITHOUT --yes it prints what it would send and
what that would cost, and stops. With --yes it runs, writes the answers to
datasets/payloads/ and registers them in datasets/index.json — the "memory".
--input a dataset id from datasets/index.json (its payload is read by this
connector, never by you), or a .json array / .jsonl file of items
--field send one field of each item as the state instead of the whole item
--key which field names an item in the rows (auto-detected: _id, id, key, …)
--items which key of a payload holds the items (auto: rows, documents, items, …)
--limit N first N items only. --limit 5 --yes is the cheap trial
--concurrency parallel calls, 1..${MAX_CONCURRENCY}, default ${DEFAULT_CONCURRENCY}
--snapshot append today's date to the id, so re-runs accumulate instead of replace
The questions file is the API's own \`questions\` map, verbatim:
{ "intent": { "type": "choice", "instructions": "...", "criteria": { "buy": null, "learn": null } },
"urgent": { "type": "noul", "instructions": "..." } }
Credentials come from .env at the project root: ${API_KEY_ENV}. Optional: TYPESAFE_MODEL
pins a model (default jev-latest); --model wins over both. Every call is billed on input
tokens; the rate and the date it was checked are printed with every estimate.
`
function parseArgs (argv) {
const out = { _: [] }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) { out._.push(a); continue }
const key = a.slice(2)
const next = argv[i + 1]
if (next === undefined || next.startsWith('--')) out[key] = true
else { out[key] = next; i++ }
}
return out
}
const str = v => (typeof v === 'string' ? v : undefined)
const num = v => (v === undefined || v === true ? null : Number(v))
const money = n => `$${Number(n).toFixed(n < 0.01 ? 6 : 4)}`
const args = parseArgs(process.argv.slice(2))
const cmd = args._[0]
const COMMANDS = ['check', 'ask', 'classify']
try {
if (!cmd || !COMMANDS.includes(cmd)) {
console.log(USAGE)
process.exit(cmd ? 1 : 0)
}
const root = findProjectRoot()
if (!root) {
console.error('error: no project.yaml found walking up from cwd — run inside a nytka project')
process.exit(1)
}
loadEnv(root)
// Capability is configuration presence (decisions/0008 §0): an unset key means this
// project has not adopted TypeSafe, not that it is broken. The one command that needs no
// key is classify WITHOUT --yes — a plan is free and must stay free, so it is exempt.
const planning = cmd === 'classify' && args.yes !== true
if (!planning && !process.env[API_KEY_ENV]) {
console.error(`${API_KEY_ENV} is not set in the project .env — this project has not configured TypeSafe.`)
console.error(` Set ${API_KEY_ENV} to your TypeSafe API key to enable "check", "ask" and "classify --yes".`)
console.error(' "classify" without --yes still works: it plans and prices a run without sending anything.')
process.exit(0)
}
const model = str(args.model) ?? modelFromEnv()
if (cmd === 'check') {
const res = await ask({
ts: client(credentials()),
state: { check: 'nytka-typesafe check' },
questions: { ok: { type: 'noul', instructions: 'Is the state a connectivity check from a tool called nytka-typesafe?' } },
model,
})
const a = res.answers.ok ?? {}
console.log(`\n TypeSafe answered. model ${res.model}`)
console.log(` noul ${a.noul ?? '?'} confidence ${a.confidence ?? '?'}`)
console.log(` usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out — about ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`)
} else if (cmd === 'ask') {
const q = await loadQuestions(root, str(args.questions))
let state
if (str(args['state-json']) !== undefined) {
try { state = JSON.parse(args['state-json']) } catch (err) { throw new TypeSafeError(`--state-json is not valid JSON: ${err.message}`) }
} else if (str(args.state) !== undefined) {
state = await loadState(root, args.state)
} else {
throw new TypeSafeError('ask needs --state <file> or --state-json \'<json>\' — the thing the questions are about.')
}
const res = await ask({ ts: client(credentials()), state, questions: q.questions, model })
// Answers to stdout and nothing else, so a pipe stays pure JSON; usage to stderr.
console.log(JSON.stringify({ model: res.model, answers: res.answers }, null, 2))
console.error(`usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out — about ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`)
} else if (cmd === 'classify') {
const common = {
root,
input: str(args.input),
questions: str(args.questions),
field: str(args.field) ?? null,
itemsKey: str(args.items) ?? null,
limit: num(args.limit),
model,
}
if (planning) {
const p = await plan(common)
console.log(`\n classify ${p.questions.name} over ${p.input.ref}`)
console.log(` input ${p.input.kind}${p.input.itemsKey ? ` (${p.input.itemsKey})` : ''}: ${p.items} item${p.items === 1 ? '' : 's'}${p.input.available > p.items ? ` of ${p.input.available} (--limit)` : ''}`)
console.log(` questions ${p.questionIds.map(id => `${id}:${p.questionTypes[id]}`).join(', ')} [${p.questions.hash}]`)
console.log(` model ${p.model}`)
console.log(` would send ${p.calls} call${p.calls === 1 ? '' : 's'}, about ${p.estimatedInputTokens} input tokens`)
console.log(` estimated ${money(p.estimatedCost)} at $${p.rate.usdPerMillionInputTokens}/Mtok (rate as of ${p.rate.asOf}; output tokens are free)`)
console.log(` dataset id ${p.id}`)
if (p.oversize.length) {
console.log(` REFUSES ${p.oversize.length} item(s) too large for one call (indices ${p.oversize.slice(0, 10).join(', ')}${p.oversize.length > 10 ? ', …' : ''}) — use --field or trim the input`)
} else {
console.log('\n Nothing was sent. Add --yes to run it.')
}
} else {
const res = await run({
...common,
keyField: str(args.key) ?? null,
concurrency: num(args.concurrency) ?? DEFAULT_CONCURRENCY,
snapshot: args.snapshot === true,
register: args['no-register'] !== true,
})
// Counts, paths and cost only — payloads must not enter agent context.
console.log(`\n classify model ${res.model.resolved}${res.model.requested !== res.model.resolved ? ` (asked for ${res.model.requested})` : ''}`)
console.log(` ${res.rowCount} rows -> ${res.rawPath}`)
console.log(res.registered
? ` registered as "${res.id}" in datasets/index.json`
: ' not registered (--no-register)')
// A metered API. A run whose cost is invisible is one nobody budgets for.
console.log(` usage ${res.usage.input_tokens} in / ${res.usage.output_tokens} out over ${res.calls} call${res.calls === 1 ? '' : 's'}`)
console.log(` estimated cost ${money(res.estimatedCost)} at $${res.rate.usdPerMillionInputTokens}/Mtok (rate as of ${res.rate.asOf})`)
}
}
} catch (err) {
const { message, hints } = explain(err)
console.error(`error: ${message}`)
for (const line of hints) console.error(` ${line}`)
// No stack trace, ever, for an error this package or the API produced — both are
// already sentences. Anything else is a bug, and the stack is the useful part of it.
if (!(err instanceof TypeSafeError) && !(err instanceof TypeSafeHttpError) && err?.stack) console.error(err.stack)
process.exit(1)
}