@holdyapp/sdk 1.1.0 → 1.6.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.
Files changed (50) hide show
  1. package/README.md +53 -1
  2. package/dist/arm.d.ts +5 -0
  3. package/dist/arm.d.ts.map +1 -0
  4. package/dist/changes.d.ts +42 -0
  5. package/dist/changes.d.ts.map +1 -0
  6. package/dist/core.d.ts +17 -6
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/friction.d.ts +91 -0
  9. package/dist/friction.d.ts.map +1 -0
  10. package/dist/holdy.js +6 -1
  11. package/dist/holdy.js.map +4 -4
  12. package/dist/index.cjs +871 -26
  13. package/dist/index.cjs.map +4 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +871 -26
  17. package/dist/index.mjs.map +4 -4
  18. package/dist/run.d.ts +37 -0
  19. package/dist/run.d.ts.map +1 -0
  20. package/dist/server.cjs +127 -23
  21. package/dist/server.cjs.map +3 -3
  22. package/dist/server.d.ts +14 -0
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.mjs +124 -22
  25. package/dist/server.mjs.map +3 -3
  26. package/dist/skeleton.d.ts +50 -0
  27. package/dist/skeleton.d.ts.map +1 -0
  28. package/dist/types.d.ts +2 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/web.d.ts +26 -0
  31. package/dist/web.d.ts.map +1 -1
  32. package/dist/web.mjs +824 -26
  33. package/dist/web.mjs.map +4 -4
  34. package/package.json +1 -1
  35. package/src/arm.test.ts +47 -0
  36. package/src/arm.ts +25 -0
  37. package/src/changes.test.ts +54 -0
  38. package/src/changes.ts +357 -0
  39. package/src/core.test.ts +78 -4
  40. package/src/core.ts +95 -31
  41. package/src/friction.test.ts +223 -0
  42. package/src/friction.ts +419 -0
  43. package/src/index.ts +1 -0
  44. package/src/run.test.ts +47 -0
  45. package/src/run.ts +80 -0
  46. package/src/server.ts +17 -0
  47. package/src/skeleton.test.ts +60 -0
  48. package/src/skeleton.ts +173 -0
  49. package/src/types.ts +2 -0
  50. package/src/web.ts +71 -1
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'
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { collectSkeleton, describeSkeletonElement, fnv1a, SKELETON_MAX_ELEMENTS, type SkeletonElementLike } from './skeleton'
3
+
4
+ function el(tag: string, attrs: Record<string, string> = {}, rect = { x: 10, y: 20, width: 100, height: 40 }): SkeletonElementLike {
5
+ return {
6
+ tagName: tag.toUpperCase(),
7
+ getAttribute: (name) => attrs[name] ?? null,
8
+ getBoundingClientRect: () => rect,
9
+ }
10
+ }
11
+
12
+ const viewport = { width: 1000, height: 2000, scrollY: 0 }
13
+
14
+ describe('fnv1a', () => {
15
+ it('é estável e conhecido', () => {
16
+ expect(fnv1a('')).toBe('811c9dc5')
17
+ expect(fnv1a('a')).toBe('e40c292c')
18
+ expect(fnv1a('holdy')).toBe(fnv1a('holdy'))
19
+ })
20
+ })
21
+
22
+ describe('describeSkeletonElement', () => {
23
+ it('guarda tag, role, data-testid, name, aria-label e tipo, nunca texto nem valor', () => {
24
+ const out = describeSkeletonElement(
25
+ el('input', { type: 'Email', name: 'email', 'aria-label': ' Seu e-mail ', value: 'segredo@x.com', placeholder: 'Digite' }),
26
+ viewport,
27
+ )
28
+ expect(out).toEqual({ t: 'input', n: 'email', l: 'Seu e-mail', k: 'email', b: [1, 1, 10, 2] })
29
+ expect(JSON.stringify(out)).not.toContain('segredo')
30
+ })
31
+
32
+ it('sem viewport não há geometria', () => {
33
+ expect(describeSkeletonElement(el('button', { 'data-testid': 'continue' }), null)).toEqual({ t: 'button', id: 'continue' })
34
+ })
35
+ })
36
+
37
+ describe('collectSkeleton', () => {
38
+ const root = (items: SkeletonElementLike[]) => ({ querySelectorAll: () => items })
39
+
40
+ it('o hash ignora a geometria e muda com a estrutura', () => {
41
+ const a = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 0, y: 0, width: 50, height: 20 })]), '/x', viewport)
42
+ const b = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 300, y: 900, width: 80, height: 30 })]), '/x', viewport)
43
+ const c = collectSkeleton(root([el('button', { 'data-testid': 'stop' })]), '/x', viewport)
44
+ expect(a.hash).toBe(b.hash)
45
+ expect(a.hash).not.toBe(c.hash)
46
+ expect(a.elements[0].b).toEqual([0, 0, 5, 1])
47
+ })
48
+
49
+ it('deixa fora input hidden e elemento sem tamanho, e conta o total antes do teto', () => {
50
+ const many = Array.from({ length: SKELETON_MAX_ELEMENTS + 10 }, (_, i) => el('a', { href: `/p/${i}` }))
51
+ const out = collectSkeleton(
52
+ root([el('input', { type: 'hidden', name: 'csrf' }), el('button', {}, { x: 0, y: 0, width: 0, height: 0 }), ...many]),
53
+ '/lista',
54
+ viewport,
55
+ )
56
+ expect(out.count).toBe(SKELETON_MAX_ELEMENTS + 10)
57
+ expect(out.elements).toHaveLength(SKELETON_MAX_ELEMENTS)
58
+ expect(out.elements.every((e) => e.t === 'a')).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,173 @@
1
+ export const SKELETON_SELECTOR =
2
+ 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]'
3
+ export const SKELETON_MAX_ELEMENTS = 80
4
+ export const SKELETON_DELAY_MS = 1500
5
+ const SENT_STORAGE_KEY = 'holdy_skeletons'
6
+ const SENT_STORAGE_MAX = 200
7
+
8
+ export interface SkeletonRect {
9
+ x: number
10
+ y: number
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export interface SkeletonElementLike {
16
+ tagName: string
17
+ getAttribute(name: string): string | null
18
+ getBoundingClientRect?(): SkeletonRect
19
+ }
20
+
21
+ export interface SkeletonRootLike {
22
+ querySelectorAll(selector: string): ArrayLike<SkeletonElementLike>
23
+ }
24
+
25
+ export interface SkeletonViewport {
26
+ width: number
27
+ height: number
28
+ scrollY: number
29
+ }
30
+
31
+ export interface SkeletonElement {
32
+ t: string
33
+ r?: string
34
+ id?: string
35
+ n?: string
36
+ l?: string
37
+ k?: string
38
+ b?: [number, number, number, number]
39
+ }
40
+
41
+ export interface PageSkeleton {
42
+ path: string
43
+ hash: string
44
+ count: number
45
+ elements: SkeletonElement[]
46
+ }
47
+
48
+ export function fnv1a(input: string): string {
49
+ let hash = 0x811c9dc5
50
+ for (let i = 0; i < input.length; i++) {
51
+ hash ^= input.charCodeAt(i)
52
+ hash = Math.imul(hash, 0x01000193) >>> 0
53
+ }
54
+ return hash.toString(16).padStart(8, '0')
55
+ }
56
+
57
+ function attr(el: SkeletonElementLike, name: string, max: number): string | undefined {
58
+ const value = el.getAttribute(name)
59
+ if (!value) return undefined
60
+ const trimmed = value.replace(/\s+/g, ' ').trim().slice(0, max)
61
+ return trimmed || undefined
62
+ }
63
+
64
+ function isVisible(el: SkeletonElementLike): boolean {
65
+ if (!el.getBoundingClientRect) return true
66
+ const rect = el.getBoundingClientRect()
67
+ return rect.width > 0 && rect.height > 0
68
+ }
69
+
70
+ function relativeBox(rect: SkeletonRect, viewport: SkeletonViewport): [number, number, number, number] {
71
+ const pct = (value: number, base: number) => (base > 0 ? Math.max(0, Math.min(100, Math.round((value / base) * 100))) : 0)
72
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)]
73
+ }
74
+
75
+ export function describeSkeletonElement(el: SkeletonElementLike, viewport: SkeletonViewport | null): SkeletonElement {
76
+ const out: SkeletonElement = { t: el.tagName.toLowerCase() }
77
+ const role = attr(el, 'role', 40)
78
+ if (role) out.r = role
79
+ const testid = attr(el, 'data-testid', 80)
80
+ if (testid) out.id = testid
81
+ const name = attr(el, 'name', 80)
82
+ if (name) out.n = name
83
+ const label = attr(el, 'aria-label', 80)
84
+ if (label) out.l = label
85
+ if (out.t === 'input' || out.t === 'button') {
86
+ const type = attr(el, 'type', 20)
87
+ if (type) out.k = type.toLowerCase()
88
+ }
89
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport)
90
+ return out
91
+ }
92
+
93
+ export function collectSkeleton(root: SkeletonRootLike, path: string, viewport: SkeletonViewport | null): PageSkeleton {
94
+ const found = root.querySelectorAll(SKELETON_SELECTOR)
95
+ const visible: SkeletonElementLike[] = []
96
+ for (let i = 0; i < found.length; i++) {
97
+ const el = found[i]
98
+ if (el.tagName.toLowerCase() === 'input' && (el.getAttribute('type') ?? '').toLowerCase() === 'hidden') continue
99
+ if (!isVisible(el)) continue
100
+ visible.push(el)
101
+ }
102
+ const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport))
103
+ const structural = elements.map(({ b: _b, ...rest }) => rest)
104
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements }
105
+ }
106
+
107
+ export interface SkeletonCaptureOptions {
108
+ onSkeleton: (skeleton: PageSkeleton) => void
109
+ delayMs?: number
110
+ }
111
+
112
+ export interface SkeletonCapture {
113
+ onNavigate: (path: string) => void
114
+ teardown: () => void
115
+ }
116
+
117
+ function loadSent(): Set<string> {
118
+ try {
119
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY)
120
+ const list = raw ? (JSON.parse(raw) as unknown) : []
121
+ return new Set(Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [])
122
+ } catch {
123
+ return new Set()
124
+ }
125
+ }
126
+
127
+ function saveSent(sent: Set<string>): void {
128
+ try {
129
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)))
130
+ } catch {
131
+ return
132
+ }
133
+ }
134
+
135
+ export function installSkeletonCapture(opts: SkeletonCaptureOptions): SkeletonCapture {
136
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
137
+ return { onNavigate: () => undefined, teardown: () => undefined }
138
+ }
139
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS
140
+ const sent = loadSent()
141
+ let pending: ReturnType<typeof setTimeout> | null = null
142
+
143
+ const capture = (path: string): void => {
144
+ pending = null
145
+ if (location.pathname !== path) return
146
+ try {
147
+ const viewport: SkeletonViewport = {
148
+ width: window.innerWidth,
149
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
150
+ scrollY: window.scrollY,
151
+ }
152
+ const skeleton = collectSkeleton(document, path, viewport)
153
+ const key = `${path}:${skeleton.hash}`
154
+ if (sent.has(key)) return
155
+ sent.add(key)
156
+ saveSent(sent)
157
+ opts.onSkeleton(skeleton)
158
+ } catch {
159
+ return
160
+ }
161
+ }
162
+
163
+ return {
164
+ onNavigate: (path: string) => {
165
+ if (pending) clearTimeout(pending)
166
+ pending = setTimeout(() => capture(path), delay)
167
+ },
168
+ teardown: () => {
169
+ if (pending) clearTimeout(pending)
170
+ pending = null
171
+ },
172
+ }
173
+ }
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,10 @@
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'
17
+ import { installSkeletonCapture, type SkeletonCapture } from './skeleton'
16
18
  import { buildWebContext } from './web-context'
19
+ import { installProductChanges, type ProductChangesHandle } from './changes'
17
20
  import type { HoldyOptions, Properties } from './types'
18
21
 
19
22
  const ANON_STORAGE_KEY = 'holdy_anon_id'
@@ -24,10 +27,34 @@ export interface HoldyWebOptions extends HoldyOptions {
24
27
  * Desligue só se o produto já chama `page()` na mão em cada tela.
25
28
  */
26
29
  autoCapture?: boolean
30
+ /**
31
+ * Capturar fricção sozinho · default true. Emite `$dead_click`, `$rage_click`,
32
+ * `$form_abandon`, `$js_error`, `$slow_request`, `$nav_loop` e `$http_error`
33
+ * (requisição com status ≥ 400 ou sem resposta, e a própria página que abre
34
+ * com erro) — eventos determinísticos, sem DOM nem replay. Objeto ajusta os
35
+ * limiares; a URL da Holdy nunca conta como erro.
36
+ */
37
+ friction?: boolean | Omit<FrictionCaptureOptions, 'onFriction'>
38
+ /**
39
+ * Aplicar as mudanças de texto e dica que a org pôs no ar na Holdy · default
40
+ * true. Parte das contas fica de fora por sorteio pra medir; sem conta
41
+ * identificada (`group()` ou `accountKey`) nada é aplicado.
42
+ */
43
+ productChanges?: boolean
44
+ /**
45
+ * Mandar o esqueleto da página · default true. Uma vez por (rota, estrutura)
46
+ * por aba: `$page_skeleton` com os elementos interativos (tag, role,
47
+ * data-testid, name, aria-label, tipo, posição relativa). Sem texto livre,
48
+ * sem valor de input, sem pixel.
49
+ */
50
+ skeleton?: boolean
27
51
  }
28
52
 
29
53
  export class HoldyWeb extends HoldyCore {
30
54
  private teardownAutoCapture: (() => void) | null = null
55
+ private friction: FrictionCapture | null = null
56
+ private changes: ProductChangesHandle | null = null
57
+ private skeleton: SkeletonCapture | null = null
31
58
 
32
59
  constructor(opts: HoldyWebOptions) {
33
60
  super('sdk_web', opts)
@@ -40,9 +67,40 @@ export class HoldyWeb extends HoldyCore {
40
67
  })
41
68
  }
42
69
 
70
+ if (opts.friction !== false) {
71
+ const frictionOpts = typeof opts.friction === 'object' ? opts.friction : {}
72
+ this.friction = installFrictionCapture({
73
+ ...frictionOpts,
74
+ ignoreUrls: [...(frictionOpts.ignoreUrls ?? []), this.baseUrl],
75
+ onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties),
76
+ })
77
+ }
78
+
79
+ if (opts.skeleton !== false) {
80
+ this.skeleton = installSkeletonCapture({
81
+ onSkeleton: (skeleton) => this.track('$page_skeleton', { ...skeleton }),
82
+ })
83
+ }
84
+
43
85
  if (opts.autoCapture !== false) {
44
86
  this.teardownAutoCapture = installAutoCapture({
45
- onNavigate: (kind: NavigationKind) => this.page(undefined, { navigation: kind }),
87
+ onNavigate: (kind: NavigationKind) => {
88
+ this.page(undefined, { navigation: kind })
89
+ const path = typeof location !== 'undefined' ? location.pathname : '/'
90
+ this.friction?.onNavigate(path)
91
+ this.changes?.onNavigate()
92
+ this.skeleton?.onNavigate(path)
93
+ },
94
+ })
95
+ }
96
+
97
+ if (opts.productChanges !== false && typeof window !== 'undefined') {
98
+ this.changes = installProductChanges({
99
+ baseUrl: this.baseUrl,
100
+ apiKey: this.apiKey,
101
+ getAccountKey: () => this.accountKey,
102
+ track: (name, properties) => this.track(name, properties),
103
+ debug: opts.debug,
46
104
  })
47
105
  }
48
106
  }
@@ -62,6 +120,11 @@ export class HoldyWeb extends HoldyCore {
62
120
  })
63
121
  }
64
122
 
123
+ override group(accountKey: string, traits?: Properties): void {
124
+ super.group(accountKey, traits)
125
+ this.changes?.onNavigate()
126
+ }
127
+
65
128
  reset(): void {
66
129
  this.userId = null
67
130
  this.accountKey = null
@@ -71,12 +134,19 @@ export class HoldyWeb extends HoldyCore {
71
134
  // storage indisponível (SSR/privacy mode) — ok
72
135
  }
73
136
  this.anonymousId = resolveAnonymousId()
137
+ this.changes?.onNavigate()
74
138
  }
75
139
 
76
140
  /** Flush final + desinstala o auto-capture (devolve `history` ao original). */
77
141
  override async shutdown(): Promise<void> {
78
142
  this.teardownAutoCapture?.()
79
143
  this.teardownAutoCapture = null
144
+ this.friction?.teardown()
145
+ this.friction = null
146
+ this.changes?.teardown()
147
+ this.changes = null
148
+ this.skeleton?.teardown()
149
+ this.skeleton = null
80
150
  await super.shutdown()
81
151
  }
82
152