@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.
- package/README.md +32 -0
- package/lib/authoring/tools.js +4 -1
- package/lib/config/store.d.ts +7 -0
- package/lib/consts.d.ts +5 -0
- package/lib/consts.js +10 -0
- package/lib/mcp/api/operations.gen.js +6 -2
- package/lib/mcp/api/tools.js +4 -0
- package/lib/mcp/server.js +6 -1
- package/lib/mcp/start.js +27 -1
- package/lib/mcp/tools/agent/proof.js +4 -2
- package/lib/mcp/tools/agent/propose.js +7 -0
- package/lib/mcp/tools/agent/publish.js +4 -1
- package/lib/mcp/tools/agent/replay.js +3 -1
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
- package/lib/mcp/tools/agent/start-integration.js +7 -0
- package/lib/mcp/tools/authenticate/authenticate.js +3 -0
- package/lib/telemetry/index.d.ts +9 -0
- package/lib/telemetry/index.js +13 -0
- package/lib/telemetry/telemetry.d.ts +54 -0
- package/lib/telemetry/telemetry.js +175 -0
- package/lib/telemetry/tool-call.d.ts +24 -0
- package/lib/telemetry/tool-call.js +102 -0
- package/package.json +2 -2
- package/skill/SKILL.builder.md +5 -0
- package/skill/SKILL.common.md +34 -3
- package/skill/SKILL.old.md +5 -0
- package/skill/builder-mode.md +1 -0
- package/skill/old-mode.md +1 -0
- package/src/authoring/tools.ts +4 -1
- package/src/config/store.ts +4 -0
- package/src/consts.ts +11 -0
- package/src/mcp/api/operations.gen.ts +6 -2
- package/src/mcp/api/tools.ts +4 -0
- package/src/mcp/server.ts +6 -1
- package/src/mcp/start.ts +34 -0
- package/src/mcp/tools/agent/proof.ts +4 -2
- package/src/mcp/tools/agent/propose.ts +8 -0
- package/src/mcp/tools/agent/publish.ts +4 -1
- package/src/mcp/tools/agent/replay.ts +3 -1
- package/src/mcp/tools/agent/start-integration.ts +10 -0
- package/src/mcp/tools/authenticate/authenticate.ts +3 -0
- package/src/telemetry/index.ts +18 -0
- package/src/telemetry/telemetry.ts +206 -0
- 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
|
+
}
|