@tacitprotocol/sdk 0.1.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/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@tacitprotocol/sdk",
3
+ "version": "0.1.0",
4
+ "description": "The Tacit Protocol SDK — verify identity, prevent fraud, and broker trusted introductions with cryptographic proof",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "module": "dist/index.mjs",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format cjs,esm --dts",
17
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "lint": "eslint src/",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "keywords": [
24
+ "tacit",
25
+ "protocol",
26
+ "agent",
27
+ "ai-agent",
28
+ "social-networking",
29
+ "decentralized",
30
+ "did",
31
+ "verifiable-credentials",
32
+ "mcp",
33
+ "a2a",
34
+ "trust",
35
+ "introductions"
36
+ ],
37
+ "author": "Tacit Protocol Contributors",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/tacitprotocol/tacit"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ },
46
+ "dependencies": {
47
+ "did-jwt": "^8.0.0",
48
+ "did-resolver": "^4.1.0",
49
+ "key-did-provider-ed25519": "^4.0.0",
50
+ "key-did-resolver": "^4.0.0",
51
+ "@stablelib/xchacha20poly1305": "^1.0.1",
52
+ "@stablelib/ed25519": "^1.0.3",
53
+ "uuid": "^9.0.0",
54
+ "zod": "^3.22.0"
55
+ },
56
+ "devDependencies": {
57
+ "typescript": "^5.4.0",
58
+ "tsup": "^8.0.0",
59
+ "vitest": "^1.3.0",
60
+ "@types/node": "^20.11.0",
61
+ "@types/uuid": "^9.0.0",
62
+ "eslint": "^8.56.0"
63
+ }
64
+ }
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Tacit Protocol — Agent Core
3
+ *
4
+ * The TacitAgent is the primary interface for interacting with the
5
+ * Tacit Protocol. It represents a human's AI agent on the network.
6
+ */
7
+
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import { createIdentity } from '../identity/did.js';
10
+ import { AuthenticityEngine } from '../identity/authenticity.js';
11
+ import type {
12
+ AgentIdentity,
13
+ AgentCard,
14
+ TacitConfig,
15
+ Intent,
16
+ IntentType,
17
+ MatchResult,
18
+ IntroProposal,
19
+ TacitEvent,
20
+ DomainType,
21
+ AuthenticityVector,
22
+ PrivacyLevel,
23
+ } from '../types/index.js';
24
+
25
+ // ─── Event Emitter ────────────────────────────────────────────────
26
+
27
+ type EventHandler = (event: TacitEvent) => void | Promise<void>;
28
+
29
+ class EventBus {
30
+ private handlers = new Map<string, Set<EventHandler>>();
31
+ private globalHandlers = new Set<EventHandler>();
32
+
33
+ on(type: string, handler: EventHandler): void {
34
+ if (!this.handlers.has(type)) {
35
+ this.handlers.set(type, new Set());
36
+ }
37
+ this.handlers.get(type)!.add(handler);
38
+ }
39
+
40
+ onAny(handler: EventHandler): void {
41
+ this.globalHandlers.add(handler);
42
+ }
43
+
44
+ off(type: string, handler: EventHandler): void {
45
+ this.handlers.get(type)?.delete(handler);
46
+ }
47
+
48
+ async emit(event: TacitEvent): Promise<void> {
49
+ const handlers = this.handlers.get(event.type) ?? new Set();
50
+ const all = [...handlers, ...this.globalHandlers];
51
+
52
+ for (const handler of all) {
53
+ try {
54
+ await handler(event);
55
+ } catch (error) {
56
+ console.error(`[Tacit] Event handler error for ${event.type}:`, error);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // ─── Agent Class ──────────────────────────────────────────────────
63
+
64
+ export class TacitAgent {
65
+ private identity: AgentIdentity | null = null;
66
+ private config: TacitConfig;
67
+ private events = new EventBus();
68
+ private authenticityEngine = new AuthenticityEngine();
69
+ private intents = new Map<string, Intent>();
70
+ private proposals = new Map<string, IntroProposal>();
71
+ private connected = false;
72
+
73
+ constructor(config: TacitConfig) {
74
+ this.config = {
75
+ relayUrl: 'wss://relay.tacitprotocol.dev',
76
+ matchThresholds: {
77
+ autoPropose: 80,
78
+ suggest: 60,
79
+ },
80
+ preferences: {
81
+ introductionStyle: 'progressive',
82
+ initialAnonymity: true,
83
+ responseTime: '24h',
84
+ languages: ['en'],
85
+ },
86
+ ...config,
87
+ };
88
+
89
+ if (config.identity) {
90
+ this.identity = config.identity;
91
+ }
92
+ }
93
+
94
+ // ─── Static Factory ───────────────────────────────────────────
95
+
96
+ /**
97
+ * Create a new agent identity.
98
+ * Returns an AgentIdentity that can be passed to the constructor.
99
+ */
100
+ static async createIdentity(): Promise<AgentIdentity> {
101
+ return createIdentity();
102
+ }
103
+
104
+ // ─── Lifecycle ────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Connect the agent to the Tacit network via a relay node.
108
+ */
109
+ async connect(): Promise<void> {
110
+ if (!this.identity) {
111
+ this.identity = await createIdentity();
112
+ }
113
+
114
+ // TODO: Establish WebSocket connection to relay node
115
+ // TODO: Publish Agent Card to the network
116
+ // TODO: Subscribe to intent matches
117
+
118
+ this.connected = true;
119
+
120
+ await this.events.emit({
121
+ type: 'connection:established',
122
+ endpoint: this.config.relayUrl!,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Disconnect the agent from the network.
128
+ * Active intents remain on the network until their TTL expires.
129
+ */
130
+ async disconnect(): Promise<void> {
131
+ // TODO: Close WebSocket connection
132
+ // TODO: Optionally withdraw all active intents
133
+
134
+ this.connected = false;
135
+ }
136
+
137
+ /**
138
+ * Check if the agent is connected to the network.
139
+ */
140
+ isConnected(): boolean {
141
+ return this.connected;
142
+ }
143
+
144
+ // ─── Identity ─────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Get the agent's DID.
148
+ */
149
+ getDid(): string {
150
+ if (!this.identity) throw new Error('Agent has no identity. Call connect() first.');
151
+ return this.identity.did;
152
+ }
153
+
154
+ /**
155
+ * Get the agent's Agent Card.
156
+ */
157
+ getAgentCard(): AgentCard {
158
+ if (!this.identity) throw new Error('Agent has no identity. Call connect() first.');
159
+
160
+ return {
161
+ version: '0.1.0',
162
+ agent: {
163
+ did: this.identity.did,
164
+ name: this.config.profile?.name ?? 'Unnamed Tacit',
165
+ description: this.config.profile?.description ?? '',
166
+ created: this.identity.created.toISOString(),
167
+ protocols: ['tacit/discovery/v0.1', 'tacit/intro/v0.1'],
168
+ transport: {
169
+ type: 'didcomm/v2',
170
+ endpoint: this.config.relayUrl!,
171
+ },
172
+ },
173
+ domains: this.config.profile ? [{
174
+ type: this.config.profile.domain,
175
+ seeking: [this.config.profile.seeking],
176
+ offering: [this.config.profile.offering],
177
+ context: {},
178
+ }] : [],
179
+ authenticity: this.getAuthenticity(),
180
+ preferences: {
181
+ introductionStyle: this.config.preferences?.introductionStyle ?? 'progressive',
182
+ initialAnonymity: this.config.preferences?.initialAnonymity ?? true,
183
+ responseTime: this.config.preferences?.responseTime ?? '24h',
184
+ languages: this.config.preferences?.languages ?? ['en'],
185
+ },
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Get the agent's current authenticity vector.
191
+ */
192
+ getAuthenticity(): AuthenticityVector {
193
+ if (!this.identity) {
194
+ return {
195
+ level: 'new',
196
+ score: 0,
197
+ dimensions: { tenure: 0, consistency: 0, attestations: 0, networkTrust: 0 },
198
+ verifiableCredentials: [],
199
+ };
200
+ }
201
+
202
+ return this.authenticityEngine.computeVector({
203
+ agentCreatedDate: this.identity.created,
204
+ consistencySignals: {
205
+ intentStability: 1.0, // New agent, no changes yet
206
+ profileConsistency: 1.0,
207
+ responseReliability: 0.5, // No track record
208
+ interactionQuality: 0.5,
209
+ },
210
+ credentials: [],
211
+ networkSignals: {
212
+ totalInteractions: 0,
213
+ positiveInteractions: 0,
214
+ totalIntros: 0,
215
+ successfulIntros: 0,
216
+ bidirectionalTrustEdges: 0,
217
+ },
218
+ lastActiveDate: new Date(),
219
+ });
220
+ }
221
+
222
+ // ─── Intents ──────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Publish an intent to the network.
226
+ */
227
+ async publishIntent(params: {
228
+ type: IntentType;
229
+ domain: DomainType;
230
+ seeking: Record<string, unknown>;
231
+ context?: Record<string, unknown>;
232
+ filters?: {
233
+ minAuthenticityScore?: number;
234
+ requiredCredentials?: string[];
235
+ };
236
+ privacyLevel?: PrivacyLevel;
237
+ ttlSeconds?: number;
238
+ }): Promise<Intent> {
239
+ if (!this.identity) throw new Error('Agent has no identity. Call connect() first.');
240
+
241
+ const intent: Intent = {
242
+ id: `intent:${this.identity.did.split(':').pop()}:${Date.now()}`,
243
+ agentDid: this.identity.did,
244
+ type: params.type,
245
+ domain: params.domain,
246
+ intent: {
247
+ seeking: params.seeking,
248
+ context: params.context ?? {},
249
+ },
250
+ filters: {
251
+ minAuthenticityScore: params.filters?.minAuthenticityScore ?? 50,
252
+ requiredCredentials: params.filters?.requiredCredentials ?? [],
253
+ excludedDomains: [],
254
+ },
255
+ privacyLevel: params.privacyLevel ?? 'filtered',
256
+ ttl: params.ttlSeconds ?? 604800, // 7 days default
257
+ created: new Date().toISOString(),
258
+ signature: '', // TODO: Sign with agent's private key
259
+ };
260
+
261
+ this.intents.set(intent.id, intent);
262
+
263
+ // TODO: Broadcast to relay node
264
+
265
+ await this.events.emit({ type: 'intent:published', intent });
266
+
267
+ return intent;
268
+ }
269
+
270
+ /**
271
+ * Withdraw an active intent.
272
+ */
273
+ async withdrawIntent(intentId: string): Promise<void> {
274
+ const intent = this.intents.get(intentId);
275
+ if (!intent) throw new Error(`Intent ${intentId} not found`);
276
+
277
+ this.intents.delete(intentId);
278
+
279
+ // TODO: Notify relay node of withdrawal
280
+ }
281
+
282
+ /**
283
+ * Get all active intents.
284
+ */
285
+ getActiveIntents(): Intent[] {
286
+ return Array.from(this.intents.values());
287
+ }
288
+
289
+ // ─── Proposals ────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Handle an incoming match from the network.
293
+ * Called when another agent's intent is compatible with ours.
294
+ */
295
+ async handleMatch(match: MatchResult): Promise<void> {
296
+ const thresholds = this.config.matchThresholds!;
297
+
298
+ if (match.score.overall >= thresholds.autoPropose) {
299
+ // Auto-propose introduction
300
+ await this.proposeIntro(match);
301
+ } else if (match.score.overall >= thresholds.suggest) {
302
+ // Suggest to human for review
303
+ await this.events.emit({ type: 'intent:matched', match });
304
+ }
305
+ // Below suggest threshold: ignore
306
+ }
307
+
308
+ /**
309
+ * Create and send an introduction proposal.
310
+ */
311
+ async proposeIntro(match: MatchResult): Promise<IntroProposal> {
312
+ if (!this.identity) throw new Error('Agent has no identity.');
313
+
314
+ const proposal: IntroProposal = {
315
+ id: `proposal:${uuidv4()}`,
316
+ type: 'introduction',
317
+ initiator: {
318
+ agentDid: this.identity.did,
319
+ persona: {
320
+ displayName: this.config.profile?.name ?? 'Anonymous',
321
+ context: this.config.profile?.seeking ?? '',
322
+ anonymityLevel: this.config.preferences?.initialAnonymity ? 'pseudonymous' : 'identified',
323
+ sessionId: uuidv4(),
324
+ },
325
+ },
326
+ responder: {
327
+ agentDid: match.agents.responder,
328
+ },
329
+ match: {
330
+ score: match.score.overall,
331
+ rationale: this.generateRationale(match),
332
+ domain: 'professional', // TODO: derive from intent
333
+ },
334
+ terms: {
335
+ initialReveal: 'pseudonymous',
336
+ revealStages: ['domain_context', 'professional_background', 'identity'],
337
+ communicationChannel: 'tacit_direct',
338
+ expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
339
+ },
340
+ status: 'pending',
341
+ created: new Date().toISOString(),
342
+ signature: '', // TODO: Sign
343
+ };
344
+
345
+ this.proposals.set(proposal.id, proposal);
346
+
347
+ // TODO: Send proposal to responder via relay
348
+
349
+ return proposal;
350
+ }
351
+
352
+ /**
353
+ * Accept an introduction proposal.
354
+ * This is the human's explicit consent (one half of double opt-in).
355
+ */
356
+ async acceptProposal(proposalId: string): Promise<void> {
357
+ const proposal = this.proposals.get(proposalId);
358
+ if (!proposal) throw new Error(`Proposal ${proposalId} not found`);
359
+
360
+ if (!this.identity) throw new Error('Agent has no identity.');
361
+
362
+ // Determine which side we are
363
+ const isInitiator = proposal.initiator.agentDid === this.identity.did;
364
+
365
+ if (isInitiator) {
366
+ proposal.status = 'accepted_by_initiator';
367
+ } else {
368
+ proposal.status = 'accepted_by_responder';
369
+ }
370
+
371
+ // TODO: Send acceptance to the other party via relay
372
+ await this.events.emit({ type: 'proposal:accepted', proposal });
373
+ }
374
+
375
+ /**
376
+ * Decline an introduction proposal.
377
+ * The other party receives a generic "not a match" response.
378
+ */
379
+ async declineProposal(proposalId: string): Promise<void> {
380
+ const proposal = this.proposals.get(proposalId);
381
+ if (!proposal) throw new Error(`Proposal ${proposalId} not found`);
382
+
383
+ proposal.status = 'declined';
384
+ this.proposals.delete(proposalId);
385
+
386
+ // TODO: Send generic decline to the other party via relay
387
+ await this.events.emit({ type: 'proposal:declined', proposalId });
388
+ }
389
+
390
+ /**
391
+ * Get all pending proposals.
392
+ */
393
+ getPendingProposals(): IntroProposal[] {
394
+ return Array.from(this.proposals.values())
395
+ .filter(p => p.status === 'pending');
396
+ }
397
+
398
+ // ─── Events ───────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Subscribe to specific event types.
402
+ */
403
+ on(type: string, handler: EventHandler): void {
404
+ this.events.on(type, handler);
405
+ }
406
+
407
+ /**
408
+ * Subscribe to all events.
409
+ */
410
+ onAny(handler: EventHandler): void {
411
+ this.events.onAny(handler);
412
+ }
413
+
414
+ /**
415
+ * Unsubscribe from an event type.
416
+ */
417
+ off(type: string, handler: EventHandler): void {
418
+ this.events.off(type, handler);
419
+ }
420
+
421
+ // ─── Helpers ──────────────────────────────────────────────────
422
+
423
+ private generateRationale(match: MatchResult): string {
424
+ const parts: string[] = [];
425
+ const b = match.score.breakdown;
426
+
427
+ if (b.intentAlignment > 0.8) parts.push('Strong intent alignment');
428
+ if (b.domainFit > 0.8) parts.push('Excellent domain fit');
429
+ if (b.authenticityCompatibility > 0.7) parts.push('High authenticity compatibility');
430
+ if (b.preferenceMatch > 0.7) parts.push('Good preference match');
431
+
432
+ return parts.length > 0
433
+ ? parts.join('. ') + '.'
434
+ : `Match score: ${match.score.overall}/100`;
435
+ }
436
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Tacit Protocol — Intent Broadcasting & Discovery
3
+ *
4
+ * Handles the creation, publication, and discovery of intents
5
+ * on the Tacit network.
6
+ */
7
+
8
+ import type {
9
+ Intent,
10
+ IntentType,
11
+ IntentStatus,
12
+ DomainType,
13
+ PrivacyLevel,
14
+ DID,
15
+ } from '../types/index.js';
16
+
17
+ // ─── Intent Builder ───────────────────────────────────────────────
18
+
19
+ export class IntentBuilder {
20
+ private partial: Partial<Intent> = {};
21
+
22
+ constructor(agentDid: DID) {
23
+ this.partial.agentDid = agentDid;
24
+ this.partial.id = `intent:${agentDid.split(':').pop()}:${Date.now()}`;
25
+ this.partial.created = new Date().toISOString();
26
+ this.partial.privacyLevel = 'filtered';
27
+ this.partial.ttl = 604800; // 7 days
28
+ this.partial.filters = {
29
+ minAuthenticityScore: 50,
30
+ requiredCredentials: [],
31
+ excludedDomains: [],
32
+ };
33
+ }
34
+
35
+ type(type: IntentType): this {
36
+ this.partial.type = type;
37
+ return this;
38
+ }
39
+
40
+ domain(domain: DomainType): this {
41
+ this.partial.domain = domain;
42
+ return this;
43
+ }
44
+
45
+ seeking(seeking: Record<string, unknown>): this {
46
+ if (!this.partial.intent) {
47
+ this.partial.intent = { seeking: {}, context: {} };
48
+ }
49
+ this.partial.intent.seeking = seeking;
50
+ return this;
51
+ }
52
+
53
+ context(context: Record<string, unknown>): this {
54
+ if (!this.partial.intent) {
55
+ this.partial.intent = { seeking: {}, context: {} };
56
+ }
57
+ this.partial.intent.context = context;
58
+ return this;
59
+ }
60
+
61
+ privacy(level: PrivacyLevel): this {
62
+ this.partial.privacyLevel = level;
63
+ return this;
64
+ }
65
+
66
+ ttl(seconds: number): this {
67
+ this.partial.ttl = seconds;
68
+ return this;
69
+ }
70
+
71
+ minAuthenticity(score: number): this {
72
+ this.partial.filters!.minAuthenticityScore = score;
73
+ return this;
74
+ }
75
+
76
+ requireCredentials(...types: string[]): this {
77
+ this.partial.filters!.requiredCredentials = types;
78
+ return this;
79
+ }
80
+
81
+ build(): Intent {
82
+ if (!this.partial.type) throw new Error('Intent type is required');
83
+ if (!this.partial.domain) throw new Error('Intent domain is required');
84
+ if (!this.partial.intent?.seeking) throw new Error('Intent seeking is required');
85
+
86
+ return {
87
+ id: this.partial.id!,
88
+ agentDid: this.partial.agentDid!,
89
+ type: this.partial.type,
90
+ domain: this.partial.domain,
91
+ intent: this.partial.intent!,
92
+ filters: this.partial.filters!,
93
+ privacyLevel: this.partial.privacyLevel!,
94
+ ttl: this.partial.ttl!,
95
+ created: this.partial.created!,
96
+ signature: '', // Signed by agent before publishing
97
+ };
98
+ }
99
+ }
100
+
101
+ // ─── Intent Store ─────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Local intent storage with lifecycle management.
105
+ * In production, this connects to the relay network.
106
+ */
107
+ export class IntentStore {
108
+ private intents = new Map<string, { intent: Intent; status: IntentStatus }>();
109
+
110
+ add(intent: Intent): void {
111
+ this.intents.set(intent.id, { intent, status: 'active' });
112
+ }
113
+
114
+ get(id: string): Intent | undefined {
115
+ return this.intents.get(id)?.intent;
116
+ }
117
+
118
+ getStatus(id: string): IntentStatus | undefined {
119
+ return this.intents.get(id)?.status;
120
+ }
121
+
122
+ setStatus(id: string, status: IntentStatus): void {
123
+ const entry = this.intents.get(id);
124
+ if (entry) entry.status = status;
125
+ }
126
+
127
+ withdraw(id: string): boolean {
128
+ const entry = this.intents.get(id);
129
+ if (!entry) return false;
130
+ entry.status = 'withdrawn';
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Get all active intents (not expired, not withdrawn, not fulfilled).
136
+ */
137
+ getActive(): Intent[] {
138
+ const now = Date.now();
139
+ const active: Intent[] = [];
140
+
141
+ for (const [, entry] of this.intents) {
142
+ if (entry.status !== 'active') continue;
143
+
144
+ // Check TTL
145
+ const created = new Date(entry.intent.created).getTime();
146
+ if (now - created > entry.intent.ttl * 1000) {
147
+ entry.status = 'expired';
148
+ continue;
149
+ }
150
+
151
+ active.push(entry.intent);
152
+ }
153
+
154
+ return active;
155
+ }
156
+
157
+ /**
158
+ * Find intents that match a given query.
159
+ * This is the local equivalent of a relay query.
160
+ */
161
+ query(params: {
162
+ type?: IntentType;
163
+ domain?: DomainType;
164
+ minAuthenticity?: number;
165
+ keywords?: string[];
166
+ }): Intent[] {
167
+ return this.getActive().filter(intent => {
168
+ if (params.type && intent.type !== params.type) return false;
169
+ if (params.domain && intent.domain !== params.domain) return false;
170
+
171
+ if (params.keywords && params.keywords.length > 0) {
172
+ const intentText = JSON.stringify(intent.intent).toLowerCase();
173
+ const hasKeyword = params.keywords.some(kw =>
174
+ intentText.includes(kw.toLowerCase())
175
+ );
176
+ if (!hasKeyword) return false;
177
+ }
178
+
179
+ return true;
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Remove expired intents from the store.
185
+ */
186
+ cleanup(): number {
187
+ const now = Date.now();
188
+ let removed = 0;
189
+
190
+ for (const [id, entry] of this.intents) {
191
+ if (entry.status === 'expired' || entry.status === 'withdrawn' || entry.status === 'fulfilled') {
192
+ this.intents.delete(id);
193
+ removed++;
194
+ }
195
+
196
+ // Check TTL for active intents
197
+ if (entry.status === 'active') {
198
+ const created = new Date(entry.intent.created).getTime();
199
+ if (now - created > entry.intent.ttl * 1000) {
200
+ entry.status = 'expired';
201
+ this.intents.delete(id);
202
+ removed++;
203
+ }
204
+ }
205
+ }
206
+
207
+ return removed;
208
+ }
209
+ }