@warlock.js/ai 4.5.0 → 4.6.1

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 (159) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/cjs/index.cjs +20 -1
  3. package/cjs/{src-DFibP2FQ.cjs → src-Bmajk4Qg.cjs} +1 -1
  4. package/cjs/{src-C02yzsLs.cjs → src-OZyDYHxm.cjs} +2789 -691
  5. package/cjs/src-OZyDYHxm.cjs.map +1 -0
  6. package/esm/agent/agent-config.type.d.mts +29 -0
  7. package/esm/agent/agent-config.type.d.mts.map +1 -1
  8. package/esm/agent/agent.d.mts.map +1 -1
  9. package/esm/agent/agent.mjs +126 -7
  10. package/esm/agent/agent.mjs.map +1 -1
  11. package/esm/agent/signature.mjs +57 -0
  12. package/esm/agent/signature.mjs.map +1 -0
  13. package/esm/agent/snapshot.mjs +101 -0
  14. package/esm/agent/snapshot.mjs.map +1 -0
  15. package/esm/ai-openai/src/image.mjs +5 -0
  16. package/esm/ai-openai/src/index.mjs +3 -0
  17. package/esm/ai-openai/src/sdk.mjs +3 -0
  18. package/esm/ai-openai/src/speech.mjs +5 -0
  19. package/esm/ai-openai/src/transcription.mjs +6 -0
  20. package/esm/ai-openai/src/utils/index.mjs +1 -0
  21. package/esm/ai-openai/src/utils/to-openai-messages.mjs +3 -0
  22. package/esm/ai.d.mts +45 -0
  23. package/esm/ai.d.mts.map +1 -1
  24. package/esm/ai.mjs +37 -1
  25. package/esm/ai.mjs.map +1 -1
  26. package/esm/contracts/agent/agent-options.type.d.mts +22 -2
  27. package/esm/contracts/agent/agent-options.type.d.mts.map +1 -1
  28. package/esm/contracts/agent/agent-snapshot.type.d.mts +90 -0
  29. package/esm/contracts/agent/agent-snapshot.type.d.mts.map +1 -0
  30. package/esm/contracts/agent/agent.contract.d.mts +29 -1
  31. package/esm/contracts/agent/agent.contract.d.mts.map +1 -1
  32. package/esm/contracts/agent/index.d.mts +2 -1
  33. package/esm/contracts/image-model.contract.d.mts +156 -0
  34. package/esm/contracts/image-model.contract.d.mts.map +1 -0
  35. package/esm/contracts/index.d.mts +8 -3
  36. package/esm/contracts/planner/index.d.mts +3 -2
  37. package/esm/contracts/planner/planner-config.type.d.mts +30 -0
  38. package/esm/contracts/planner/planner-config.type.d.mts.map +1 -1
  39. package/esm/contracts/planner/planner-execute-options.type.d.mts +13 -1
  40. package/esm/contracts/planner/planner-execute-options.type.d.mts.map +1 -1
  41. package/esm/contracts/planner/planner-snapshot.type.d.mts +77 -0
  42. package/esm/contracts/planner/planner-snapshot.type.d.mts.map +1 -0
  43. package/esm/contracts/planner/planner.contract.d.mts +21 -1
  44. package/esm/contracts/planner/planner.contract.d.mts.map +1 -1
  45. package/esm/contracts/result/base-report.type.d.mts +1 -1
  46. package/esm/contracts/result/base-report.type.d.mts.map +1 -1
  47. package/esm/contracts/result/base-report.type.mjs.map +1 -1
  48. package/esm/contracts/sdk-adapter.contract.d.mts +37 -0
  49. package/esm/contracts/sdk-adapter.contract.d.mts.map +1 -1
  50. package/esm/contracts/speech-model.contract.d.mts +97 -0
  51. package/esm/contracts/speech-model.contract.d.mts.map +1 -0
  52. package/esm/contracts/transcription-model.contract.d.mts +101 -0
  53. package/esm/contracts/transcription-model.contract.d.mts.map +1 -0
  54. package/esm/errors/agent-drift-error.d.mts +32 -0
  55. package/esm/errors/agent-drift-error.d.mts.map +1 -0
  56. package/esm/errors/agent-drift-error.mjs +31 -0
  57. package/esm/errors/agent-drift-error.mjs.map +1 -0
  58. package/esm/errors/error-code.type.d.mts +1 -1
  59. package/esm/errors/index.d.mts +2 -0
  60. package/esm/errors/index.mjs +2 -0
  61. package/esm/errors/planner-drift-error.d.mts +34 -0
  62. package/esm/errors/planner-drift-error.d.mts.map +1 -0
  63. package/esm/errors/planner-drift-error.mjs +33 -0
  64. package/esm/errors/planner-drift-error.mjs.map +1 -0
  65. package/esm/image/image-cost.d.mts +32 -0
  66. package/esm/image/image-cost.d.mts.map +1 -0
  67. package/esm/image/image-cost.mjs +55 -0
  68. package/esm/image/image-cost.mjs.map +1 -0
  69. package/esm/image/image.d.mts +92 -0
  70. package/esm/image/image.d.mts.map +1 -0
  71. package/esm/image/image.mjs +113 -0
  72. package/esm/image/image.mjs.map +1 -0
  73. package/esm/image/index.mjs +4 -0
  74. package/esm/index.d.mts +26 -4
  75. package/esm/index.mjs +20 -1
  76. package/esm/mock/index.d.mts +3 -0
  77. package/esm/mock/index.mjs +3 -0
  78. package/esm/mock/mock-config.type.d.mts +22 -0
  79. package/esm/mock/mock-config.type.d.mts.map +1 -1
  80. package/esm/mock/mock-image-model.d.mts +41 -0
  81. package/esm/mock/mock-image-model.d.mts.map +1 -0
  82. package/esm/mock/mock-image-model.mjs +52 -0
  83. package/esm/mock/mock-image-model.mjs.map +1 -0
  84. package/esm/mock/mock-sdk.d.mts +7 -1
  85. package/esm/mock/mock-sdk.d.mts.map +1 -1
  86. package/esm/mock/mock-sdk.mjs +27 -0
  87. package/esm/mock/mock-sdk.mjs.map +1 -1
  88. package/esm/mock/mock-speech-model.d.mts +31 -0
  89. package/esm/mock/mock-speech-model.d.mts.map +1 -0
  90. package/esm/mock/mock-speech-model.mjs +39 -0
  91. package/esm/mock/mock-speech-model.mjs.map +1 -0
  92. package/esm/mock/mock-transcription-model.d.mts +32 -0
  93. package/esm/mock/mock-transcription-model.d.mts.map +1 -0
  94. package/esm/mock/mock-transcription-model.mjs +36 -0
  95. package/esm/mock/mock-transcription-model.mjs.map +1 -0
  96. package/esm/planner/planner-run.d.mts +8 -0
  97. package/esm/planner/planner-run.d.mts.map +1 -1
  98. package/esm/planner/planner-run.mjs +161 -6
  99. package/esm/planner/planner-run.mjs.map +1 -1
  100. package/esm/planner/planner.d.mts.map +1 -1
  101. package/esm/planner/planner.mjs +25 -1
  102. package/esm/planner/planner.mjs.map +1 -1
  103. package/esm/planner/snapshot.mjs +95 -0
  104. package/esm/planner/snapshot.mjs.map +1 -0
  105. package/esm/rag/index.d.mts +7 -0
  106. package/esm/rag/index.mjs +7 -0
  107. package/esm/rag/loaders/errors.d.mts +19 -0
  108. package/esm/rag/loaders/errors.d.mts.map +1 -0
  109. package/esm/rag/loaders/errors.mjs +25 -0
  110. package/esm/rag/loaders/errors.mjs.map +1 -0
  111. package/esm/rag/loaders/index.mjs +7 -0
  112. package/esm/rag/loaders/load-html.d.mts +26 -0
  113. package/esm/rag/loaders/load-html.d.mts.map +1 -0
  114. package/esm/rag/loaders/load-html.mjs +138 -0
  115. package/esm/rag/loaders/load-html.mjs.map +1 -0
  116. package/esm/rag/loaders/load-pdf.d.mts +38 -0
  117. package/esm/rag/loaders/load-pdf.d.mts.map +1 -0
  118. package/esm/rag/loaders/load-pdf.mjs +150 -0
  119. package/esm/rag/loaders/load-pdf.mjs.map +1 -0
  120. package/esm/rag/loaders/load-text.d.mts +47 -0
  121. package/esm/rag/loaders/load-text.d.mts.map +1 -0
  122. package/esm/rag/loaders/load-text.mjs +60 -0
  123. package/esm/rag/loaders/load-text.mjs.map +1 -0
  124. package/esm/rag/loaders/load-web.d.mts +42 -0
  125. package/esm/rag/loaders/load-web.d.mts.map +1 -0
  126. package/esm/rag/loaders/load-web.mjs +89 -0
  127. package/esm/rag/loaders/load-web.mjs.map +1 -0
  128. package/esm/rag/loaders/loader.type.d.mts +89 -0
  129. package/esm/rag/loaders/loader.type.d.mts.map +1 -0
  130. package/esm/rag/store/pg-vector-store.d.mts +139 -0
  131. package/esm/rag/store/pg-vector-store.d.mts.map +1 -0
  132. package/esm/rag/store/pg-vector-store.mjs +328 -0
  133. package/esm/rag/store/pg-vector-store.mjs.map +1 -0
  134. package/esm/speech/index.mjs +3 -0
  135. package/esm/speech/speech.d.mts +65 -0
  136. package/esm/speech/speech.d.mts.map +1 -0
  137. package/esm/speech/speech.mjs +123 -0
  138. package/esm/speech/speech.mjs.map +1 -0
  139. package/esm/supervisor/entries.mjs +2 -2
  140. package/esm/supervisor/entries.mjs.map +1 -1
  141. package/esm/transcribe/audio-input.d.mts +47 -0
  142. package/esm/transcribe/audio-input.d.mts.map +1 -0
  143. package/esm/transcribe/audio-input.mjs +84 -0
  144. package/esm/transcribe/audio-input.mjs.map +1 -0
  145. package/esm/transcribe/index.mjs +4 -0
  146. package/esm/transcribe/transcribe.d.mts +64 -0
  147. package/esm/transcribe/transcribe.d.mts.map +1 -0
  148. package/esm/transcribe/transcribe.mjs +128 -0
  149. package/esm/transcribe/transcribe.mjs.map +1 -0
  150. package/llms-full.txt +753 -0
  151. package/llms.txt +5 -0
  152. package/package.json +3 -3
  153. package/skills/README.md +4 -0
  154. package/skills/durable-agent-runs/SKILL.md +135 -0
  155. package/skills/generate-images/SKILL.md +138 -0
  156. package/skills/generate-speech/SKILL.md +139 -0
  157. package/skills/rag-loaders-and-stores/SKILL.md +164 -0
  158. package/skills/transcribe-audio/SKILL.md +157 -0
  159. package/cjs/src-C02yzsLs.cjs.map +0 -1
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: rag-loaders-and-stores
3
+ description: 'Turn any source into a RagDocument and index it in a production vector store — the document loaders ai.rag.loadText / loadHtml / loadWeb (SSRF-safe via guardedFetch) / loadPdf (lazy pdf-parse peer), plus the swappable stores ai.rag.pgVectorStore({client}) (pgvector + ensureSchema DDL + hnsw/ivfflat index) and ai.rag.cacheVectorStore(driver), both satisfying VectorStoreContract (upsert / query / removeNamespace). Loaders return the exact RagDocument[] that kb.index() consumes — no adapter. Triggers: `ai.rag.loadText`, `ai.rag.loadHtml`, `ai.rag.loadWeb`, `ai.rag.loadPdf`, `loadText`, `loadHtml`, `loadWeb`, `loadPdf`, `ai.rag.pgVectorStore`, `ai.rag.cacheVectorStore`, `pgVectorStore`, `cacheVectorStore`, `VectorStore`, `PgVectorStoreOptions`, `PgVectorStoreInstance`, `ensureSchema`, `schema()`, `RagLoaderResult`, `LoadWebOptions`, `LoadPdfOptions`, `perPage`, `OutboundPolicy`, `guardedFetch`, `hnsw`, `ivfflat`, `pgvector`, `dimensions`, `PgClientLike`, `PDF_PARSE_INSTALL_INSTRUCTIONS`; ''load a website into a knowledge base'', ''index a PDF for RAG'', ''strip HTML to text for embedding'', ''pgvector store for RAG'', ''SSRF-safe document fetch'', ''one document per PDF page'', ''swap the vector store''; typical import `import { ai } from "@warlock.js/ai"`. Skip: the chunk → embed → retrieve → rerank → cite pipeline that consumes these — `@warlock.js/ai/run-ai-rag/SKILL.md`; the raw embedder primitive — `@warlock.js/ai/embed-text/SKILL.md`; cache similarity internals — `@warlock.js/cache/use-cache-similarity/SKILL.md`; competing libs `langchain` loaders, `llamaindex` readers.'
4
+ ---
5
+
6
+ # RAG loaders + vector stores — source → `RagDocument` → durable store
7
+
8
+ Two feature groups that bracket `ai.rag()`: **loaders** turn a source (string, raw HTML, a URL, or PDF bytes) into the exact `RagDocument[]` shape `kb.index()` consumes, and **stores** are the swappable backends that hold the embeddings. Both live on the `ai.rag.*` namespace — present the moment `@warlock.js/ai` is imported, no side-effect import, no module augmentation.
9
+
10
+ ## Contract — what each side produces / satisfies
11
+
12
+ Every loader returns `RagLoaderResult` — a plain `RagDocument[]` — so a load feeds `index()` with no adapter and callers never branch on arity (one page ⇒ 1 doc, a per-page PDF ⇒ N docs):
13
+
14
+ ```ts
15
+ type RagLoaderResult = RagDocument[];
16
+ type RagDocument = { id: string; text: string; metadata?: Record<string, unknown>; tags?: string[] };
17
+ ```
18
+
19
+ Every store satisfies the three-method `VectorStore` contract (a thin narrowing of the cache `similar()` surface — NOT a new engine):
20
+
21
+ ```ts
22
+ interface VectorStore {
23
+ upsert(key: string, value: unknown, vector: number[], tags?: string[]): Promise<void>;
24
+ query<T>(vector: number[], options: { topK: number; threshold?: number; tags?: string[] }): Promise<{ key: string; value: T; score: number }[]>;
25
+ removeNamespace(namespace: string): Promise<void>;
26
+ }
27
+ ```
28
+
29
+ ## Loaders
30
+
31
+ | Loader | Input | Deps | Emits |
32
+ |---|---|---|---|
33
+ | `ai.rag.loadText(input, opts?)` | `string` \| `{ id, text }` \| array of either | none | one doc per non-empty item |
34
+ | `ai.rag.loadHtml(html, opts?)` | raw HTML string | none (regex strip) | one doc, `metadata.title` from `<title>` |
35
+ | `ai.rag.loadWeb(url, opts?)` | absolute URL | none (uses core `guardedFetch`) | one doc, SSRF-safe fetch |
36
+ | `ai.rag.loadPdf(bytes, opts?)` | `Buffer` \| `ArrayBuffer` \| `Uint8Array` | lazy `pdf-parse` peer | one doc, or one per page with `perPage: true` |
37
+
38
+ Shared options (`RagLoaderOptions`): `id` (source id — falls back to the URL for web, `"document"` otherwise), `metadata` (merged **over** the loader-derived keys, so an explicit `metadata.title` always wins), and `tags` (applied to every chunk for `retrieve({ tags })` filtering). Loader-derived keys: `source`, `loader` (`"text" | "html" | "web" | "pdf"`), plus `title` / `page` / `pageCount` / `contentType` where determinable. Empty / whitespace-only / all-markup inputs emit **no** document — never a no-op record for `index()` to skip.
39
+
40
+ ```ts
41
+ import { ai } from "@warlock.js/ai";
42
+
43
+ // Bare string, or many records → many distinctly-identified docs:
44
+ await kb.index(ai.rag.loadText([
45
+ { id: "faq-billing", text: "…", metadata: { section: "billing" } },
46
+ { id: "faq-shipping", text: "…" },
47
+ ]));
48
+
49
+ // Raw HTML → readable text (scripts/styles dropped, entities decoded):
50
+ await kb.index(ai.rag.loadHtml(rawHtml, { id: "landing", tags: ["marketing"] }));
51
+ ```
52
+
53
+ ### `loadWeb` is SSRF-safe — never a raw `fetch`
54
+
55
+ Every request goes through core's `guardedFetch` under an `OutboundPolicy`. The strict defaults (https-only, private-IP-deny on, 10s timeout, 5 MiB cap) apply even when you pass no `policy`, so an untuned call is already hardened. Tighten it per call:
56
+
57
+ ```ts
58
+ await kb.index(await ai.rag.loadWeb("https://docs.example.com/guide", {
59
+ policy: { hostAllowlist: ["docs.example.com"], maxBytes: 2_000_000, timeoutMs: 5_000 },
60
+ tags: ["docs"],
61
+ }));
62
+ ```
63
+
64
+ HTML responses run through the same tag-strip pass as `loadHtml`; non-HTML text (`text/plain`, markdown) is used verbatim. `metadata.source` is the resolved URL, `metadata.contentType` the server-reported type. A non-OK response, a policy block, a timeout, or an over-cap body throws `OutboundPolicyError`.
65
+
66
+ ### `loadPdf` — lazy optional peer, page-precise citations
67
+
68
+ `pdf-parse` is an **optional** peer, dynamic-imported on the FIRST `loadPdf` call — importing `@warlock.js/ai` never forces it. When it is absent, the curated `PDF_PARSE_INSTALL_INSTRUCTIONS` string is thrown as a plain `Error` (a missing infra peer, not a content problem), never a raw module-resolution stack trace.
69
+
70
+ ```ts
71
+ import { readFile } from "node:fs/promises";
72
+
73
+ // Whole PDF → one doc carrying metadata.pageCount:
74
+ await kb.index(await ai.rag.loadPdf(await readFile("manual.pdf"), { id: "manual" }));
75
+
76
+ // One doc per page → citations stay page-precise (id suffixed `#p<n>`, metadata.page set):
77
+ await kb.index(await ai.rag.loadPdf(bytes, { id: "manual", perPage: true }));
78
+ ```
79
+
80
+ An image-only / scanned page has no text layer and is dropped, so a fully-scanned PDF yields zero docs (nothing to embed).
81
+
82
+ ## Stores
83
+
84
+ ### `ai.rag.cacheVectorStore(driver)` — adapt any `@warlock.js/cache` driver
85
+
86
+ The cache driver **is** the vector store — `upsert → set({ vector, tags })`, `query → similar()`, `removeNamespace → removeNamespace()`. A driver without similarity support throws `CacheUnsupportedError` unchanged (pointing you at the `pg` / `redis` cache drivers).
87
+
88
+ ```ts
89
+ import { MemoryCacheDriver } from "@warlock.js/cache";
90
+
91
+ const store = ai.rag.cacheVectorStore(new MemoryCacheDriver()); // dev / tests
92
+ ```
93
+
94
+ ### `ai.rag.pgVectorStore(options)` — production pgvector
95
+
96
+ One durable row per chunk keyed by the pipeline's dotted key, the chunk payload in a `JSONB` `value` column, the embedding in a pgvector `vector` column. Pass a live pool (`{ client }` — `@warlock.js/ai` imports **nothing**) or a `{ connectionString }` and let the store lazily `import("pg")` (the optional peer; curated install string on first use if absent). Exactly one of the two is required.
97
+
98
+ ```ts
99
+ type PgVectorStoreOptions = {
100
+ client?: PgClientLike; // a pg.Pool / pg.Client — only `query` is ever called
101
+ connectionString?: string; // else the store builds its own Pool lazily
102
+ table?: string; // default "warlock_ai_rag_vectors"; must be a safe identifier
103
+ dimensions?: number; // vector(N) width in the DDL, default 1536
104
+ index?: "hnsw" | "ivfflat" | "none"; // ANN strategy, default "hnsw"
105
+ ivfflatLists?: number; // ivfflat only, default 100
106
+ };
107
+ ```
108
+
109
+ `schema()` (alias `ensureSchema()`) returns the reference migration DDL — `CREATE EXTENSION vector`, the table, a GIN index on `tags`, and the chosen ANN index (`USING hnsw (embedding vector_cosine_ops)`). It **only returns the string**; the framework never auto-migrates — you run it once through your own tool. Index and query MUST use the same embedding model: the `vector(N)` width is fixed at table-creation time from `dimensions`.
110
+
111
+ ```ts
112
+ import { Pool } from "pg";
113
+
114
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
115
+ const store = ai.rag.pgVectorStore({ client: pool, dimensions: 1536, index: "hnsw" });
116
+
117
+ // Once, via your migration tooling — never auto-run:
118
+ await pool.query(store.ensureSchema());
119
+ ```
120
+
121
+ `query()` runs the cosine floor (`threshold`) and `tags` overlap filter **in SQL** (a below-floor row never crosses the wire), orders by `embedding <=> $vec`, caps at `topK`, and maps the pgvector distance back to a `[0,1]` cosine-similarity `score` — the same scale the cache store emits. `removeNamespace()` is a prefix DELETE that escapes `_` / `%` so dropping `ai.rag.docs` never also catches `ai.rag.docs2`.
122
+
123
+ ## Pattern — a knowledge base from a website, backed by pgvector
124
+
125
+ ```ts
126
+ import { Pool } from "pg";
127
+ import { ai } from "@warlock.js/ai";
128
+ import { OpenAISDK } from "@warlock.js/ai-openai";
129
+
130
+ const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
131
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
132
+
133
+ const store = ai.rag.pgVectorStore({ client: pool, dimensions: 1536 });
134
+ await pool.query(store.ensureSchema()); // once at boot / migration
135
+
136
+ const kb = ai.rag({
137
+ name: "docs",
138
+ embedder: openai.embedder({ name: "text-embedding-3-small" }), // 1536 dims — matches the DDL
139
+ store,
140
+ });
141
+
142
+ // Crawl a few pages (SSRF-safe) + a spec PDF into the same namespace:
143
+ for (const url of ["https://docs.example.com/intro", "https://docs.example.com/config"]) {
144
+ await kb.index(await ai.rag.loadWeb(url, { policy: { hostAllowlist: ["docs.example.com"] }, tags: ["docs"] }));
145
+ }
146
+ await kb.index(await ai.rag.loadPdf(await readFile("spec.pdf"), { id: "spec", perPage: true, tags: ["spec"] }));
147
+
148
+ // Now retrieve — every hit's citation traces back to its source URL / page:
149
+ const { chunks } = await kb.retrieve("how do I configure caching?", { topK: 4, tags: ["docs"] });
150
+ ```
151
+
152
+ The `embedder`'s `dimensions` MUST equal the store's `dimensions` — a mismatch is a runtime insert failure at the pgvector column, not a type error.
153
+
154
+ ## Cost + testing
155
+
156
+ - **Loaders are cheap.** `loadText` / `loadHtml` are zero-dependency string passes; `loadWeb` costs one guarded HTTP round-trip; `loadPdf` costs the `pdf-parse` parse. **None embed** — embedding cost lands entirely in `kb.index()` (batched, 96 texts per `embedMany` call). The token spend is per chunk, so `perPage` PDFs and finer chunking mean more, smaller vectors.
157
+ - **`pgVectorStore` construction is synchronous and does no I/O** — the `pg` import + pool build are deferred to first `query`/`upsert`. Table-name validation (`/^[A-Za-z_][A-Za-z0-9_]*$/`) throws at construction, so a `table: "bad; DROP TABLE x"` fails fast.
158
+ - **Unit-test loaders with fixtures** (a stubbed `policy.fetch` for `loadWeb`, `vi.mock("pdf-parse")` for `loadPdf` — the literal specifier is mockable). Test stores against a `FakePgClient` implementing `{ query }`, or `cacheVectorStore(new MemoryCacheDriver())` for a real end-to-end index/retrieve with no external service.
159
+
160
+ ## See also
161
+
162
+ - [[run-ai-rag]] — the chunk → embed → retrieve → rerank → cite pipeline that **consumes** these loaders and stores (`ai.rag({ embedder, store })`, `index()` / `retrieve()`).
163
+ - [[embed-text]] — the `sdk.embedder` primitive whose `dimensions` must match the store's `vector(N)` width.
164
+ - [`@warlock.js/cache/use-cache-similarity/SKILL.md`](@warlock.js/cache/use-cache-similarity/SKILL.md) — the cache driver `cacheVectorStore` adapts.
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: transcribe-audio
3
+ description: 'Speech-to-text via ai.transcribe({ model: sdk.transcribe({ name }), audio }) — the audio-INPUT verb (Theme I), returning the uniform never-throws { data, error, usage, report } envelope with cost-truth + panoptic observation. Feed it an AudioInput = { base64; mediaType; filename? } — build one with ai.audioFromFile(path) (reads disk, infers media type incl. WhatsApp .ogg/.opus) or ai.audioFromBuffer(bytes, mediaType). Models: OpenAI whisper-1 (verbose_json, per-minute, segments + durationSeconds) or gpt-4o-transcribe (json, per-token). Triggers: `ai.transcribe`, `ai.audioFromFile`, `ai.audioFromBuffer`, `sdk.transcribe`, `openai.transcribe`, `TranscriptionModelContract`, `AudioInput`, `TranscriptionSegment`, `MockTranscriptionModel`; ''speech to text'', ''transcribe audio'', ''voice note to text'', ''WhatsApp voice message'', ''whisper'', ''gpt-4o-transcribe'', ''subtitle segments'', ''audio input''; typical import `import { ai } from "@warlock.js/ai"` + `import { OpenAISDK } from "@warlock.js/ai-openai"`. Skip: text-to-speech / synthesizing a voice — [[generate-speech]]; competing libs raw `openai.audio.transcriptions.create`, `whisper.cpp`.'
4
+ ---
5
+
6
+ # Transcribe audio — the speech-to-text verb (`ai.transcribe`)
7
+
8
+ `ai.transcribe()` is the inverse of `ai.speech()` on the modality track (Theme I). Audio-in / text-out, wrapped in the same uniform result contract every executable returns — so transcribing a support voicemail slots into cost dashboards and panoptic traces exactly like an agent run.
9
+
10
+ **Extracting text from an audio file NEEDS AI** — that is the `ai.transcribe` step. The file handling (`ai.audioFromFile` / `ai.audioFromBuffer`) is pure, non-AI **utility** that just packages bytes into an `AudioInput`; it does no I/O to a provider on its own.
11
+
12
+ This is audio **input** (STT). For audio **output** (synthesizing a voice line), see [[generate-speech]].
13
+
14
+ ## Shape — WhatsApp voice note → text, end to end
15
+
16
+ ```ts
17
+ import { ai } from "@warlock.js/ai";
18
+ import { OpenAISDK } from "@warlock.js/ai-openai";
19
+
20
+ const openai = new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY! });
21
+
22
+ // audioFromFile reads the file + infers the media type from the extension.
23
+ // .ogg / .opus (Android WhatsApp) and .m4a (iOS) are recognized out of the box.
24
+ const audio = await ai.audioFromFile("./voice-note.ogg");
25
+
26
+ const { data, error } = await ai.transcribe({
27
+ model: openai.transcribe({ name: "whisper-1" }),
28
+ audio,
29
+ language: "en", // BCP-47 hint — improves accuracy + latency
30
+ });
31
+
32
+ if (error) console.warn(error.code); // typed AIError
33
+ else console.log(data.text); // the transcript
34
+ ```
35
+
36
+ `TranscriptionModelContract` mirrors `SpeechModelContract` — a peer primitive produced by the adapter's optional `transcribe?()` factory. A non-STT model id (`openai.transcribe({ name: "gpt-4o" })`) throws `InvalidRequestError` **at construction** — fail fast, like the speech/embedder guards.
37
+
38
+ ## The `AudioInput` shape + the two builders
39
+
40
+ ```ts
41
+ type AudioInput = {
42
+ base64: string; // base64-encoded audio bytes
43
+ mediaType: string; // IANA type, e.g. "audio/ogg", "audio/mpeg"
44
+ filename?: string; // helps providers infer the codec from the extension
45
+ };
46
+
47
+ // From a file on disk — reads + infers media type (override for extensionless files).
48
+ const fromDisk = await ai.audioFromFile("./meeting.m4a");
49
+ const forced = await ai.audioFromFile("./blob", { mediaType: "audio/ogg" });
50
+
51
+ // From bytes you already hold (an upload buffer, a downloaded blob) — no I/O, no AI.
52
+ const fromBytes = ai.audioFromBuffer(uploadBuffer, "audio/ogg", "note.ogg");
53
+ ```
54
+
55
+ Keeping `AudioInput` as inlined base64 + explicit media type makes the verb provider-neutral and serializable — there is no `fs` coupling in core, so the same request can cross a queue or an RPC boundary.
56
+
57
+ ## The result envelope
58
+
59
+ ```ts
60
+ type TranscriptionResult = {
61
+ type: "transcription";
62
+ data?: {
63
+ text: string; // full transcript
64
+ segments?: TranscriptionSegment[]; // timestamped, in verbose mode
65
+ }; // undefined on failure
66
+ error?: AIError; // undefined on success — NEVER thrown
67
+ usage: Usage; // tokens (gpt-4o-transcribe) + cost when priced
68
+ report: TranscriptionReport; // type:"transcription", model, durationSeconds, lineage
69
+ };
70
+
71
+ type TranscriptionSegment = { text: string; start?: number; end?: number };
72
+ ```
73
+
74
+ `segments` and `report.durationSeconds` appear only when the provider returns them (whisper's `verbose_json` mode). Use segments to build subtitles or to jump-to-timestamp in a player.
75
+
76
+ ## Transcribe options (provider-neutral)
77
+
78
+ ```ts
79
+ await ai.transcribe({
80
+ model,
81
+ audio,
82
+ language: "en", // BCP-47 hint
83
+ prompt: "Names: Acme, Zoë", // priming — spelling / style hints
84
+ format: "verbose_json", // response-format override (segments + duration)
85
+ signal, // AbortSignal
86
+ observe: collector, // route the report to an Observer (panoptic)
87
+ sessionId: "ticket-88", // group into a session for flat cost/trace queries
88
+ options: { /* provider passthrough */ },
89
+ });
90
+ ```
91
+
92
+ ## OpenAI — whisper-1 (per-minute) + gpt-4o-transcribe (per-token)
93
+
94
+ ```ts
95
+ // whisper-1 — defaults to verbose_json → segments + duration; billed PER MINUTE.
96
+ const whisper = openai.transcribe({ name: "whisper-1", pricing: { perMinute: 0.006 } });
97
+
98
+ // gpt-4o-transcribe — defaults to json; billed PER TOKEN like a chat model.
99
+ const gpt = openai.transcribe({ name: "gpt-4o-transcribe", pricing: { input: 2.5, output: 10 } });
100
+
101
+ const { data, usage } = await ai.transcribe({ model: whisper, audio });
102
+ // data.segments → [{ text, start, end }, …]; usage.cost from report.durationSeconds
103
+ ```
104
+
105
+ The adapter wraps the base64 bytes in an uploadable via the SDK's `toFile`, using `audio.filename` (or `"audio"`) and `audio.mediaType` so the codec is declared correctly.
106
+
107
+ ## Cost-truth — one rollup, two metering models
108
+
109
+ `ai.transcribe` fills `usage.cost` so STT spend folds into the **same** `Usage.cost` rollup as text:
110
+
111
+ - **Per-minute** (`whisper-1`): `{ perMinute }` × `(durationSeconds / 60)` → `cost.input`. If the provider didn't report a duration, cost stays **`undefined`** (no guessing).
112
+ - **Token-metered** (`gpt-4o-transcribe`): `{ input, output }` USD-per-1M-tokens → standard `computeCost` against the returned token usage.
113
+
114
+ Per-minute wins when both are set; an unpriced model leaves `usage.cost` **`undefined`** (honest "cost unknown", never a false zero).
115
+
116
+ ## Pattern — inbound voice-message webhook
117
+
118
+ ```ts
119
+ const stt = openai.transcribe({ name: "whisper-1" });
120
+
121
+ async function onVoiceMessage(buffer: Buffer, mediaType: string) {
122
+ const audio = ai.audioFromBuffer(buffer, mediaType, "inbound.ogg");
123
+ const { data, error } = await ai.transcribe({ model: stt, audio, sessionId: "inbox" });
124
+
125
+ if (error) return replyWith("Sorry, I couldn't understand that audio.");
126
+ return routeToAgent(data.text); // hand the transcript to an ai.agent for a reply
127
+ }
128
+ ```
129
+
130
+ ## Observability
131
+
132
+ The completed `TranscriptionReport` (with `report.durationSeconds` and cost/latency attributed to `report.model`) routes to any registered `Observer` (panoptic, OTel, …) through the shared `observe` seam — `observe: true` (global), an `Observer` (flow-local), or observe-all. See [[observe-ai-flows]]. Provider faults surface as typed `AIError`s on `result.error`; see [[handle-ai-errors]].
133
+
134
+ ## Testing
135
+
136
+ `MockTranscriptionModel(name, responses, pricing?)` is a deterministic `TranscriptionModelContract` double — no HTTP. Script text/segments/duration/usage/errors and inspect `model.calls`. `MockSDK({ transcriptionResponses, transcriptionPricing }).transcribe({ name })` wires the same double behind a full adapter.
137
+
138
+ ```ts
139
+ import { MockTranscriptionModel, transcribe } from "@warlock.js/ai";
140
+
141
+ const AUDIO = { base64: "QUJD", mediaType: "audio/mpeg", filename: "clip.mp3" };
142
+
143
+ const model = new MockTranscriptionModel("whisper-1", [{ durationSeconds: 120 }], { perMinute: 0.006 });
144
+ const { data, usage, report } = await transcribe({ model, audio: AUDIO });
145
+ // data.text → "mock transcript"
146
+ // usage.cost.input → (120 / 60) * 0.006 report.durationSeconds → 120
147
+ // model.calls[0] records { audio, options } for assertions
148
+ ```
149
+
150
+ Scripting `[{ error: new ProviderRateLimitError("slow down") }]` drives the never-throws path — `result.error` is the typed error and `result.data` is `undefined`.
151
+
152
+ ## See also
153
+
154
+ - [[generate-speech]] — the inverse verb (`ai.speech`), text → audio
155
+ - [[generate-images]] — the sibling image-output verb (`ai.image`)
156
+ - [[observe-ai-flows]] — routing the `TranscriptionReport` to panoptic / OTel
157
+ - [[handle-ai-errors]] — the typed `AIError` taxonomy on `result.error`