@storyshelf/ai 0.0.0-bootstrap.0 → 0.7.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/README.md +29 -2
- package/dist/client.mjs +68 -0
- package/dist/client.mjs.map +1 -0
- package/dist/codec.mjs +128 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +57 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +43 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/metrics.mjs +34 -0
- package/dist/metrics.mjs.map +1 -0
- package/dist/operations.mjs +123 -0
- package/dist/operations.mjs.map +1 -0
- package/dist/telemetry.mjs +39 -0
- package/dist/telemetry.mjs.map +1 -0
- package/dist/types.d.mts +54 -0
- package/dist/types.mjs +19 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +77 -6
package/README.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @storyshelf/ai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI insights engine for StoryShelf (build triage and project health) on the Vercel AI SDK. See ADR 0016.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This package never sees API keys: you install a provider package yourself (`@ai-sdk/openai`, `@ai-sdk/anthropic`, Ollama, Bedrock, Vertex, …) and pass configured models in.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
nub add @storyshelf/ai
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createAi } from "@storyshelf/ai";
|
|
17
|
+
import { createShelfApp } from "@storyshelf/app";
|
|
18
|
+
import { openai } from "@ai-sdk/openai";
|
|
19
|
+
|
|
20
|
+
const ai = createAi({
|
|
21
|
+
profiles: { default: { defaultModel: openai("gpt-4o-mini") } },
|
|
22
|
+
budget: { dailyTokens: 2_000_000 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const app = createShelfApp({ database, storage, ai });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Passing `ai` turns AI on site-wide; a site admin then enables it per project by choosing a profile in project settings. One export, `createAi`, plus the option types. Everything else is internal.
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
MIT
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { TASK_DEFAULTS } from "./types.mjs";
|
|
5
|
+
//#region src/client.ts
|
|
6
|
+
/** True for the object form `{ model, ... }` (a bare model has a spec version). */
|
|
7
|
+
function isObjectSlot(slot) {
|
|
8
|
+
return typeof slot === "object" && slot !== null && "model" in slot && !("specificationVersion" in slot);
|
|
9
|
+
}
|
|
10
|
+
/** Normalize a slot, applying the task defaults. */
|
|
11
|
+
function resolveSlot(slot, task) {
|
|
12
|
+
const defaults = TASK_DEFAULTS[task];
|
|
13
|
+
if (isObjectSlot(slot)) return {
|
|
14
|
+
model: slot.model,
|
|
15
|
+
maxTokens: slot.maxTokens ?? defaults.maxTokens,
|
|
16
|
+
timeoutMs: slot.timeoutMs ?? defaults.timeoutMs,
|
|
17
|
+
declaredVision: slot.vision,
|
|
18
|
+
providerOptions: slot.providerOptions
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
model: slot,
|
|
22
|
+
...defaults,
|
|
23
|
+
declaredVision: void 0,
|
|
24
|
+
providerOptions: void 0
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Effective vision (ADR 0016 §3): a `vision` slot is assumed vision-capable
|
|
29
|
+
* unless it declares `vision: false`; `vision: null` disables images; a base
|
|
30
|
+
* slot only counts when it declares `vision: true`. The vision slot is used only
|
|
31
|
+
* when images are actually sent; text-only calls use the task/default model.
|
|
32
|
+
*/
|
|
33
|
+
function effectiveSlot(profile, task, withImages = false) {
|
|
34
|
+
const base = resolveSlot(profile.models?.[task] ?? profile.defaultModel, task);
|
|
35
|
+
const visionSlot = task === "triage" ? profile.models?.vision : void 0;
|
|
36
|
+
if (visionSlot && withImages) {
|
|
37
|
+
const resolved = resolveSlot(visionSlot, task);
|
|
38
|
+
return {
|
|
39
|
+
...resolved,
|
|
40
|
+
vision: resolved.declaredVision !== false
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
vision: visionSlot !== null && base.declaredVision === true
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Stable identifier of a model for rows and cache hashing. */
|
|
49
|
+
function modelId(model) {
|
|
50
|
+
if (typeof model === "string") return model;
|
|
51
|
+
return `${model.provider}:${model.modelId}`;
|
|
52
|
+
}
|
|
53
|
+
/** Throw on an unusable `createAi` configuration. */
|
|
54
|
+
function validateOptions(options) {
|
|
55
|
+
const names = Object.keys(options.profiles);
|
|
56
|
+
if (names.length === 0) throw new Error("createAi: `profiles` must define at least one profile");
|
|
57
|
+
if (names.includes("")) throw new Error("createAi: profile names must be non-empty");
|
|
58
|
+
if (options.defaultProfile !== void 0 && !names.includes(options.defaultProfile)) throw new Error(`createAi: unknown defaultProfile "${options.defaultProfile}"`);
|
|
59
|
+
}
|
|
60
|
+
/** The default profile name: explicit, else `default`, else the first key. */
|
|
61
|
+
function pickDefaultProfile(options) {
|
|
62
|
+
const names = Object.keys(options.profiles);
|
|
63
|
+
return options.defaultProfile ?? (names.includes("default") ? "default" : names[0] ?? "");
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { effectiveSlot, modelId, pickDefaultProfile, resolveSlot, validateOptions };
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=client.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["/** Pure slot resolution, effective-vision rules and option validation. */\nimport type { AiTask } from \"@storyshelf/core/ai\";\nimport type { LanguageModel } from \"ai\";\nimport {\n type CreateAiOptions,\n type ModelSlot,\n type ProfileConfig,\n type SlotProviderOptions,\n TASK_DEFAULTS,\n} from \"./types.ts\";\n\n/** A slot with defaults applied. */\nexport interface ResolvedSlot {\n model: LanguageModel;\n maxTokens: number;\n timeoutMs: number;\n /** Declared vision flag (undefined = not declared). */\n declaredVision: boolean | undefined;\n /** Provider options configured on the slot (undefined = none). */\n providerOptions: SlotProviderOptions | undefined;\n}\n\n/** True for the object form `{ model, ... }` (a bare model has a spec version). */\nfunction isObjectSlot(slot: ModelSlot): slot is Extract<ModelSlot, { model: LanguageModel }> {\n return (\n typeof slot === \"object\" &&\n slot !== null &&\n \"model\" in slot &&\n !(\"specificationVersion\" in slot)\n );\n}\n\n/** Normalize a slot, applying the task defaults. */\nexport function resolveSlot(slot: ModelSlot, task: AiTask): ResolvedSlot {\n const defaults = TASK_DEFAULTS[task];\n if (isObjectSlot(slot)) {\n return {\n model: slot.model,\n maxTokens: slot.maxTokens ?? defaults.maxTokens,\n timeoutMs: slot.timeoutMs ?? defaults.timeoutMs,\n declaredVision: slot.vision,\n providerOptions: slot.providerOptions,\n };\n }\n return { model: slot, ...defaults, declaredVision: undefined, providerOptions: undefined };\n}\n\n/** The slot chosen for a call and whether it accepts images. */\nexport interface EffectiveSlot extends ResolvedSlot {\n vision: boolean;\n}\n\n/**\n * Effective vision (ADR 0016 §3): a `vision` slot is assumed vision-capable\n * unless it declares `vision: false`; `vision: null` disables images; a base\n * slot only counts when it declares `vision: true`. The vision slot is used only\n * when images are actually sent; text-only calls use the task/default model.\n */\nexport function effectiveSlot(\n profile: ProfileConfig,\n task: AiTask,\n withImages = false,\n): EffectiveSlot {\n const base = resolveSlot(profile.models?.[task] ?? profile.defaultModel, task);\n const visionSlot = task === \"triage\" ? profile.models?.vision : undefined;\n if (visionSlot && withImages) {\n const resolved = resolveSlot(visionSlot, task);\n return { ...resolved, vision: resolved.declaredVision !== false };\n }\n return { ...base, vision: visionSlot !== null && base.declaredVision === true };\n}\n\n/** Stable identifier of a model for rows and cache hashing. */\nexport function modelId(model: LanguageModel): string {\n if (typeof model === \"string\") {\n return model;\n }\n return `${model.provider}:${model.modelId}`;\n}\n\n/** Throw on an unusable `createAi` configuration. */\nexport function validateOptions<P extends string>(options: CreateAiOptions<P>): void {\n const names = Object.keys(options.profiles);\n if (names.length === 0) {\n throw new Error(\"createAi: `profiles` must define at least one profile\");\n }\n if (names.includes(\"\")) {\n throw new Error(\"createAi: profile names must be non-empty\");\n }\n if (options.defaultProfile !== undefined && !names.includes(options.defaultProfile)) {\n throw new Error(`createAi: unknown defaultProfile \"${options.defaultProfile}\"`);\n }\n}\n\n/** The default profile name: explicit, else `default`, else the first key. */\nexport function pickDefaultProfile<P extends string>(options: CreateAiOptions<P>): string {\n const names = Object.keys(options.profiles);\n return options.defaultProfile ?? (names.includes(\"default\") ? \"default\" : (names[0] ?? \"\"));\n}\n"],"mappings":";;;;;;AAuBA,SAAS,aAAa,MAAuE;CAC3F,OACE,OAAO,SAAS,YAChB,SAAS,QACT,WAAW,QACX,EAAE,0BAA0B;AAEhC;;AAGA,SAAgB,YAAY,MAAiB,MAA4B;CACvE,MAAM,WAAW,cAAc;CAC/B,IAAI,aAAa,IAAI,GACnB,OAAO;EACL,OAAO,KAAK;EACZ,WAAW,KAAK,aAAa,SAAS;EACtC,WAAW,KAAK,aAAa,SAAS;EACtC,gBAAgB,KAAK;EACrB,iBAAiB,KAAK;CACxB;CAEF,OAAO;EAAE,OAAO;EAAM,GAAG;EAAU,gBAAgB,KAAA;EAAW,iBAAiB,KAAA;CAAU;AAC3F;;;;;;;AAaA,SAAgB,cACd,SACA,MACA,aAAa,OACE;CACf,MAAM,OAAO,YAAY,QAAQ,SAAS,SAAS,QAAQ,cAAc,IAAI;CAC7E,MAAM,aAAa,SAAS,WAAW,QAAQ,QAAQ,SAAS,KAAA;CAChE,IAAI,cAAc,YAAY;EAC5B,MAAM,WAAW,YAAY,YAAY,IAAI;EAC7C,OAAO;GAAE,GAAG;GAAU,QAAQ,SAAS,mBAAmB;EAAM;CAClE;CACA,OAAO;EAAE,GAAG;EAAM,QAAQ,eAAe,QAAQ,KAAK,mBAAmB;CAAK;AAChF;;AAGA,SAAgB,QAAQ,OAA8B;CACpD,IAAI,OAAO,UAAU,UACnB,OAAO;CAET,OAAO,GAAG,MAAM,SAAS,GAAG,MAAM;AACpC;;AAGA,SAAgB,gBAAkC,SAAmC;CACnF,MAAM,QAAQ,OAAO,KAAK,QAAQ,QAAQ;CAC1C,IAAI,MAAM,WAAW,GACnB,MAAM,IAAI,MAAM,uDAAuD;CAEzE,IAAI,MAAM,SAAS,EAAE,GACnB,MAAM,IAAI,MAAM,2CAA2C;CAE7D,IAAI,QAAQ,mBAAmB,KAAA,KAAa,CAAC,MAAM,SAAS,QAAQ,cAAc,GAChF,MAAM,IAAI,MAAM,qCAAqC,QAAQ,eAAe,EAAE;AAElF;;AAGA,SAAgB,mBAAqC,SAAqC;CACxF,MAAM,QAAQ,OAAO,KAAK,QAAQ,QAAQ;CAC1C,OAAO,QAAQ,mBAAmB,MAAM,SAAS,SAAS,IAAI,YAAa,MAAM,MAAM;AACzF"}
|
package/dist/codec.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { APICallError, JSONParseError, NoObjectGeneratedError, Output, TypeValidationError, generateText } from "ai";
|
|
5
|
+
import { AiError } from "@storyshelf/core/ai";
|
|
6
|
+
//#region src/codec.ts
|
|
7
|
+
/** Evidence → messages, one structured call, one schema-repair retry. */
|
|
8
|
+
function userMessage(evidence, withImages) {
|
|
9
|
+
const images = withImages ? evidence.images : [];
|
|
10
|
+
return {
|
|
11
|
+
role: "user",
|
|
12
|
+
content: [{
|
|
13
|
+
type: "text",
|
|
14
|
+
text: evidence.text
|
|
15
|
+
}, ...images.flatMap((image) => [{
|
|
16
|
+
type: "text",
|
|
17
|
+
text: `Screenshot: ${image.label}`
|
|
18
|
+
}, {
|
|
19
|
+
type: "image",
|
|
20
|
+
image: image.data,
|
|
21
|
+
mediaType: image.mediaType
|
|
22
|
+
}])]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Provider-reported usage, or a char/4 estimate flagged `estimated`. */
|
|
26
|
+
function toUsage(fallbackChars, usage) {
|
|
27
|
+
if (usage?.inputTokens !== void 0 || usage?.outputTokens !== void 0) return {
|
|
28
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
29
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
30
|
+
estimated: false
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
inputTokens: Math.ceil(fallbackChars / 4),
|
|
34
|
+
outputTokens: 0,
|
|
35
|
+
estimated: true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function sum(a, b) {
|
|
39
|
+
if (!a || !b) return a ?? b;
|
|
40
|
+
return {
|
|
41
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
42
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
43
|
+
estimated: a.estimated || b.estimated
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** True for failures a "reply with valid JSON" retry can fix. */
|
|
47
|
+
function isSchemaFailure(error) {
|
|
48
|
+
return NoObjectGeneratedError.isInstance(error) || TypeValidationError.isInstance(error) || JSONParseError.isInstance(error);
|
|
49
|
+
}
|
|
50
|
+
function classify(error) {
|
|
51
|
+
const name = error instanceof Error ? error.name : "";
|
|
52
|
+
if (name === "TimeoutError") return "timeout";
|
|
53
|
+
if (name === "AbortError") return "aborted";
|
|
54
|
+
if (APICallError.isInstance(error)) return "provider";
|
|
55
|
+
return isSchemaFailure(error) ? "schema" : "unknown";
|
|
56
|
+
}
|
|
57
|
+
/** Map any thrown value to an {@link AiError} (carrying known usage). */
|
|
58
|
+
function toAiError(error, usage) {
|
|
59
|
+
if (error instanceof AiError) return error;
|
|
60
|
+
const message = error instanceof Error ? error.message.slice(0, 300) : "unknown error";
|
|
61
|
+
return new AiError(classify(error), message, usage, { cause: error });
|
|
62
|
+
}
|
|
63
|
+
async function attempt(call, messages, chars) {
|
|
64
|
+
try {
|
|
65
|
+
const result = await generateText({
|
|
66
|
+
model: call.model,
|
|
67
|
+
instructions: call.system,
|
|
68
|
+
messages,
|
|
69
|
+
output: Output.object({ schema: call.schema }),
|
|
70
|
+
maxOutputTokens: call.maxTokens,
|
|
71
|
+
...call.providerOptions ? { providerOptions: call.providerOptions } : {},
|
|
72
|
+
maxRetries: 0,
|
|
73
|
+
abortSignal: call.signal,
|
|
74
|
+
telemetry: {
|
|
75
|
+
functionId: `storyshelf-${call.task}`,
|
|
76
|
+
recordInputs: false,
|
|
77
|
+
recordOutputs: false
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
object: result.output,
|
|
83
|
+
usage: toUsage(chars, result.usage)
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error,
|
|
89
|
+
usage: NoObjectGeneratedError.isInstance(error) ? toUsage(chars, error.usage) : void 0
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const REPAIR = {
|
|
94
|
+
role: "user",
|
|
95
|
+
content: [{
|
|
96
|
+
type: "text",
|
|
97
|
+
text: "Your previous reply was not valid. Reply with only a JSON object matching the schema."
|
|
98
|
+
}]
|
|
99
|
+
};
|
|
100
|
+
async function retryOnce(call, messages, chars, first) {
|
|
101
|
+
const second = await attempt(call, [...messages, REPAIR], chars);
|
|
102
|
+
if (second.ok) return {
|
|
103
|
+
object: second.object,
|
|
104
|
+
usage: sum(first.usage, second.usage) ?? second.usage
|
|
105
|
+
};
|
|
106
|
+
throw toAiError(second.error, sum(first.usage, second.usage));
|
|
107
|
+
}
|
|
108
|
+
/** Run the call; on a schema failure retry once asking for valid JSON only. */
|
|
109
|
+
async function runStructured(call) {
|
|
110
|
+
const chars = call.system.length + call.evidence.text.length;
|
|
111
|
+
const messages = [userMessage(call.evidence, call.withImages)];
|
|
112
|
+
const imagesSent = call.withImages ? call.evidence.images.length : 0;
|
|
113
|
+
const first = await attempt(call, messages, chars);
|
|
114
|
+
if (first.ok) return {
|
|
115
|
+
object: first.object,
|
|
116
|
+
usage: first.usage,
|
|
117
|
+
imagesSent
|
|
118
|
+
};
|
|
119
|
+
if (!isSchemaFailure(first.error)) throw toAiError(first.error, first.usage);
|
|
120
|
+
return {
|
|
121
|
+
...await retryOnce(call, messages, chars, first),
|
|
122
|
+
imagesSent
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { isSchemaFailure, runStructured, toAiError, toUsage };
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=codec.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codec.mjs","names":[],"sources":["../src/codec.ts"],"sourcesContent":["/** Evidence → messages, one structured call, one schema-repair retry. */\nimport { AiError, type AiEvidence, type AiTask, type AiUsageReport } from \"@storyshelf/core/ai\";\nimport {\n APICallError,\n generateText,\n JSONParseError,\n NoObjectGeneratedError,\n Output,\n TypeValidationError,\n type LanguageModel,\n type LanguageModelUsage,\n type ModelMessage,\n} from \"ai\";\nimport type { z } from \"zod\";\nimport type { SlotProviderOptions } from \"./types.ts\";\n\n/** One structured call's parameters. */\nexport interface StructuredCall<T> {\n model: LanguageModel;\n task: AiTask;\n system: string;\n evidence: AiEvidence;\n schema: z.ZodType<T>;\n maxTokens: number;\n /** Provider-specific settings from the slot (forwarded unchanged). */\n providerOptions?: SlotProviderOptions | undefined;\n signal: AbortSignal;\n /** Send images (false = text-only fallback). */\n withImages: boolean;\n}\n\n/** Parsed result plus accumulated usage. */\nexport interface StructuredResult<T> {\n object: T;\n usage: AiUsageReport;\n imagesSent: number;\n}\n\nfunction userMessage(evidence: AiEvidence, withImages: boolean): ModelMessage {\n const images = withImages ? evidence.images : [];\n return {\n role: \"user\",\n content: [\n { type: \"text\", text: evidence.text },\n ...images.flatMap((image) => [\n { type: \"text\" as const, text: `Screenshot: ${image.label}` },\n { type: \"image\" as const, image: image.data, mediaType: image.mediaType },\n ]),\n ],\n };\n}\n\n/** Provider-reported usage, or a char/4 estimate flagged `estimated`. */\nexport function toUsage(fallbackChars: number, usage?: LanguageModelUsage): AiUsageReport {\n if (usage?.inputTokens !== undefined || usage?.outputTokens !== undefined) {\n return {\n inputTokens: usage.inputTokens ?? 0,\n outputTokens: usage.outputTokens ?? 0,\n estimated: false,\n };\n }\n return { inputTokens: Math.ceil(fallbackChars / 4), outputTokens: 0, estimated: true };\n}\n\nfunction sum(\n a: AiUsageReport | undefined,\n b: AiUsageReport | undefined,\n): AiUsageReport | undefined {\n if (!a || !b) {\n return a ?? b;\n }\n return {\n inputTokens: a.inputTokens + b.inputTokens,\n outputTokens: a.outputTokens + b.outputTokens,\n estimated: a.estimated || b.estimated,\n };\n}\n\n/** True for failures a \"reply with valid JSON\" retry can fix. */\nexport function isSchemaFailure(error: unknown): boolean {\n return (\n NoObjectGeneratedError.isInstance(error) ||\n TypeValidationError.isInstance(error) ||\n JSONParseError.isInstance(error)\n );\n}\n\nfunction classify(error: unknown): AiError[\"code\"] {\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\") {\n return \"timeout\";\n }\n if (name === \"AbortError\") {\n return \"aborted\";\n }\n if (APICallError.isInstance(error)) {\n return \"provider\";\n }\n return isSchemaFailure(error) ? \"schema\" : \"unknown\";\n}\n\n/** Map any thrown value to an {@link AiError} (carrying known usage). */\nexport function toAiError(error: unknown, usage?: AiUsageReport): AiError {\n if (error instanceof AiError) {\n return error;\n }\n const message = error instanceof Error ? error.message.slice(0, 300) : \"unknown error\";\n return new AiError(classify(error), message, usage, { cause: error });\n}\n\ntype Attempt<T> =\n | { ok: true; object: T; usage: AiUsageReport }\n | { ok: false; error: unknown; usage: AiUsageReport | undefined };\n\nasync function attempt<T>(\n call: StructuredCall<T>,\n messages: ModelMessage[],\n chars: number,\n): Promise<Attempt<T>> {\n try {\n const result = await generateText({\n model: call.model,\n instructions: call.system,\n messages,\n output: Output.object({ schema: call.schema }),\n maxOutputTokens: call.maxTokens,\n ...(call.providerOptions ? { providerOptions: call.providerOptions } : {}),\n maxRetries: 0,\n abortSignal: call.signal,\n telemetry: {\n functionId: `storyshelf-${call.task}`,\n recordInputs: false,\n recordOutputs: false,\n },\n });\n return { ok: true, object: result.output, usage: toUsage(chars, result.usage) };\n } catch (error) {\n const usage = NoObjectGeneratedError.isInstance(error)\n ? toUsage(chars, error.usage)\n : undefined;\n return { ok: false, error, usage };\n }\n}\n\nconst REPAIR: ModelMessage = {\n role: \"user\",\n content: [\n {\n type: \"text\",\n text: \"Your previous reply was not valid. Reply with only a JSON object matching the schema.\",\n },\n ],\n};\n\nasync function retryOnce<T>(\n call: StructuredCall<T>,\n messages: ModelMessage[],\n chars: number,\n first: Extract<Attempt<T>, { ok: false }>,\n): Promise<{ object: T; usage: AiUsageReport }> {\n const second = await attempt(call, [...messages, REPAIR], chars);\n if (second.ok) {\n return { object: second.object, usage: sum(first.usage, second.usage) ?? second.usage };\n }\n throw toAiError(second.error, sum(first.usage, second.usage));\n}\n\n/** Run the call; on a schema failure retry once asking for valid JSON only. */\nexport async function runStructured<T>(call: StructuredCall<T>): Promise<StructuredResult<T>> {\n const chars = call.system.length + call.evidence.text.length;\n const messages: ModelMessage[] = [userMessage(call.evidence, call.withImages)];\n const imagesSent = call.withImages ? call.evidence.images.length : 0;\n const first = await attempt(call, messages, chars);\n if (first.ok) {\n return { object: first.object, usage: first.usage, imagesSent };\n }\n if (!isSchemaFailure(first.error)) {\n throw toAiError(first.error, first.usage);\n }\n return { ...(await retryOnce(call, messages, chars, first)), imagesSent };\n}\n"],"mappings":"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"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { BudgetOptions, CreateAiOptions, ModelSlot, ProfileConfig, ProfileModels, SlotProviderOptions } from "./types.mjs";
|
|
4
|
+
import { Ai } from "@storyshelf/core/ai";
|
|
5
|
+
//#region src/index.d.ts
|
|
6
|
+
/** Create the AI surface from named profiles of deployer-supplied models. */
|
|
7
|
+
declare function createAi<const P extends string>(options: CreateAiOptions<P>): Ai;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { type BudgetOptions, type CreateAiOptions, type ModelSlot, type ProfileConfig, type ProfileModels, type SlotProviderOptions, createAi };
|
|
10
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { modelId, pickDefaultProfile, validateOptions } from "./client.mjs";
|
|
5
|
+
import { health, setup } from "./lifecycle.mjs";
|
|
6
|
+
import { slotFor, summarize } from "./operations.mjs";
|
|
7
|
+
//#region src/index.ts
|
|
8
|
+
/** Create the AI surface from named profiles of deployer-supplied models. */
|
|
9
|
+
function createAi(options) {
|
|
10
|
+
validateOptions(options);
|
|
11
|
+
const state = {
|
|
12
|
+
profiles: options.profiles,
|
|
13
|
+
defaultProfile: pickDefaultProfile(options),
|
|
14
|
+
budget: toBudget(options),
|
|
15
|
+
limits: options.limits ?? {}
|
|
16
|
+
};
|
|
17
|
+
let logger;
|
|
18
|
+
const slot = (profile, task, withImages = false) => slotFor(state, profile, task, withImages);
|
|
19
|
+
return {
|
|
20
|
+
summarize: async (input) => await summarize(state, input, () => logger),
|
|
21
|
+
profileNames: () => Object.keys(state.profiles),
|
|
22
|
+
defaultProfile: () => state.defaultProfile,
|
|
23
|
+
hasVision: (profile, task) => slot(profile, task, true).vision,
|
|
24
|
+
modelId: (profile, task, withImages) => modelId(slot(profile, task, withImages).model),
|
|
25
|
+
budget: () => state.budget,
|
|
26
|
+
limits: () => state.limits,
|
|
27
|
+
timeoutMs: (profile, task, withImages) => slot(profile, task, withImages).timeoutMs,
|
|
28
|
+
setLogger: (next) => {
|
|
29
|
+
logger = next;
|
|
30
|
+
},
|
|
31
|
+
setup: async () => {
|
|
32
|
+
await setup(state, otelEnabled(options.otel));
|
|
33
|
+
},
|
|
34
|
+
health: async () => await health(state),
|
|
35
|
+
teardown: async () => {
|
|
36
|
+
await Promise.resolve();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function toBudget(options) {
|
|
41
|
+
const budget = options.budget ?? {};
|
|
42
|
+
return {
|
|
43
|
+
...budget.dailyTokens === void 0 ? {} : { dailyTokens: budget.dailyTokens },
|
|
44
|
+
visionWeight: budget.visionWeight ?? 5,
|
|
45
|
+
perProjectCallsPerHour: budget.perProjectCallsPerHour ?? 50
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** OTEL is on when the host initialised an SDK (standard env switches). */
|
|
49
|
+
function otelEnabled(override) {
|
|
50
|
+
if (override !== void 0) return override;
|
|
51
|
+
const env = process.env;
|
|
52
|
+
return env["OTEL_SDK_DISABLED"] !== "true" && (Boolean(env["OTEL_EXPORTER_OTLP_ENDPOINT"]) || env["OTEL_DENO"] === "true");
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { createAi };
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * `@storyshelf/ai`: the AI insights engine on the Vercel AI SDK (ADR 0016).\n *\n * Providers (`@ai-sdk/openai`, `@ai-sdk/anthropic`, Ollama, …) are installed by\n * the deployer and passed in as configured models; this package never sees keys.\n */\nimport type { Ai, AiBudget, AiTask } from \"@storyshelf/core/ai\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { modelId, pickDefaultProfile, validateOptions } from \"./client.ts\";\nimport { health, setup } from \"./lifecycle.ts\";\nimport { slotFor, summarize } from \"./operations.ts\";\nimport type { AiState } from \"./state.ts\";\nimport type { CreateAiOptions } from \"./types.ts\";\n\n/** Create the AI surface from named profiles of deployer-supplied models. */\nexport function createAi<const P extends string>(options: CreateAiOptions<P>): Ai {\n validateOptions(options);\n const state: AiState = {\n profiles: options.profiles,\n defaultProfile: pickDefaultProfile(options),\n budget: toBudget(options),\n limits: options.limits ?? {},\n };\n let logger: Logger | undefined;\n const slot = (\n profile: string | undefined,\n task: AiTask,\n withImages = false,\n ): ReturnType<typeof slotFor> => slotFor(state, profile, task, withImages);\n return {\n summarize: async (input) => await summarize(state, input, () => logger),\n profileNames: () => Object.keys(state.profiles),\n defaultProfile: () => state.defaultProfile,\n hasVision: (profile, task) => slot(profile, task, true).vision,\n modelId: (profile, task, withImages) => modelId(slot(profile, task, withImages).model),\n budget: () => state.budget,\n limits: () => state.limits,\n timeoutMs: (profile, task, withImages) => slot(profile, task, withImages).timeoutMs,\n setLogger: (next) => {\n logger = next;\n },\n setup: async () => {\n await setup(state, otelEnabled(options.otel));\n },\n health: async () => await health(state),\n teardown: async () => {\n await Promise.resolve();\n },\n };\n}\n\nfunction toBudget<P extends string>(options: CreateAiOptions<P>): AiBudget {\n const budget = options.budget ?? {};\n return {\n ...(budget.dailyTokens === undefined ? {} : { dailyTokens: budget.dailyTokens }),\n visionWeight: budget.visionWeight ?? 5,\n perProjectCallsPerHour: budget.perProjectCallsPerHour ?? 50,\n };\n}\n\n/** OTEL is on when the host initialised an SDK (standard env switches). */\nfunction otelEnabled(override: boolean | undefined): boolean {\n if (override !== undefined) {\n return override;\n }\n const env = process.env;\n return (\n env[\"OTEL_SDK_DISABLED\"] !== \"true\" &&\n (Boolean(env[\"OTEL_EXPORTER_OTLP_ENDPOINT\"]) || env[\"OTEL_DENO\"] === \"true\")\n );\n}\n\nexport type {\n BudgetOptions,\n CreateAiOptions,\n ModelSlot,\n ProfileConfig,\n ProfileModels,\n SlotProviderOptions,\n} from \"./types.ts\";\n"],"mappings":";;;;;;;;AAeA,SAAgB,SAAiC,SAAiC;CAChF,gBAAgB,OAAO;CACvB,MAAM,QAAiB;EACrB,UAAU,QAAQ;EAClB,gBAAgB,mBAAmB,OAAO;EAC1C,QAAQ,SAAS,OAAO;EACxB,QAAQ,QAAQ,UAAU,CAAC;CAC7B;CACA,IAAI;CACJ,MAAM,QACJ,SACA,MACA,aAAa,UACkB,QAAQ,OAAO,SAAS,MAAM,UAAU;CACzE,OAAO;EACL,WAAW,OAAO,UAAU,MAAM,UAAU,OAAO,aAAa,MAAM;EACtE,oBAAoB,OAAO,KAAK,MAAM,QAAQ;EAC9C,sBAAsB,MAAM;EAC5B,YAAY,SAAS,SAAS,KAAK,SAAS,MAAM,IAAI,CAAC,CAAC;EACxD,UAAU,SAAS,MAAM,eAAe,QAAQ,KAAK,SAAS,MAAM,UAAU,CAAC,CAAC,KAAK;EACrF,cAAc,MAAM;EACpB,cAAc,MAAM;EACpB,YAAY,SAAS,MAAM,eAAe,KAAK,SAAS,MAAM,UAAU,CAAC,CAAC;EAC1E,YAAY,SAAS;GACnB,SAAS;EACX;EACA,OAAO,YAAY;GACjB,MAAM,MAAM,OAAO,YAAY,QAAQ,IAAI,CAAC;EAC9C;EACA,QAAQ,YAAY,MAAM,OAAO,KAAK;EACtC,UAAU,YAAY;GACpB,MAAM,QAAQ,QAAQ;EACxB;CACF;AACF;AAEA,SAAS,SAA2B,SAAuC;CACzE,MAAM,SAAS,QAAQ,UAAU,CAAC;CAClC,OAAO;EACL,GAAI,OAAO,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa,OAAO,YAAY;EAC9E,cAAc,OAAO,gBAAgB;EACrC,wBAAwB,OAAO,0BAA0B;CAC3D;AACF;;AAGA,SAAS,YAAY,UAAwC;CAC3D,IAAI,aAAa,KAAA,GACf,OAAO;CAET,MAAM,MAAM,QAAQ;CACpB,OACE,IAAI,yBAAyB,WAC5B,QAAQ,IAAI,8BAA8B,KAAK,IAAI,iBAAiB;AAEzE"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { effectiveSlot, modelId } from "./client.mjs";
|
|
5
|
+
import { registerOtelOnce } from "./telemetry.mjs";
|
|
6
|
+
//#region src/lifecycle.ts
|
|
7
|
+
const SUPPORTED_SPECS = /* @__PURE__ */ new Set([
|
|
8
|
+
"v2",
|
|
9
|
+
"v3",
|
|
10
|
+
"v4"
|
|
11
|
+
]);
|
|
12
|
+
function eachModel(state) {
|
|
13
|
+
const models = [];
|
|
14
|
+
for (const profile of Object.values(state.profiles)) {
|
|
15
|
+
for (const task of ["triage", "health"]) models.push(effectiveSlot(profile, task).model);
|
|
16
|
+
if (profile.models?.vision) models.push(effectiveSlot(profile, "triage", true).model);
|
|
17
|
+
}
|
|
18
|
+
return models;
|
|
19
|
+
}
|
|
20
|
+
/** Models whose specification version the pinned `ai` core cannot drive. */
|
|
21
|
+
function unsupportedModels(state) {
|
|
22
|
+
return eachModel(state).filter((model) => typeof model !== "string" && !SUPPORTED_SPECS.has(model.specificationVersion)).map((model) => modelId(model));
|
|
23
|
+
}
|
|
24
|
+
/** Boot checks: spec versions, then OTEL registration when the host enabled it. */
|
|
25
|
+
async function setup(state, otelEnabled) {
|
|
26
|
+
const bad = unsupportedModels(state);
|
|
27
|
+
if (bad.length > 0) throw new Error(`@storyshelf/ai: unsupported model specification version for ${bad.join(", ")} (align provider packages with the pinned ai core)`);
|
|
28
|
+
if (otelEnabled) registerOtelOnce();
|
|
29
|
+
await Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
/** Liveness: configuration is present; no inference is run. */
|
|
32
|
+
async function health(state) {
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
const bad = unsupportedModels(state);
|
|
35
|
+
return bad.length === 0 ? { ok: true } : {
|
|
36
|
+
ok: false,
|
|
37
|
+
detail: `unsupported model specification: ${bad.join(", ")}`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { health, setup, unsupportedModels };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=lifecycle.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.mjs","names":[],"sources":["../src/lifecycle.ts"],"sourcesContent":["/** setup/health/teardown: free checks only (never a billed call). */\nimport type { AiHealth } from \"@storyshelf/core/ai\";\nimport type { LanguageModel } from \"ai\";\nimport { effectiveSlot, modelId } from \"./client.ts\";\nimport type { AiState } from \"./state.ts\";\nimport { registerOtelOnce } from \"./telemetry.ts\";\n\nconst SUPPORTED_SPECS = new Set([\"v2\", \"v3\", \"v4\"]);\n\nfunction eachModel(state: AiState): LanguageModel[] {\n const models: LanguageModel[] = [];\n for (const profile of Object.values(state.profiles)) {\n for (const task of [\"triage\", \"health\"] as const) {\n models.push(effectiveSlot(profile, task).model);\n }\n if (profile.models?.vision) {\n models.push(effectiveSlot(profile, \"triage\", true).model);\n }\n }\n return models;\n}\n\n/** Models whose specification version the pinned `ai` core cannot drive. */\nexport function unsupportedModels(state: AiState): string[] {\n return eachModel(state)\n .filter(\n (model) => typeof model !== \"string\" && !SUPPORTED_SPECS.has(model.specificationVersion),\n )\n .map((model) => modelId(model));\n}\n\n/** Boot checks: spec versions, then OTEL registration when the host enabled it. */\nexport async function setup(state: AiState, otelEnabled: boolean): Promise<void> {\n const bad = unsupportedModels(state);\n if (bad.length > 0) {\n throw new Error(\n `@storyshelf/ai: unsupported model specification version for ${bad.join(\", \")} (align provider packages with the pinned ai core)`,\n );\n }\n if (otelEnabled) {\n registerOtelOnce();\n }\n await Promise.resolve();\n}\n\n/** Liveness: configuration is present; no inference is run. */\nexport async function health(state: AiState): Promise<AiHealth> {\n await Promise.resolve();\n const bad = unsupportedModels(state);\n return bad.length === 0\n ? { ok: true }\n : { ok: false, detail: `unsupported model specification: ${bad.join(\", \")}` };\n}\n"],"mappings":";;;;;;AAOA,MAAM,kCAAkB,IAAI,IAAI;CAAC;CAAM;CAAM;AAAI,CAAC;AAElD,SAAS,UAAU,OAAiC;CAClD,MAAM,SAA0B,CAAC;CACjC,KAAK,MAAM,WAAW,OAAO,OAAO,MAAM,QAAQ,GAAG;EACnD,KAAK,MAAM,QAAQ,CAAC,UAAU,QAAQ,GACpC,OAAO,KAAK,cAAc,SAAS,IAAI,CAAC,CAAC,KAAK;EAEhD,IAAI,QAAQ,QAAQ,QAClB,OAAO,KAAK,cAAc,SAAS,UAAU,IAAI,CAAC,CAAC,KAAK;CAE5D;CACA,OAAO;AACT;;AAGA,SAAgB,kBAAkB,OAA0B;CAC1D,OAAO,UAAU,KAAK,CAAC,CACpB,QACE,UAAU,OAAO,UAAU,YAAY,CAAC,gBAAgB,IAAI,MAAM,oBAAoB,CACzF,CAAC,CACA,KAAK,UAAU,QAAQ,KAAK,CAAC;AAClC;;AAGA,eAAsB,MAAM,OAAgB,aAAqC;CAC/E,MAAM,MAAM,kBAAkB,KAAK;CACnC,IAAI,IAAI,SAAS,GACf,MAAM,IAAI,MACR,+DAA+D,IAAI,KAAK,IAAI,EAAE,mDAChF;CAEF,IAAI,aACF,iBAAiB;CAEnB,MAAM,QAAQ,QAAQ;AACxB;;AAGA,eAAsB,OAAO,OAAmC;CAC9D,MAAM,QAAQ,QAAQ;CACtB,MAAM,MAAM,kBAAkB,KAAK;CACnC,OAAO,IAAI,WAAW,IAClB,EAAE,IAAI,KAAK,IACX;EAAE,IAAI;EAAO,QAAQ,oCAAoC,IAAI,KAAK,IAAI;CAAI;AAChF"}
|
package/dist/metrics.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { metrics } from "@opentelemetry/api";
|
|
5
|
+
//#region src/metrics.ts
|
|
6
|
+
/** Bounded-cardinality OTEL instruments (task + outcome only; ADR 0016 §8). */
|
|
7
|
+
let cached;
|
|
8
|
+
function instruments() {
|
|
9
|
+
if (!cached) {
|
|
10
|
+
const meter = metrics.getMeter("storyshelf.ai");
|
|
11
|
+
cached = {
|
|
12
|
+
duration: meter.createHistogram("ai.summarize.duration", { unit: "ms" }),
|
|
13
|
+
tokens: meter.createHistogram("ai.summarize.tokens", { unit: "{token}" }),
|
|
14
|
+
completed: meter.createCounter("ai.insights.completed"),
|
|
15
|
+
failed: meter.createCounter("ai.insights.failed")
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
/** Record one finished call (never per profile, model, story or build). */
|
|
21
|
+
function recordSummarize(task, outcome, durationMs, tokens) {
|
|
22
|
+
const { duration, tokens: tokenHistogram, completed, failed } = instruments();
|
|
23
|
+
const attributes = {
|
|
24
|
+
"ai.task": task,
|
|
25
|
+
outcome
|
|
26
|
+
};
|
|
27
|
+
duration.record(durationMs, attributes);
|
|
28
|
+
tokenHistogram.record(tokens, attributes);
|
|
29
|
+
(outcome === "ok" ? completed : failed).add(1, { "ai.task": task });
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { recordSummarize };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=metrics.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.mjs","names":[],"sources":["../src/metrics.ts"],"sourcesContent":["/** Bounded-cardinality OTEL instruments (task + outcome only; ADR 0016 §8). */\nimport { metrics, type Counter, type Histogram } from \"@opentelemetry/api\";\n\ninterface Instruments {\n duration: Histogram;\n tokens: Histogram;\n completed: Counter;\n failed: Counter;\n}\n\nlet cached: Instruments | undefined;\n\nfunction instruments(): Instruments {\n if (!cached) {\n const meter = metrics.getMeter(\"storyshelf.ai\");\n cached = {\n duration: meter.createHistogram(\"ai.summarize.duration\", { unit: \"ms\" }),\n tokens: meter.createHistogram(\"ai.summarize.tokens\", { unit: \"{token}\" }),\n completed: meter.createCounter(\"ai.insights.completed\"),\n failed: meter.createCounter(\"ai.insights.failed\"),\n };\n }\n return cached;\n}\n\n/** Record one finished call (never per profile, model, story or build). */\nexport function recordSummarize(\n task: string,\n outcome: \"ok\" | \"error\",\n durationMs: number,\n tokens: number,\n): void {\n const { duration, tokens: tokenHistogram, completed, failed } = instruments();\n const attributes = { \"ai.task\": task, outcome };\n duration.record(durationMs, attributes);\n tokenHistogram.record(tokens, attributes);\n (outcome === \"ok\" ? completed : failed).add(1, { \"ai.task\": task });\n}\n\n/** Test hook: drop memoized instruments so a new meter provider is picked up. */\nexport function resetMetricsForTests(): void {\n cached = undefined;\n}\n"],"mappings":";;;;;;AAUA,IAAI;AAEJ,SAAS,cAA2B;CAClC,IAAI,CAAC,QAAQ;EACX,MAAM,QAAQ,QAAQ,SAAS,eAAe;EAC9C,SAAS;GACP,UAAU,MAAM,gBAAgB,yBAAyB,EAAE,MAAM,KAAK,CAAC;GACvE,QAAQ,MAAM,gBAAgB,uBAAuB,EAAE,MAAM,UAAU,CAAC;GACxE,WAAW,MAAM,cAAc,uBAAuB;GACtD,QAAQ,MAAM,cAAc,oBAAoB;EAClD;CACF;CACA,OAAO;AACT;;AAGA,SAAgB,gBACd,MACA,SACA,YACA,QACM;CACN,MAAM,EAAE,UAAU,QAAQ,gBAAgB,WAAW,WAAW,YAAY;CAC5E,MAAM,aAAa;EAAE,WAAW;EAAM;CAAQ;CAC9C,SAAS,OAAO,YAAY,UAAU;CACtC,eAAe,OAAO,QAAQ,UAAU;CACxC,CAAC,YAAY,OAAO,YAAY,OAAA,CAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;AACpE"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { effectiveSlot, modelId } from "./client.mjs";
|
|
5
|
+
import { withSpan } from "./telemetry.mjs";
|
|
6
|
+
import { runStructured, toAiError } from "./codec.mjs";
|
|
7
|
+
import { recordSummarize } from "./metrics.mjs";
|
|
8
|
+
import { AiError } from "@storyshelf/core/ai";
|
|
9
|
+
//#region src/operations.ts
|
|
10
|
+
/** `summarize`: profile resolution, vision fallback, timeout and spans. */
|
|
11
|
+
/** Resolve the requested profile; unknown names fall back with a warning. */
|
|
12
|
+
function resolveProfile(state, requested) {
|
|
13
|
+
const known = requested === void 0 ? void 0 : state.profiles[requested];
|
|
14
|
+
const name = known && requested !== void 0 ? requested : state.defaultProfile;
|
|
15
|
+
const profile = state.profiles[name];
|
|
16
|
+
if (!profile) throw new Error(`@storyshelf/ai: profile "${name}" is not configured`);
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
profile,
|
|
20
|
+
warnings: requested !== void 0 && !known ? [`unknown-profile:${requested}`] : []
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** The effective slot a call would use. */
|
|
24
|
+
function slotFor(state, profile, task, withImages = false) {
|
|
25
|
+
return effectiveSlot(resolveProfile(state, profile).profile, task, withImages);
|
|
26
|
+
}
|
|
27
|
+
function signalFor(external, timeoutMs) {
|
|
28
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
29
|
+
return external ? AbortSignal.any([external, timeout]) : timeout;
|
|
30
|
+
}
|
|
31
|
+
/** Try with images; a provider rejection falls back to text-only once. */
|
|
32
|
+
async function callWithVisionFallback(input, slot, onSkip) {
|
|
33
|
+
const base = {
|
|
34
|
+
...input,
|
|
35
|
+
model: slot.model,
|
|
36
|
+
maxTokens: slot.maxTokens,
|
|
37
|
+
providerOptions: slot.providerOptions,
|
|
38
|
+
signal: signalFor(input.signal, slot.timeoutMs)
|
|
39
|
+
};
|
|
40
|
+
const useImages = input.evidence.images.length > 0 && slot.vision;
|
|
41
|
+
try {
|
|
42
|
+
return await runStructured({
|
|
43
|
+
...base,
|
|
44
|
+
withImages: useImages
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!useImages || !(error instanceof AiError) || error.code !== "provider") throw error;
|
|
48
|
+
onSkip();
|
|
49
|
+
return await runStructured({
|
|
50
|
+
...base,
|
|
51
|
+
withImages: false
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function toResult(ctx, done, skipped) {
|
|
56
|
+
const wantsImages = ctx.input.evidence.images.length > 0;
|
|
57
|
+
return {
|
|
58
|
+
object: done.object,
|
|
59
|
+
usage: done.usage,
|
|
60
|
+
profileRequested: ctx.input.profile ?? null,
|
|
61
|
+
profileEffective: ctx.name,
|
|
62
|
+
model: ctx.model,
|
|
63
|
+
imagesSent: done.imagesSent,
|
|
64
|
+
visionSkipped: skipped,
|
|
65
|
+
warnings: [...ctx.warnings, ...skipped && wantsImages ? ["vision-skipped"] : []]
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function noteOk(ctx, done, started) {
|
|
69
|
+
const tokens = done.usage.inputTokens + done.usage.outputTokens;
|
|
70
|
+
recordSummarize(ctx.input.task, "ok", performance.now() - started, tokens);
|
|
71
|
+
ctx.log?.info({
|
|
72
|
+
task: ctx.input.task,
|
|
73
|
+
profile: ctx.name,
|
|
74
|
+
model: ctx.model
|
|
75
|
+
}, "ai summarize done");
|
|
76
|
+
}
|
|
77
|
+
function noteError(ctx, failure, started) {
|
|
78
|
+
const billed = (failure.usage?.inputTokens ?? 0) + (failure.usage?.outputTokens ?? 0);
|
|
79
|
+
recordSummarize(ctx.input.task, "error", performance.now() - started, billed);
|
|
80
|
+
ctx.log?.warn({
|
|
81
|
+
task: ctx.input.task,
|
|
82
|
+
profile: ctx.name,
|
|
83
|
+
code: failure.code
|
|
84
|
+
}, "ai summarize failed");
|
|
85
|
+
}
|
|
86
|
+
async function runMeasured(ctx, span, started) {
|
|
87
|
+
let skipped = ctx.input.evidence.images.length > 0 && !ctx.slot.vision;
|
|
88
|
+
try {
|
|
89
|
+
const done = await callWithVisionFallback(ctx.input, ctx.slot, () => {
|
|
90
|
+
skipped = true;
|
|
91
|
+
span.addEvent("vision-skipped");
|
|
92
|
+
});
|
|
93
|
+
noteOk(ctx, done, started);
|
|
94
|
+
return toResult(ctx, done, skipped);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const failure = toAiError(error);
|
|
97
|
+
noteError(ctx, failure, started);
|
|
98
|
+
throw failure;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Run one structured summarize call. */
|
|
102
|
+
async function summarize(state, input, logger) {
|
|
103
|
+
const { name, profile, warnings } = resolveProfile(state, input.profile);
|
|
104
|
+
const slot = effectiveSlot(profile, input.task, input.evidence.images.length > 0);
|
|
105
|
+
const ctx = {
|
|
106
|
+
input,
|
|
107
|
+
slot,
|
|
108
|
+
name,
|
|
109
|
+
model: modelId(slot.model),
|
|
110
|
+
warnings,
|
|
111
|
+
log: logger()
|
|
112
|
+
};
|
|
113
|
+
const started = performance.now();
|
|
114
|
+
return await withSpan("ai.summarize", async (span) => await runMeasured(ctx, span, started), {
|
|
115
|
+
"ai.task": input.task,
|
|
116
|
+
"ai.profile": name,
|
|
117
|
+
"ai.model": ctx.model
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
export { resolveProfile, slotFor, summarize };
|
|
122
|
+
|
|
123
|
+
//# sourceMappingURL=operations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operations.mjs","names":[],"sources":["../src/operations.ts"],"sourcesContent":["import type { Span } from \"@opentelemetry/api\";\n/** `summarize`: profile resolution, vision fallback, timeout and spans. */\nimport { AiError, type AiSummarizeInput, type AiSummarizeResult } from \"@storyshelf/core/ai\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { effectiveSlot, modelId, type EffectiveSlot } from \"./client.ts\";\nimport { runStructured, toAiError, type StructuredResult } from \"./codec.ts\";\nimport { recordSummarize } from \"./metrics.ts\";\nimport type { AiState } from \"./state.ts\";\nimport { withSpan } from \"./telemetry.ts\";\nimport type { ProfileConfig } from \"./types.ts\";\n\n/** Resolve the requested profile; unknown names fall back with a warning. */\nexport function resolveProfile(\n state: AiState,\n requested: string | undefined,\n): { name: string; profile: ProfileConfig; warnings: string[] } {\n const known = requested === undefined ? undefined : state.profiles[requested];\n const name = known && requested !== undefined ? requested : state.defaultProfile;\n const profile = state.profiles[name];\n if (!profile) {\n throw new Error(`@storyshelf/ai: profile \"${name}\" is not configured`);\n }\n const warnings = requested !== undefined && !known ? [`unknown-profile:${requested}`] : [];\n return { name, profile, warnings };\n}\n\n/** The effective slot a call would use. */\nexport function slotFor(\n state: AiState,\n profile: string | undefined,\n task: AiSummarizeInput<unknown>[\"task\"],\n withImages = false,\n): EffectiveSlot {\n return effectiveSlot(resolveProfile(state, profile).profile, task, withImages);\n}\n\nfunction signalFor(external: AbortSignal | undefined, timeoutMs: number): AbortSignal {\n const timeout = AbortSignal.timeout(timeoutMs);\n return external ? AbortSignal.any([external, timeout]) : timeout;\n}\n\n/** Try with images; a provider rejection falls back to text-only once. */\nasync function callWithVisionFallback<T>(\n input: AiSummarizeInput<T>,\n slot: EffectiveSlot,\n onSkip: () => void,\n): Promise<StructuredResult<T>> {\n const base = {\n ...input,\n model: slot.model,\n maxTokens: slot.maxTokens,\n providerOptions: slot.providerOptions,\n signal: signalFor(input.signal, slot.timeoutMs),\n };\n const useImages = input.evidence.images.length > 0 && slot.vision;\n try {\n return await runStructured({ ...base, withImages: useImages });\n } catch (error) {\n if (!useImages || !(error instanceof AiError) || error.code !== \"provider\") {\n throw error;\n }\n onSkip();\n return await runStructured({ ...base, withImages: false });\n }\n}\n\ninterface CallContext<T> {\n input: AiSummarizeInput<T>;\n slot: EffectiveSlot;\n name: string;\n model: string;\n warnings: string[];\n log: Logger | undefined;\n}\n\nfunction toResult<T>(\n ctx: CallContext<T>,\n done: StructuredResult<T>,\n skipped: boolean,\n): AiSummarizeResult<T> {\n const wantsImages = ctx.input.evidence.images.length > 0;\n return {\n object: done.object,\n usage: done.usage,\n profileRequested: ctx.input.profile ?? null,\n profileEffective: ctx.name,\n model: ctx.model,\n imagesSent: done.imagesSent,\n visionSkipped: skipped,\n warnings: [...ctx.warnings, ...(skipped && wantsImages ? [\"vision-skipped\"] : [])],\n };\n}\n\nfunction noteOk<T>(ctx: CallContext<T>, done: StructuredResult<T>, started: number): void {\n const tokens = done.usage.inputTokens + done.usage.outputTokens;\n recordSummarize(ctx.input.task, \"ok\", performance.now() - started, tokens);\n ctx.log?.info({ task: ctx.input.task, profile: ctx.name, model: ctx.model }, \"ai summarize done\");\n}\n\nfunction noteError<T>(ctx: CallContext<T>, failure: AiError, started: number): void {\n const billed = (failure.usage?.inputTokens ?? 0) + (failure.usage?.outputTokens ?? 0);\n recordSummarize(ctx.input.task, \"error\", performance.now() - started, billed);\n ctx.log?.warn(\n { task: ctx.input.task, profile: ctx.name, code: failure.code },\n \"ai summarize failed\",\n );\n}\n\nasync function runMeasured<T>(\n ctx: CallContext<T>,\n span: Span,\n started: number,\n): Promise<AiSummarizeResult<T>> {\n let skipped = ctx.input.evidence.images.length > 0 && !ctx.slot.vision;\n try {\n const done = await callWithVisionFallback(ctx.input, ctx.slot, () => {\n skipped = true;\n span.addEvent(\"vision-skipped\");\n });\n noteOk(ctx, done, started);\n return toResult(ctx, done, skipped);\n } catch (error) {\n const failure = toAiError(error);\n noteError(ctx, failure, started);\n throw failure;\n }\n}\n\n/** Run one structured summarize call. */\nexport async function summarize<T>(\n state: AiState,\n input: AiSummarizeInput<T>,\n logger: () => Logger | undefined,\n): Promise<AiSummarizeResult<T>> {\n const { name, profile, warnings } = resolveProfile(state, input.profile);\n const slot = effectiveSlot(profile, input.task, input.evidence.images.length > 0);\n const ctx: CallContext<T> = {\n input,\n slot,\n name,\n model: modelId(slot.model),\n warnings,\n log: logger(),\n };\n const started = performance.now();\n return await withSpan(\"ai.summarize\", async (span) => await runMeasured(ctx, span, started), {\n \"ai.task\": input.task,\n \"ai.profile\": name,\n \"ai.model\": ctx.model,\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { OpenTelemetry } from "@ai-sdk/otel";
|
|
5
|
+
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
6
|
+
import { registerTelemetry } from "ai";
|
|
7
|
+
//#region src/telemetry.ts
|
|
8
|
+
/** OTEL wiring: idempotent integration registration and a local span helper. */
|
|
9
|
+
let registered = false;
|
|
10
|
+
/** Register the SDK's OpenTelemetry integration once per process. */
|
|
11
|
+
function registerOtelOnce() {
|
|
12
|
+
if (registered) return;
|
|
13
|
+
registered = true;
|
|
14
|
+
registerTelemetry(new OpenTelemetry());
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run `fn` inside an internal span (intentional copy of the core/observability
|
|
18
|
+
* helper: ADR 0015 §5 no-cycle rule — do not deduplicate).
|
|
19
|
+
*/
|
|
20
|
+
async function withSpan(name, fn, attributes) {
|
|
21
|
+
return await trace.getTracer("storyshelf.ai").startActiveSpan(name, {
|
|
22
|
+
kind: SpanKind.INTERNAL,
|
|
23
|
+
attributes
|
|
24
|
+
}, async (span) => {
|
|
25
|
+
try {
|
|
26
|
+
return await fn(span);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
span.recordException(error instanceof Error ? error : String(error));
|
|
29
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
30
|
+
throw error;
|
|
31
|
+
} finally {
|
|
32
|
+
span.end();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { registerOtelOnce, withSpan };
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=telemetry.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.mjs","names":[],"sources":["../src/telemetry.ts"],"sourcesContent":["/** OTEL wiring: idempotent integration registration and a local span helper. */\nimport { OpenTelemetry } from \"@ai-sdk/otel\";\nimport { SpanKind, SpanStatusCode, trace, type Attributes, type Span } from \"@opentelemetry/api\";\nimport { registerTelemetry } from \"ai\";\n\nlet registered = false;\n\n/** Register the SDK's OpenTelemetry integration once per process. */\nexport function registerOtelOnce(): void {\n if (registered) {\n return;\n }\n registered = true;\n registerTelemetry(new OpenTelemetry());\n}\n\n/** Test hook: forget the registration flag. */\nexport function resetOtelRegistrationForTests(): void {\n registered = false;\n}\n\n/**\n * Run `fn` inside an internal span (intentional copy of the core/observability\n * helper: ADR 0015 §5 no-cycle rule — do not deduplicate).\n */\nexport async function withSpan<T>(\n name: string,\n fn: (span: Span) => Promise<T>,\n attributes?: Attributes,\n): Promise<T> {\n const tracer = trace.getTracer(\"storyshelf.ai\");\n return await tracer.startActiveSpan(\n name,\n { kind: SpanKind.INTERNAL, attributes },\n async (span) => {\n try {\n return await fn(span);\n } catch (error) {\n span.recordException(error instanceof Error ? error : String(error));\n span.setStatus({ code: SpanStatusCode.ERROR });\n throw error;\n } finally {\n span.end();\n }\n },\n );\n}\n"],"mappings":";;;;;;;;AAKA,IAAI,aAAa;;AAGjB,SAAgB,mBAAyB;CACvC,IAAI,YACF;CAEF,aAAa;CACb,kBAAkB,IAAI,cAAc,CAAC;AACvC;;;;;AAWA,eAAsB,SACpB,MACA,IACA,YACY;CAEZ,OAAO,MADQ,MAAM,UAAU,eACb,CAAC,CAAC,gBAClB,MACA;EAAE,MAAM,SAAS;EAAU;CAAW,GACtC,OAAO,SAAS;EACd,IAAI;GACF,OAAO,MAAM,GAAG,IAAI;EACtB,SAAS,OAAO;GACd,KAAK,gBAAgB,iBAAiB,QAAQ,QAAQ,OAAO,KAAK,CAAC;GACnE,KAAK,UAAU,EAAE,MAAM,eAAe,MAAM,CAAC;GAC7C,MAAM;EACR,UAAU;GACR,KAAK,IAAI;EACX;CACF,CACF;AACF"}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { LanguageModel, generateText } from "ai";
|
|
4
|
+
import { EvidenceLimits } from "@storyshelf/core/insights";
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
/** Provider-specific call settings, forwarded unchanged (opaque to this package). */
|
|
7
|
+
type SlotProviderOptions = NonNullable<Parameters<typeof generateText>[0]["providerOptions"]>;
|
|
8
|
+
/** A model, or a model with per-slot overrides. */
|
|
9
|
+
type ModelSlot = LanguageModel | {
|
|
10
|
+
model: LanguageModel;
|
|
11
|
+
/** Max output tokens (defaults: triage ~1500, health ~4000). */
|
|
12
|
+
maxTokens?: number;
|
|
13
|
+
/** Declared vision capability (see ADR 0016 §3 effective-vision rules). */
|
|
14
|
+
vision?: boolean;
|
|
15
|
+
/** Per-call timeout in milliseconds. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Provider-specific settings sent with every call of this slot (for example a
|
|
19
|
+
* reasoning-effort or thinking-budget cap, or provider routing). Passed to the
|
|
20
|
+
* SDK unchanged; this package contains no provider code.
|
|
21
|
+
*/
|
|
22
|
+
providerOptions?: SlotProviderOptions;
|
|
23
|
+
};
|
|
24
|
+
/** Per-task models of one profile; `vision: null` disables images. */
|
|
25
|
+
interface ProfileModels {
|
|
26
|
+
triage?: ModelSlot;
|
|
27
|
+
vision?: ModelSlot | null;
|
|
28
|
+
health?: ModelSlot;
|
|
29
|
+
}
|
|
30
|
+
/** One named profile (an admin's data-flow decision). */
|
|
31
|
+
interface ProfileConfig {
|
|
32
|
+
defaultModel: ModelSlot;
|
|
33
|
+
models?: ProfileModels;
|
|
34
|
+
}
|
|
35
|
+
/** Instance-level budget options. */
|
|
36
|
+
interface BudgetOptions {
|
|
37
|
+
dailyTokens?: number;
|
|
38
|
+
visionWeight?: number;
|
|
39
|
+
perProjectCallsPerHour?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Options for {@link createAi}. */
|
|
42
|
+
interface CreateAiOptions<P extends string> {
|
|
43
|
+
profiles: Record<P, ProfileConfig>;
|
|
44
|
+
/** Defaults to the `default` key if present, else the first key. */
|
|
45
|
+
defaultProfile?: NoInfer<P>;
|
|
46
|
+
budget?: BudgetOptions;
|
|
47
|
+
/** Force SDK telemetry registration on/off (default: on when `OTEL_EXPORTER_OTLP_ENDPOINT` is set). */
|
|
48
|
+
otel?: boolean;
|
|
49
|
+
/** Evidence limit overrides (ADR 0016 §5 defaults otherwise). */
|
|
50
|
+
limits?: Partial<EvidenceLimits>;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { BudgetOptions, CreateAiOptions, ModelSlot, ProfileConfig, ProfileModels, SlotProviderOptions };
|
|
54
|
+
//# sourceMappingURL=types.d.mts.map
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
//#region src/types.ts
|
|
5
|
+
/** Task token/time defaults. */
|
|
6
|
+
const TASK_DEFAULTS = {
|
|
7
|
+
triage: {
|
|
8
|
+
maxTokens: 1500,
|
|
9
|
+
timeoutMs: 6e4
|
|
10
|
+
},
|
|
11
|
+
health: {
|
|
12
|
+
maxTokens: 4e3,
|
|
13
|
+
timeoutMs: 18e4
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { TASK_DEFAULTS };
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=types.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.mjs","names":[],"sources":["../src/types.ts"],"sourcesContent":["/** Options and slot types for `createAi` (no behavior). */\nimport type { EvidenceLimits } from \"@storyshelf/core/insights\";\nimport type { LanguageModel, generateText } from \"ai\";\n\n/** Provider-specific call settings, forwarded unchanged (opaque to this package). */\nexport type SlotProviderOptions = NonNullable<\n Parameters<typeof generateText>[0][\"providerOptions\"]\n>;\n\n/** A model, or a model with per-slot overrides. */\nexport type ModelSlot =\n | LanguageModel\n | {\n model: LanguageModel;\n /** Max output tokens (defaults: triage ~1500, health ~4000). */\n maxTokens?: number;\n /** Declared vision capability (see ADR 0016 §3 effective-vision rules). */\n vision?: boolean;\n /** Per-call timeout in milliseconds. */\n timeoutMs?: number;\n /**\n * Provider-specific settings sent with every call of this slot (for example a\n * reasoning-effort or thinking-budget cap, or provider routing). Passed to the\n * SDK unchanged; this package contains no provider code.\n */\n providerOptions?: SlotProviderOptions;\n };\n\n/** Per-task models of one profile; `vision: null` disables images. */\nexport interface ProfileModels {\n triage?: ModelSlot;\n vision?: ModelSlot | null;\n health?: ModelSlot;\n}\n\n/** One named profile (an admin's data-flow decision). */\nexport interface ProfileConfig {\n defaultModel: ModelSlot;\n models?: ProfileModels;\n}\n\n/** Instance-level budget options. */\nexport interface BudgetOptions {\n dailyTokens?: number;\n visionWeight?: number;\n perProjectCallsPerHour?: number;\n}\n\n/** Options for {@link createAi}. */\nexport interface CreateAiOptions<P extends string> {\n profiles: Record<P, ProfileConfig>;\n /** Defaults to the `default` key if present, else the first key. */\n defaultProfile?: NoInfer<P>;\n budget?: BudgetOptions;\n /** Force SDK telemetry registration on/off (default: on when `OTEL_EXPORTER_OTLP_ENDPOINT` is set). */\n otel?: boolean;\n /** Evidence limit overrides (ADR 0016 §5 defaults otherwise). */\n limits?: Partial<EvidenceLimits>;\n}\n\n/** Task token/time defaults. */\nexport const TASK_DEFAULTS = {\n triage: { maxTokens: 1500, timeoutMs: 60_000 },\n health: { maxTokens: 4000, timeoutMs: 180_000 },\n} as const;\n"],"mappings":";;;;;AA6DA,MAAa,gBAAgB;CAC3B,QAAQ;EAAE,WAAW;EAAM,WAAW;CAAO;CAC7C,QAAQ;EAAE,WAAW;EAAM,WAAW;CAAQ;AAChD"}
|
package/package.json
CHANGED
|
@@ -1,15 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyshelf/ai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "StoryShelf AI insights engine on the Vercel AI SDK: build triage and project health (providers are supplied by the deployer).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"insights",
|
|
8
|
+
"self-hosted",
|
|
9
|
+
"storybook",
|
|
10
|
+
"visual-testing"
|
|
11
|
+
],
|
|
7
12
|
"homepage": "https://github.com/GuptaSiddhant/StoryShelf#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/GuptaSiddhant/StoryShelf/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Siddhant Gupta",
|
|
19
|
+
"url": "https://guptasiddhant.com"
|
|
20
|
+
},
|
|
8
21
|
"repository": {
|
|
9
22
|
"type": "git",
|
|
10
23
|
"url": "git+https://github.com/GuptaSiddhant/StoryShelf.git",
|
|
11
24
|
"directory": "packages/ai"
|
|
12
25
|
},
|
|
13
|
-
"files": [
|
|
14
|
-
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"source": "./src/index.ts",
|
|
35
|
+
"default": "./dist/index.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"exports": {
|
|
41
|
+
".": "./dist/index.mjs",
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsdown",
|
|
48
|
+
"dev": "tsdown -w",
|
|
49
|
+
"fmt": "oxfmt ./src",
|
|
50
|
+
"lint": "oxlint --type-aware --type-check ./src",
|
|
51
|
+
"prepublishOnly": "nub run build",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@ai-sdk/otel": "1.0.133",
|
|
56
|
+
"@opentelemetry/api": "^1.9.0",
|
|
57
|
+
"@storyshelf/core": "0.7.0",
|
|
58
|
+
"ai": "7.0.133"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
62
|
+
"@opentelemetry/core": "^2.11.0",
|
|
63
|
+
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
64
|
+
"@types/node": "^26.0.0",
|
|
65
|
+
"@vitest/coverage-v8": "^4.0.0",
|
|
66
|
+
"oxfmt": "^0.65.0",
|
|
67
|
+
"oxlint": "^1.80.0",
|
|
68
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
69
|
+
"tsdown": "^0.22.14",
|
|
70
|
+
"typescript": "^7.0.2",
|
|
71
|
+
"vitest": "^5.0.0",
|
|
72
|
+
"zod": "^4.2.0"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=24"
|
|
76
|
+
},
|
|
77
|
+
"jsr": {
|
|
78
|
+
"runtimeCompat": {
|
|
79
|
+
"node": true,
|
|
80
|
+
"deno": true,
|
|
81
|
+
"bun": true,
|
|
82
|
+
"workerd": false,
|
|
83
|
+
"browser": false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
15
86
|
}
|