@pikku/skills 0.12.4 → 0.12.8

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 (65) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +74 -33
  4. package/skills/pikku-ai-agent/SKILL.md +197 -105
  5. package/skills/pikku-ai-vercel/SKILL.md +57 -18
  6. package/skills/pikku-ai-voice/SKILL.md +126 -52
  7. package/skills/pikku-audit/SKILL.md +35 -13
  8. package/skills/pikku-aws/SKILL.md +66 -16
  9. package/skills/pikku-backblaze/SKILL.md +44 -11
  10. package/skills/pikku-better-auth/SKILL.md +45 -10
  11. package/skills/pikku-cli/SKILL.md +67 -18
  12. package/skills/pikku-cli/references/complete-example.md +2 -0
  13. package/skills/pikku-concepts/SKILL.md +75 -10
  14. package/skills/pikku-config/SKILL.md +56 -14
  15. package/skills/pikku-cron/SKILL.md +13 -6
  16. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  17. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  18. package/skills/pikku-deploy-express/SKILL.md +40 -4
  19. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  20. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  21. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  22. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  23. package/skills/pikku-deps/SKILL.md +29 -8
  24. package/skills/pikku-emails/SKILL.md +36 -5
  25. package/skills/pikku-fabric/SKILL.md +27 -2
  26. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  27. package/skills/pikku-feature/SKILL.md +6 -1
  28. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  29. package/skills/pikku-http/SKILL.md +18 -5
  30. package/skills/pikku-http/references/http-options.md +10 -5
  31. package/skills/pikku-i18n/SKILL.md +18 -7
  32. package/skills/pikku-info/SKILL.md +18 -8
  33. package/skills/pikku-jose/SKILL.md +35 -6
  34. package/skills/pikku-knowledge/SKILL.md +50 -7
  35. package/skills/pikku-kysely/SKILL.md +78 -15
  36. package/skills/pikku-machine-auth/SKILL.md +36 -1
  37. package/skills/pikku-mcp/SKILL.md +159 -149
  38. package/skills/pikku-middleware/SKILL.md +17 -5
  39. package/skills/pikku-mongodb/SKILL.md +10 -2
  40. package/skills/pikku-n8n-import/SKILL.md +14 -6
  41. package/skills/pikku-permissions/SKILL.md +102 -22
  42. package/skills/pikku-pino/SKILL.md +12 -4
  43. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  44. package/skills/pikku-queue/SKILL.md +45 -16
  45. package/skills/pikku-react/SKILL.md +41 -14
  46. package/skills/pikku-react-query/SKILL.md +14 -10
  47. package/skills/pikku-realtime/SKILL.md +44 -22
  48. package/skills/pikku-redis/SKILL.md +12 -3
  49. package/skills/pikku-rpc/SKILL.md +23 -12
  50. package/skills/pikku-rtl/SKILL.md +21 -17
  51. package/skills/pikku-scenario/SKILL.md +141 -76
  52. package/skills/pikku-schedule/SKILL.md +39 -6
  53. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  54. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  55. package/skills/pikku-security/SKILL.md +54 -9
  56. package/skills/pikku-services/SKILL.md +49 -9
  57. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  58. package/skills/pikku-template-clone/SKILL.md +10 -5
  59. package/skills/pikku-trigger/SKILL.md +50 -6
  60. package/skills/pikku-versioning/SKILL.md +46 -17
  61. package/skills/pikku-websocket/SKILL.md +72 -44
  62. package/skills/pikku-workflow/SKILL.md +123 -11
  63. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  64. package/skills/pikku-workflows-client/SKILL.md +13 -6
  65. package/skills/pikku-ws/SKILL.md +44 -8
@@ -37,7 +37,9 @@ yarn add @pikku/ai-vercel ai @ai-sdk/openai # or any AI SDK provider
37
37
  import { VercelAIAgentRunner } from '@pikku/ai-vercel'
38
38
 
39
39
  const runner = new VercelAIAgentRunner(
40
- providers: Record<string, any> // Map of provider name → Vercel AI SDK provider instance
40
+ providers: Record<string, any>, // provider name → AI SDK provider
41
+ providerFactory?: (apiKey: string) => Record<string, any>,
42
+ allowedAttachmentHosts?: string[]
41
43
  )
42
44
  ```
43
45
 
@@ -45,8 +47,28 @@ const runner = new VercelAIAgentRunner(
45
47
 
46
48
  - `stream(params: AIAgentRunnerParams, channel: AIStreamChannel): Promise<AIAgentStepResult>` — Stream AI responses with tool calls
47
49
  - `run(params: AIAgentRunnerParams): Promise<AIAgentStepResult>` — Execute a single AI step (non-streaming)
50
+ - `transcribe({ model, audio, … })` / `generateSpeech({ model, text, voice, … })` — what `voiceInput`/`voiceOutput` call; see `pikku-ai-voice`
51
+ - `generateImage`, `embed`, `embedMany`, `rerank` — the remaining AI SDK surfaces
52
+ - `withApiKey(apiKey)` — returns a **new** runner built from `providerFactory`; returns `this` unchanged when no factory was supplied or the key is blank. This is the per-user-credential path
48
53
 
49
- The `providers` map lets you register multiple AI providers. Model strings use `provider:model` format (e.g., `"openai:gpt-4o"`).
54
+ ### Model strings are `provider/model`
55
+
56
+ Slash, not colon: `'openai/gpt-5-mini'`, `'deepinfra/hexgrad/Kokoro-82M'`,
57
+ `'ollama/qwen2.5:7b'`. Only the **first** slash splits, so the model name may
58
+ contain its own. A string with no slash at all throws rather than defaulting to
59
+ a provider.
60
+
61
+ ### The `'*'` catch-all
62
+
63
+ `providers['*']` resolves any provider name with no exact entry, and exact
64
+ entries win — which makes "everything through the gateway except this one"
65
+ expressible as `{ deepinfra: direct, '*': gateway }`. Point it only at something
66
+ that genuinely accepts arbitrary model names (a gateway, or a scripted test
67
+ provider); aimed at a single vendor, an `anthropic/...` string silently reaching
68
+ OpenAI is a bug, not a fallback.
69
+
70
+ `providers` is public and mutable so deploy-time contributors can swap in
71
+ gateway-routed providers after construction.
50
72
 
51
73
  ## Usage Patterns
52
74
 
@@ -54,29 +76,46 @@ The `providers` map lets you register multiple AI providers. Model strings use `
54
76
 
55
77
  ```typescript
56
78
  import { VercelAIAgentRunner } from '@pikku/ai-vercel'
57
- import { openai } from '@ai-sdk/openai'
58
- import { anthropic } from '@ai-sdk/anthropic'
59
-
60
- const createSingletonServices = pikkuServices(async (config) => {
61
- const aiRunner = new VercelAIAgentRunner({
62
- openai: openai,
63
- anthropic: anthropic,
64
- })
65
- return { config, aiRunner }
79
+ import { createOpenAI } from '@ai-sdk/openai'
80
+ import { createAnthropic } from '@ai-sdk/anthropic'
81
+
82
+ const createSingletonServices = pikkuServices(async (config, { secrets }) => {
83
+ const providers: Record<string, any> = {}
84
+ if (await secrets.hasSecret('OPENAI_API_KEY')) {
85
+ providers.openai = createOpenAI({
86
+ apiKey: (await secrets.getSecret('OPENAI_API_KEY')).reveal(),
87
+ })
88
+ }
89
+ return { config, aiAgentRunner: new VercelAIAgentRunner(providers) }
66
90
  })
67
91
  ```
68
92
 
69
- ### With AI Agent Wiring
93
+ The service key is **`aiAgentRunner`** — that is the name the agent wiring looks
94
+ up. Registering it as `aiRunner` leaves every agent unable to call a model.
95
+
96
+ ### With an agent
70
97
 
71
98
  ```typescript
72
- import { wireAIAgent } from '@pikku/core/ai-agent'
99
+ import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
73
100
 
74
- wireAIAgent({
101
+ export const assistant = pikkuAIAgent({
75
102
  name: 'assistant',
76
- model: 'openai:gpt-4o',
77
- systemPrompt: 'You are a helpful assistant.',
78
- func: myAgentFunc,
103
+ description: 'Answers questions',
104
+ goal: 'You are a helpful assistant.',
105
+ model: 'openai/gpt-5-mini',
79
106
  })
80
107
  ```
81
108
 
82
- The `VercelAIAgentRunner` is used internally by Pikku's AI agent wiring to execute model calls. See `pikku-ai-agent` for wiring details.
109
+ There is no `wireAIAgent` — agents are declared with `pikkuAIAgent` from the
110
+ generated agent types. See `pikku-ai-agent` for the full config.
111
+
112
+ ### Testing without a real provider
113
+
114
+ Replacing the *provider* rather than the runner keeps every code path under test
115
+ real — tool loop, streaming, memory, approvals — and only scripts the replies.
116
+ Sealing it with `'*'` means no model string, including ones added later, can
117
+ reach a live endpoint:
118
+
119
+ ```typescript
120
+ new VercelAIAgentRunner({ '*': createMockLlmProvider() })
121
+ ```
@@ -2,10 +2,11 @@
2
2
  name: pikku-ai-voice
3
3
  description: >-
4
4
  Use when adding voice input (speech-to-text) or voice output (text-to-speech) to AI agents in a
5
- Pikku app. Covers voiceInput/voiceOutput middleware hooks and STT/TTS service interfaces.
6
- TRIGGER when: code uses voiceInput, voiceOutput, STTService, TTSService, or user asks about
7
- voice, speech-to-text, text-to-speech, or @pikku/ai-voice. DO NOT TRIGGER when: user asks about
8
- AI agent wiring (use pikku-ai-agent) or Vercel AI SDK (use pikku-ai-vercel).
5
+ Pikku app. Covers the voiceInput/voiceOutput AI middleware from @pikku/core/ai-agent, per-script
6
+ voices, and barge-in. TRIGGER when: code uses voiceInput, voiceOutput, or user asks about voice
7
+ agents, speech-to-text, text-to-speech, transcription, or @pikku/ai-voice. DO NOT TRIGGER when:
8
+ user asks about AI agent wiring generally (use pikku-ai-agent) or the runner itself (use
9
+ pikku-ai-vercel).
9
10
  ---
10
11
 
11
12
  # Pikku AI Voice (Speech I/O)
@@ -20,69 +21,142 @@ Use this skill as an execution checklist, not reference material.
20
21
  4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.
21
22
  5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.
22
23
 
23
- `@pikku/ai-voice` provides speech-to-text and text-to-speech middleware hooks for Pikku AI agents.
24
+ ## `@pikku/ai-voice` is deprecated and empty
24
25
 
25
- ## Installation
26
+ The package still publishes, but its entire source is `export {}` — there are no
27
+ `STTService`/`TTSService` interfaces and nothing to import. Do not add it as a
28
+ dependency.
26
29
 
27
- ```bash
28
- yarn add @pikku/ai-voice
29
- ```
30
+ Voice now lives in **`@pikku/core/ai-agent`** as two AI middlewares, and the
31
+ speech models are reached through the `aiAgentRunner` (`transcribe` /
32
+ `generateSpeech`) rather than through separate services. See `pikku-ai-vercel`.
30
33
 
31
34
  ## API Reference
32
35
 
33
- ### Service Interfaces
34
-
35
36
  ```typescript
36
- interface STTService {
37
- transcribe(
38
- audio: Uint8Array,
39
- options?: { language?: string; format?: string }
40
- ): Promise<string>
41
- }
42
-
43
- interface TTSService {
44
- synthesize(
45
- text: string,
46
- options?: { voice?: string; format?: string }
47
- ): Promise<Uint8Array>
48
- synthesizeStream?(
49
- text: string,
50
- options?: { voice?: string; format?: string }
51
- ): AsyncIterable<Uint8Array>
52
- }
53
- ```
37
+ import { voiceInput, voiceOutput } from '@pikku/core/ai-agent'
54
38
 
55
- ### Middleware Hooks
56
-
57
- ```typescript
58
- import { voiceInput, voiceOutput } from '@pikku/ai-voice'
39
+ voiceInput(config?: {
40
+ model?: string // transcription model — required in practice
41
+ language?: string // forwarded as openai providerOptions.language
42
+ allowedAudioHosts?: string[] // allowlist for audio parts given as a URL
43
+ })
59
44
 
60
- voiceInput(config?: { language?: string }): PikkuAIMiddlewareHooks
61
- voiceOutput(config?: { format?: string; voice?: string }): PikkuAIMiddlewareHooks
45
+ voiceOutput(config?: {
46
+ model?: string // speech model — required in practice
47
+ voice?: string
48
+ format?: string
49
+ instructions?: string
50
+ speed?: number
51
+ language?: string
52
+ speakableScripts?: string[] | Record<string, string>
53
+ always?: boolean
54
+ })
62
55
  ```
63
56
 
64
- These return middleware hooks that can be attached to AI agent wirings to automatically transcribe audio input and synthesize audio output.
65
-
66
- ## Usage Patterns
67
-
68
- ### Voice-Enabled Agent
57
+ Both attach through the agent's **`aiMiddleware`** array, not a
58
+ `middlewareHooks` option, and the agent is declared with `pikkuAIAgent` — there
59
+ is no `wireAIAgent`.
60
+
61
+ ### `voiceInput` — audio in, text in its place
62
+
63
+ It rewrites the last user message, replacing each `audio/*` file part with a
64
+ text part holding the transcript. Downstream nothing can tell the turn was
65
+ spoken, which is why it records two shared-notes keys on the way past:
66
+
67
+ - `SPOKEN_TURN` (`'voice:spokenTurn'`) — `true`/`false` on every turn it sees.
68
+ **Absent** when the middleware isn't wired at all, which is what lets
69
+ `voiceOutput` still speak for a caller that has no voice input.
70
+ - `SPOKEN_TRANSCRIPT` (`'voice:transcript'`) — what the user was heard to say,
71
+ only when something was heard. The stream wiring forwards it to the client as
72
+ a `transcript` event; a voice client has no other way to know what its own
73
+ audio said, and without it the user's turn renders as an empty bubble.
74
+
75
+ Behaviours that decide how a voice loop should be written:
76
+
77
+ - **It is a no-op without `aiAgentRunner.transcribe`** — no error, the audio
78
+ simply passes through untouched.
79
+ - **`config.model` is required once audio actually arrives**, and throws then
80
+ rather than at wiring time.
81
+ - **A turn that was entirely non-speech throws `NoSpeechDetectedError`.** Catch
82
+ it and go back to listening without running the agent — answering a
83
+ hallucinated sentence is worse than answering nothing. It is deliberately
84
+ distinct from a transcription failure, which is worth reporting.
85
+ - **Non-speech means an empty transcript, and nothing cleverer.** There was a
86
+ per-segment confidence gate here and it was removed: Whisper is
87
+ subtitle-trained, so it is *confident* when it invents ("Thank you." scored
88
+ better than the real sentence beside it). Pick an ASR that returns an empty
89
+ string on silence rather than trying to filter one that doesn't.
90
+ - Audio arrives either inline (base64 `data`) or as a `url` fetched through
91
+ `safeFetch`; either way 50MB is the ceiling.
92
+
93
+ ### `voiceOutput` — sentence-at-a-time synthesis
94
+
95
+ It intercepts the output stream, buffers `text-delta`s to a sentence boundary,
96
+ and synthesizes each finished sentence immediately, so the first is playing while
97
+ the rest is still being written. Emissions are chained even though generation
98
+ overlaps, so the client hears them in order; on `done` it flushes the tail,
99
+ awaits the chain, and emits `audio-done` before the `done` event.
100
+
101
+ - **It speaks only in reply to speech** unless `always: true`. Only an explicit
102
+ `SPOKEN_TURN === false` silences it — the key being absent (no `voiceInput`
103
+ wired) still speaks. Set `always` for a read-aloud mode or a kiosk, where the
104
+ whole output is meant to be heard; leave it off for an agent serving both typed
105
+ and spoken callers, since synthesizing replies nobody is listening to costs
106
+ real money per sentence.
107
+ - **A failed sentence is logged and skipped**, not thrown — one silent sentence
108
+ beats the rest of the reply never arriving.
109
+ - **Barge-in aborts synthesis, not just playback**: the stream's `signal` is
110
+ passed to the speech model, so sentences in flight stop being billed.
111
+
112
+ ### `speakableScripts` — declare what the model can pronounce
113
+
114
+ Handed a script it has no voice for, a speech model typically neither fails nor
115
+ stays quiet: Kokoro reads out the *letter names* — 24 seconds of "Arabic meem,
116
+ Arabic ra" for a one-line sentence. Declaring the range leaves anything outside
117
+ it unspoken and reports it once per reply as a `voice-unsupported` data event.
118
+
119
+ The record form maps script → voice, because a multilingual model usually needs
120
+ the matching voice too: asked for Chinese in a default American-English voice,
121
+ Kokoro spells the characters out in 9.9s where `zf_xiaobei` says it in 3.5.
122
+
123
+ Known scripts: `latin`, `devanagari`, `han`, `kana`, `arabic`, `cyrillic`,
124
+ `hangul`, `hebrew`, `greek`, `thai`. A sentence in several is settled by
125
+ precedence, not config order — `kana` first (it appears only in Japanese, so it
126
+ decides; `han` alone cannot), `latin` last (it turns up inside sentences in every
127
+ other script). Omitting the option means no check at all, which is right for a
128
+ genuinely multilingual provider.
129
+
130
+ `unspeakableScripts(text, speakable)` and `voiceForText(text, speakable,
131
+ fallback)` are exported if you need the same decision outside the middleware.
132
+
133
+ ## Usage Pattern
69
134
 
70
135
  ```typescript
71
- import { voiceInput, voiceOutput } from '@pikku/ai-voice'
72
- import { wireAIAgent } from '@pikku/core/ai-agent'
136
+ import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
137
+ import { voiceInput, voiceOutput } from '@pikku/core/ai-agent'
73
138
 
74
- wireAIAgent({
139
+ export const voiceAssistant = pikkuAIAgent({
75
140
  name: 'voice-assistant',
76
- model: 'openai:gpt-4o',
77
- systemPrompt: 'You are a voice assistant.',
78
- middlewareHooks: [
79
- voiceInput({ language: 'en' }),
80
- voiceOutput({ voice: 'alloy', format: 'mp3' }),
141
+ description: 'Holds a spoken conversation',
142
+ goal: 'You are a voice assistant. You are being listened to, not read.',
143
+ model: 'openai/gpt-5-mini',
144
+ aiMiddleware: [
145
+ voiceInput({ model: 'deepinfra/openai/whisper-large-v3-turbo' }),
146
+ voiceOutput({
147
+ model: 'deepinfra/hexgrad/Kokoro-82M',
148
+ speakableScripts: {
149
+ han: 'zf_xiaobei',
150
+ kana: 'jf_alpha',
151
+ devanagari: 'hf_alpha',
152
+ latin: 'af_bella',
153
+ },
154
+ }),
81
155
  ],
82
- func: myAgentFunc,
83
156
  })
84
157
  ```
85
158
 
86
- ### Custom STT/TTS Services
87
-
88
- Implement the `STTService` and `TTSService` interfaces with your provider (OpenAI Whisper, ElevenLabs, etc.) and register them as singleton services.
159
+ Write the goal for the ear: no lists, no markdown, no IDs read digit by digit.
160
+ The one thing worth spelling out is approvals — spoken aloud, the confirmation
161
+ sentence is all the user gets, so let `approvalDescription` on the tool produce
162
+ it and forbid the model from asking for permission in its own words.
@@ -20,16 +20,18 @@ installGroups: [core]
20
20
  Use this skill as an execution checklist, not reference material.
21
21
 
22
22
  1. Discover before editing. Check how services are wired (`services.ts`) and whether an `audit` table migration exists before adding audit calls.
23
- 2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing actor/trace/wire context, hand-written CHECK constraints that reject valid events, no prod sink). Use the built-in path below.
23
+ 2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing user/trace/wire context, hand-written CHECK constraints that reject valid events, no prod sink). Use the built-in path below.
24
24
  3. Make the smallest source change: mark the function `audit: true`, inject `auditLog`, call `auditLog.write(...)`. Do not invent a new service.
25
25
  4. Validate with `pikku all` (regenerates the service flags) then run the app / e2e.
26
26
 
27
27
  ## Mental model — two layers
28
28
 
29
29
  - **`audit` (singleton `AuditService`)** — the durable **sink**. Write-only: `audit(event)` + optional `write(batch)`. Defaults to `NoopAuditService` (discards). Swap in a real sink to persist (see Sinks).
30
- - **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `actor` (from the wire session) automatically, then flushes to the sink when the invocation ends.
30
+ - **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `userIdentity` (from the wire session) automatically, then flushes to the sink when the invocation ends.
31
31
 
32
- An event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns.
32
+ An event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns once per invocation, naming the function that dropped the write.
33
+
34
+ `audit` also takes a config object, `{ durability: 'best-effort' | 'transactional' }`, and `audit: true` is shorthand for `'best-effort'`. Best-effort buffers events and flushes them when the invocation closes, swallowing sink failures with a warning — the function's result is never held hostage to the audit sink. `'transactional'` awaits the sink on every `write()` instead, so a sink failure fails the invocation. Reach for it only when losing the record is worse than failing the call.
33
35
 
34
36
  ## Wiring (services.ts)
35
37
 
@@ -46,15 +48,22 @@ export const createSingletonServices = pikkuServices(async (config, existing) =>
46
48
  // a write from a function that forgot `audit: true` warns instead of vanishing.
47
49
  export const createWireServices = pikkuWireServices(async (services, wire) => {
48
50
  if (!services.audit) return {}
49
- return { auditLog: createInvocationAudit(services.audit, wire) }
51
+ return {
52
+ auditLog: createInvocationAudit(services.audit, wire, services.logger),
53
+ }
50
54
  })
51
55
  ```
52
56
 
57
+ The optional third argument is the fallback logger for the dropped-write warning
58
+ and for best-effort flush failures. Without it those messages only surface when
59
+ the wire happens to carry a logger, which is how a missing `audit: true` goes
60
+ unnoticed.
61
+
53
62
  `audit` and `auditLog` are already declared on `CoreSingletonServices` / `CoreServices`, so no type change is needed to inject them.
54
63
 
55
64
  ## Recording events — explicit domain events (default)
56
65
 
57
- Mark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the actor is derived from the session, so do NOT pass it manually.
66
+ Mark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the user identity is derived from the session, so do NOT pass it manually.
58
67
 
59
68
  ```typescript
60
69
  export const cancelInvoice = pikkuFunc({
@@ -82,12 +91,14 @@ export const cancelInvoice = pikkuFunc({
82
91
  })
83
92
  ```
84
93
 
85
- For a **system/cron** function there is no session, so `actor` is simply absent (nulls out `actor_user_id`). Use `pikkuVoidFunc({ audit: true, func: async ({ auditLog }) => { ... } })` — the void/config form accepts `audit`.
94
+ For a **system/cron** function there is no session, so `userIdentity` is simply absent (nulls out `user_id`). Use `pikkuVoidFunc({ audit: true, func: async ({ auditLog }) => { ... } })` — the void/config form accepts `audit`.
86
95
 
87
96
  Helper functions (in `lib/`) that record audit take `auditLog?: AuditLog` in their services arg and are passed it from a `audit: true` caller — never import a service.
88
97
 
89
98
  Note: events buffer and flush on invocation close. For a write inside a DB transaction, call `auditLog.write()` **after** the transaction commits — the sink is not part of your `trx`, so only record committed state.
90
99
 
100
+ `write` is `Safe<>`-guarded the way the logger is: `input` and `metadata` are `unknown`, so a `SecretValue` nested anywhere in the event collapses the call to `never` and it stops compiling. An unrevealed secret would serialize as `[secret]` regardless — the guard just makes putting one in an audit row a decision rather than an accident. Reveal it explicitly if you genuinely mean to record it.
101
+
91
102
  ## Recording events — automatic query capture (optional)
92
103
 
93
104
  To audit every DB mutation without explicit calls, wrap kysely so each query emits an event. Note this captures table/column changes only — it cannot see semantic events that do no DB write (e.g. "email sent"), so combine with explicit writes when you need those.
@@ -101,6 +112,11 @@ export const createWireServices = pikkuWireServices(async (services, wire) => {
101
112
  })
102
113
  ```
103
114
 
115
+ It is a Kysely plugin, so it wraps the instance rather than replacing it. Only
116
+ mutations are captured by default; `auditReads: true` adds selects, which is
117
+ usually far more volume than it is worth. `eventType`, `transactionId` and
118
+ `queryIdPrefix` are also accepted for labelling the emitted events.
119
+
104
120
  ## Sinks
105
121
 
106
122
  - **`NoopAuditService`** (`@pikku/core/services`) — default; discards events. Fine when audit isn't needed.
@@ -121,8 +137,9 @@ CREATE TABLE IF NOT EXISTS audit (
121
137
  trace_id TEXT,
122
138
  transaction_id TEXT,
123
139
  query_id TEXT,
124
- actor_user_id TEXT,
125
- actor_org_id TEXT,
140
+ user_id TEXT,
141
+ org_id TEXT,
142
+ pikku_user_id TEXT,
126
143
  tables TEXT, -- JSON: table names touched (auto capture)
127
144
  changed_cols TEXT, -- JSON: changed column names (auto capture)
128
145
  event TEXT, -- custom event label
@@ -131,12 +148,14 @@ CREATE TABLE IF NOT EXISTS audit (
131
148
  );
132
149
  ```
133
150
 
151
+ The defaults above are SQLite; on Postgres swap them for `gen_random_uuid()::text` and `now()::text`. Every column stays TEXT on every engine so a locally-run project and a deployed stage write identical rows, and the sink inserts with `ON CONFLICT DO NOTHING` so a retried flush is idempotent.
152
+
134
153
  `auditLog.write({ metadata })` lands in the `data` column. Read history back by filtering it (SQLite `json_extract`, Postgres `->>`):
135
154
 
136
155
  ```typescript
137
156
  const rows = await kysely
138
157
  .selectFrom('audit')
139
- .leftJoin('user', 'user.id', 'audit.actorUserId')
158
+ .leftJoin('user', 'user.id', 'audit.userId')
140
159
  .where(sql<boolean>`json_extract(audit.data, '$.entity') = 'invoice'`)
141
160
  .where(sql<boolean>`json_extract(audit.data, '$.entityId') = ${invoiceId}`)
142
161
  .orderBy('audit.occurredAt', 'desc')
@@ -156,20 +175,23 @@ type AuditEvent = {
156
175
  type: string // e.g. 'invoice.update'
157
176
  source: 'auto' | 'explicit'
158
177
  occurredAt: string // auto-filled by auditLog
159
- outcome?: string
178
+ eventId?: string
179
+ outcome?: 'success' | 'failed' | 'denied'
160
180
  functionId?; wireType?; wireId?; traceId?; transactionId?; queryId? // auto
161
- actor?: { userId?; orgId? } // auto from wire session
181
+ userIdentity?: { userId?; orgId?; pikkuUserId? } // auto from wire session
162
182
  input?: unknown
163
183
  metadata?: Record<string, unknown> // your domain payload
164
184
  }
165
185
  ```
166
186
 
167
- `auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `actor` if overriding the session default).
187
+ `auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `userIdentity` if overriding the session default).
188
+
189
+ `userIdentity` is filled from the wire's session plus its `pikkuUserId`, and is left off entirely when all three are absent — which is what makes a cron or system invocation land with a null actor rather than an empty object.
168
190
 
169
191
  ## Do / Don't
170
192
 
171
193
  - DO mark recording functions `audit: true`, inject `auditLog`, and call `auditLog.write({ type, source: 'explicit', metadata })`.
172
- - DO let the actor come from the session — don't thread `userId` into metadata for the actor.
194
+ - DO let the user identity come from the session — don't thread `userId` into metadata for it.
173
195
  - DON'T create a custom `audit_log`/history table or `insertInto('audit_log')` by hand.
174
196
  - DON'T annotate the function's I/O from audit; audit is a side channel, not part of `input`/`output`.
175
197
  - DON'T write audit inside a DB transaction expecting rollback — record after commit.
@@ -36,52 +36,102 @@ yarn add @pikku/aws-services
36
36
  import { S3Content } from '@pikku/aws-services'
37
37
 
38
38
  const content = new S3Content(
39
- config: S3ContentConfig,
39
+ config: { bucketName: string; region: string; endpoint?: string },
40
40
  logger: Logger,
41
41
  signConfig: { keyPairId: string; privateKey: string }
42
42
  )
43
43
  ```
44
44
 
45
- **Methods:**
46
-
47
- - `signURL(url: string, dateLessThan: Date, dateGreaterThan?: Date): Promise<string>` — Sign a CloudFront URL
48
- - `signContentKey(key: string, dateLessThan: Date, dateGreaterThan?: Date): Promise<string>` — Sign a content key
49
- - `getUploadURL(Key: string, ContentType: string): Promise<{ uploadUrl, assetKey }>` — Get presigned upload URL
50
- - `readFile(Key: string): Promise<ReadableStream>` — Read file as stream
51
- - `readFileAsBuffer(Key: string): Promise<Buffer>` — Read file as buffer
52
- - `writeFile(Key: string, stream: ReadableStream): Promise<boolean>` — Write file from stream
53
- - `copyFile(Key: string, fromAbsolutePath: string): Promise<boolean>` — Copy local file to S3
54
- - `deleteFile(Key: string): Promise<boolean>` — Delete file
45
+ `endpoint` is what points the client at LocalStack or an S3-compatible store.
46
+
47
+ **Methods** — every one takes a single **args object**, matching the shared
48
+ `ContentService` interface. None of them are positional:
49
+
50
+ - `signURL({ url, dateLessThan, dateGreaterThan? }): Promise<string>` — CloudFront-sign an absolute URL
51
+ - `signContentKey({ bucket, contentKey, dateLessThan, dateGreaterThan? }): Promise<string>`
52
+ - `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<{ uploadUrl, assetKey }>` — `visibility` is ignored
53
+ - `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`
54
+ - `readFileAsBuffer({ bucket, key }): Promise<Buffer>`
55
+ - `writeFile({ bucket, key, stream }): Promise<boolean>`
56
+ - `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`
57
+ - `deleteFile({ bucket, key }): Promise<boolean>`
58
+
59
+ ### One real bucket, logical buckets as prefixes
60
+
61
+ The `bucket` on every call is a **logical** bucket stored as a path prefix
62
+ (`${bucket}/${key}`) inside the single S3 bucket named by `bucketName`. Don't
63
+ provision an S3 bucket per logical bucket — the config takes only one.
64
+
65
+ ### Behaviours worth knowing before you rely on them
66
+
67
+ - **`signURL` fails open.** A signing error is logged and the *unsigned* URL is
68
+ returned rather than thrown. If your CloudFront distribution is private the
69
+ client then gets a 403; if it isn't, you have just handed out an unrestricted
70
+ link. Check that `signConfig` is a valid CloudFront key pair at boot.
71
+ - **`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>`** — it
72
+ uses `bucketName` as the *host*. For signed content the value must therefore be
73
+ your CloudFront domain, not a plain bucket name, which also means the same
74
+ config field is doing two jobs.
75
+ - **Presigned upload URLs expire after a fixed 3600s.** It is not configurable
76
+ through the service.
77
+ - **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log
78
+ and return `false` rather than throwing; the read paths throw. Check the
79
+ boolean.
55
80
 
56
81
  ### `SQSQueueService` (Queue)
57
82
 
58
83
  ```typescript
59
84
  import { SQSQueueService } from '@pikku/aws-services'
60
85
 
61
- const queue = new SQSQueueService(config: SQSQueueServiceConfig)
86
+ const queue = new SQSQueueService({
87
+ region: string,
88
+ queueUrlPrefix: string, // e.g. 'https://sqs.us-east-1.amazonaws.com/123456789/'
89
+ endpoint?: string, // LocalStack or a custom SQS endpoint
90
+ })
62
91
  ```
63
92
 
64
93
  Implements `QueueService`. Note: `supportsResults = false` — job status tracking is not supported.
65
94
 
66
95
  **Methods:**
67
96
 
68
- - `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message
97
+ - `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message; returns SQS's `MessageId`
98
+ - `getJob()` — always **throws**. SQS is fire-and-forget; reach for BullMQ or PgBoss when you need the result back.
99
+
100
+ The queue URL is `queueUrlPrefix + queueName`, so the queue name in `wireQueueWorker` has to match the SQS queue exactly.
101
+
102
+ Constraints inherited from SQS, enforced in `add`:
103
+
104
+ - `options.delay` is in **milliseconds** and is floored to whole seconds. Over
105
+ 900_000ms (15 minutes) or negative throws before the message is sent.
106
+ - Standard queues only — no FIFO, so no `MessageGroupId` and no ordering
107
+ guarantee.
108
+ - `data` is `JSON.stringify`d, which is where a `Date` or a `Map` quietly
109
+ degrades.
69
110
 
70
111
  ### `AWSSecrets` (Secrets Manager)
71
112
 
72
113
  ```typescript
73
114
  import { AWSSecrets } from '@pikku/aws-services'
74
115
 
75
- const secrets = new AWSSecrets(config: AWSConfig)
116
+ const secrets = new AWSSecrets({ awsRegion: 'eu-west-2' })
76
117
  ```
77
118
 
119
+ `AWSConfig` has one field, `awsRegion` — there is no credentials option; the SDK's
120
+ default provider chain (instance role, env, profile) supplies those.
121
+
78
122
  **Methods:**
79
123
 
80
- - `getSecret<T = string>(SecretId: string): Promise<T>` — Get a secret value; a JSON secret is parsed automatically, so pass a shape as `T` (a non-JSON value comes back as the raw string)
124
+ - `getSecret<T = string>(SecretId: string): Promise<SecretValue<T>>` — a JSON secret is parsed automatically, so pass a shape as `T` (a non-JSON value comes back as the raw string). The result is a branded `SecretValue`, not a bare value — reveal it where it is used rather than passing it through logs
81
125
  - `getSecrets<T>(SecretIds: (keyof T & string)[]): Promise<Partial<T>>` — Batch fetch; missing keys are omitted rather than thrown
82
126
  - `hasSecret(SecretId: string): Promise<boolean>` — Check if secret exists
83
127
  - `setSecret` / `deleteSecret` — **not implemented** for `AWSSecrets`; it throws. Manage AWS secrets out of band.
84
128
 
129
+ Every `getSecret` failure — missing secret, denied permission, a secret holding
130
+ only binary — surfaces as the same `FATAL: Error finding secret: <id>`, with the
131
+ real reason on the error's `cause`. Read `cause` before concluding the secret
132
+ doesn't exist. `hasSecret` performs a full fetch and returns `false` for any
133
+ error, so it can't distinguish "absent" from "not allowed" either.
134
+
85
135
  ## Usage Patterns
86
136
 
87
137
  ### S3 Content Service
@@ -90,7 +140,7 @@ const secrets = new AWSSecrets(config: AWSConfig)
90
140
  const createSingletonServices = pikkuServices(async (config) => {
91
141
  const logger = new PinoLogger()
92
142
  const content = new S3Content(
93
- { bucket: config.s3Bucket, region: config.awsRegion },
143
+ { bucketName: config.s3Bucket, region: config.awsRegion },
94
144
  logger,
95
145
  { keyPairId: config.cfKeyPairId, privateKey: config.cfPrivateKey }
96
146
  )
@@ -39,16 +39,41 @@ const content = new B2Content(
39
39
  )
40
40
  ```
41
41
 
42
- **Methods:**
43
-
44
- - `signContentKey(key: string, dateLessThan: Date): Promise<string>` — Sign a content key
45
- - `signURL(url: string, dateLessThan: Date): Promise<string>` — Sign a URL
46
- - `getUploadURL(fileKey: string, contentType: string): Promise<{ uploadUrl, assetKey, uploadMethod?, uploadHeaders? }>` — Get upload URL
47
- - `writeFile(assetKey: string, stream: ReadableStream): Promise<boolean>` — Write file
48
- - `copyFile(assetKey: string, fromAbsolutePath: string): Promise<boolean>` — Copy local file to B2
49
- - `readFile(assetKey: string): Promise<ReadableStream>` — Read file as stream
50
- - `readFileAsBuffer(assetKey: string): Promise<Buffer>` — Read file as buffer
51
- - `deleteFile(fileName: string): Promise<boolean>` — Delete file
42
+ `B2ContentConfig` has exactly three fields — `applicationKeyId`, `applicationKey`
43
+ and `bucketId`. There is no `cdnUrl`: downloads are served from the `downloadUrl`
44
+ B2 returns at authorization.
45
+
46
+ **Methods** — every one takes a single **args object**, matching the shared
47
+ `ContentService` interface. None of them are positional:
48
+
49
+ - `signContentKey({ bucket, contentKey, dateLessThan }): Promise<string>` — a full download URL with an `Authorization` query param
50
+ - `signURL({ url, dateLessThan }): Promise<string>` — re-signs an existing `/file/` URL; a URL with no `/file/` segment is returned untouched
51
+ - `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<UploadURLResult>` — `visibility` is ignored by this backend
52
+ - `writeFile({ bucket, key, stream }): Promise<boolean>`
53
+ - `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`
54
+ - `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`
55
+ - `readFileAsBuffer({ bucket, key }): Promise<Buffer>`
56
+ - `deleteFile({ bucket, key }): Promise<boolean>`
57
+
58
+ ### One real bucket, logical buckets as prefixes
59
+
60
+ The `bucket` on every call is a **logical** bucket stored as a path prefix
61
+ (`${bucket}/${key}`) inside the single B2 bucket named by `bucketId`. Don't
62
+ provision a B2 bucket per logical bucket — the config takes only one.
63
+
64
+ ### Behaviours worth knowing before you rely on them
65
+
66
+ - **Writes are buffered in memory.** `writeFile` drains the whole stream into a
67
+ `Buffer` before uploading, because B2's upload endpoint needs a SHA-1 and a
68
+ content length up front. Large uploads should go through `getUploadURL` and be
69
+ sent by the client directly.
70
+ - **`getUploadURL` sets `X-Bz-Content-Sha1: do_not_verify`**, since the server
71
+ can't hash a body it never sees. The client-side upload is unverified.
72
+ - **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log
73
+ and return `false` rather than throwing; the read paths and the signing paths
74
+ throw. Check the boolean — an ignored return is a silently lost file.
75
+ - Authorization and the bucket-name lookup are cached on the instance for its
76
+ lifetime, so a rotated application key needs a new `B2Content`.
52
77
 
53
78
  ## Usage Patterns
54
79
 
@@ -62,10 +87,18 @@ const createSingletonServices = pikkuServices(async (config) => {
62
87
  applicationKeyId: config.b2KeyId,
63
88
  applicationKey: config.b2AppKey,
64
89
  bucketId: config.b2BucketId,
65
- cdnUrl: config.b2CdnUrl,
66
90
  },
67
91
  logger
68
92
  )
69
93
  return { config, logger, content }
70
94
  })
71
95
  ```
96
+
97
+ ```typescript
98
+ await content.writeFile({ bucket: 'avatars', key: `${userId}.png`, stream })
99
+ const url = await content.signContentKey({
100
+ bucket: 'avatars',
101
+ contentKey: `${userId}.png`,
102
+ dateLessThan: new Date(Date.now() + 60_000),
103
+ })
104
+ ```