@holdyapp/sdk 1.0.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Execução como unidade de trabalho (source: sdk_server).
3
+ *
4
+ * Produto de agente não tem sessão de browser: o trabalho roda no servidor,
5
+ * por API, em background. Pra Holdy dizer "começou e concluiu na mesma
6
+ * execução" e dividir recurso por resultado entregue, todos os eventos de uma
7
+ * tarefa carregam a mesma `ai_run_id` — a chave que o composer usa por padrão
8
+ * no grão de execução (lib/knowledge/compose-definition.ts).
9
+ *
10
+ * Não inventa vocabulário: início e fim saem com o nome que o cliente deu à
11
+ * execução (`support_agent.started`), e todo passo no meio é nome dele. O
12
+ * retorno passa direto e o erro propaga; só o NOME do erro vira propriedade,
13
+ * porque a mensagem carrega dado do cliente com frequência demais.
14
+ */
15
+
16
+ import type { Properties, TrackOverrides } from './types'
17
+
18
+ export interface RunOptions {
19
+ /** Nome da execução no vocabulário do produto, ex.: 'support_agent'. */
20
+ name: string
21
+ /** Conta (id externo) dona da execução. */
22
+ accountKey?: string
23
+ userId?: string
24
+ /** Chave própria (trace id do agente); ausente = gerada aqui. */
25
+ runId?: string
26
+ /** Propriedades repetidas em todos os eventos da execução. */
27
+ properties?: Properties
28
+ }
29
+
30
+ export interface RunHandle {
31
+ /** A chave que agrupa tudo desta execução. */
32
+ id: string
33
+ /** Um passo dentro da execução, já carimbado com ai_run_id. */
34
+ track(event: string, properties?: Properties): void
35
+ }
36
+
37
+ export type TrackFn = (name: string, properties?: Properties, overrides?: TrackOverrides) => void
38
+
39
+ export function newRunId(): string {
40
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
41
+ if (typeof c?.randomUUID === 'function') return c.randomUUID()
42
+ return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
43
+ }
44
+
45
+ export function runEventName(name: string, suffix: 'started' | 'completed' | 'failed'): string {
46
+ const base = name.trim().replace(/\.$/, '')
47
+ return `${base || 'run'}.${suffix}`
48
+ }
49
+
50
+ export async function withRun<T>(
51
+ track: TrackFn,
52
+ opts: RunOptions,
53
+ fn: (run: RunHandle) => Promise<T> | T,
54
+ ): Promise<T> {
55
+ if (!opts || !opts.name) throw new Error('[holdy] run: name is required')
56
+
57
+ const id = opts.runId && opts.runId.length > 0 ? opts.runId : newRunId()
58
+ const base: Properties = { ...(opts.properties ?? {}), ai_run_id: id }
59
+ const overrides: TrackOverrides = {
60
+ ...(opts.accountKey ? { accountKey: opts.accountKey } : {}),
61
+ ...(opts.userId ? { userId: opts.userId } : {}),
62
+ }
63
+ const emit = (event: string, properties?: Properties) =>
64
+ track(event, { ...base, ...(properties ?? {}), ai_run_id: id }, overrides)
65
+
66
+ const startedAt = Date.now()
67
+ emit(runEventName(opts.name, 'started'))
68
+
69
+ try {
70
+ const result = await fn({ id, track: emit })
71
+ emit(runEventName(opts.name, 'completed'), { duration_ms: Date.now() - startedAt })
72
+ return result
73
+ } catch (err) {
74
+ emit(runEventName(opts.name, 'failed'), {
75
+ duration_ms: Date.now() - startedAt,
76
+ error: err instanceof Error ? err.name : 'Error',
77
+ })
78
+ throw err
79
+ }
80
+ }
package/src/server.ts CHANGED
@@ -7,13 +7,30 @@
7
7
  */
8
8
 
9
9
  import { HoldyCore } from './core'
10
+ import { withRun, type RunHandle, type RunOptions } from './run'
10
11
  import type { HoldyOptions } from './types'
11
12
 
12
13
  export class Holdy extends HoldyCore {
13
14
  constructor(opts: HoldyOptions) {
14
15
  super('sdk_server', opts)
15
16
  }
17
+
18
+ /**
19
+ * Executa `fn` como UMA execução de agente: todo evento lá dentro carrega a
20
+ * mesma `ai_run_id`, e `<name>.started` / `.completed` / `.failed` saem
21
+ * sozinhos.
22
+ *
23
+ * await holdy.run({ name: 'support_agent', accountKey: 'acme' }, async (run) => {
24
+ * run.track('ticket_drafted', { ai_tokens: 1200, ai_cost_usd: 0.02 })
25
+ * return resolveTicket(ticket)
26
+ * })
27
+ */
28
+ run<T>(opts: RunOptions, fn: (run: RunHandle) => Promise<T> | T): Promise<T> {
29
+ return withRun((name, properties, overrides) => this.track(name, properties, overrides), opts, fn)
30
+ }
16
31
  }
17
32
 
18
33
  export { HoldyCore, DEFAULT_BASE_URL } from './core'
19
34
  export type { HoldyOptions, Properties, TrackOverrides, CanonicalEvent } from './types'
35
+ export { newRunId, runEventName } from './run'
36
+ export type { RunHandle, RunOptions } from './run'
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ export interface CanonicalEvent {
12
12
  event: string
13
13
  occurred_at: string
14
14
  source: 'sdk_web' | 'sdk_server'
15
+ /** Id do evento gerado no SDK · torna o reenvio de um lote idempotente. */
16
+ external_id?: string
15
17
  user_id?: string
16
18
  anonymous_id?: string
17
19
  properties: Properties
package/src/web.ts CHANGED
@@ -13,7 +13,9 @@
13
13
 
14
14
  import { HoldyCore } from './core'
15
15
  import { installAutoCapture, type NavigationKind } from './auto-capture'
16
+ import { installFrictionCapture, type FrictionCapture, type FrictionCaptureOptions } from './friction'
16
17
  import { buildWebContext } from './web-context'
18
+ import { installProductChanges, type ProductChangesHandle } from './changes'
17
19
  import type { HoldyOptions, Properties } from './types'
18
20
 
19
21
  const ANON_STORAGE_KEY = 'holdy_anon_id'
@@ -24,10 +26,26 @@ export interface HoldyWebOptions extends HoldyOptions {
24
26
  * Desligue só se o produto já chama `page()` na mão em cada tela.
25
27
  */
26
28
  autoCapture?: boolean
29
+ /**
30
+ * Capturar fricção sozinho · default true. Emite `$dead_click`, `$rage_click`,
31
+ * `$form_abandon`, `$js_error`, `$slow_request`, `$nav_loop` e `$http_error`
32
+ * (requisição com status ≥ 400 ou sem resposta, e a própria página que abre
33
+ * com erro) — eventos determinísticos, sem DOM nem replay. Objeto ajusta os
34
+ * limiares; a URL da Holdy nunca conta como erro.
35
+ */
36
+ friction?: boolean | Omit<FrictionCaptureOptions, 'onFriction'>
37
+ /**
38
+ * Aplicar as mudanças de texto e dica que a org pôs no ar na Holdy · default
39
+ * true. Parte das contas fica de fora por sorteio pra medir; sem conta
40
+ * identificada (`group()` ou `accountKey`) nada é aplicado.
41
+ */
42
+ productChanges?: boolean
27
43
  }
28
44
 
29
45
  export class HoldyWeb extends HoldyCore {
30
46
  private teardownAutoCapture: (() => void) | null = null
47
+ private friction: FrictionCapture | null = null
48
+ private changes: ProductChangesHandle | null = null
31
49
 
32
50
  constructor(opts: HoldyWebOptions) {
33
51
  super('sdk_web', opts)
@@ -40,9 +58,32 @@ export class HoldyWeb extends HoldyCore {
40
58
  })
41
59
  }
42
60
 
61
+ if (opts.friction !== false) {
62
+ const frictionOpts = typeof opts.friction === 'object' ? opts.friction : {}
63
+ this.friction = installFrictionCapture({
64
+ ...frictionOpts,
65
+ ignoreUrls: [...(frictionOpts.ignoreUrls ?? []), this.baseUrl],
66
+ onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties),
67
+ })
68
+ }
69
+
43
70
  if (opts.autoCapture !== false) {
44
71
  this.teardownAutoCapture = installAutoCapture({
45
- onNavigate: (kind: NavigationKind) => this.page(undefined, { navigation: kind }),
72
+ onNavigate: (kind: NavigationKind) => {
73
+ this.page(undefined, { navigation: kind })
74
+ this.friction?.onNavigate(typeof location !== 'undefined' ? location.pathname : '/')
75
+ this.changes?.onNavigate()
76
+ },
77
+ })
78
+ }
79
+
80
+ if (opts.productChanges !== false && typeof window !== 'undefined') {
81
+ this.changes = installProductChanges({
82
+ baseUrl: this.baseUrl,
83
+ apiKey: this.apiKey,
84
+ getAccountKey: () => this.accountKey,
85
+ track: (name, properties) => this.track(name, properties),
86
+ debug: opts.debug,
46
87
  })
47
88
  }
48
89
  }
@@ -62,6 +103,11 @@ export class HoldyWeb extends HoldyCore {
62
103
  })
63
104
  }
64
105
 
106
+ override group(accountKey: string, traits?: Properties): void {
107
+ super.group(accountKey, traits)
108
+ this.changes?.onNavigate()
109
+ }
110
+
65
111
  reset(): void {
66
112
  this.userId = null
67
113
  this.accountKey = null
@@ -71,12 +117,17 @@ export class HoldyWeb extends HoldyCore {
71
117
  // storage indisponível (SSR/privacy mode) — ok
72
118
  }
73
119
  this.anonymousId = resolveAnonymousId()
120
+ this.changes?.onNavigate()
74
121
  }
75
122
 
76
123
  /** Flush final + desinstala o auto-capture (devolve `history` ao original). */
77
124
  override async shutdown(): Promise<void> {
78
125
  this.teardownAutoCapture?.()
79
126
  this.teardownAutoCapture = null
127
+ this.friction?.teardown()
128
+ this.friction = null
129
+ this.changes?.teardown()
130
+ this.changes = null
80
131
  await super.shutdown()
81
132
  }
82
133
 
@@ -101,7 +152,16 @@ let singleton: HoldyWeb | null = null
101
152
 
102
153
  /** Init idempotente — segunda chamada retorna a instância existente. */
103
154
  export function initHoldy(opts: HoldyWebOptions): HoldyWeb {
104
- if (!singleton) singleton = new HoldyWeb(opts)
155
+ if (!singleton) {
156
+ singleton = new HoldyWeb(opts)
157
+ // Instância no window: track() inserido pelo `--instrument` do CLI não
158
+ // precisa importar nada nem saber onde o init mora. Mesma superfície do
159
+ // loader por script tag, então o snippet é um só nos dois caminhos.
160
+ if (typeof window !== 'undefined') {
161
+ const w = window as unknown as { Holdy?: HoldyWeb }
162
+ if (!w.Holdy) w.Holdy = singleton
163
+ }
164
+ }
105
165
  return singleton
106
166
  }
107
167