@swanlabs/veil-core 1.0.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/README.md +155 -0
- package/VEIL_CORE_SPEC_v1.md +364 -0
- package/package.json +36 -0
- package/server.ts +302 -0
- package/veil-runtime.ts +414 -0
- package/veil-sdk.ts +311 -0
package/veil-sdk.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VEIL SDK v1.0.0
|
|
3
|
+
* Swan Labs
|
|
4
|
+
*
|
|
5
|
+
* Drop this into any website. Connect to VEIL CORE.
|
|
6
|
+
* The entity knows when you arrive. It remembers when you leave.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { VEILClient } from "@swanlabs/veil-sdk";
|
|
10
|
+
*
|
|
11
|
+
* const veil = new VEILClient({
|
|
12
|
+
* serverUrl: "https://your-veil-server.com",
|
|
13
|
+
* entityId: "your-entity-uuid",
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* const { morphology, relationship } = await veil.enter();
|
|
17
|
+
* veil.on("tick", (state) => updateRendering(state));
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface VEILConfig {
|
|
21
|
+
serverUrl: string;
|
|
22
|
+
entityId: string;
|
|
23
|
+
autoEnter?: boolean; // Auto-call enter() on construction
|
|
24
|
+
autoRecordDwell?: boolean; // Auto-record dwell on page unload
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VEILMorphology {
|
|
28
|
+
visual_density: number;
|
|
29
|
+
color_temperature: number;
|
|
30
|
+
motion_speed: number;
|
|
31
|
+
presence_radius: number;
|
|
32
|
+
revelation: number;
|
|
33
|
+
phase_label: string;
|
|
34
|
+
can_initiate: boolean;
|
|
35
|
+
can_fuse: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VEILRelationshipSummary {
|
|
39
|
+
vid: string;
|
|
40
|
+
visit_count: number;
|
|
41
|
+
phase: number;
|
|
42
|
+
phase_label: string;
|
|
43
|
+
total_dwell_seconds: number;
|
|
44
|
+
axes: Record<string, number>;
|
|
45
|
+
first_contact: number;
|
|
46
|
+
last_contact: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VEILEntitySummary {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
clock_age: number;
|
|
53
|
+
clock_age_display: string;
|
|
54
|
+
axes: Record<string, number>;
|
|
55
|
+
phase: number;
|
|
56
|
+
visitor_count: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EnterResult {
|
|
60
|
+
entity: VEILEntitySummary;
|
|
61
|
+
relationship: VEILRelationshipSummary;
|
|
62
|
+
morphology: VEILMorphology;
|
|
63
|
+
is_returning: boolean;
|
|
64
|
+
gap_minutes: number;
|
|
65
|
+
phase_transitioned: boolean;
|
|
66
|
+
new_phase?: number;
|
|
67
|
+
vid: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type VEILEventType = "tick" | "phase_transition" | "visitor_join" | "connected" | "disconnected" | "error";
|
|
71
|
+
type VEILEventHandler = (data: unknown) => void;
|
|
72
|
+
|
|
73
|
+
const VID_STORAGE_KEY = "veil_vid";
|
|
74
|
+
const DWELL_START_KEY = "veil_dwell_start";
|
|
75
|
+
|
|
76
|
+
export class VEILClient {
|
|
77
|
+
private config: VEILConfig;
|
|
78
|
+
private vid: string | null = null;
|
|
79
|
+
private ws: WebSocket | null = null;
|
|
80
|
+
private listeners = new Map<VEILEventType, Set<VEILEventHandler>>();
|
|
81
|
+
private currentMorphology: VEILMorphology | null = null;
|
|
82
|
+
private enterResult: EnterResult | null = null;
|
|
83
|
+
private dwellStart = Date.now();
|
|
84
|
+
private reconnectAttempts = 0;
|
|
85
|
+
private maxReconnects = 5;
|
|
86
|
+
|
|
87
|
+
constructor(config: VEILConfig) {
|
|
88
|
+
this.config = { autoEnter: false, autoRecordDwell: true, ...config };
|
|
89
|
+
this.vid = this.loadVID();
|
|
90
|
+
|
|
91
|
+
if (this.config.autoRecordDwell) {
|
|
92
|
+
this.setupDwellTracking();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.config.autoEnter) {
|
|
96
|
+
this.enter().catch(console.error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Enter the entity. Records visit, returns relationship state and morphology.
|
|
104
|
+
* This is the moment of contact.
|
|
105
|
+
*/
|
|
106
|
+
async enter(): Promise<EnterResult> {
|
|
107
|
+
const fingerprints = this.collectFingerprint();
|
|
108
|
+
const response = await this.post(`/v1/entity/${this.config.entityId}/visit`, {
|
|
109
|
+
vid: this.vid,
|
|
110
|
+
fingerprint_inputs: fingerprints,
|
|
111
|
+
metadata: {
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
timezone_offset: new Date().getTimezoneOffset(),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Store VID for future visits
|
|
118
|
+
this.vid = response.vid;
|
|
119
|
+
this.saveVID(response.vid);
|
|
120
|
+
|
|
121
|
+
this.currentMorphology = response.morphology;
|
|
122
|
+
this.enterResult = response;
|
|
123
|
+
this.dwellStart = Date.now();
|
|
124
|
+
|
|
125
|
+
// Connect to live stream
|
|
126
|
+
this.connectStream();
|
|
127
|
+
|
|
128
|
+
return response as EnterResult;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get current morphology — the entity's visual/behavioral state for this visitor.
|
|
133
|
+
*/
|
|
134
|
+
getMorphology(): VEILMorphology | null {
|
|
135
|
+
return this.currentMorphology;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get the entity's current state.
|
|
140
|
+
*/
|
|
141
|
+
async getEntityState(): Promise<VEILEntitySummary> {
|
|
142
|
+
const response = await this.get(`/v1/entity/${this.config.entityId}`);
|
|
143
|
+
return response.entity;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Record dwell time. Call this when the visitor leaves or on intervals.
|
|
148
|
+
*/
|
|
149
|
+
async recordDwell(seconds?: number): Promise<void> {
|
|
150
|
+
if (!this.vid) return;
|
|
151
|
+
const dwellSeconds = seconds ?? (Date.now() - this.dwellStart) / 1000;
|
|
152
|
+
if (dwellSeconds < 5) return; // Don't record trivial dwell
|
|
153
|
+
|
|
154
|
+
await this.post(`/v1/entity/${this.config.entityId}/dwell`, {
|
|
155
|
+
vid: this.vid,
|
|
156
|
+
dwell_seconds: Math.round(dwellSeconds),
|
|
157
|
+
}).catch(() => {}); // Fail silently on unload
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Subscribe to entity events.
|
|
162
|
+
*/
|
|
163
|
+
on(event: VEILEventType, handler: VEILEventHandler): () => void {
|
|
164
|
+
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
|
165
|
+
this.listeners.get(event)!.add(handler);
|
|
166
|
+
return () => this.listeners.get(event)?.delete(handler);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Disconnect from the entity stream.
|
|
171
|
+
*/
|
|
172
|
+
disconnect(): void {
|
|
173
|
+
this.ws?.close();
|
|
174
|
+
this.ws = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Private ────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
private async post(path: string, body: object): Promise<Record<string, unknown>> {
|
|
180
|
+
const res = await fetch(`${this.config.serverUrl}${path}`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify(body),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) throw new Error(`VEIL: ${res.status} ${await res.text()}`);
|
|
186
|
+
return res.json();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async get(path: string): Promise<Record<string, unknown>> {
|
|
190
|
+
const res = await fetch(`${this.config.serverUrl}${path}`);
|
|
191
|
+
if (!res.ok) throw new Error(`VEIL: ${res.status}`);
|
|
192
|
+
return res.json();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private connectStream(): void {
|
|
196
|
+
if (this.ws) return;
|
|
197
|
+
const wsUrl = this.config.serverUrl
|
|
198
|
+
.replace("https://", "wss://")
|
|
199
|
+
.replace("http://", "ws://");
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
this.ws = new WebSocket(`${wsUrl}/v1/stream?entity_id=${this.config.entityId}`);
|
|
203
|
+
|
|
204
|
+
this.ws.onopen = () => {
|
|
205
|
+
this.reconnectAttempts = 0;
|
|
206
|
+
this.emit("connected", { entity_id: this.config.entityId });
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
this.ws.onmessage = (event) => {
|
|
210
|
+
try {
|
|
211
|
+
const msg = JSON.parse(event.data as string);
|
|
212
|
+
if (msg.type === "tick" && msg.payload) {
|
|
213
|
+
this.emit("tick", msg.payload);
|
|
214
|
+
} else if (msg.type === "phase_transition") {
|
|
215
|
+
this.emit("phase_transition", msg.payload);
|
|
216
|
+
} else if (msg.type === "visitor_join") {
|
|
217
|
+
this.emit("visitor_join", msg.payload);
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
this.ws.onclose = () => {
|
|
223
|
+
this.ws = null;
|
|
224
|
+
this.emit("disconnected", {});
|
|
225
|
+
this.attemptReconnect();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
this.ws.onerror = (err) => {
|
|
229
|
+
this.emit("error", err);
|
|
230
|
+
};
|
|
231
|
+
} catch {
|
|
232
|
+
// WebSocket not available (SSR, etc.) — degrade gracefully
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private attemptReconnect(): void {
|
|
237
|
+
if (this.reconnectAttempts >= this.maxReconnects) return;
|
|
238
|
+
this.reconnectAttempts++;
|
|
239
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
|
240
|
+
setTimeout(() => this.connectStream(), delay);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private emit(event: VEILEventType, data: unknown): void {
|
|
244
|
+
this.listeners.get(event)?.forEach(handler => {
|
|
245
|
+
try { handler(data); } catch {}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Collects behavioral fingerprint inputs.
|
|
251
|
+
* Hashed server-side — never raw values stored.
|
|
252
|
+
*/
|
|
253
|
+
private collectFingerprint(): string[] {
|
|
254
|
+
if (typeof window === "undefined") return ["server"];
|
|
255
|
+
return [
|
|
256
|
+
navigator.userAgent.slice(0, 20), // Truncated
|
|
257
|
+
String(new Date().getTimezoneOffset()),
|
|
258
|
+
`${screen.width}x${screen.height}`,
|
|
259
|
+
navigator.language,
|
|
260
|
+
String(navigator.hardwareConcurrency ?? 0),
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private loadVID(): string | null {
|
|
265
|
+
try { return localStorage.getItem(VID_STORAGE_KEY); } catch { return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private saveVID(vid: string): void {
|
|
269
|
+
try { localStorage.setItem(VID_STORAGE_KEY, vid); } catch {}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private setupDwellTracking(): void {
|
|
273
|
+
if (typeof window === "undefined") return;
|
|
274
|
+
window.addEventListener("beforeunload", () => {
|
|
275
|
+
this.recordDwell();
|
|
276
|
+
});
|
|
277
|
+
// Also record on visibility change
|
|
278
|
+
document.addEventListener("visibilitychange", () => {
|
|
279
|
+
if (document.visibilityState === "hidden") {
|
|
280
|
+
this.recordDwell();
|
|
281
|
+
this.dwellStart = Date.now(); // Reset for return
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── React Hook (optional) ────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* useVEIL — React hook for connecting to a VEIL entity.
|
|
291
|
+
*
|
|
292
|
+
* Usage:
|
|
293
|
+
* const { morphology, entity, phase, isReturning } = useVEIL({
|
|
294
|
+
* serverUrl: "https://your-veil-server.com",
|
|
295
|
+
* entityId: "your-entity-uuid",
|
|
296
|
+
* });
|
|
297
|
+
*/
|
|
298
|
+
export function useVEIL(config: VEILConfig) {
|
|
299
|
+
// This is a sketch — real implementation uses useState/useEffect
|
|
300
|
+
// Provided here as the interface contract
|
|
301
|
+
return {
|
|
302
|
+
morphology: null as VEILMorphology | null,
|
|
303
|
+
entity: null as VEILEntitySummary | null,
|
|
304
|
+
phase: 0,
|
|
305
|
+
phaseName: "DORMANT",
|
|
306
|
+
isReturning: false,
|
|
307
|
+
isConnected: false,
|
|
308
|
+
vid: null as string | null,
|
|
309
|
+
client: null as VEILClient | null,
|
|
310
|
+
};
|
|
311
|
+
}
|