@drkmttr/adhere 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { AdhereConfig, AdhereConfigLive } from "#services/AdhereConfig.ts";
2
2
  import { AuditCacheLive } from "#services/AuditCache.ts";
3
- import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
3
+ import {
4
+ Credentials,
5
+ CredentialsLive,
6
+ CredentialsUnavailable,
7
+ KEYS,
8
+ } from "#services/Credentials.ts";
4
9
  import { JevLive } from "#services/Jev.http.ts";
10
+ import { SourceReadsLive } from "#services/SourceReads.ts";
5
11
  import { SourceWalkerLive } from "#services/SourceWalker.ts";
6
12
  import { Status } from "#services/Status.ts";
7
13
  import {
@@ -26,6 +32,7 @@ import { type Install, initProject } from "#init.ts";
26
32
  import { installRules, listRules, parseSource } from "#install.ts";
27
33
  import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
28
34
  import { type PresetName, presetNames } from "#presets.ts";
35
+ import type { SavedKey } from "#providers/provider.ts";
29
36
  import { globalRuleSet } from "#rules.ts";
30
37
  import { formatStrays, straysAmong } from "#wording.ts";
31
38
  import {
@@ -44,7 +51,6 @@ import {
44
51
  Stream,
45
52
  } from "effect";
46
53
  import { Argument, Command, Flag, Prompt } from "effect/cli";
47
- import { FetchHttpClient } from "effect/http";
48
54
  // A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
49
55
  // contents become a string at bundle time, so the compiled binary carries the
50
56
  // skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
@@ -110,12 +116,12 @@ const sufficiencyThreshold = Flag.Finite("sufficiency-threshold").pipe(
110
116
 
111
117
  const limit = Flag.Int("limit").pipe(
112
118
  Flag.filter(
113
- (checks) => checks >= 0,
114
- (checks) => `--limit is a number of checks, 0 or more, not ${checks}.`,
119
+ (files) => files >= 0,
120
+ (files) => `--limit is a number of files, 0 or more, not ${files}.`,
115
121
  ),
116
122
  Flag.optional,
117
123
  Flag.withDescription(
118
- "Judge at most this many checks. The rest wait for a later run, which picks up where this one stopped, since judgments are cached. --limit 0 shows the plan and judges nothing.",
124
+ "Process at most this many files needing requests, in path order, including every pending rule per selected file. Fully cached and located files do not count. A file can send multiple requests. --limit 0 shows the plan without resolving native reads or sending requests.",
119
125
  ),
120
126
  );
121
127
 
@@ -221,8 +227,9 @@ const force = Flag.Boolean("force").pipe(
221
227
  export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
222
228
  Layer.mergeAll(
223
229
  SourceWalkerLive(filter),
230
+ SourceReadsLive(),
224
231
  AuditCacheLive,
225
- JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
232
+ JevLive.pipe(Layer.provide(CredentialsLive)),
226
233
  ).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
227
234
 
228
235
  /** `adhere lint`: the audit. */
@@ -235,10 +242,19 @@ export const lintCommand = Command.make(
235
242
  const plan = yield* planAudit({ limit: Option.getOrUndefined(input.limit) });
236
243
  const limits = { filter: input.filter, rpm: Option.getOrUndefined(input.rpm) };
237
244
  yield* toStderr(`${describePlan(plan, limits).join("\n")}\n`);
245
+ if (plan.planOnly === true) {
246
+ // Planning still knows the live content keys, so a complete walk can prune without sending.
247
+ if (input.filter.length === 0) yield* pruneCache(plan);
248
+ return;
249
+ }
238
250
  // A prompt draws on stdout, so it needs a terminal at both ends: none when
239
251
  // the report is redirected, or in CI.
240
252
  const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
241
- if (plan.requests > 0 && interactive && !input.yes) {
253
+ if (
254
+ (plan.requests > 0 || (plan.cachedLocateRequests ?? 0) > 0) &&
255
+ interactive &&
256
+ !input.yes
257
+ ) {
242
258
  const send = yield* Prompt.run(
243
259
  Prompt.Confirm({ message: sendQuestion(plan), initial: true }),
244
260
  );
@@ -263,6 +279,7 @@ export const lintCommand = Command.make(
263
279
  root: path.resolve(),
264
280
  threshold: plan.threshold,
265
281
  sufficiencyThreshold: plan.sufficiencyThreshold,
282
+ ...(plan.provider === undefined ? {} : { provider: plan.provider }),
266
283
  }).join("\n"),
267
284
  );
268
285
  // Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
@@ -290,10 +307,10 @@ export const lintCommand = Command.make(
290
307
 
291
308
  /** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
292
309
  export const validateLayer = (flags: Flags) =>
293
- Layer.merge(
294
- JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
295
- AuditCacheLive,
296
- ).pipe(Layer.provideMerge(AdhereConfigLive(flags)), Layer.provideMerge(statusLayer));
310
+ Layer.merge(JevLive.pipe(Layer.provide(CredentialsLive)), AuditCacheLive).pipe(
311
+ Layer.provideMerge(AdhereConfigLive(flags)),
312
+ Layer.provideMerge(statusLayer),
313
+ );
297
314
 
298
315
  /**
299
316
  * `adhere validate`: everything `lint` loads, then how each rule's wording
@@ -439,10 +456,10 @@ export const installCommand = Command.make(
439
456
  * The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
440
457
  * Whitespace around it, like the newline a pipe ends with, is dropped.
441
458
  */
442
- const readApiKey = Effect.fn("login.readApiKey")(function* () {
459
+ const readApiKey = Effect.fn("login.readApiKey")(function* (label: string) {
443
460
  const stdio = yield* Stdio.Stdio;
444
461
  const typed: string = (yield* stdio.stdinIsTerminal)
445
- ? Redacted.value(yield* Prompt.run(Prompt.Password({ message: "TypeSafe AI API key" })))
462
+ ? Redacted.value(yield* Prompt.run(Prompt.Password({ message: label })))
446
463
  : yield* stdio.stdin.pipe(
447
464
  Stream.decodeText(),
448
465
  Stream.mkString,
@@ -461,33 +478,49 @@ const readApiKey = Effect.fn("login.readApiKey")(function* () {
461
478
  return Redacted.make(key);
462
479
  });
463
480
 
464
- /** `adhere login`: save the API key, so a run needs no TYPESAFE_API_KEY. */
465
- export const loginCommand = Command.make("login", {}, () =>
481
+ /** `--openai`: the OpenAI key, for the `openai` provider, in place of TypeSafe AI's. */
482
+ const openaiKey = Flag.Boolean("openai").pipe(
483
+ Flag.withDefault(false),
484
+ Flag.withDescription(
485
+ "The OpenAI API key, for a config whose provider is openai(), in place of the TypeSafe AI key Jev asks with.",
486
+ ),
487
+ );
488
+
489
+ /** Which saved key a command means, and how it names it: TypeSafe AI's by default. */
490
+ const keyOf = (openai: boolean) => {
491
+ const name: SavedKey = openai ? "openai" : "typesafe";
492
+ return { name, ...KEYS[name] };
493
+ };
494
+
495
+ /** `adhere login`: save an API key, so a run needs no TYPESAFE_API_KEY, or with `--openai`, no OPENAI_API_KEY. */
496
+ export const loginCommand = Command.make("login", { openai: openaiKey }, ({ openai }) =>
466
497
  Effect.gen(function* () {
498
+ const { name, label, said } = keyOf(openai);
467
499
  const credentials = yield* Credentials;
468
- yield* credentials.save(yield* readApiKey());
469
- yield* Console.log(`Saved the API key to ${yield* credentials.file}.`);
500
+ yield* credentials.save(name, yield* readApiKey(label));
501
+ yield* Console.log(`Saved the ${said} to ${yield* credentials.file}.`);
470
502
  }),
471
503
  ).pipe(
472
504
  Command.withDescription(
473
- "Save a TypeSafe AI API key in ~/.config/adhere ($XDG_CONFIG_HOME/adhere when set), readable only by you, so lint runs without TYPESAFE_API_KEY. Prompts for the key, or reads it from stdin when piped.",
505
+ "Save a TypeSafe AI API key in ~/.config/adhere ($XDG_CONFIG_HOME/adhere when set), readable only by you, so lint runs without TYPESAFE_API_KEY. With --openai, save an OpenAI API key beside it instead, for the openai provider. Prompts for the key, or reads it from stdin when piped.",
474
506
  ),
475
507
  Command.provide(CredentialsLive),
476
508
  );
477
509
 
478
- /** `adhere logout`: delete the saved API key. */
479
- export const logoutCommand = Command.make("logout", {}, () =>
510
+ /** `adhere logout`: delete a saved API key. */
511
+ export const logoutCommand = Command.make("logout", { openai: openaiKey }, ({ openai }) =>
480
512
  Effect.gen(function* () {
513
+ const { name, said } = keyOf(openai);
481
514
  const credentials = yield* Credentials;
482
515
  const file = yield* credentials.file;
483
- const removed = yield* credentials.remove;
516
+ const removed = yield* credentials.remove(name);
484
517
  yield* Console.log(
485
- removed ? `Deleted the API key saved in ${file}.` : `No API key is saved in ${file}.`,
518
+ removed ? `Deleted the ${said} saved in ${file}.` : `No ${said} is saved in ${file}.`,
486
519
  );
487
520
  }),
488
521
  ).pipe(
489
522
  Command.withDescription(
490
- "Delete the API key adhere login saved. TYPESAFE_API_KEY, when set, still applies.",
523
+ "Delete the API key adhere login saved, or with --openai, the OpenAI one. TYPESAFE_API_KEY or OPENAI_API_KEY, when set, still applies.",
491
524
  ),
492
525
  Command.provide(CredentialsLive),
493
526
  );
package/src/config.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { type PresetName, presetNames } from "#presets.ts";
2
+ import { type Provider, SAVED_KEYS } from "#providers/provider.ts";
2
3
  import type { RuleSet } from "#rules.ts";
3
- import { Effect, Schema, SchemaTransformation } from "effect";
4
+ import { Effect, Predicate, Schema, SchemaTransformation } from "effect";
4
5
 
5
6
  export type RuleId = string;
6
7
 
7
- /** The state a request to Jev carries: the file's code as numbered sections, which every question reads. */
8
+ /** Shared context prepared for a file's requests; it does not affect cache validity. */
9
+ export type ReadState = Readonly<Record<string, unknown>>;
10
+
11
+ /** The file's numbered sections and the context enabled by its config. */
8
12
  export interface JevState {
9
13
  readonly code: Readonly<Record<string, string>>;
14
+ readonly [key: string]: unknown;
10
15
  }
11
16
 
12
17
  /** The file a request is about, as a rule's hook gets it: its absolute path, and its text as written. */
@@ -15,6 +20,21 @@ export interface JudgedFile {
15
20
  readonly contents: string;
16
21
  }
17
22
 
23
+ /**
24
+ * A config's provider, as `Provider` in `providers/provider.ts` describes it:
25
+ * a model, and how to ask it. Checked rather than copied, so a class keeps
26
+ * what its `ask` reads off `this`.
27
+ */
28
+ const ProviderSchema = Schema.declare(
29
+ (value: unknown): value is Provider =>
30
+ Predicate.hasProperty(value, "model") &&
31
+ Predicate.isString(value.model) &&
32
+ Predicate.hasProperty(value, "ask") &&
33
+ Predicate.isFunction(value.ask) &&
34
+ (!Predicate.hasProperty(value, "savedKey") || SAVED_KEYS.some((key) => key === value.savedKey)),
35
+ { expected: "a provider: a model, and a function to ask it" },
36
+ );
37
+
18
38
  /**
19
39
  * Bun's API, typed when the project has Bun's types. Read off `globalThis`,
20
40
  * so this package's source type-checks in a project without them.
@@ -33,13 +53,15 @@ export type AppendState = (
33
53
  ) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
34
54
 
35
55
  /**
36
- * What a rule can have Jev read beside the code, each by the name of the key
37
- * the state carries it under, which the rule's description can name in
38
- * backticks. `workspacePackages` is the workspace's packages: each one's name
39
- * with its directory, so a rule can tell an import of the repository's own
40
- * package from an installed one's.
56
+ * What the config can have Jev read beside each file's code, shared by all
57
+ * its rules. Each name is the key the state carries it under, which a rule's
58
+ * description can name in backticks. `workspacePackages` maps only workspace
59
+ * packages imported by this file to their directories. `symbols` maps names
60
+ * to native compiler types, with documentation and JSDoc tags shared across variants.
61
+ * `references` maps used names to complete AST declaration scopes by
62
+ * repo-relative path, rather than fixed line windows or full related files.
41
63
  */
42
- export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
64
+ export const Reads = Schema.Array(Schema.Literals(["workspacePackages", "symbols", "references"]));
43
65
 
44
66
  /**
45
67
  * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
@@ -83,12 +105,6 @@ const RuleFields = Schema.Struct({
83
105
  * that breaks the rule is as it describes, and a yes to any drops the finding.
84
106
  */
85
107
  excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
86
- /**
87
- * What Jev reads for the rule beside the code, by name (see `Reads`). The
88
- * rule's questions go in requests of their own, so only they read it, and
89
- * a change to what is read judges the rule again.
90
- */
91
- reads: Schema.optionalKey(Reads),
92
108
  /**
93
109
  * A hook on the state Jev reads for the rule, in a rule written in
94
110
  * TypeScript (see `AppendState`). The rule's questions go in requests of
@@ -106,6 +122,12 @@ export const Rule = Schema.Struct({
106
122
  ...RuleFields.fields,
107
123
  reference: Schema.optionalKey(Schema.String),
108
124
  avoid: Schema.optionalKey(Schema.String),
125
+ // Guard the removed key instead of silently dropping it as an excess property.
126
+ reads: Schema.optionalKey(
127
+ Schema.declare((_value: unknown): _value is never => false, {
128
+ expected: "no rule-level reads; move reads to the top level of defineConfig({...})",
129
+ }),
130
+ ),
109
131
  })
110
132
  .pipe(
111
133
  Schema.decodeTo(
@@ -179,6 +201,8 @@ export const AdhereConfig = Schema.Struct({
179
201
  * The model id sent to typesafe. Defaults to "jev-latest"
180
202
  */
181
203
  model: Schema.optionalKey(Schema.String),
204
+ /** Where the questions go in place of TypeSafe's Jev, with its own model (see `Provider`). */
205
+ provider: Schema.optionalKey(ProviderSchema),
182
206
  /**
183
207
  * The minimum confidence threshold for a positive match. Defaults to 0.8
184
208
  */
@@ -196,6 +220,12 @@ export const AdhereConfig = Schema.Struct({
196
220
  presets: Schema.Array(Schema.Literals(presetNames)).pipe(
197
221
  Schema.withDecodingDefaultKey(Effect.succeed([])),
198
222
  ),
223
+ /** Shared context for every audited file and all its rules. Unset: all built-in reads; []: code only. */
224
+ reads: Schema.optionalKey(Reads),
225
+ /** Maximum characters of each symbol's complete resolved content. Defaults to 256. */
226
+ maxSymbolLength: Schema.optionalKey(Schema.Natural),
227
+ /** Maximum characters of documentation and JSDoc tags per symbol. Defaults to 256; 0 omits them. */
228
+ maxSymbolDocumentationLength: Schema.optionalKey(Schema.Natural),
199
229
  /**
200
230
  * A record of rules, or a directory path relative to the config file,
201
231
  * which holds its rules as `.adhere/rules/` does. Unset: the rules in
@@ -303,10 +333,15 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
303
333
  export const DEFAULT_MODEL = "jev-latest";
304
334
  export const DEFAULT_THRESHOLD = 0.8;
305
335
  export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
336
+ export const DEFAULT_READS: typeof Reads.Type = ["workspacePackages", "symbols", "references"];
337
+ export const DEFAULT_MAX_SYMBOL_DOCUMENTATION_LENGTH = 256;
338
+ export const DEFAULT_MAX_SYMBOL_LENGTH = 256;
306
339
 
307
340
  /** A config with its presets folded in and every default applied. */
308
341
  export interface ResolvedConfig {
309
342
  readonly model: string;
343
+ /** Where the questions go in place of Jev; `model` is its model. */
344
+ readonly provider?: Provider;
310
345
  readonly threshold: number;
311
346
  /** Below this, a finding's sufficiency adds a warning to it. */
312
347
  readonly sufficiencyThreshold: number;
@@ -318,9 +353,15 @@ export interface ResolvedConfig {
318
353
  readonly exclude?: ReadonlyArray<string>;
319
354
  /** Whether Jev reads every comment; unset, only the `@adhere` notes. */
320
355
  readonly includeComments?: boolean;
356
+ /** Built-in context shared by every rule, with the default applied. */
357
+ readonly reads: typeof Reads.Type;
358
+ /** Maximum characters of each symbol's complete resolved content. */
359
+ readonly maxSymbolLength: number;
360
+ /** Maximum characters of documentation and JSDoc tags before the complete symbol limit applies. */
361
+ readonly maxSymbolDocumentationLength: number;
321
362
  /**
322
363
  * The workspace's packages, each name with its directory from the working
323
- * directory, read from its manifests on every run, when a rule's `reads`
364
+ * directory, read from its manifests on every run when the config's reads
324
365
  * names them.
325
366
  */
326
367
  readonly workspacePackages?: Readonly<Record<string, string>>;
@@ -370,7 +411,8 @@ export const resolveConfig = (
370
411
  const last = <K extends "model" | "threshold">(key: K) =>
371
412
  applied.map((preset) => preset[key]).findLast((value) => value !== undefined);
372
413
  return {
373
- model: config.model ?? last("model") ?? DEFAULT_MODEL,
414
+ model: config.provider?.model ?? config.model ?? last("model") ?? DEFAULT_MODEL,
415
+ ...(config.provider === undefined ? {} : { provider: config.provider }),
374
416
  threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
375
417
  sufficiencyThreshold:
376
418
  flags.sufficiencyThreshold ?? config.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
@@ -378,6 +420,10 @@ export const resolveConfig = (
378
420
  ...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
379
421
  ...(config.exclude === undefined ? {} : { exclude: config.exclude }),
380
422
  ...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
423
+ reads: config.reads ?? DEFAULT_READS,
424
+ maxSymbolLength: config.maxSymbolLength ?? DEFAULT_MAX_SYMBOL_LENGTH,
425
+ maxSymbolDocumentationLength:
426
+ config.maxSymbolDocumentationLength ?? DEFAULT_MAX_SYMBOL_DOCUMENTATION_LENGTH,
381
427
  };
382
428
  };
383
429
 
package/src/index.ts CHANGED
@@ -12,6 +12,20 @@ export {
12
12
  type RuleSource,
13
13
  } from "#config.ts";
14
14
  export { findContradictions, formatContradictions, type Contradiction } from "#contradictions.ts";
15
+ export { cloudflare, type CloudflareOptions } from "#providers/cloudflare.ts";
16
+ export { jev, type JevOptions } from "#providers/jev.ts";
17
+ export { openai, type OpenAIOptions } from "#providers/openai.ts";
18
+ export {
19
+ type Answered,
20
+ type AskOptions,
21
+ ContextOverflow,
22
+ type Provider,
23
+ type Question,
24
+ RequestBlocked,
25
+ RequestFailed,
26
+ type SavedKey,
27
+ type State,
28
+ } from "#providers/provider.ts";
15
29
  export { initProject, type InitOptions, type InitResult, type Install } from "#init.ts";
16
30
  export { parseRuleMarkdown } from "#markdown.ts";
17
31
  export { type PresetName, presetNames } from "#presets.ts";
package/src/markdown.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
@@ -34,8 +34,7 @@ import { Effect, Schema } from "effect";
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
35
  * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
36
  * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
37
- * So is `reads`, of the names of what Jev reads for the rule beside the code,
38
- * as in `reads: ["workspacePackages"]`.
37
+
39
38
  */
40
39
  const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
41
40
 
@@ -46,7 +45,6 @@ const FrontMatter = Schema.Struct({
46
45
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
47
46
  appliesTo: Schema.optionalKey(Matchers),
48
47
  excludeIf: Schema.optionalKey(Matchers),
49
- reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
50
48
  });
51
49
 
52
50
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
@@ -209,7 +207,13 @@ export const parseRuleMarkdown = (
209
207
  message: `${file}: a rule starts with front matter between --- lines`,
210
208
  });
211
209
  }
212
- const front = yield* Schema.decodeUnknownEffect(FrontMatter)(parseFrontMatter(match[1])).pipe(
210
+ const fields = parseFrontMatter(match[1]);
211
+ if (Object.hasOwn(fields, "reads")) {
212
+ return yield* ConfigUnavailable.make({
213
+ message: `${file}: move reads from the rule's front matter to the top level of defineConfig({...}) in your config file.`,
214
+ });
215
+ }
216
+ const front = yield* Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
213
217
  Effect.mapError((problem) =>
214
218
  ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),
215
219
  ),
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Cloudflare's Clef on Workers AI. Clef follows System One, so adhere's
3
+ * questions go as Jev's provider sends them, but for three differences: at
4
+ * most 64 questions to a request, ids of letters, digits, `_`, `.` and `-`
5
+ * where adhere's hold `/` and `:`, and no `choice` of one option. So the
6
+ * questions go in chunks of 64, each under its place in its chunk, and a
7
+ * choice of one option is answered by it, unasked.
8
+ *
9
+ * Clef cuts a state too long for it short rather than refuse it, and says so
10
+ * only by the tokens it read: on 2026-10-10, clef read at most 64,000 and
11
+ * clef-flash 24,000, well under the 65,536 their pages give. A request that
12
+ * reads that many, or that Workers AI refuses as too large with a 413, is
13
+ * over the model's context, and skips the file.
14
+ */
15
+ import { decoded, failed, post } from "#providers/post.ts";
16
+ import { ContextOverflow, type Provider, withoutSingleChoices } from "#providers/provider.ts";
17
+ import { answersOf, requestOf, SystemOneAnswered } from "#providers/systemOne.ts";
18
+ import { Schema } from "effect";
19
+
20
+ const QUESTIONS_PER_REQUEST = 64;
21
+ const MOST_TOKENS = { clef: 64_000, "clef-flash": 24_000 } as const;
22
+
23
+ /** Workers AI's envelope around Clef's answers. */
24
+ const Ran = Schema.fromJsonString(Schema.Struct({ result: SystemOneAnswered }));
25
+
26
+ export interface CloudflareOptions {
27
+ /** The Cloudflare account that runs the model. */
28
+ readonly accountId: string;
29
+ /** A Cloudflare API token that can run Workers AI models. */
30
+ readonly apiToken: string;
31
+ /** The model to ask. Defaults to "clef". */
32
+ readonly model?: "clef" | "clef-flash";
33
+ /** What sends the requests. Defaults to `fetch`. */
34
+ readonly fetch?: typeof globalThis.fetch;
35
+ }
36
+
37
+ export const cloudflare = ({
38
+ accountId,
39
+ apiToken,
40
+ model = "clef",
41
+ fetch,
42
+ }: CloudflareOptions): Provider => ({
43
+ model,
44
+ ask: async (state, questions, { signal }) => {
45
+ const { answers, asked } = withoutSingleChoices(questions);
46
+ const chunks = Array.from({ length: Math.ceil(asked.length / QUESTIONS_PER_REQUEST) }, (_, n) =>
47
+ asked.slice(n * QUESTIONS_PER_REQUEST, (n + 1) * QUESTIONS_PER_REQUEST),
48
+ );
49
+ const ran = await Promise.all(
50
+ chunks.map(async (chunk) => {
51
+ const { response, text } = await post(
52
+ "Cloudflare",
53
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/cloudflare/${model}`,
54
+ { Authorization: `Bearer ${apiToken}` },
55
+ requestOf(
56
+ model,
57
+ state,
58
+ chunk.map((question, place) => ({ ...question, id: `q${place}` })),
59
+ ),
60
+ signal,
61
+ fetch,
62
+ );
63
+ const overflow = () => new ContextOverflow(`The request is over ${model}'s context`);
64
+ if (response.status === 413) throw overflow();
65
+ if (!response.ok) throw failed("Cloudflare", response, text);
66
+ const { result } = decoded("Cloudflare", Ran, text);
67
+ if ((result.usage?.input_tokens ?? 0) >= MOST_TOKENS[model]) throw overflow();
68
+ return { chunk, result };
69
+ }),
70
+ );
71
+ for (const { chunk, result } of ran) {
72
+ const answered = answersOf(result.answers);
73
+ chunk.forEach((question, place) => {
74
+ const answer = answered[`q${place}`];
75
+ if (answer !== undefined) answers[question.id] = answer;
76
+ });
77
+ }
78
+ return {
79
+ answers,
80
+ inputTokens: ran.reduce((sum, { result }) => sum + (result.usage?.input_tokens ?? 0), 0),
81
+ model,
82
+ };
83
+ },
84
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TypeSafe's Jev, adhere's default provider: adhere's questions as System One
3
+ * requests to TypeSafe's API, as adhere built them. With no provider in the
4
+ * config, adhere asks this one with the key `adhere login` saved, or
5
+ * `TYPESAFE_API_KEY`; a config names it to ask with another key or model.
6
+ */
7
+ import { decoded, failed, post } from "#providers/post.ts";
8
+ import { ContextOverflow, type Provider, RequestBlocked } from "#providers/provider.ts";
9
+ import { answersOf, requestOf, SystemOneAnswered } from "#providers/systemOne.ts";
10
+ import { Option, Schema } from "effect";
11
+
12
+ const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
13
+
14
+ const Answer = Schema.fromJsonString(SystemOneAnswered);
15
+
16
+ /** What Jev answers, with a 400, to a request over its context. */
17
+ const Overflowed = Schema.fromJsonString(
18
+ Schema.Struct({ detail: Schema.Struct({ error_type: Schema.Literal("max_tokens_exceeded") }) }),
19
+ );
20
+
21
+ export interface JevOptions {
22
+ /** A TypeSafe AI API key. Defaults to `TYPESAFE_API_KEY`, or the key `adhere login` saved. */
23
+ readonly apiKey?: string;
24
+ /** The model to ask. Defaults to "jev-latest". */
25
+ readonly model?: string;
26
+ /** What sends the requests. Defaults to `fetch`. */
27
+ readonly fetch?: typeof globalThis.fetch;
28
+ }
29
+
30
+ export const jev = ({ apiKey, model = "jev-latest", fetch }: JevOptions = {}): Provider => ({
31
+ model,
32
+ ...(apiKey === undefined ? { savedKey: "typesafe" as const } : {}),
33
+ ask: async (state, questions, { apiKey: saved, signal }) => {
34
+ const key = apiKey ?? saved;
35
+ if (key === undefined) throw new Error("No TypeSafe AI API key to ask Jev with");
36
+ const { response, text } = await post(
37
+ "Jev",
38
+ SYSTEM_ONE,
39
+ { Authorization: `Bearer ${key}` },
40
+ requestOf(model, state, questions),
41
+ signal,
42
+ fetch,
43
+ );
44
+ if (response.ok) {
45
+ const answered = decoded("Jev", Answer, text);
46
+ return {
47
+ answers: answersOf(answered.answers),
48
+ inputTokens: answered.usage?.input_tokens,
49
+ model: answered.model,
50
+ };
51
+ }
52
+ // Over Jev's context, a 400 that skips the file. The firewall in front of
53
+ // TypeSafe's API answers a 403 with an HTML page, where Jev answers JSON:
54
+ // a block, with the Ray ID TypeSafe can look it up by.
55
+ if (response.status === 400 && Option.isSome(Schema.decodeUnknownOption(Overflowed)(text))) {
56
+ throw new ContextOverflow("The request is over Jev's context");
57
+ }
58
+ const html =
59
+ (response.headers.get("content-type") ?? "").includes("text/html") ||
60
+ text.trimStart().startsWith("<");
61
+ if (response.status === 403 && html) {
62
+ throw new RequestBlocked(response.headers.get("cf-ray") ?? "none given");
63
+ }
64
+ throw failed("Jev", response, text);
65
+ },
66
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * OpenAI's Decisions API: each of adhere's questions as a decision question,
3
+ * a `noul` as a `predicate` and a `choice` as a `choice`. A decision
4
+ * question's instructions are one string, so they hold the question as
5
+ * System One asks it, in JSON: the question beside the fields it names, and
6
+ * what yes and no mean. On the providers study's four repos, gpt-6-luna
7
+ * ranked findings better and located them more often than with the same parts
8
+ * in prose. The state is the input, in JSON. A question OpenAI refuses goes
9
+ * unanswered.
10
+ */
11
+ import { decoded, failed, post } from "#providers/post.ts";
12
+ import { type Provider, type Question, withoutSingleChoices } from "#providers/provider.ts";
13
+ import { systemOneQuestionOf } from "#providers/systemOne.ts";
14
+ import { Schema } from "effect";
15
+
16
+ const DECISIONS = "https://api.openai.com/v1/decisions";
17
+
18
+ /** A decision's answers, in question order: a predicate's probability, a choice's value, or a refusal, which has neither. */
19
+ const Decided = Schema.fromJsonString(
20
+ Schema.Struct({
21
+ answers: Schema.Array(
22
+ Schema.Struct({
23
+ name: Schema.NullOr(Schema.String),
24
+ probability: Schema.optionalKey(Schema.Finite),
25
+ choice: Schema.optionalKey(Schema.Union([Schema.String, Schema.Boolean])),
26
+ }),
27
+ ),
28
+ model: Schema.optionalKey(Schema.String),
29
+ usage: Schema.optionalKey(Schema.Struct({ input_tokens: Schema.Finite })),
30
+ }),
31
+ );
32
+
33
+ /** System One's question without its type, in JSON, or its instructions alone when it says nothing of what yes and no mean. */
34
+ const instructionsOf = (question: Question): string => {
35
+ const { type, ...asked } = systemOneQuestionOf(question);
36
+ if (type === "noul" && asked.criteria !== undefined) return JSON.stringify(asked);
37
+ return typeof asked.instructions === "string"
38
+ ? asked.instructions
39
+ : JSON.stringify(asked.instructions);
40
+ };
41
+
42
+ const decisionOf = (question: Question) =>
43
+ question.type === "noul"
44
+ ? { type: "predicate", name: question.id, instructions: instructionsOf(question) }
45
+ : {
46
+ type: "choice",
47
+ name: question.id,
48
+ instructions: instructionsOf(question),
49
+ choices: Object.entries(question.options).map(([value, description]) =>
50
+ description === null ? { value } : { value, description },
51
+ ),
52
+ };
53
+
54
+ export interface OpenAIOptions {
55
+ /** An OpenAI API key. Defaults to `OPENAI_API_KEY`, or the key `adhere login --openai` saved. */
56
+ readonly apiKey?: string;
57
+ /** The model to ask. Defaults to "gpt-6-luna", the only one the Decisions API takes. */
58
+ readonly model?: string;
59
+ /** What sends the requests. Defaults to `fetch`. */
60
+ readonly fetch?: typeof globalThis.fetch;
61
+ }
62
+
63
+ export const openai = ({ apiKey, model = "gpt-6-luna", fetch }: OpenAIOptions = {}): Provider => ({
64
+ model,
65
+ ...(apiKey === undefined ? { savedKey: "openai" as const } : {}),
66
+ ask: async (state, questions, { apiKey: saved, signal }) => {
67
+ const key = apiKey ?? saved;
68
+ if (key === undefined) throw new Error("No OpenAI API key to ask gpt-6-luna with");
69
+ // OpenAI takes no choice of one option.
70
+ const { answers, asked } = withoutSingleChoices(questions);
71
+ if (asked.length === 0) return { answers };
72
+ const { response, text } = await post(
73
+ "OpenAI",
74
+ DECISIONS,
75
+ { Authorization: `Bearer ${key}` },
76
+ { model, input: JSON.stringify(state), questions: asked.map(decisionOf) },
77
+ signal,
78
+ fetch,
79
+ );
80
+ if (!response.ok) throw failed("OpenAI", response, text);
81
+ const decided = decoded("OpenAI", Decided, text);
82
+ for (const { name, probability, choice } of decided.answers) {
83
+ if (name === null) continue;
84
+ if (probability !== undefined) answers[name] = probability;
85
+ else if (choice !== undefined) answers[name] = String(choice);
86
+ }
87
+ return { answers, inputTokens: decided.usage?.input_tokens, model: decided.model };
88
+ },
89
+ });