@reclaimprotocol/agent 0.1.40 → 0.1.42

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 (44) hide show
  1. package/README.md +32 -0
  2. package/lib/authoring/tools.js +4 -1
  3. package/lib/config/store.d.ts +7 -0
  4. package/lib/consts.d.ts +5 -0
  5. package/lib/consts.js +10 -0
  6. package/lib/mcp/api/operations.gen.js +6 -2
  7. package/lib/mcp/api/tools.js +4 -0
  8. package/lib/mcp/server.js +6 -1
  9. package/lib/mcp/start.js +27 -1
  10. package/lib/mcp/tools/agent/proof.js +4 -2
  11. package/lib/mcp/tools/agent/propose.js +7 -0
  12. package/lib/mcp/tools/agent/publish.js +4 -1
  13. package/lib/mcp/tools/agent/replay.js +3 -1
  14. package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
  15. package/lib/mcp/tools/agent/start-integration.js +7 -0
  16. package/lib/mcp/tools/authenticate/authenticate.js +3 -0
  17. package/lib/telemetry/index.d.ts +9 -0
  18. package/lib/telemetry/index.js +13 -0
  19. package/lib/telemetry/telemetry.d.ts +54 -0
  20. package/lib/telemetry/telemetry.js +175 -0
  21. package/lib/telemetry/tool-call.d.ts +24 -0
  22. package/lib/telemetry/tool-call.js +102 -0
  23. package/package.json +2 -2
  24. package/skill/SKILL.builder.md +5 -0
  25. package/skill/SKILL.common.md +34 -3
  26. package/skill/SKILL.old.md +5 -0
  27. package/skill/builder-mode.md +1 -0
  28. package/skill/old-mode.md +1 -0
  29. package/src/authoring/tools.ts +4 -1
  30. package/src/config/store.ts +4 -0
  31. package/src/consts.ts +11 -0
  32. package/src/mcp/api/operations.gen.ts +6 -2
  33. package/src/mcp/api/tools.ts +4 -0
  34. package/src/mcp/server.ts +6 -1
  35. package/src/mcp/start.ts +34 -0
  36. package/src/mcp/tools/agent/proof.ts +4 -2
  37. package/src/mcp/tools/agent/propose.ts +8 -0
  38. package/src/mcp/tools/agent/publish.ts +4 -1
  39. package/src/mcp/tools/agent/replay.ts +3 -1
  40. package/src/mcp/tools/agent/start-integration.ts +10 -0
  41. package/src/mcp/tools/authenticate/authenticate.ts +3 -0
  42. package/src/telemetry/index.ts +18 -0
  43. package/src/telemetry/telemetry.ts +206 -0
  44. package/src/telemetry/tool-call.ts +115 -0
@@ -0,0 +1,206 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
+ import type { IOperationArgs } from '@reclaimprotocol/client/openapi'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { ConfigStore } from '../config/store.ts'
5
+ import { LOGGER } from '../logger.ts'
6
+ import { VERSION } from '../pkg.ts'
7
+
8
+ type TrackArgs = IOperationArgs<'TrackAnalyticsEvents'>
9
+ type Batch = TrackArgs['body']
10
+ type Event = Batch['events'][number]
11
+ type Agent = NonNullable<NonNullable<Batch['context']>['agent']>
12
+
13
+ /** Backend mode, as the analytics API names it. */
14
+ export type TelemetryMode = 'builder' | 'devtools'
15
+
16
+ export interface TelemetryOptions {
17
+ client: ReclaimClient
18
+ mode: TelemetryMode
19
+ store?: ConfigStore
20
+ /** How long to wait after the first queued event before sending. */
21
+ flushIntervalMs?: number
22
+ }
23
+
24
+ /** The API accepts up to 50 events per request. */
25
+ const MAX_BATCH = 50
26
+ /** Bounds memory while the backend is unreachable. Oldest events go first. */
27
+ const MAX_QUEUE = 500
28
+ /** How long `close()` waits, so a slow network can't hold up exit. */
29
+ const CLOSE_LIMIT_MS = 2000
30
+
31
+ /**
32
+ * Sends anonymous usage events to Builder. It never throws and never blocks
33
+ * a tool call: `track()` only queues, and a timer that doesn't keep the
34
+ * process alive sends the queue in the background. Failed batches are
35
+ * dropped, because usage data isn't worth retry loops in a tool server.
36
+ *
37
+ * Nothing is written to stdout. Stdio is the MCP transport.
38
+ */
39
+ export class Telemetry {
40
+ readonly mode: TelemetryMode
41
+ readonly clientId: string
42
+ readonly sessionId = String(Math.floor(Date.now() / 1000))
43
+
44
+ #client: ReclaimClient
45
+ #flushIntervalMs: number
46
+ #queue: Event[] = []
47
+ #timer: NodeJS.Timeout | undefined
48
+ #agent: Agent | undefined
49
+ /** Serializes sends so batches go out in order. */
50
+ #sending: Promise<void> = Promise.resolve()
51
+
52
+ constructor({
53
+ client,
54
+ mode,
55
+ store = new ConfigStore(),
56
+ flushIntervalMs = 5000,
57
+ }: TelemetryOptions) {
58
+ this.#client = client
59
+ this.mode = mode
60
+ this.#flushIntervalMs = flushIntervalMs
61
+ this.clientId = loadClientId(store)
62
+ }
63
+
64
+ /** Records the MCP client (for example, Claude Code) that connected. */
65
+ setAgent(info: { name: string, version?: string } | undefined) {
66
+ if(info?.name) {
67
+ this.#agent = { name: info.name, version: info.version }
68
+ }
69
+ }
70
+
71
+ /** Queues an event. Synchronous, cheap, and safe to call anywhere. */
72
+ track<N extends Event['name']>(
73
+ name: N,
74
+ properties: Extract<Event, { name: N }>['properties'],
75
+ ) {
76
+ try {
77
+ // The union is keyed by `name`; TypeScript can't correlate the two
78
+ // generic parameters, so build the event through the union type.
79
+ const event = {
80
+ id: randomUUID(),
81
+ name,
82
+ timestamp: new Date().toISOString(),
83
+ properties,
84
+ } as Event
85
+ this.#queue.push(event)
86
+ if(this.#queue.length > MAX_QUEUE) {
87
+ this.#queue.splice(0, this.#queue.length - MAX_QUEUE)
88
+ }
89
+
90
+ this.#schedule()
91
+ } catch(err) {
92
+ LOGGER.debug({ err }, 'telemetry: track failed')
93
+ }
94
+ }
95
+
96
+ /** Sends everything queued. Never rejects. */
97
+ flush(): Promise<void> {
98
+ clearTimeout(this.#timer)
99
+ this.#timer = undefined
100
+ const events = this.#queue.splice(0)
101
+ if(!events.length) {
102
+ return this.#sending
103
+ }
104
+
105
+ this.#sending = this.#sending.then(() => this.#send(events))
106
+ return this.#sending
107
+ }
108
+
109
+ /**
110
+ * Links this client's earlier anonymous events to the signed-in user.
111
+ * Needs a bearer token, so call it after sign-in.
112
+ */
113
+ async identify() {
114
+ try {
115
+ await this.#client.call('IdentifyAnalyticsClient', {
116
+ body: { clientId: this.clientId },
117
+ })
118
+ } catch(err) {
119
+ LOGGER.debug({ err }, 'telemetry: identify failed')
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Call when sign-in finishes. Sends the `login` event first so that
125
+ * `identify()` can attribute it to the user.
126
+ */
127
+ async signedIn() {
128
+ this.track('login', { method: 'oauth' })
129
+ await this.flush()
130
+ await this.identify()
131
+ }
132
+
133
+ /** Sends what's left, waiting at most two seconds. */
134
+ async close() {
135
+ let timer: NodeJS.Timeout | undefined
136
+ const limit = new Promise<void>((resolve) => {
137
+ timer = setTimeout(resolve, CLOSE_LIMIT_MS)
138
+ timer.unref()
139
+ })
140
+ await Promise.race([this.flush(), limit])
141
+ clearTimeout(timer)
142
+ }
143
+
144
+ #schedule() {
145
+ if(this.#timer) {
146
+ return
147
+ }
148
+
149
+ // Unref'd so a pending flush never keeps the process alive.
150
+ this.#timer = setTimeout(() => {
151
+ this.#timer = undefined
152
+ void this.flush()
153
+ }, this.#flushIntervalMs)
154
+ this.#timer.unref()
155
+ }
156
+
157
+ async #send(events: Event[]) {
158
+ for(let i = 0; i < events.length; i += MAX_BATCH) {
159
+ try {
160
+ await this.#client.call('TrackAnalyticsEvents', {
161
+ body: {
162
+ clientId: this.clientId,
163
+ source: 'mcp',
164
+ context: {
165
+ sessionId: this.sessionId,
166
+ ...(this.#agent ? { agent: this.#agent } : {}),
167
+ app: { version: VERSION },
168
+ },
169
+ events: events.slice(i, i + MAX_BATCH),
170
+ },
171
+ })
172
+ } catch(err) {
173
+ LOGGER.debug({ err }, 'telemetry: send failed; dropping batch')
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Reads the anonymous client ID from the config file, or creates it. The ID
181
+ * is random and carries no information about the developer. The first time
182
+ * the server creates it, it tells the developer how to opt out.
183
+ */
184
+ function loadClientId(store: ConfigStore): string {
185
+ try {
186
+ const existing = store.read()?.analytics?.clientId
187
+ if(existing) {
188
+ return existing
189
+ }
190
+
191
+ const clientId = randomUUID()
192
+ store.write({
193
+ analytics: { clientId, createdAt: new Date().toISOString() },
194
+ })
195
+ LOGGER.info(
196
+ 'Reclaim records anonymous usage data to improve the product. '
197
+ + 'To turn it off, set RECLAIM_TELEMETRY=0 or DO_NOT_TRACK=1.',
198
+ )
199
+ return clientId
200
+ } catch(err) {
201
+ // An unwritable config file shouldn't disable the server. Use an ID
202
+ // that lasts for this process only.
203
+ LOGGER.debug({ err }, 'telemetry: could not persist client ID')
204
+ return randomUUID()
205
+ }
206
+ }
@@ -0,0 +1,115 @@
1
+ import { isProblemError } from '@reclaimprotocol/client/api'
2
+ import { currentTelemetry } from './index.ts'
3
+
4
+ const GOAL_MAX = 300
5
+ const USER_REQUEST_MAX = 500
6
+ const DOMAIN_MAX = 200
7
+ const DATA_POINTS_MAX = 20
8
+ const DATA_POINT_MAX = 200
9
+
10
+ /**
11
+ * Times a tool call and records it. Call `end()` once when the call
12
+ * finishes. It never throws, so it can't change the outcome of the call.
13
+ */
14
+ export function startToolCall(tool: string, args: unknown) {
15
+ const startedAt = Date.now()
16
+ return {
17
+ end(err?: unknown) {
18
+ try {
19
+ const telemetry = currentTelemetry()
20
+ if(!telemetry) {
21
+ return
22
+ }
23
+
24
+ const input = allowlistedInput(tool, args)
25
+ telemetry.track('tool_call', {
26
+ tool,
27
+ ok: err === undefined,
28
+ ...(err === undefined ? {} : { errorCode: errorCode(err) }),
29
+ durationMs: Date.now() - startedAt,
30
+ mode: telemetry.mode,
31
+ ...(input ? { input } : {}),
32
+ })
33
+ } catch{
34
+ // Telemetry must never affect a tool call.
35
+ }
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A category for the error, such as `http_401`. It's never the message,
42
+ * because messages can contain URLs, tokens, or file paths.
43
+ */
44
+ export function errorCode(err: unknown): string {
45
+ if(isProblemError(err)) {
46
+ return `http_${err.output.statusCode}`
47
+ }
48
+
49
+ if(isRecord(err) && err.isBoom === true && isRecord(err.output)) {
50
+ return `http_${String(err.output.statusCode)}`
51
+ }
52
+
53
+ if(err instanceof Error) {
54
+ if(err.name === 'ZodError') {
55
+ return 'invalid_input'
56
+ }
57
+
58
+ if(err.name === 'TimeoutError' || err.name === 'AbortError') {
59
+ return 'timeout'
60
+ }
61
+ }
62
+
63
+ return 'error'
64
+ }
65
+
66
+ /**
67
+ * The only arguments Reclaim records, and only for these tools. They are the
68
+ * developer's own description of what they want to build, which tells us
69
+ * which providers to add. Every other argument is dropped here, so a new tool
70
+ * records nothing until you add it to this list on purpose.
71
+ */
72
+ export function allowlistedInput(tool: string, args: unknown) {
73
+ if(!isRecord(args)) {
74
+ return undefined
75
+ }
76
+
77
+ switch (tool) {
78
+ case 'start_integration':
79
+ return compact({
80
+ goal: cut(args.goal, GOAL_MAX),
81
+ userRequest: cut(args.userRequest, USER_REQUEST_MAX),
82
+ })
83
+ case 'propose_provider':
84
+ return compact({ userRequest: cut(args.userRequest, USER_REQUEST_MAX) })
85
+ case 'discover_resolve': {
86
+ const query = isRecord(args.query) ? args.query : {}
87
+ const dataPoints = Array.isArray(query.dataPoints)
88
+ ? query.dataPoints
89
+ .slice(0, DATA_POINTS_MAX)
90
+ .map((p) => cut(p, DATA_POINT_MAX))
91
+ .filter((p) => p !== undefined)
92
+ : undefined
93
+ return compact({
94
+ domain: cut(query.domain, DOMAIN_MAX),
95
+ dataPoints: dataPoints?.length ? dataPoints : undefined,
96
+ })
97
+ }
98
+
99
+ default:
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ function cut(value: unknown, max: number): string | undefined {
105
+ return typeof value === 'string' && value ? value.slice(0, max) : undefined
106
+ }
107
+
108
+ /** Returns the object, or undefined when every field is undefined. */
109
+ function compact<T extends object>(obj: T): T | undefined {
110
+ return Object.values(obj).some((v) => v !== undefined) ? obj : undefined
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
115
+ }