@shoalrun/cli 0.1.0-alpha.16 → 0.1.0-alpha.17

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.
Files changed (103) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -3
  3. package/agent-docs/SNAPSHOT.json +5 -5
  4. package/agent-docs/author/frontmatter-reference.md +15 -11
  5. package/agent-docs/author/frontmatter-reference.sections/choose-a-publication-and-visibility.md +13 -11
  6. package/agent-docs/author/frontmatter-reference.sections/control-output-details.md +1 -1
  7. package/agent-docs/author/frontmatter-reference.sections/global-defaults-and-enforced-values.md +1 -1
  8. package/agent-docs/author/frontmatter-reference.sections/html-markdown-and-agents.md +1 -1
  9. package/agent-docs/author/frontmatter-reference.sections/identify-and-route-a-page.md +1 -1
  10. package/agent-docs/author/frontmatter-reference.sections/point-agents-to-it.md +1 -1
  11. package/agent-docs/author/frontmatter-reference.sections/project-defined-fields.md +1 -1
  12. package/agent-docs/author/frontmatter-reference.sections/related-reading.md +1 -1
  13. package/agent-docs/author/frontmatter-reference.sections/use-the-local-files-while-authoring.md +1 -1
  14. package/agent-docs/author/frontmatter-reference.sections/when-the-files-update.md +1 -1
  15. package/agent-docs/author/recipe-drafts.md +2 -2
  16. package/agent-docs/author/recipe-drafts.sections/1-write-one-reusable-step.md +1 -1
  17. package/agent-docs/author/recipe-drafts.sections/2-list-the-steps-for-one-complete-goal.md +1 -1
  18. package/agent-docs/author/recipe-drafts.sections/3-generate-a-draft-and-review-it.md +1 -1
  19. package/agent-docs/author/recipe-drafts.sections/4-maintain-shoalrun-s-published-recipe-snapshots.md +1 -1
  20. package/agent-docs/author/recipe-drafts.sections/related-reading.md +1 -1
  21. package/agent-docs/author/recipe-drafts.sections/start-an-ingredient-from-an-api-example.md +2 -2
  22. package/agent-docs/author/recipes.md +3 -3
  23. package/agent-docs/author/recipes.sections/how-agents-use-this.md +2 -2
  24. package/agent-docs/author/recipes.sections/one-example-from-shoalrun.md +2 -2
  25. package/agent-docs/author/recipes.sections/related-reading.md +2 -2
  26. package/agent-docs/author/recipes.sections/try-a-complete-workflow.md +3 -3
  27. package/agent-docs/author/recipes.sections/use-recipes-for-task-oriented-how-tos.md +2 -2
  28. package/agent-docs/author/recipes.sections/what-changing-an-ingredient-updates.md +2 -2
  29. package/agent-docs/author/recipes.sections/what-each-file-does.md +2 -2
  30. package/agent-docs/author/recipes.sections/what-happens-and-when.md +2 -2
  31. package/agent-docs/author/search.md +55 -6
  32. package/agent-docs/author/search.sections/agent-and-mcp-search-scope.md +48 -0
  33. package/agent-docs/author/search.sections/choose-a-provider.md +7 -6
  34. package/agent-docs/author/search.sections/cli-and-mcp-ranking-details.md +1 -1
  35. package/agent-docs/author/search.sections/enable-included-semantic-search.md +66 -0
  36. package/agent-docs/author/search.sections/hosted-semantic-search-limits.md +1 -1
  37. package/agent-docs/author/search.sections/html-markdown-and-agents.md +1 -1
  38. package/agent-docs/author/search.sections/lexical-search.md +1 -1
  39. package/agent-docs/author/search.sections/local-semantic-preview.md +1 -1
  40. package/agent-docs/author/search.sections/read-a-search-result-with-an-agent.md +1 -1
  41. package/agent-docs/author/search.sections/related-reading.md +1 -1
  42. package/agent-docs/author/search.sections/reuse-the-lexical-index.md +1 -1
  43. package/agent-docs/author/search.sections/turn-search-off.md +1 -1
  44. package/agent-docs/author/search.sections/use-the-search-box.md +1 -1
  45. package/agent-docs/author/search.sections/write-a-custom-provider.md +1 -1
  46. package/agent-docs/chat/passage-index.md +16 -12
  47. package/agent-docs/chat/passage-index.sections/build-the-index.md +3 -3
  48. package/agent-docs/chat/passage-index.sections/embed-passages.md +3 -3
  49. package/agent-docs/chat/passage-index.sections/how-the-default-chunker-splits-a-page.md +3 -3
  50. package/agent-docs/chat/passage-index.sections/limits.md +3 -3
  51. package/agent-docs/chat/passage-index.sections/passage-ids-and-links.md +14 -10
  52. package/agent-docs/chat/passage-index.sections/related-reading.md +3 -3
  53. package/agent-docs/chat/passage-index.sections/what-a-passage-contains.md +5 -5
  54. package/agent-docs/cookbooks/welcome-email.md +12 -9
  55. package/agent-docs/documents.json +207 -183
  56. package/agent-docs/provenance.json +4 -4
  57. package/agent-docs/recipes/queue-email.md +1 -1
  58. package/agent-docs/recipes/send-welcome-email.md +3 -3
  59. package/agent-docs/search-index.json +1 -1
  60. package/agent-docs/start.md +2 -2
  61. package/agent-docs/start.sections/add-the-page-to-navigation.md +1 -1
  62. package/agent-docs/start.sections/build-and-check-both-outputs.md +1 -1
  63. package/agent-docs/start.sections/build-this-site.md +1 -1
  64. package/agent-docs/start.sections/check-writing-style.md +1 -1
  65. package/agent-docs/start.sections/create-a-page.md +1 -1
  66. package/agent-docs/start.sections/make-it-yours.md +1 -1
  67. package/agent-docs/start.sections/migrating-from-github-packages.md +1 -1
  68. package/agent-docs/start.sections/prerequisites.md +1 -1
  69. package/agent-docs/start.sections/related-reading.md +1 -1
  70. package/agent-docs/start.sections/start-your-own-project.md +2 -2
  71. package/dist/ai-directory.d.ts +1 -0
  72. package/dist/ai-directory.js +12 -0
  73. package/dist/answer-cards.js +1 -1
  74. package/dist/ask-function.d.ts +4 -18
  75. package/dist/ask-function.js +12 -71
  76. package/dist/ask-runtime.d.ts +45 -0
  77. package/dist/ask-runtime.js +96 -0
  78. package/dist/ask-setup.d.ts +3 -42
  79. package/dist/ask-setup.js +5 -93
  80. package/dist/embeddings.d.ts +1 -1
  81. package/dist/embeddings.js +1 -1
  82. package/dist/function-package.js +5 -0
  83. package/dist/function-runtime.d.ts +23 -0
  84. package/dist/function-runtime.js +45 -0
  85. package/dist/index-build.d.ts +1 -2
  86. package/dist/index-build.js +3 -11
  87. package/dist/retrieval-runtime.d.ts +5 -0
  88. package/dist/retrieval-runtime.js +21 -0
  89. package/dist/search-function.d.ts +2 -0
  90. package/dist/search-function.js +30 -0
  91. package/dist/search-handler.js +2 -2
  92. package/dist/search-identifier-core.d.ts +4 -0
  93. package/dist/search-identifier-core.js +30 -0
  94. package/dist/search-identifier.d.ts +3 -3
  95. package/dist/search-identifier.js +7 -30
  96. package/dist/search-literal.js +1 -1
  97. package/dist/search-results.js +1 -1
  98. package/dist/search-snippet.d.ts +1 -1
  99. package/dist/search-snippet.js +2 -14
  100. package/dist/search-text.d.ts +1 -0
  101. package/dist/search-text.js +14 -0
  102. package/dist/vercel-function.js +10 -2
  103. package/package.json +1 -1
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  This quickstart takes you from installing Shoalrun to your own working documentation site. You will scaffold a page from a template, complete its draft, put it in the sidebar, and read its HTML and Markdown output.
22
22
 
@@ -36,7 +36,7 @@ Install an exact version of the alpha and initialize the minimal starter:
36
36
  mkdir my-docs
37
37
  cd my-docs
38
38
  npm init -y
39
- npm install --save-exact @shoalrun/cli@0.1.0-alpha.14
39
+ npm install --save-exact @shoalrun/cli@0.1.0-alpha.16
40
40
  npx --no-install shoalrun init .
41
41
  ```
42
42
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-30.**
19
+ **Updated: 2026-11-04.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -38,7 +38,7 @@ Install an exact version of the alpha and initialize the minimal starter:
38
38
  mkdir my-docs
39
39
  cd my-docs
40
40
  npm init -y
41
- npm install --save-exact @shoalrun/cli@0.1.0-alpha.14
41
+ npm install --save-exact @shoalrun/cli@0.1.0-alpha.16
42
42
  npx --no-install shoalrun init .
43
43
  ```
44
44
 
@@ -0,0 +1 @@
1
+ export declare function aiDirectory(directory: string): Promise<string>;
@@ -0,0 +1,12 @@
1
+ import { mkdir, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function aiDirectory(directory) {
4
+ const root = path.resolve(directory);
5
+ const target = path.join(root, 'ai');
6
+ await mkdir(target, { recursive: true });
7
+ const real = await realpath(root);
8
+ const inside = await realpath(target);
9
+ if (inside !== real && !inside.startsWith(real + path.sep))
10
+ throw new Error('The ai directory leaves the site directory');
11
+ return target;
12
+ }
@@ -2,7 +2,7 @@ import { readFile, realpath, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { cacheable, normalizeQuestion } from './answer-cache.js';
5
- import { aiDirectory } from './index-build.js';
5
+ import { aiDirectory } from './ai-directory.js';
6
6
  export const encodeVector = (vector) => Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength).toString('base64');
7
7
  export function decodeVector(value) {
8
8
  const bytes = Buffer.from(value, 'base64');
@@ -1,19 +1,5 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- export type FunctionRuntime = {
3
- schemaVersion: 1;
4
- settings: Record<string, unknown>;
5
- variant?: {
6
- channel: string;
7
- version: string;
8
- language: string;
9
- };
10
- hosts?: string[];
11
- callerHeader?: string;
12
- maxRequestsPerMinute?: number;
13
- };
14
- export type AskFunction = (request: Request) => Promise<Response>;
15
- export declare function createHostedSearchFunction(directory: string, env?: NodeJS.ProcessEnv): Promise<AskFunction>;
1
+ import { type AskFunction } from './function-runtime.js';
2
+ export { nodeAdapter } from './function-runtime.js';
3
+ export type { FunctionRuntime, AskFunction } from './function-runtime.js';
4
+ export { createHostedSearchFunction } from './search-function.js';
16
5
  export declare function createAskFunction(directory: string, env?: NodeJS.ProcessEnv, send?: typeof fetch): Promise<AskFunction>;
17
- export declare function nodeAdapter(load: () => Promise<AskFunction>, limits?: {
18
- bodyBytes: number;
19
- }): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -1,40 +1,23 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { DailyBudget, SharedRateLimit, adaptiveChallenge } from './budget.js';
3
+ import { SharedRateLimit, adaptiveChallenge } from './budget.js';
4
4
  import { askQuestion } from './ask.js';
5
5
  import { clientKeyFromHeader, createAskHandler } from './ask-handler.js';
6
- import { prepareAsk } from './ask-setup.js';
6
+ import { prepareAskRuntime } from './ask-runtime.js';
7
+ import { allowedHosts } from './function-runtime.js';
8
+ export { nodeAdapter } from './function-runtime.js';
9
+ // Keep the previous deep import available without making Search import Ask.
10
+ export { createHostedSearchFunction } from './search-function.js';
7
11
  import { validateChat } from './chat-config.js';
8
12
  import { validateReaderFilters } from './reader-filters.js';
9
13
  import { createSession, turnstileVerifier } from './turnstile.js';
10
- import { envBudgetStore, envRedis } from './budget-store.js';
11
- import { createSearchHandler } from './search-handler.js';
12
- import { validateSearch } from './search-config.js';
14
+ import { envRedis } from './budget-store.js';
13
15
  import { pausedKey } from './chat-pause.js';
14
16
  import { deleteKvLogRecord, kvLogSink } from './ask-log-kv.js';
15
- function allowedHosts(runtime, env) {
16
- return [...new Set([...(runtime.hosts ?? []), env.VERCEL_URL, env.VERCEL_BRANCH_URL, env.VERCEL_PROJECT_PRODUCTION_URL, ...(env.SHOALRUN_ASK_HOSTS?.split(',') ?? [])].map(host => host?.trim().toLowerCase()).filter((host) => Boolean(host)))];
17
- }
18
- // Hosted search uses the same private index as chat, with separate limits for embedding spend.
19
- export async function createHostedSearchFunction(directory, env = process.env) {
20
- const runtime = JSON.parse(await readFile(path.join(directory, 'runtime.json'), 'utf8'));
21
- if (runtime?.schemaVersion !== 1 || !runtime.settings)
22
- throw new Error('runtime.json is not a Shoalrun search function file');
23
- const filters = validateReaderFilters(runtime.settings.readerFilters);
24
- const chat = validateChat(runtime.settings.chat, filters);
25
- if (!chat?.budget?.store)
26
- throw new Error('Hosted semantic search needs a shared budget store');
27
- const setup = await prepareAsk({ siteDirectory: path.join(directory, 'site'), settings: runtime.settings, sharedStore: true, ...(runtime.variant ?? {}) });
28
- const store = envBudgetStore(chat.budget.store, env);
29
- const { limits } = validateSearch(runtime.settings.search);
30
- return createSearchHandler({
31
- retriever: setup.options.retriever, embedQuery: setup.options.embedQuery, readerFilters: filters, hosts: allowedHosts(runtime, env),
32
- onFallback: reason => console.warn(`Search fallback: ${reason}`),
33
- rateLimiter: new SharedRateLimit(store, limits.perCallerPerMinute, Date.now, error => console.error(`Search rate limit: ${error.message}`), 'search-rate'),
34
- budget: new DailyBudget(store, { perCaller: limits.perCallerPerDay, perDay: limits.perDay }, Date.now, error => console.error(`Search budget: ${error.message}`), 'search-budget'),
35
- clientKey: clientKeyFromHeader(runtime.callerHeader ?? 'x-vercel-forwarded-for')
36
- });
37
- }
17
+ // The answer function for a hosted site. The build writes a directory holding `runtime.json` (the chat and retrieval settings, with no
18
+ // keys) and `site/ai` (the passages and vectors), and this module turns that directory into a Web `Request` to `Response` function. Keys come
19
+ // only from the environment variables the settings name. It refuses to start without a shared budget store, because per-instance counters
20
+ // on a serverless host would not limit anything.
38
21
  export async function createAskFunction(directory, env = process.env, send) {
39
22
  const runtime = JSON.parse(await readFile(path.join(directory, 'runtime.json'), 'utf8'));
40
23
  if (runtime?.schemaVersion !== 1 || !runtime.settings)
@@ -44,7 +27,7 @@ export async function createAskFunction(directory, env = process.env, send) {
44
27
  throw new Error('The site config has no chat section');
45
28
  if (!chat.budget?.store)
46
29
  throw new Error('A hosted chat needs chat.budget with a shared store, so its daily caps hold across server instances');
47
- const setup = await prepareAsk({ siteDirectory: path.join(directory, 'site'), settings: runtime.settings, cache: true, sharedStore: true, ...(runtime.variant ?? {}) });
30
+ const setup = await prepareAskRuntime({ siteDirectory: path.join(directory, 'site'), settings: runtime.settings, cache: true, sharedStore: true, ...(runtime.variant ?? {}) });
48
31
  for (const note of setup.notes)
49
32
  console.error(note);
50
33
  if (!setup.budget)
@@ -95,45 +78,3 @@ export async function createAskFunction(directory, env = process.env, send) {
95
78
  answer: (question, history, context) => askQuestion({ question, history, ...setup.options, modelAllowed: context.modelAllowed, signal: context.signal, requireCitations: true })
96
79
  });
97
80
  }
98
- const failure = (status, message) => new Response(JSON.stringify({ error: message }), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
99
- // Vercel's Node runtime calls `(req, res)`. This turns that into a Web request and back. Starting up happens on the first request, and a
100
- // failed start is tried again on the next one. The reader never sees why it failed; the reason goes to the function log.
101
- export function nodeAdapter(load, limits = { bodyBytes: 32_768 }) {
102
- let ready;
103
- const write = async (res, response) => {
104
- res.statusCode = response.status;
105
- response.headers.forEach((value, name) => { if (name !== 'set-cookie')
106
- res.setHeader(name, value); });
107
- const cookies = response.headers.getSetCookie();
108
- if (cookies.length)
109
- res.setHeader('set-cookie', cookies);
110
- res.end(Buffer.from(await response.arrayBuffer()));
111
- };
112
- return async (req, res) => {
113
- try {
114
- const handler = await (ready ??= load().catch(error => { ready = undefined; throw error; }));
115
- const chunks = [];
116
- let size = 0;
117
- for await (const chunk of req) {
118
- size += chunk.length;
119
- if (size > limits.bodyBytes) {
120
- await write(res, failure(413, 'The request is too large.'));
121
- return;
122
- }
123
- chunks.push(chunk);
124
- }
125
- const headers = new Headers();
126
- for (const [name, value] of Object.entries(req.headers))
127
- for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value])
128
- headers.append(name, item);
129
- const method = req.method ?? 'GET';
130
- const request = new Request(`https://${req.headers.host ?? 'localhost'}${req.url ?? '/'}`, { method, headers, ...(method === 'GET' || method === 'HEAD' ? {} : { body: Buffer.concat(chunks) }) });
131
- await write(res, await handler(request));
132
- }
133
- catch (error) {
134
- console.error(`The answer function failed: ${error.message}`);
135
- if (!res.headersSent)
136
- await write(res, failure(503, 'The chat is unavailable right now.'));
137
- }
138
- };
139
- }
@@ -0,0 +1,45 @@
1
+ import type { AskOptions } from './ask.js';
2
+ import { type ChatProvider } from './chat.js';
3
+ import { type ReaderSelection } from './reader-filters.js';
4
+ import { DailyBudget, type BudgetStore } from './budget.js';
5
+ export declare function parseReaderView(specs: string[]): ReaderSelection | undefined;
6
+ export type AskSetupInput = {
7
+ sharedStore?: boolean;
8
+ siteDirectory: string;
9
+ configFile?: string;
10
+ settings?: Record<string, unknown>;
11
+ model?: string;
12
+ passages?: number;
13
+ channel?: string;
14
+ version?: string;
15
+ language?: string;
16
+ noRerank?: boolean;
17
+ noDirect?: boolean;
18
+ noEscalate?: boolean;
19
+ noCards?: boolean;
20
+ cache?: boolean;
21
+ direct?: {
22
+ threshold: number;
23
+ maxChars?: number;
24
+ };
25
+ jevCacheDir?: string;
26
+ readerView?: ReaderSelection;
27
+ maxTokens?: number;
28
+ };
29
+ export type AskSetup = {
30
+ budget?: DailyBudget;
31
+ budgetStore?: BudgetStore;
32
+ chat: ChatProvider;
33
+ options: Omit<AskOptions, 'question'>;
34
+ notes: string[];
35
+ embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
36
+ embedding?: {
37
+ provider: string;
38
+ model: string;
39
+ space: string;
40
+ dimension: number;
41
+ };
42
+ };
43
+ export declare function prepareAskRuntime(input: AskSetupInput & {
44
+ settings: Record<string, unknown>;
45
+ }): Promise<AskSetup>;
@@ -0,0 +1,96 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { AnswerCache } from './answer-cache.js';
4
+ import { CardIndex, readCards } from './answer-cards.js';
5
+ import { chatProviderConfig, chatRoutes, validateChat } from './chat-config.js';
6
+ import { createChatProvider, fallbackChat } from './chat.js';
7
+ import { validateReaderFilters, validateReaderSelection } from './reader-filters.js';
8
+ import { createProvider, readEmbeddings } from './embeddings.js';
9
+ import { DailyBudget, memoryBudgetStore } from './budget.js';
10
+ import { envBudgetStore } from './budget-store.js';
11
+ import { jevAvailable, jevEndpoint } from './jev.js';
12
+ import { queryProviderConfig, validateRetrieval } from './retrieval-config.js';
13
+ import { Retriever } from './retrieval.js';
14
+ // `--reader-view audience=authors,maintainers`, repeatable for more than one filter.
15
+ export function parseReaderView(specs) {
16
+ if (!specs.length)
17
+ return undefined;
18
+ const selection = {};
19
+ for (const spec of specs) {
20
+ const match = /^([a-z][a-z0-9-]*)=(.+)$/.exec(spec);
21
+ if (!match)
22
+ throw new Error(`--reader-view needs facet=choice[,choice], such as audience=authors; got ${JSON.stringify(spec)}`);
23
+ if (selection[match[1]])
24
+ throw new Error(`--reader-view names ${match[1]} twice`);
25
+ selection[match[1]] = match[2].split(',').map(choice => choice.trim()).filter(Boolean);
26
+ }
27
+ return selection;
28
+ }
29
+ // Everything `shoalrun ask` and the answer eval share: read the project's chat and retrieval settings, load the passages and any
30
+ // vectors, and wire up the optional layers. A layer that isn't configured is left out, and one that is configured but can't run
31
+ // (Jev without a key) is left out with a note.
32
+ export async function prepareAskRuntime(input) {
33
+ const settings = input.settings;
34
+ const readerFilters = validateReaderFilters(settings.readerFilters);
35
+ const chatConfig = validateChat(settings.chat, readerFilters);
36
+ const retrieval = validateRetrieval(settings.retrieval);
37
+ // --model replaces the main model only. Fallback and escalation models still inherit the config's settings.
38
+ const mainConfig = chatProviderConfig(chatConfig, { model: input.model });
39
+ const routes = chatRoutes(chatConfig, chatProviderConfig(chatConfig));
40
+ const chat = fallbackChat([createChatProvider(mainConfig), ...routes.fallbacks.map(createChatProvider)]);
41
+ let chunks;
42
+ try {
43
+ chunks = JSON.parse(await readFile(path.join(input.siteDirectory, 'ai', 'chunks.json'), 'utf8'));
44
+ }
45
+ catch (error) {
46
+ throw error.code === 'ENOENT' ? new Error(`No ai/chunks.json in ${input.siteDirectory}; run shoalrun index first`) : error;
47
+ }
48
+ const embeddings = await readEmbeddings(input.siteDirectory);
49
+ const retriever = new Retriever(chunks, embeddings);
50
+ const variants = retriever.variants();
51
+ const variant = input.channel && input.version && input.language ? `${input.channel}|${input.version}|${input.language}` : variants.length === 1 ? variants[0] : undefined;
52
+ if (!variant)
53
+ throw new Error(`The site has several publications (${variants.join(', ')}); pass --channel, --version, and --language`);
54
+ let embedQuery;
55
+ let embedTexts;
56
+ let embedding;
57
+ if (embeddings) {
58
+ const queryProvider = createProvider(queryProviderConfig(retrieval, embeddings.meta));
59
+ if (queryProvider.space !== embeddings.meta.space || (embeddings.meta.backend && queryProvider.backend !== embeddings.meta.backend))
60
+ throw new Error(`${queryProvider.model} is not in the embedding space or backend of ${embeddings.meta.model}; set retrieval.embeddings.queryModel and baseUrl to compatible values`);
61
+ embedQuery = async (text, signal) => (await queryProvider.embed([text], 'query', signal))[0];
62
+ embedTexts = texts => queryProvider.embed(texts, 'query');
63
+ embedding = { provider: queryProvider.name, model: queryProvider.model, space: queryProvider.space, dimension: queryProvider.dimension };
64
+ }
65
+ // A reader view on the command line replaces the config's, so one run can answer for a different audience.
66
+ if (input.readerView && !readerFilters)
67
+ throw new Error('--reader-view needs readerFilters.facets in the site config');
68
+ const readerSelection = input.readerView ? validateReaderSelection(input.readerView, readerFilters, '--reader-view') : chatConfig?.readerView;
69
+ const notes = [];
70
+ const rerank = Boolean(chatConfig?.rerank) && !input.noRerank;
71
+ const scoring = chatConfig?.rerank;
72
+ const scorer = { ...(scoring?.endpoint ? { endpoint: jevEndpoint(scoring.endpoint) } : {}), ...(scoring?.model ? { model: scoring.model } : {}), ...(scoring?.keyEnv ? { keyEnv: scoring.keyEnv } : {}), ...(scoring?.stateFormat ? { stateFormat: scoring.stateFormat } : {}), ...(input.jevCacheDir ? { cacheDir: input.jevCacheDir } : {}) };
73
+ const scoringReady = jevAvailable(scoring?.keyEnv);
74
+ // A scorer that is configured but has no key counts as down, not as absent: it is what keeps off-topic questions from the model.
75
+ if (rerank && !scoringReady)
76
+ notes.push(`The relevance scorer has no key: set ${scoring?.keyEnv ?? 'JEV_API_KEY'}. Until then it counts as down, so ${scoring?.onError === 'answer' ? 'the model answers without the gate and the claim check' : 'questions get the closest pages and no model call'}`);
77
+ // Answer cards need semantic search and Jev. A configured but missing card file is a note, not an error, so the chat still answers.
78
+ let cards;
79
+ if (chatConfig?.cards && !input.noCards && rerank && scoringReady) {
80
+ const file = await readCards(input.siteDirectory);
81
+ if (!file)
82
+ notes.push('chat.cards is set but there is no ai/answers.json; run shoalrun answers to make it');
83
+ else if (!embedding || file.embedding.space !== embedding.space || file.embedding.dimension !== embedding.dimension)
84
+ notes.push('The answer cards were made with a different embedding model, so they are not used; run shoalrun answers again');
85
+ else
86
+ cards = { index: new CardIndex(file), threshold: chatConfig.cards.threshold, confirm: chatConfig.cards.confirm, jev: { ...scorer, maxCalls: 1 } };
87
+ }
88
+ // The shared store is for a hosted function. A local run keeps its counters in memory, so it never needs the store's keys.
89
+ const store = input.sharedStore && chatConfig?.budget?.store ? envBudgetStore(chatConfig.budget.store) : memoryBudgetStore();
90
+ const reported = new Set();
91
+ const onBudgetError = (error) => { if (!reported.has(error.message)) {
92
+ reported.add(error.message);
93
+ process.stderr.write(`Budget: ${error.message}. Questions get the closest pages, with no model call, until it works.\n`);
94
+ } };
95
+ return { ...(chatConfig?.budget ? { budget: new DailyBudget(store, chatConfig.budget, Date.now, onBudgetError), budgetStore: store } : {}), chat, notes, embedTexts, embedding, options: { ...(scoring?.onError ? { onScorerError: scoring.onError } : {}), retriever, chat, variant, readerSelection, embedQuery, passages: input.passages ?? chatConfig?.passages, gate: chatConfig?.gate, ...(input.maxTokens ? { maxTokens: input.maxTokens } : {}), ...(rerank ? { jev: { ...scorer, top: 24, maxCalls: 2 } } : {}), ...(cards ? { cards } : {}), ...(input.cache && chatConfig?.cache ? { cache: new AnswerCache({ maxEntries: chatConfig.cache.maxEntries, ttlMs: chatConfig.cache.ttlMinutes * 60_000 }) } : {}), ...(routes.escalate && !input.noEscalate ? { escalate: { chat: createChatProvider(routes.escalate.config), when: routes.escalate.when } } : {}), ...(rerank && scoringReady && !input.noDirect && (input.direct || chatConfig?.direct) ? { direct: { threshold: input.direct?.threshold ?? chatConfig.direct.threshold, maxChars: input.direct?.maxChars ?? chatConfig?.direct?.maxChars ?? 1200, margin: chatConfig?.direct?.margin ?? 0.1, others: chatConfig?.direct?.others ?? 1, jev: { ...scorer, maxCalls: 2 } } } : {}), ...(rerank && scoringReady && chatConfig?.verify ? { verify: { ...chatConfig.verify, jev: { ...scorer, maxCalls: 3 } } } : {}) } };
96
+ }
@@ -1,43 +1,4 @@
1
- import type { AskOptions } from './ask.js';
2
- import { type ChatProvider } from './chat.js';
3
- import { type ReaderSelection } from './reader-filters.js';
4
- import { DailyBudget, type BudgetStore } from './budget.js';
5
- export declare function parseReaderView(specs: string[]): ReaderSelection | undefined;
6
- export type AskSetupInput = {
7
- sharedStore?: boolean;
8
- siteDirectory: string;
9
- configFile?: string;
10
- settings?: Record<string, unknown>;
11
- model?: string;
12
- passages?: number;
13
- channel?: string;
14
- version?: string;
15
- language?: string;
16
- noRerank?: boolean;
17
- noDirect?: boolean;
18
- noEscalate?: boolean;
19
- noCards?: boolean;
20
- cache?: boolean;
21
- direct?: {
22
- threshold: number;
23
- maxChars?: number;
24
- };
25
- jevCacheDir?: string;
26
- readerView?: ReaderSelection;
27
- maxTokens?: number;
28
- };
29
- export type AskSetup = {
30
- budget?: DailyBudget;
31
- budgetStore?: BudgetStore;
32
- chat: ChatProvider;
33
- options: Omit<AskOptions, 'question'>;
34
- notes: string[];
35
- embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
36
- embedding?: {
37
- provider: string;
38
- model: string;
39
- space: string;
40
- dimension: number;
41
- };
42
- };
1
+ import { type AskSetup, type AskSetupInput } from './ask-runtime.js';
2
+ export { parseReaderView } from './ask-runtime.js';
3
+ export type { AskSetup, AskSetupInput } from './ask-runtime.js';
43
4
  export declare function prepareAsk(input: AskSetupInput): Promise<AskSetup>;
package/dist/ask-setup.js CHANGED
@@ -1,99 +1,11 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { AnswerCache } from './answer-cache.js';
4
- import { CardIndex, readCards } from './answer-cards.js';
5
- import { chatProviderConfig, chatRoutes, validateChat } from './chat-config.js';
6
- import { createChatProvider, fallbackChat } from './chat.js';
7
1
  import { loadConfig } from './config.js';
8
- import { validateReaderFilters, validateReaderSelection } from './reader-filters.js';
9
- import { createProvider, readEmbeddings } from './embeddings.js';
10
- import { DailyBudget, memoryBudgetStore } from './budget.js';
11
- import { envBudgetStore } from './budget-store.js';
12
- import { jevAvailable, jevEndpoint } from './jev.js';
13
- import { queryProviderConfig, validateRetrieval } from './retrieval-config.js';
14
- import { Retriever } from './retrieval.js';
15
- // `--reader-view audience=authors,maintainers`, repeatable for more than one filter.
16
- export function parseReaderView(specs) {
17
- if (!specs.length)
18
- return undefined;
19
- const selection = {};
20
- for (const spec of specs) {
21
- const match = /^([a-z][a-z0-9-]*)=(.+)$/.exec(spec);
22
- if (!match)
23
- throw new Error(`--reader-view needs facet=choice[,choice], such as audience=authors; got ${JSON.stringify(spec)}`);
24
- if (selection[match[1]])
25
- throw new Error(`--reader-view names ${match[1]} twice`);
26
- selection[match[1]] = match[2].split(',').map(choice => choice.trim()).filter(Boolean);
27
- }
28
- return selection;
29
- }
30
- // Everything `shoalrun ask` and the answer eval share: read the project's chat and retrieval settings, load the passages and any
31
- // vectors, and wire up the optional layers. A layer that isn't configured is left out, and one that is configured but can't run
32
- // (Jev without a key) is left out with a note.
2
+ import { prepareAskRuntime } from './ask-runtime.js';
3
+ export { parseReaderView } from './ask-runtime.js';
4
+ // Local authoring/evaluation accepts a source config. Hosted functions import
5
+ // ask-runtime directly and receive already-resolved settings instead.
33
6
  export async function prepareAsk(input) {
34
7
  if (!input.settings && !input.configFile)
35
8
  throw new Error('prepareAsk needs a config file or settings');
36
9
  const settings = input.settings ?? (await loadConfig(input.configFile)).value;
37
- const readerFilters = validateReaderFilters(settings.readerFilters);
38
- const chatConfig = validateChat(settings.chat, readerFilters);
39
- const retrieval = validateRetrieval(settings.retrieval);
40
- // --model replaces the main model only. Fallback and escalation models still inherit the config's settings.
41
- const mainConfig = chatProviderConfig(chatConfig, { model: input.model });
42
- const routes = chatRoutes(chatConfig, chatProviderConfig(chatConfig));
43
- const chat = fallbackChat([createChatProvider(mainConfig), ...routes.fallbacks.map(createChatProvider)]);
44
- let chunks;
45
- try {
46
- chunks = JSON.parse(await readFile(path.join(input.siteDirectory, 'ai', 'chunks.json'), 'utf8'));
47
- }
48
- catch (error) {
49
- throw error.code === 'ENOENT' ? new Error(`No ai/chunks.json in ${input.siteDirectory}; run shoalrun index first`) : error;
50
- }
51
- const embeddings = await readEmbeddings(input.siteDirectory);
52
- const retriever = new Retriever(chunks, embeddings);
53
- const variants = retriever.variants();
54
- const variant = input.channel && input.version && input.language ? `${input.channel}|${input.version}|${input.language}` : variants.length === 1 ? variants[0] : undefined;
55
- if (!variant)
56
- throw new Error(`The site has several publications (${variants.join(', ')}); pass --channel, --version, and --language`);
57
- let embedQuery;
58
- let embedTexts;
59
- let embedding;
60
- if (embeddings) {
61
- const queryProvider = createProvider(queryProviderConfig(retrieval, embeddings.meta));
62
- if (queryProvider.space !== embeddings.meta.space || (embeddings.meta.backend && queryProvider.backend !== embeddings.meta.backend))
63
- throw new Error(`${queryProvider.model} is not in the embedding space or backend of ${embeddings.meta.model}; set retrieval.embeddings.queryModel and baseUrl to compatible values`);
64
- embedQuery = async (text, signal) => (await queryProvider.embed([text], 'query', signal))[0];
65
- embedTexts = texts => queryProvider.embed(texts, 'query');
66
- embedding = { provider: queryProvider.name, model: queryProvider.model, space: queryProvider.space, dimension: queryProvider.dimension };
67
- }
68
- // A reader view on the command line replaces the config's, so one run can answer for a different audience.
69
- if (input.readerView && !readerFilters)
70
- throw new Error('--reader-view needs readerFilters.facets in the site config');
71
- const readerSelection = input.readerView ? validateReaderSelection(input.readerView, readerFilters, '--reader-view') : chatConfig?.readerView;
72
- const notes = [];
73
- const rerank = Boolean(chatConfig?.rerank) && !input.noRerank;
74
- const scoring = chatConfig?.rerank;
75
- const scorer = { ...(scoring?.endpoint ? { endpoint: jevEndpoint(scoring.endpoint) } : {}), ...(scoring?.model ? { model: scoring.model } : {}), ...(scoring?.keyEnv ? { keyEnv: scoring.keyEnv } : {}), ...(scoring?.stateFormat ? { stateFormat: scoring.stateFormat } : {}), ...(input.jevCacheDir ? { cacheDir: input.jevCacheDir } : {}) };
76
- const scoringReady = jevAvailable(scoring?.keyEnv);
77
- // A scorer that is configured but has no key counts as down, not as absent: it is what keeps off-topic questions from the model.
78
- if (rerank && !scoringReady)
79
- notes.push(`The relevance scorer has no key: set ${scoring?.keyEnv ?? 'JEV_API_KEY'}. Until then it counts as down, so ${scoring?.onError === 'answer' ? 'the model answers without the gate and the claim check' : 'questions get the closest pages and no model call'}`);
80
- // Answer cards need semantic search and Jev. A configured but missing card file is a note, not an error, so the chat still answers.
81
- let cards;
82
- if (chatConfig?.cards && !input.noCards && rerank && scoringReady) {
83
- const file = await readCards(input.siteDirectory);
84
- if (!file)
85
- notes.push('chat.cards is set but there is no ai/answers.json; run shoalrun answers to make it');
86
- else if (!embedding || file.embedding.space !== embedding.space || file.embedding.dimension !== embedding.dimension)
87
- notes.push('The answer cards were made with a different embedding model, so they are not used; run shoalrun answers again');
88
- else
89
- cards = { index: new CardIndex(file), threshold: chatConfig.cards.threshold, confirm: chatConfig.cards.confirm, jev: { ...scorer, maxCalls: 1 } };
90
- }
91
- // The shared store is for a hosted function. A local run keeps its counters in memory, so it never needs the store's keys.
92
- const store = input.sharedStore && chatConfig?.budget?.store ? envBudgetStore(chatConfig.budget.store) : memoryBudgetStore();
93
- const reported = new Set();
94
- const onBudgetError = (error) => { if (!reported.has(error.message)) {
95
- reported.add(error.message);
96
- process.stderr.write(`Budget: ${error.message}. Questions get the closest pages, with no model call, until it works.\n`);
97
- } };
98
- return { ...(chatConfig?.budget ? { budget: new DailyBudget(store, chatConfig.budget, Date.now, onBudgetError), budgetStore: store } : {}), chat, notes, embedTexts, embedding, options: { ...(scoring?.onError ? { onScorerError: scoring.onError } : {}), retriever, chat, variant, readerSelection, embedQuery, passages: input.passages ?? chatConfig?.passages, gate: chatConfig?.gate, ...(input.maxTokens ? { maxTokens: input.maxTokens } : {}), ...(rerank ? { jev: { ...scorer, top: 24, maxCalls: 2 } } : {}), ...(cards ? { cards } : {}), ...(input.cache && chatConfig?.cache ? { cache: new AnswerCache({ maxEntries: chatConfig.cache.maxEntries, ttlMs: chatConfig.cache.ttlMinutes * 60_000 }) } : {}), ...(routes.escalate && !input.noEscalate ? { escalate: { chat: createChatProvider(routes.escalate.config), when: routes.escalate.when } } : {}), ...(rerank && scoringReady && !input.noDirect && (input.direct || chatConfig?.direct) ? { direct: { threshold: input.direct?.threshold ?? chatConfig.direct.threshold, maxChars: input.direct?.maxChars ?? chatConfig?.direct?.maxChars ?? 1200, margin: chatConfig?.direct?.margin ?? 0.1, others: chatConfig?.direct?.others ?? 1, jev: { ...scorer, maxCalls: 2 } } } : {}), ...(rerank && scoringReady && chatConfig?.verify ? { verify: { ...chatConfig.verify, jev: { ...scorer, maxCalls: 3 } } } : {}) } };
10
+ return prepareAskRuntime({ ...input, settings });
99
11
  }
@@ -1,4 +1,4 @@
1
- import { type Chunk, type ChunkIndex } from './index-build.js';
1
+ import type { Chunk, ChunkIndex } from './index-build.js';
2
2
  import { type Transport } from './provider-http.js';
3
3
  export type EmbedInputType = 'document' | 'query';
4
4
  export type EmbeddingProvider = {
@@ -1,7 +1,7 @@
1
1
  import { readFile, realpath, writeFile } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import path from 'node:path';
4
- import { aiDirectory } from './index-build.js';
4
+ import { aiDirectory } from './ai-directory.js';
5
5
  import { postJson, throwIfCancelled } from './provider-http.js';
6
6
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
7
7
  function normalize(vector) {
@@ -55,4 +55,9 @@ export async function copyFunctionRuntime(dist, entry, target) {
55
55
  const done = new Set();
56
56
  for (const name of packages)
57
57
  await copyPackage(name, dist, target, done);
58
+ // Preserve the runtime author's license as well as the licenses retained in
59
+ // copied dependency packages. Custom runtime directories may have no parent license.
60
+ const license = path.join(dist, '..', 'LICENSE');
61
+ if (await stat(license).then(info => info.isFile(), () => false))
62
+ await cp(license, path.join(target, 'LICENSE.shoalrun'));
58
63
  }