@lensmcp/core 1.18.4 → 1.18.7

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/index.js CHANGED
@@ -1,11 +1 @@
1
- export * from './lib/common.js';
2
- export * from './lib/ulid.js';
3
- export * from './lib/event-bus.js';
4
- export * from './lib/resource-store.js';
5
- export * from './lib/graph-store.js';
6
- export * from './lib/fingerprint.js';
7
- export * from './lib/reducer-registry.js';
8
- export * from './lib/story.js';
9
- export * from './lib/patterns.js';
10
- export * from './lib/thresholds.js';
11
- export * from './lib/graph-from-events.js';
1
+ "use strict";export*from"./lib/common.js";export*from"./lib/ulid.js";export*from"./lib/event-bus.js";export*from"./lib/resource-store.js";export*from"./lib/graph-store.js";export*from"./lib/fingerprint.js";export*from"./lib/reducer-registry.js";export*from"./lib/story.js";export*from"./lib/patterns.js";export*from"./lib/thresholds.js";export*from"./lib/graph-from-events.js";
package/lib/common.js CHANGED
@@ -1 +1 @@
1
- export {};
1
+ "use strict";export{};
package/lib/event-bus.js CHANGED
@@ -1,65 +1 @@
1
- /**
2
- * In-process, single-threaded, bounded event bus. Subscribers are notified
3
- * synchronously (errors from one handler do not stop others). Backpressure
4
- * is enforced by a fixed ring buffer of buffered events used only when
5
- * deferred delivery is configured — by default we deliver immediately.
6
- *
7
- * The bus is intentionally tiny. Reducers run on top of it.
8
- */
9
- export class EventBus {
10
- constructor(opts = {}) {
11
- this.subs = new Set();
12
- this.dropped = 0;
13
- this.capacity = opts.capacity ?? 10_000;
14
- this.onDrop = opts.onDrop;
15
- this.transform = opts.transform;
16
- }
17
- publish(event) {
18
- if (this.subs.size > this.capacity) {
19
- this.dropped++;
20
- this.onDrop?.(1);
21
- return;
22
- }
23
- let delivered = event;
24
- if (this.transform) {
25
- try {
26
- delivered = this.transform(event) ?? event;
27
- }
28
- catch {
29
- // A misbehaving transform must never swallow an event: fall back to
30
- // the original rather than dropping observability on the floor.
31
- delivered = event;
32
- }
33
- }
34
- for (const sub of this.subs) {
35
- if (sub.filter && !sub.filter(delivered))
36
- continue;
37
- try {
38
- const ret = sub.handler(delivered);
39
- if (ret && typeof ret.then === 'function') {
40
- ret.catch(() => {
41
- /* swallow async handler errors; reducers must not affect bus */
42
- });
43
- }
44
- }
45
- catch {
46
- /* swallow sync handler errors */
47
- }
48
- }
49
- }
50
- subscribe(handler, filter) {
51
- const sub = { handler, filter };
52
- this.subs.add(sub);
53
- return () => {
54
- this.subs.delete(sub);
55
- };
56
- }
57
- /** Number of dropped events since construction. */
58
- droppedCount() {
59
- return this.dropped;
60
- }
61
- /** Number of currently registered subscribers. */
62
- subscriberCount() {
63
- return this.subs.size;
64
- }
65
- }
1
+ "use strict";var o=Object.defineProperty;var n=(e,t)=>o(e,"name",{value:t,configurable:!0});var c=Object.defineProperty,h=n((e,t)=>c(e,"name",{value:t,configurable:!0}),"e");export class EventBus{static{n(this,"EventBus")}static{h(this,"EventBus")}constructor(t={}){this.subs=new Set,this.dropped=0,this.capacity=t.capacity??1e4,this.onDrop=t.onDrop,this.transform=t.transform}publish(t){if(this.subs.size>this.capacity){this.dropped++,this.onDrop?.(1);return}let s=t;if(this.transform)try{s=this.transform(t)??t}catch{s=t}for(const r of this.subs)if(!(r.filter&&!r.filter(s)))try{const i=r.handler(s);i&&typeof i.then=="function"&&i.catch(()=>{})}catch{}}subscribe(t,s){const r={handler:t,filter:s};return this.subs.add(r),()=>{this.subs.delete(r)}}droppedCount(){return this.dropped}subscriberCount(){return this.subs.size}}
@@ -1,24 +1 @@
1
- /**
2
- * Stable, deterministic fingerprints for events. Same root cause →
3
- * same fingerprint, even if line numbers or stack traces drift between
4
- * runs. See `02-data-model.md#fingerprinting`.
5
- *
6
- * We deliberately do not use a strong hash — fingerprints are routing
7
- * keys, not security tokens. A lightweight FNV-1a 32-bit hash is enough
8
- * to dedupe noisy event streams.
9
- */
10
- const FNV_OFFSET_BASIS = 0x811c9dc5;
11
- const FNV_PRIME = 0x01000193;
12
- function fnv1a32(input) {
13
- let h = FNV_OFFSET_BASIS;
14
- for (let i = 0; i < input.length; i++) {
15
- h ^= input.charCodeAt(i);
16
- h = Math.imul(h, FNV_PRIME);
17
- }
18
- // Force to unsigned 32-bit and emit zero-padded hex.
19
- return (h >>> 0).toString(16).padStart(8, '0');
20
- }
21
- export function fingerprint(input) {
22
- const parts = [input.kind, input.identity, input.file ?? '', input.detail ?? ''];
23
- return `${input.kind}:${fnv1a32(parts.join('|'))}`;
24
- }
1
+ "use strict";var a=Object.defineProperty;var r=(n,e)=>a(n,"name",{value:e,configurable:!0});var f=Object.defineProperty,i=r((n,e)=>f(n,"name",{value:e,configurable:!0}),"e");const l=2166136261,u=16777619;function o(n){let e=2166136261;for(let t=0;t<n.length;t++)e^=n.charCodeAt(t),e=Math.imul(e,16777619);return(e>>>0).toString(16).padStart(8,"0")}r(o,"i"),i(o,"fnv1a32");export function fingerprint(n){const e=[n.kind,n.identity,n.file??"",n.detail??""];return`${n.kind}:${o(e.join("|"))}`}r(fingerprint,"fingerprint"),i(fingerprint,"fingerprint");
@@ -1,141 +1 @@
1
- /**
2
- * Convert one event into a graph node, or return undefined if the event
3
- * doesn't promote to a node.
4
- */
5
- function eventToNode(event) {
6
- const raw = (event.raw ?? null);
7
- const kind = raw?.kind;
8
- const baseContext = event.context;
9
- const common = {
10
- id: event.id,
11
- parentTraceNodeId: baseContext.causedByNodeId ?? baseContext.originNodeId,
12
- lifecycle: 'completed',
13
- context: baseContext,
14
- startTime: event.timestamp,
15
- attributes: { ...(raw ?? {}) },
16
- };
17
- if (event.source === 'react' && kind === 'render') {
18
- const r = raw;
19
- return {
20
- parentAxis: 'render',
21
- node: {
22
- ...common,
23
- type: 'render',
24
- logicalId: r.render?.componentLogicalId,
25
- instanceId: r.render?.componentInstanceId,
26
- name: `render ${r.render?.componentName ?? '?'}`,
27
- durationMs: r.render?.actualDurationMs,
28
- },
29
- };
30
- }
31
- if (event.source === 'valtio' && kind === 'state-update') {
32
- const r = raw;
33
- return {
34
- parentAxis: 'state',
35
- node: {
36
- ...common,
37
- type: 'state-update',
38
- logicalId: r.update?.storeId,
39
- name: `${r.update?.storeId ?? '?'}.${r.update?.path ?? '?'}`,
40
- },
41
- };
42
- }
43
- if (event.source === 'nestjs' && kind === 'server-request') {
44
- const r = raw;
45
- return {
46
- parentAxis: 'backend',
47
- node: {
48
- ...common,
49
- type: 'server-request',
50
- name: `${r.request?.method ?? '?'} ${r.request?.route ?? '?'}`,
51
- durationMs: r.request?.durationMs,
52
- },
53
- };
54
- }
55
- if (kind === 'loop') {
56
- const r = raw;
57
- return {
58
- parentAxis: 'performance',
59
- node: {
60
- ...common,
61
- type: 'loop',
62
- name: 'loop',
63
- durationMs: r.loop?.durationMs,
64
- },
65
- };
66
- }
67
- if (event.source === 'client-runtime' && kind === 'user-action') {
68
- const r = raw;
69
- return {
70
- parentAxis: 'caused',
71
- node: {
72
- ...common,
73
- type: 'ui-event',
74
- logicalId: r.origin?.nodeId,
75
- name: r.origin?.label ?? event.title,
76
- },
77
- };
78
- }
79
- return undefined;
80
- }
81
- /**
82
- * Populate `store` from `events`. Edges are created two ways:
83
- * 1. explicit `causedByNodeId` → an edge on the node's natural axis.
84
- * 2. same-flow chronological chaining on the 'caused' axis so a flow's
85
- * nodes form a walkable spine even when explicit causality is absent.
86
- */
87
- export function buildGraphFromEvents(store, events) {
88
- const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
89
- const mapped = [];
90
- for (const e of sorted) {
91
- const m = eventToNode(e);
92
- if (!m)
93
- continue;
94
- store.addNode(m.node);
95
- mapped.push(m);
96
- }
97
- let edgeCount = 0;
98
- const addEdge = (from, to, axis, type) => {
99
- const edge = {
100
- id: `${axis}:${from}->${to}`,
101
- from,
102
- to,
103
- axis,
104
- type,
105
- timestamp: store.getNode(to)?.startTime ?? 0,
106
- };
107
- store.addEdge(edge);
108
- edgeCount++;
109
- };
110
- // Explicit causedBy edges.
111
- for (const m of mapped) {
112
- const caused = m.node.context.causedByNodeId;
113
- if (caused && store.getNode(caused)) {
114
- addEdge(caused, m.node.id, m.parentAxis ?? 'caused', 'caused-by');
115
- }
116
- }
117
- // Same-flow chronological spine on the caused axis.
118
- const byFlow = new Map();
119
- for (const m of mapped) {
120
- const flowId = m.node.context.flowId;
121
- if (!flowId)
122
- continue;
123
- let arr = byFlow.get(flowId);
124
- if (!arr) {
125
- arr = [];
126
- byFlow.set(flowId, arr);
127
- }
128
- arr.push(m);
129
- }
130
- for (const chain of byFlow.values()) {
131
- for (let i = 1; i < chain.length; i++) {
132
- const prev = chain[i - 1].node;
133
- const cur = chain[i].node;
134
- // Skip if an explicit edge already connects them.
135
- if (cur.context.causedByNodeId === prev.id)
136
- continue;
137
- addEdge(prev.id, cur.id, 'caused', 'flow-sequence');
138
- }
139
- }
140
- return { nodeCount: mapped.length, edgeCount };
141
- }
1
+ "use strict";var g=Object.defineProperty;var u=(t,r)=>g(t,"name",{value:r,configurable:!0});var I=Object.defineProperty,l=u((t,r)=>I(t,"name",{value:r,configurable:!0}),"u");function m(t){const r=t.raw??null,c=r?.kind,s=t.context,a={id:t.id,parentTraceNodeId:s.causedByNodeId??s.originNodeId,lifecycle:"completed",context:s,startTime:t.timestamp,attributes:{...r??{}}};if(t.source==="react"&&c==="render"){const n=r;return{parentAxis:"render",node:{...a,type:"render",logicalId:n.render?.componentLogicalId,instanceId:n.render?.componentInstanceId,name:`render ${n.render?.componentName??"?"}`,durationMs:n.render?.actualDurationMs}}}if(t.source==="valtio"&&c==="state-update"){const n=r;return{parentAxis:"state",node:{...a,type:"state-update",logicalId:n.update?.storeId,name:`${n.update?.storeId??"?"}.${n.update?.path??"?"}`}}}if(t.source==="nestjs"&&c==="server-request"){const n=r;return{parentAxis:"backend",node:{...a,type:"server-request",name:`${n.request?.method??"?"} ${n.request?.route??"?"}`,durationMs:n.request?.durationMs}}}if(c==="loop")return{parentAxis:"performance",node:{...a,type:"loop",name:"loop",durationMs:r.loop?.durationMs}};if(t.source==="client-runtime"&&c==="user-action"){const n=r;return{parentAxis:"caused",node:{...a,type:"ui-event",logicalId:n.origin?.nodeId,name:n.origin?.label??t.title}}}}u(m,"f"),l(m,"eventToNode");export function buildGraphFromEvents(t,r){const c=[...r].sort((o,e)=>o.timestamp-e.timestamp),s=[];for(const o of c){const e=m(o);e&&(t.addNode(e.node),s.push(e))}let a=0;const n=l((o,e,d,i)=>{const f={id:`${d}:${o}->${e}`,from:o,to:e,axis:d,type:i,timestamp:t.getNode(e)?.startTime??0};t.addEdge(f),a++},"addEdge");for(const o of s){const e=o.node.context.causedByNodeId;e&&t.getNode(e)&&n(e,o.node.id,o.parentAxis??"caused","caused-by")}const p=new Map;for(const o of s){const e=o.node.context.flowId;if(!e)continue;let d=p.get(e);d||(d=[],p.set(e,d)),d.push(o)}for(const o of p.values())for(let e=1;e<o.length;e++){const d=o[e-1].node,i=o[e].node;i.context.causedByNodeId!==d.id&&n(d.id,i.id,"caused","flow-sequence")}return{nodeCount:s.length,edgeCount:a}}u(buildGraphFromEvents,"buildGraphFromEvents"),l(buildGraphFromEvents,"buildGraphFromEvents");
@@ -1,171 +1 @@
1
- const DEFAULT_DEPTH = 4;
2
- const DEFAULT_MAX_NODES = 1000;
3
- /**
4
- * Typed multi-axis trace graph. Hot-tier only (in-memory). Nodes and
5
- * edges are indexed for cheap per-axis traversal.
6
- */
7
- export class GraphStore {
8
- constructor() {
9
- this.nodes = new Map();
10
- this.edges = new Map();
11
- // Indices
12
- this.byParent = new Map();
13
- this.byFlow = new Map();
14
- this.byLogical = new Map();
15
- this.byInstance = new Map();
16
- this.byRequest = new Map();
17
- /** axis → { from-nodeId → Set<edgeId> } */
18
- this.outByAxis = new Map();
19
- /** axis → { to-nodeId → Set<edgeId> } */
20
- this.inByAxis = new Map();
21
- }
22
- addNode(node) {
23
- this.nodes.set(node.id, node);
24
- if (node.parentTraceNodeId) {
25
- this.addToIndex(this.byParent, node.parentTraceNodeId, node.id);
26
- }
27
- if (node.context.flowId) {
28
- this.addToIndex(this.byFlow, node.context.flowId, node.id);
29
- }
30
- if (node.logicalId) {
31
- this.addToIndex(this.byLogical, node.logicalId, node.id);
32
- }
33
- if (node.instanceId) {
34
- this.addToIndex(this.byInstance, node.instanceId, node.id);
35
- }
36
- if (node.context.requestId) {
37
- this.addToIndex(this.byRequest, node.context.requestId, node.id);
38
- }
39
- }
40
- updateNode(id, patch) {
41
- const existing = this.nodes.get(id);
42
- if (!existing)
43
- throw new Error(`Unknown node: ${id}`);
44
- const next = { ...existing, ...patch, id: existing.id };
45
- this.nodes.set(id, next);
46
- return next;
47
- }
48
- getNode(id) {
49
- return this.nodes.get(id);
50
- }
51
- addEdge(edge) {
52
- this.edges.set(edge.id, edge);
53
- let outMap = this.outByAxis.get(edge.axis);
54
- if (!outMap) {
55
- outMap = new Map();
56
- this.outByAxis.set(edge.axis, outMap);
57
- }
58
- this.addToIndex(outMap, edge.from, edge.id);
59
- let inMap = this.inByAxis.get(edge.axis);
60
- if (!inMap) {
61
- inMap = new Map();
62
- this.inByAxis.set(edge.axis, inMap);
63
- }
64
- this.addToIndex(inMap, edge.to, edge.id);
65
- }
66
- getEdge(id) {
67
- return this.edges.get(id);
68
- }
69
- // ---------- Indexed lookups ----------
70
- childrenOf(parentId) {
71
- return this.materialiseNodes(this.byParent.get(parentId));
72
- }
73
- nodesInFlow(flowId) {
74
- return this.materialiseNodes(this.byFlow.get(flowId));
75
- }
76
- nodesByLogical(logicalId) {
77
- return this.materialiseNodes(this.byLogical.get(logicalId));
78
- }
79
- nodesByInstance(instanceId) {
80
- return this.materialiseNodes(this.byInstance.get(instanceId));
81
- }
82
- nodesByRequest(requestId) {
83
- return this.materialiseNodes(this.byRequest.get(requestId));
84
- }
85
- // ---------- Lens traversal ----------
86
- traverse(opts) {
87
- const start = this.nodes.get(opts.from);
88
- if (!start)
89
- return { nodes: [], edges: [] };
90
- const axes = Array.isArray(opts.lens)
91
- ? opts.lens
92
- : [opts.lens];
93
- const direction = opts.direction ?? 'both';
94
- const depth = opts.depth ?? DEFAULT_DEPTH;
95
- const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
96
- const visitedNodes = new Set();
97
- const visitedEdges = new Set();
98
- const queue = [{ id: start.id, d: 0 }];
99
- while (queue.length) {
100
- if (visitedNodes.size >= maxNodes)
101
- break;
102
- const { id, d } = queue.shift();
103
- if (visitedNodes.has(id))
104
- continue;
105
- visitedNodes.add(id);
106
- if (d >= depth)
107
- continue;
108
- for (const axis of axes) {
109
- if (direction === 'out' || direction === 'both') {
110
- const outMap = this.outByAxis.get(axis);
111
- const edgeIds = outMap?.get(id);
112
- if (edgeIds) {
113
- for (const eid of edgeIds) {
114
- visitedEdges.add(eid);
115
- const edge = this.edges.get(eid);
116
- if (!visitedNodes.has(edge.to)) {
117
- queue.push({ id: edge.to, d: d + 1 });
118
- }
119
- }
120
- }
121
- }
122
- if (direction === 'in' || direction === 'both') {
123
- const inMap = this.inByAxis.get(axis);
124
- const edgeIds = inMap?.get(id);
125
- if (edgeIds) {
126
- for (const eid of edgeIds) {
127
- visitedEdges.add(eid);
128
- const edge = this.edges.get(eid);
129
- if (!visitedNodes.has(edge.from)) {
130
- queue.push({ id: edge.from, d: d + 1 });
131
- }
132
- }
133
- }
134
- }
135
- }
136
- }
137
- return {
138
- nodes: this.materialiseNodes(visitedNodes),
139
- edges: [...visitedEdges]
140
- .map((eid) => this.edges.get(eid))
141
- .filter((e) => Boolean(e)),
142
- };
143
- }
144
- // ---------- Stats ----------
145
- nodeCount() {
146
- return this.nodes.size;
147
- }
148
- edgeCount() {
149
- return this.edges.size;
150
- }
151
- // ---------- internals ----------
152
- addToIndex(index, key, value) {
153
- let set = index.get(key);
154
- if (!set) {
155
- set = new Set();
156
- index.set(key, set);
157
- }
158
- set.add(value);
159
- }
160
- materialiseNodes(ids) {
161
- if (!ids)
162
- return [];
163
- const out = [];
164
- for (const id of ids) {
165
- const n = this.nodes.get(id);
166
- if (n)
167
- out.push(n);
168
- }
169
- return out;
170
- }
171
- }
1
+ "use strict";var w=Object.defineProperty;var l=(h,e)=>w(h,"name",{value:e,configurable:!0});var p=Object.defineProperty,I=l((h,e)=>p(h,"name",{value:e,configurable:!0}),"l");const m=4,N=1e3;export class GraphStore{static{l(this,"GraphStore")}static{I(this,"GraphStore")}constructor(){this.nodes=new Map,this.edges=new Map,this.byParent=new Map,this.byFlow=new Map,this.byLogical=new Map,this.byInstance=new Map,this.byRequest=new Map,this.outByAxis=new Map,this.inByAxis=new Map}addNode(e){this.nodes.set(e.id,e),e.parentTraceNodeId&&this.addToIndex(this.byParent,e.parentTraceNodeId,e.id),e.context.flowId&&this.addToIndex(this.byFlow,e.context.flowId,e.id),e.logicalId&&this.addToIndex(this.byLogical,e.logicalId,e.id),e.instanceId&&this.addToIndex(this.byInstance,e.instanceId,e.id),e.context.requestId&&this.addToIndex(this.byRequest,e.context.requestId,e.id)}updateNode(e,s){const i=this.nodes.get(e);if(!i)throw new Error(`Unknown node: ${e}`);const t={...i,...s,id:i.id};return this.nodes.set(e,t),t}getNode(e){return this.nodes.get(e)}addEdge(e){this.edges.set(e.id,e);let s=this.outByAxis.get(e.axis);s||(s=new Map,this.outByAxis.set(e.axis,s)),this.addToIndex(s,e.from,e.id);let i=this.inByAxis.get(e.axis);i||(i=new Map,this.inByAxis.set(e.axis,i)),this.addToIndex(i,e.to,e.id)}getEdge(e){return this.edges.get(e)}childrenOf(e){return this.materialiseNodes(this.byParent.get(e))}nodesInFlow(e){return this.materialiseNodes(this.byFlow.get(e))}nodesByLogical(e){return this.materialiseNodes(this.byLogical.get(e))}nodesByInstance(e){return this.materialiseNodes(this.byInstance.get(e))}nodesByRequest(e){return this.materialiseNodes(this.byRequest.get(e))}traverse(e){const s=this.nodes.get(e.from);if(!s)return{nodes:[],edges:[]};const i=Array.isArray(e.lens)?e.lens:[e.lens],t=e.direction??"both",f=e.depth??4,x=e.maxNodes??1e3,n=new Set,g=new Set,c=[{id:s.id,d:0}];for(;c.length&&!(n.size>=x);){const{id:d,d:u}=c.shift();if(!n.has(d)&&(n.add(d),!(u>=f)))for(const y of i){if(t==="out"||t==="both"){const o=this.outByAxis.get(y)?.get(d);if(o)for(const a of o){g.add(a);const r=this.edges.get(a);n.has(r.to)||c.push({id:r.to,d:u+1})}}if(t==="in"||t==="both"){const o=this.inByAxis.get(y)?.get(d);if(o)for(const a of o){g.add(a);const r=this.edges.get(a);n.has(r.from)||c.push({id:r.from,d:u+1})}}}}return{nodes:this.materialiseNodes(n),edges:[...g].map(d=>this.edges.get(d)).filter(d=>!!d)}}nodeCount(){return this.nodes.size}edgeCount(){return this.edges.size}addToIndex(e,s,i){let t=e.get(s);t||(t=new Set,e.set(s,t)),t.add(i)}materialiseNodes(e){if(!e)return[];const s=[];for(const i of e){const t=this.nodes.get(i);t&&s.push(t)}return s}}
package/lib/patterns.js CHANGED
@@ -1,163 +1 @@
1
- /**
2
- * Flag `loop` events whose `dbCallsInsideLoop` exceeds the threshold.
3
- * Events are expected to carry `raw.kind === 'loop'` with a `loop`
4
- * attribute matching `LoopAttrs`.
5
- */
6
- export function detectDbInLoop(events, options = {}) {
7
- const threshold = options.threshold ?? 5;
8
- const out = [];
9
- for (const e of events) {
10
- const raw = (e.raw ?? null);
11
- if (raw?.kind !== 'loop' || !raw.loop)
12
- continue;
13
- const dbCalls = raw.loop.dbCallsInsideLoop ?? 0;
14
- if (dbCalls > threshold) {
15
- out.push({
16
- kind: 'db-in-loop',
17
- severity: 'warning',
18
- title: `${dbCalls} DB calls inside a loop (${raw.loop.iterations ?? '?'} iterations)`,
19
- evidence: [e.id],
20
- details: {
21
- dbCallsInsideLoop: dbCalls,
22
- iterations: raw.loop.iterations,
23
- durationMs: raw.loop.durationMs,
24
- threshold,
25
- },
26
- detectedAt: e.timestamp,
27
- });
28
- }
29
- }
30
- return out;
31
- }
32
- /**
33
- * Flag repeated identical DB query signatures. Events are expected to
34
- * carry `raw.kind === 'db-query'` with `raw.query.signature`.
35
- */
36
- export function detectNPlusOne(events, options = {}) {
37
- const threshold = options.threshold ?? 3;
38
- const groups = new Map();
39
- for (const e of events) {
40
- const raw = (e.raw ?? null);
41
- if (raw?.kind !== 'db-query' || !raw.query?.signature)
42
- continue;
43
- const sig = raw.query.signature;
44
- let arr = groups.get(sig);
45
- if (!arr) {
46
- arr = [];
47
- groups.set(sig, arr);
48
- }
49
- arr.push(e);
50
- }
51
- const out = [];
52
- for (const [signature, group] of groups) {
53
- if (group.length >= threshold) {
54
- out.push({
55
- kind: 'n+1-query',
56
- severity: 'warning',
57
- title: `Query repeated ${group.length}× — likely N+1`,
58
- evidence: group.map((e) => e.id),
59
- details: { signature, count: group.length, threshold },
60
- detectedAt: group[group.length - 1].timestamp,
61
- });
62
- }
63
- }
64
- return out;
65
- }
66
- /**
67
- * Flag a component instance that rendered more than `threshold` times
68
- * inside any `windowMs` window. Events are `source:'react'`,
69
- * `raw.kind === 'render'`, `raw.render.componentInstanceId`.
70
- */
71
- export function detectRenderStorm(events, options = {}) {
72
- const threshold = options.threshold ?? 5;
73
- const windowMs = options.windowMs ?? 250;
74
- const byInstance = new Map();
75
- for (const e of events) {
76
- if (e.source !== 'react')
77
- continue;
78
- const raw = (e.raw ?? null);
79
- if (raw?.kind !== 'render' || !raw.render?.componentInstanceId)
80
- continue;
81
- const id = raw.render.componentInstanceId;
82
- let arr = byInstance.get(id);
83
- if (!arr) {
84
- arr = [];
85
- byInstance.set(id, arr);
86
- }
87
- arr.push(e);
88
- }
89
- const out = [];
90
- for (const [instanceId, group] of byInstance) {
91
- group.sort((a, b) => a.timestamp - b.timestamp);
92
- // Sliding window: find the densest run.
93
- let start = 0;
94
- for (let end = 0; end < group.length; end++) {
95
- while (group[end].timestamp - group[start].timestamp > windowMs) {
96
- start++;
97
- }
98
- const count = end - start + 1;
99
- if (count > threshold) {
100
- const window = group.slice(start, end + 1);
101
- const name = window[0].raw.render
102
- ?.componentName ?? instanceId;
103
- out.push({
104
- kind: 'render-storm',
105
- severity: 'warning',
106
- title: `${name} rendered ${count}× in ${windowMs}ms`,
107
- evidence: window.map((e) => e.id),
108
- details: {
109
- componentInstanceId: instanceId,
110
- count,
111
- windowMs,
112
- threshold,
113
- },
114
- detectedAt: window[window.length - 1].timestamp,
115
- });
116
- break; // one match per instance is enough
117
- }
118
- }
119
- }
120
- return out;
121
- }
122
- /**
123
- * Find the single slowest cumulative chain on the caused/async axis,
124
- * given a precomputed adjacency (nodeId → outgoing nodeIds) and a
125
- * per-node duration map. Pure + bounded (visits each node once via
126
- * memoised DFS).
127
- */
128
- export function findSlowPath(roots, adjacency, durationOf) {
129
- const memo = new Map();
130
- const inProgress = new Set();
131
- function best(nodeId) {
132
- const cached = memo.get(nodeId);
133
- if (cached)
134
- return cached;
135
- if (inProgress.has(nodeId)) {
136
- // cycle guard
137
- return { path: [nodeId], totalDurationMs: durationOf(nodeId) };
138
- }
139
- inProgress.add(nodeId);
140
- const selfMs = durationOf(nodeId);
141
- const children = adjacency.get(nodeId) ?? [];
142
- let bestChild;
143
- for (const child of children) {
144
- const r = best(child);
145
- if (!bestChild || r.totalDurationMs > bestChild.totalDurationMs) {
146
- bestChild = r;
147
- }
148
- }
149
- inProgress.delete(nodeId);
150
- const result = bestChild
151
- ? { path: [nodeId, ...bestChild.path], totalDurationMs: selfMs + bestChild.totalDurationMs }
152
- : { path: [nodeId], totalDurationMs: selfMs };
153
- memo.set(nodeId, result);
154
- return result;
155
- }
156
- let overall = { path: [], totalDurationMs: 0 };
157
- for (const root of roots) {
158
- const r = best(root);
159
- if (r.totalDurationMs > overall.totalDurationMs)
160
- overall = r;
161
- }
162
- return overall;
163
- }
1
+ "use strict";var g=Object.defineProperty;var p=(c,a)=>g(c,"name",{value:a,configurable:!0});var M=Object.defineProperty,f=p((c,a)=>M(c,"name",{value:a,configurable:!0}),"p");export function detectDbInLoop(c,a={}){const d=a.threshold??5,i=[];for(const s of c){const n=s.raw??null;if(n?.kind!=="loop"||!n.loop)continue;const e=n.loop.dbCallsInsideLoop??0;e>d&&i.push({kind:"db-in-loop",severity:"warning",title:`${e} DB calls inside a loop (${n.loop.iterations??"?"} iterations)`,evidence:[s.id],details:{dbCallsInsideLoop:e,iterations:n.loop.iterations,durationMs:n.loop.durationMs,threshold:d},detectedAt:s.timestamp})}return i}p(detectDbInLoop,"detectDbInLoop"),f(detectDbInLoop,"detectDbInLoop");export function detectNPlusOne(c,a={}){const d=a.threshold??3,i=new Map;for(const n of c){const e=n.raw??null;if(e?.kind!=="db-query"||!e.query?.signature)continue;const t=e.query.signature;let o=i.get(t);o||(o=[],i.set(t,o)),o.push(n)}const s=[];for(const[n,e]of i)e.length>=d&&s.push({kind:"n+1-query",severity:"warning",title:`Query repeated ${e.length}\xD7 \u2014 likely N+1`,evidence:e.map(t=>t.id),details:{signature:n,count:e.length,threshold:d},detectedAt:e[e.length-1].timestamp});return s}p(detectNPlusOne,"detectNPlusOne"),f(detectNPlusOne,"detectNPlusOne");export function detectRenderStorm(c,a={}){const d=a.threshold??5,i=a.windowMs??250,s=new Map;for(const e of c){if(e.source!=="react")continue;const t=e.raw??null;if(t?.kind!=="render"||!t.render?.componentInstanceId)continue;const o=t.render.componentInstanceId;let r=s.get(o);r||(r=[],s.set(o,r)),r.push(e)}const n=[];for(const[e,t]of s){t.sort((r,u)=>r.timestamp-u.timestamp);let o=0;for(let r=0;r<t.length;r++){for(;t[r].timestamp-t[o].timestamp>i;)o++;const u=r-o+1;if(u>d){const l=t.slice(o,r+1),h=l[0].raw.render?.componentName??e;n.push({kind:"render-storm",severity:"warning",title:`${h} rendered ${u}\xD7 in ${i}ms`,evidence:l.map(m=>m.id),details:{componentInstanceId:e,count:u,windowMs:i,threshold:d},detectedAt:l[l.length-1].timestamp});break}}}return n}p(detectRenderStorm,"detectRenderStorm"),f(detectRenderStorm,"detectRenderStorm");export function findSlowPath(c,a,d){const i=new Map,s=new Set;function n(t){const o=i.get(t);if(o)return o;if(s.has(t))return{path:[t],totalDurationMs:d(t)};s.add(t);const r=d(t),u=a.get(t)??[];let l;for(const m of u){const w=n(m);(!l||w.totalDurationMs>l.totalDurationMs)&&(l=w)}s.delete(t);const h=l?{path:[t,...l.path],totalDurationMs:r+l.totalDurationMs}:{path:[t],totalDurationMs:r};return i.set(t,h),h}p(n,"o"),f(n,"best");let e={path:[],totalDurationMs:0};for(const t of c){const o=n(t);o.totalDurationMs>e.totalDurationMs&&(e=o)}return e}p(findSlowPath,"findSlowPath"),f(findSlowPath,"findSlowPath");
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Redaction + bounding for anything LensMCP writes to the event bus.
3
+ *
4
+ * ── Why this exists ─────────────────────────────────────────────────────────
5
+ * Until the browser taps were enriched they recorded almost nothing about an
6
+ * error, so there was nothing to leak. Enriching them (message, stack,
7
+ * method/URL/status) changed that: a request URL routinely carries a
8
+ * credential in its query string (`?access_token=…`, `?code=` on an OAuth
9
+ * callback, `?key=` on a Google API), and an error message can quote a JWT or
10
+ * an `Authorization` header. Those values then land in `runtime://browser/*`,
11
+ * which an agent reads and may quote into a transcript.
12
+ *
13
+ * ── Why it lives in `core` and not in the bridge ────────────────────────────
14
+ * It shipped inside `@lensmcp/bridge`, applied in `envelopeToEvent` — the ONE
15
+ * function every BROWSER envelope crosses. That covered the browser and only
16
+ * the browser. The GATEWAY publishes its own events straight onto the same bus
17
+ * without ever crossing that function, so it stayed unredacted — and the
18
+ * gateway sees every request from every app in the workspace, which makes it
19
+ * the higher-volume path by a wide margin. Observed on a real machine
20
+ * (foodguard, 1.18.4): a single request wrote
21
+ * `?access_token=sk_live_…` verbatim into `title`, `raw.url` AND
22
+ * `raw.steps[].url` of `.lensmcp/events.jsonl`.
23
+ *
24
+ * So the module moved DOWN to `layer:core`, where both the browser bridge
25
+ * (`layer:host`) and the cluster gateway (`layer:install`) can reach it. There
26
+ * is exactly one implementation; a second one would diverge and one of the two
27
+ * would be the one that misses. `@lensmcp/bridge` re-exports every symbol, so
28
+ * its public API is unchanged.
29
+ *
30
+ * It is imported through the dependency-free `@lensmcp/core/redact` subpath
31
+ * rather than the package root, so the prod gateway pulls in this one file and
32
+ * not the whole core graph (event bus, graph store, zod).
33
+ *
34
+ * `nx.json` has carried a `redaction: { headers, paths }` block since the init
35
+ * generator was written, with no reader anywhere in the repo. This is the
36
+ * first code to honour that intent; the lists below are a superset of it.
37
+ *
38
+ * ── Deliberately NOT done here ──────────────────────────────────────────────
39
+ * • Request/response HEADERS are never captured in the first place (the
40
+ * browser taps read none, and the gateway trace records none), which is the
41
+ * strongest possible defence — nothing to redact. Keep it that way; do not
42
+ * add header capture without extending this module.
43
+ * • Request/response BODIES are likewise never captured.
44
+ */
45
+ /** Replacement for a redacted value. Short, obvious, greppable. */
46
+ export declare const REDACTED = "REDACTED";
47
+ /**
48
+ * Bounds. `raw` is structurally clamped to 32KiB by the bus transform
49
+ * (`createEventClamp`, wired in `@lensmcp/session`), but `title`/`message` are
50
+ * NOT — they are plain strings on `BaseEvent` and reach every reader
51
+ * unclamped. A stack from a deep React tree is routinely tens of KB, so these
52
+ * are the bounds that actually keep an event small.
53
+ */
54
+ export declare const MAX_TITLE_CHARS = 300;
55
+ export declare const MAX_MESSAGE_CHARS = 4096;
56
+ export declare const MAX_URL_CHARS = 1024;
57
+ /** Is this query-parameter name carrying a credential? */
58
+ export declare function isSecretParam(name: string): boolean;
59
+ /**
60
+ * Strip credentials from a URL — userinfo, then any query OR fragment
61
+ * parameter whose NAME looks like a credential — preserving every other byte
62
+ * and applying no length bound.
63
+ *
64
+ * Split out of {@link redactUrl} because the same pass has to run over strings
65
+ * that are not *only* a URL: a gateway event's title is
66
+ * `gateway GET host/path?a=1&token=… → 401 (3ms)`, and bounding that at
67
+ * `MAX_URL_CHARS` would be the wrong limit. `PARAM_RE` is not anchored, so it
68
+ * finds the pairs wherever they sit in the surrounding prose, and
69
+ * {@link isSecretParam} keeps `?limit=1` and `→ 401 (3ms)` untouched.
70
+ *
71
+ * Implemented as a regex rewrite rather than `new URL(...)` on purpose:
72
+ * `URL` re-encodes as it round-trips (`+` vs `%20`, dropped default ports,
73
+ * sorted-looking output), which would mean the URL an agent reads is not the
74
+ * URL the app requested. It also throws on relative inputs, which is most of
75
+ * what a SPA fetches. This keeps the string byte-identical apart from the
76
+ * redacted values, and never throws.
77
+ *
78
+ * The fragment matters — an OAuth implicit-flow callback puts the access token
79
+ * there (`#access_token=…`).
80
+ */
81
+ export declare function redactUrlParams(value: string): string;
82
+ /** {@link redactUrlParams}, bounded — the standard treatment for a captured URL. */
83
+ export declare function redactUrl(url: string): string;
84
+ /**
85
+ * Scrub credentials out of free text (an event title, an error message, a
86
+ * stack, a console line). Cheap, linear, and tuned for precision over recall —
87
+ * a false positive destroys the diagnostic value this whole change is adding.
88
+ *
89
+ * Runs {@link redactUrlParams} FIRST, so a URL embedded in prose is cleaned
90
+ * wherever it appears. The bridge originally left free text alone on the
91
+ * grounds that "widening that means running the URL redactor over every
92
+ * message, which is a cost/false-positive trade not worth making blind" — the
93
+ * gateway leak above made it no longer blind. A gateway event's title IS a URL
94
+ * in prose, and it was the field that leaked. The trade is cheap on both
95
+ * counts: one extra linear pass, and the false-positive surface is
96
+ * {@link isSecretParam}, the exact predicate {@link redactUrl} already trusts
97
+ * (`?author=`/`?keywords=` survive, and a string with no `?name=value` pair
98
+ * comes back byte-identical).
99
+ */
100
+ export declare function redactText(text: string): string;
101
+ /**
102
+ * Bound a string, marking what was removed. Marker text matches
103
+ * `@lensmcp/storage`'s `clampEventRaw` so the two truncations read the same in
104
+ * a transcript.
105
+ */
106
+ export declare function clip(value: string, max: number): string;
107
+ /** Redact then bound — the standard treatment for any captured free text. */
108
+ export declare function safeText(value: string | undefined, max?: number): string | undefined;
109
+ /** Redact then bound — the standard treatment for a captured title. */
110
+ export declare function safeTitle(value: string): string;
111
+ /**
112
+ * Scrub credentials out of every string leaf of an arbitrary value.
113
+ *
114
+ * Needed for `console` arguments, which are the one captured payload that is
115
+ * genuinely unstructured — `console.log('auth', { token })` is ordinary
116
+ * debugging code, and the whole point of that change is that those payloads
117
+ * now reach a transcript. It is also what covers a structured `raw`: the
118
+ * gateway's request event carries the URL at `raw.url` AND again at
119
+ * `raw.steps[].url`, and walking every string leaf catches BOTH plus whatever
120
+ * URL-bearing field a future event type adds — which naming the fields
121
+ * one-by-one would not. Bounded on depth and array length so a pathological
122
+ * object cannot make redaction the slow part of a page's logging.
123
+ *
124
+ * Values are returned by reference when nothing changed, so the common case
125
+ * (no credential anywhere) allocates nothing.
126
+ */
127
+ export declare function redactDeep(value: unknown, depth?: number): unknown;
package/lib/redact.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";var f=Object.defineProperty;var o=(e,r)=>f(e,"name",{value:r,configurable:!0});var l=Object.defineProperty,a=o((e,r)=>l(e,"name",{value:r,configurable:!0}),"o");export const REDACTED="REDACTED",MAX_TITLE_CHARS=300,MAX_MESSAGE_CHARS=4096,MAX_URL_CHARS=1024;const d=["token","secret","password","passwd","apikey","credential","assertion","signature"],x=new Set(["key","auth","authorization","jwt","code","sig","pwd","pass","session","sid","bearer","otp","cookie","set-cookie"]);export function isSecretParam(e){const r=e.toLowerCase();if(d.some(t=>r.includes(t)))return!0;for(const t of r.split(/[-_.]+/))if(t&&x.has(t))return!0;return!1}o(isSecretParam,"isSecretParam"),a(isSecretParam,"isSecretParam");const A=/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]*@/i,$=/([?&#])([^=&#\s]+)=([^&#\s]*)/g;export function redactUrlParams(e){return e&&e.replace(A,`$1${REDACTED}@`).replace($,(r,t,s)=>isSecretParam(s)?`${t}${s}=${REDACTED}`:r)}o(redactUrlParams,"redactUrlParams"),a(redactUrlParams,"redactUrlParams");export function redactUrl(e){return e&&clip(redactUrlParams(e),1024)}o(redactUrl,"redactUrl"),a(redactUrl,"redactUrl");const _=/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g,b=/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,y=/\b(authorization|x-api-key|api-key|cookie|set-cookie|proxy-authorization)\b(\s*[:=]\s*)(["']?)([^\s"',;]{4,})\3/gi;export function redactText(e){return redactUrlParams(e).replace(y,(r,t,s,n)=>`${t}${s}${n}${REDACTED}${n}`).replace(_,REDACTED).replace(b,(r,t)=>`${t} ${REDACTED}`)}o(redactText,"redactText"),a(redactText,"redactText");export function clip(e,r){return e.length<=r?e:`${e.slice(0,r)}\u2026[lensmcp truncated: ${e.length-r} more chars]`}o(clip,"clip"),a(clip,"clip");export function safeText(e,r=4096){if(e!==void 0)return clip(redactText(e),r)}o(safeText,"safeText"),a(safeText,"safeText");export function safeTitle(e){return clip(redactText(e),300)}o(safeTitle,"safeTitle"),a(safeTitle,"safeTitle");const T=6,g=64;export function redactDeep(e,r=0){if(typeof e=="string")return redactText(e);if(e===null||typeof e!="object"||r>=T)return e;if(Array.isArray(e)){let n=!1;const i=e.map((c,p)=>{if(p>=g)return c;const u=redactDeep(c,r+1);return u!==c&&(n=!0),u});return n?i:e}let t=!1;const s={};for(const[n,i]of Object.entries(e)){const c=isSecretParam(n)&&i!==void 0&&i!==null?REDACTED:redactDeep(i,r+1);c!==i&&(t=!0),s[n]=c}return t?s:e}o(redactDeep,"redactDeep"),a(redactDeep,"redactDeep");
@@ -1,28 +1 @@
1
- export class ReducerRegistry {
2
- constructor() {
3
- this.reducers = [];
4
- }
5
- register(name, reducer) {
6
- if (this.reducers.some((r) => r.name === name)) {
7
- throw new Error(`Reducer already registered: ${name}`);
8
- }
9
- this.reducers.push({ name, reducer });
10
- }
11
- list() {
12
- return this.reducers;
13
- }
14
- /** Run every registered reducer against one event. */
15
- async dispatch(event) {
16
- for (const { reducer } of this.reducers) {
17
- try {
18
- const r = reducer(event);
19
- if (r && typeof r.then === 'function') {
20
- await r;
21
- }
22
- }
23
- catch {
24
- /* swallow reducer errors */
25
- }
26
- }
27
- }
28
- }
1
+ "use strict";var i=Object.defineProperty;var c=(t,e)=>i(t,"name",{value:e,configurable:!0});var u=Object.defineProperty,a=c((t,e)=>u(t,"name",{value:e,configurable:!0}),"c");export class ReducerRegistry{static{c(this,"ReducerRegistry")}static{a(this,"ReducerRegistry")}constructor(){this.reducers=[]}register(e,s){if(this.reducers.some(r=>r.name===e))throw new Error(`Reducer already registered: ${e}`);this.reducers.push({name:e,reducer:s})}list(){return this.reducers}async dispatch(e){for(const{reducer:s}of this.reducers)try{const r=s(e);r&&typeof r.then=="function"&&await r}catch{}}}
@@ -1,71 +1 @@
1
- /**
2
- * The resource store. URIs are registered once; reading is cheap; the
3
- * `markDirty` call bumps the revision counter and notifies subscribers
4
- * only when the produced JSON actually changes (diff-aware).
5
- */
6
- export class ResourceStore {
7
- constructor() {
8
- this.entries = new Map();
9
- this.subs = new Map();
10
- }
11
- register(uri, produce) {
12
- if (this.entries.has(uri)) {
13
- throw new Error(`Resource already registered: ${uri}`);
14
- }
15
- this.entries.set(uri, { uri, produce, revision: 0 });
16
- return () => {
17
- this.entries.delete(uri);
18
- this.subs.delete(uri);
19
- };
20
- }
21
- async read(uri) {
22
- const entry = this.entries.get(uri);
23
- if (!entry)
24
- throw new Error(`Unknown resource: ${uri}`);
25
- return entry.produce();
26
- }
27
- list() {
28
- return [...this.entries.keys()];
29
- }
30
- /**
31
- * Recompute the JSON, bump the revision counter if it differs, and
32
- * notify subscribers. No-op if the URI is unknown or the JSON didn't
33
- * change.
34
- */
35
- async markDirty(uri) {
36
- const entry = this.entries.get(uri);
37
- if (!entry)
38
- return;
39
- const next = await entry.produce();
40
- const serialised = JSON.stringify(next);
41
- if (serialised === entry.lastJson)
42
- return;
43
- entry.lastJson = serialised;
44
- entry.revision += 1;
45
- const handlers = this.subs.get(uri);
46
- if (handlers) {
47
- for (const h of handlers) {
48
- try {
49
- h(uri, entry.revision);
50
- }
51
- catch {
52
- /* swallow handler errors */
53
- }
54
- }
55
- }
56
- }
57
- revision(uri) {
58
- return this.entries.get(uri)?.revision;
59
- }
60
- subscribe(uri, handler) {
61
- let set = this.subs.get(uri);
62
- if (!set) {
63
- set = new Set();
64
- this.subs.set(uri, set);
65
- }
66
- set.add(handler);
67
- return () => {
68
- set?.delete(handler);
69
- };
70
- }
71
- }
1
+ "use strict";var u=Object.defineProperty;var i=(r,e)=>u(r,"name",{value:e,configurable:!0});var a=Object.defineProperty,h=i((r,e)=>a(r,"name",{value:e,configurable:!0}),"o");export class ResourceStore{static{i(this,"ResourceStore")}static{h(this,"ResourceStore")}constructor(){this.entries=new Map,this.subs=new Map}register(e,t){if(this.entries.has(e))throw new Error(`Resource already registered: ${e}`);return this.entries.set(e,{uri:e,produce:t,revision:0}),()=>{this.entries.delete(e),this.subs.delete(e)}}async read(e){const t=this.entries.get(e);if(!t)throw new Error(`Unknown resource: ${e}`);return t.produce()}list(){return[...this.entries.keys()]}async markDirty(e){const t=this.entries.get(e);if(!t)return;const s=await t.produce(),n=JSON.stringify(s);if(n===t.lastJson)return;t.lastJson=n,t.revision+=1;const o=this.subs.get(e);if(o)for(const c of o)try{c(e,t.revision)}catch{}}revision(e){return this.entries.get(e)?.revision}subscribe(e,t){let s=this.subs.get(e);return s||(s=new Set,this.subs.set(e,s)),s.add(t),()=>{s?.delete(t)}}}
package/lib/story.js CHANGED
@@ -1,199 +1 @@
1
- /** Group events by `context.flowId`. Events without a flowId are dropped. */
2
- export function groupByFlow(events) {
3
- const out = new Map();
4
- for (const e of events) {
5
- const flowId = e.context.flowId;
6
- if (!flowId)
7
- continue;
8
- let arr = out.get(flowId);
9
- if (!arr) {
10
- arr = [];
11
- out.set(flowId, arr);
12
- }
13
- arr.push(e);
14
- }
15
- return out;
16
- }
17
- /**
18
- * Compile a single flow's events into a `FlowStory`. The caller can
19
- * either pre-filter events (preferred) or pass everything and supply
20
- * `options.flowId`.
21
- */
22
- export function compileStory(events, options = {}) {
23
- let pool;
24
- let flowId;
25
- if (options.flowId) {
26
- flowId = options.flowId;
27
- pool = events.filter((e) => e.context.flowId === flowId);
28
- }
29
- else {
30
- const first = events.find((e) => e.context.flowId);
31
- if (!first)
32
- return undefined;
33
- flowId = first.context.flowId;
34
- pool = events.filter((e) => e.context.flowId === flowId);
35
- }
36
- if (pool.length === 0)
37
- return undefined;
38
- pool.sort((a, b) => a.timestamp - b.timestamp);
39
- // Bound the compiled narrative: a story is a READABLE mechanism trace, and stories are pinned
40
- // (50 of them) in a long-lived registry — an unbounded flow (event flood, long-lived flowId)
41
- // must not compile into hundreds of thousands of phases each holding refs into event.raw.
42
- // Keep the origin plus the most RECENT window (the resolution is what the reader needs).
43
- const MAX_STORY_EVENTS = 1000;
44
- if (pool.length > MAX_STORY_EVENTS) {
45
- pool = [pool[0], ...pool.slice(pool.length - (MAX_STORY_EVENTS - 1))];
46
- }
47
- const origin = pool[0];
48
- const phases = [];
49
- let finalRender;
50
- let finalVisualFrame;
51
- for (const event of pool) {
52
- const phase = classifyEvent(event);
53
- if (!phase)
54
- continue;
55
- phases.push(phase);
56
- if (phase.kind === 'render' || phase.kind === 'final-render') {
57
- const raw = event.raw;
58
- if (raw?.render) {
59
- finalRender = {
60
- id: raw.render.id,
61
- page: raw.render.page,
62
- route: raw.render.route,
63
- componentName: raw.render.componentName,
64
- };
65
- }
66
- }
67
- else if (event.source === 'visual' && phase.kind === 'visual-violation') {
68
- const raw = event.raw;
69
- if (raw?.frame)
70
- finalVisualFrame = raw.frame.id;
71
- }
72
- }
73
- // Promote the final `render` phase (if any) to `final-render`.
74
- for (let i = phases.length - 1; i >= 0; i--) {
75
- if (phases[i].kind === 'render') {
76
- phases[i] = { ...phases[i], kind: 'final-render' };
77
- break;
78
- }
79
- }
80
- const startedAt = pool[0].timestamp;
81
- const endedAt = pool[pool.length - 1].timestamp;
82
- return {
83
- flowId,
84
- origin: {
85
- type: origin.context.originType,
86
- nodeId: origin.context.originNodeId,
87
- at: origin.timestamp,
88
- },
89
- phases,
90
- finalState: finalRender
91
- ? {
92
- page: finalRender.page,
93
- route: finalRender.route,
94
- componentName: finalRender.componentName,
95
- renderId: finalRender.id,
96
- visualFrameId: finalVisualFrame,
97
- }
98
- : undefined,
99
- startedAt,
100
- endedAt,
101
- eventCount: pool.length,
102
- };
103
- }
104
- /**
105
- * Classify a single event into a story phase. Returns undefined for
106
- * events that don't carry phase semantics (we still keep them under
107
- * `flow://flow/{flowId}.events` but don't surface them in the story).
108
- */
109
- function classifyEvent(event) {
110
- const raw = (event.raw ?? null);
111
- const kind = raw?.kind;
112
- // Flow origin events (synthetic user-action emitter)
113
- if (kind === 'flow-origin' || (event.source === 'client-runtime' && kind === 'user-action')) {
114
- const r = event.raw;
115
- return {
116
- kind: 'user-action',
117
- at: event.timestamp,
118
- label: r?.origin?.label ?? event.title,
119
- eventIds: [event.id],
120
- };
121
- }
122
- // React renders
123
- if (event.source === 'react' && kind === 'render') {
124
- const r = event.raw;
125
- return {
126
- kind: 'render',
127
- at: event.timestamp,
128
- label: `render ${r?.render?.componentName ?? '?'}`,
129
- eventIds: [event.id],
130
- details: r?.render
131
- ? { componentName: r.render.componentName, durationMs: r.render.actualDurationMs }
132
- : undefined,
133
- };
134
- }
135
- // Slot transitions (loading / idle UI)
136
- if (event.source === 'react' && kind === 'slot-content-changed') {
137
- const r = event.raw;
138
- const to = r?.slot?.toComponent ?? '?';
139
- const isLoading = /spinner|loader|loading/i.test(to);
140
- return {
141
- kind: isLoading ? 'loading-ui' : 'idle-ui',
142
- at: event.timestamp,
143
- label: `slot ${r?.slot?.slotLogicalId ?? ''} → ${to}`,
144
- eventIds: [event.id],
145
- };
146
- }
147
- // Route transitions (client-runtime emits)
148
- if (event.source === 'client-runtime' && kind === 'route') {
149
- const r = event.raw;
150
- return {
151
- kind: 'route-transition',
152
- at: event.timestamp,
153
- label: `route ${r?.from ?? ''} → ${r?.url ?? ''}`,
154
- eventIds: [event.id],
155
- };
156
- }
157
- // Valtio state-update
158
- if (event.source === 'valtio' && kind === 'state-update') {
159
- const r = event.raw;
160
- return {
161
- kind: 'state-update',
162
- at: event.timestamp,
163
- label: `${r?.update?.storeId ?? '?'}.${r?.update?.path ?? '?'}`,
164
- eventIds: [event.id],
165
- details: r?.update,
166
- };
167
- }
168
- // Backend (NestJS) server-request
169
- if (event.source === 'nestjs' && kind === 'server-request') {
170
- const r = event.raw;
171
- return {
172
- kind: 'backend-request',
173
- at: event.timestamp,
174
- label: `${r?.request?.method ?? '?'} ${r?.request?.route ?? '?'} → ${r?.request?.status ?? '?'}`,
175
- eventIds: [event.id],
176
- details: r?.request,
177
- };
178
- }
179
- // Chrome fetch (browser side) — synthesised by client-runtime.
180
- if (event.source === 'chrome' && event.category === 'network') {
181
- return {
182
- kind: 'backend-request',
183
- at: event.timestamp,
184
- label: event.title,
185
- eventIds: [event.id],
186
- };
187
- }
188
- // Visual violations
189
- if (event.source === 'visual' && kind === 'visual-violation') {
190
- const r = event.raw;
191
- return {
192
- kind: 'visual-violation',
193
- at: event.timestamp,
194
- label: `${r?.violation?.ruleType ?? 'rule'} ${r?.violation?.ruleId ?? ''}`,
195
- eventIds: [event.id],
196
- };
197
- }
198
- return undefined;
199
- }
1
+ "use strict";var w=Object.defineProperty;var u=(e,n)=>w(e,"name",{value:n,configurable:!0});var I=Object.defineProperty,m=u((e,n)=>I(e,"name",{value:n,configurable:!0}),"c");export function groupByFlow(e){const n=new Map;for(const t of e){const o=t.context.flowId;if(!o)continue;let s=n.get(o);s||(s=[],n.set(o,s)),s.push(t)}return n}u(groupByFlow,"groupByFlow"),m(groupByFlow,"groupByFlow");export function compileStory(e,n={}){let t,o;if(n.flowId)o=n.flowId,t=e.filter(r=>r.context.flowId===o);else{const r=e.find(i=>i.context.flowId);if(!r)return;o=r.context.flowId,t=e.filter(i=>i.context.flowId===o)}if(t.length===0)return;t.sort((r,i)=>r.timestamp-i.timestamp);const s=1e3;t.length>s&&(t=[t[0],...t.slice(t.length-(s-1))]);const c=t[0],d=[];let l,p;for(const r of t){const i=f(r);if(i){if(d.push(i),i.kind==="render"||i.kind==="final-render"){const a=r.raw;a?.render&&(l={id:a.render.id,page:a.render.page,route:a.render.route,componentName:a.render.componentName})}else if(r.source==="visual"&&i.kind==="visual-violation"){const a=r.raw;a?.frame&&(p=a.frame.id)}}}for(let r=d.length-1;r>=0;r--)if(d[r].kind==="render"){d[r]={...d[r],kind:"final-render"};break}const v=t[0].timestamp,g=t[t.length-1].timestamp;return{flowId:o,origin:{type:c.context.originType,nodeId:c.context.originNodeId,at:c.timestamp},phases:d,finalState:l?{page:l.page,route:l.route,componentName:l.componentName,renderId:l.id,visualFrameId:p}:void 0,startedAt:v,endedAt:g,eventCount:t.length}}u(compileStory,"compileStory"),m(compileStory,"compileStory");function f(e){const n=(e.raw??null)?.kind;if(n==="flow-origin"||e.source==="client-runtime"&&n==="user-action"){const t=e.raw;return{kind:"user-action",at:e.timestamp,label:t?.origin?.label??e.title,eventIds:[e.id]}}if(e.source==="react"&&n==="render"){const t=e.raw;return{kind:"render",at:e.timestamp,label:`render ${t?.render?.componentName??"?"}`,eventIds:[e.id],details:t?.render?{componentName:t.render.componentName,durationMs:t.render.actualDurationMs}:void 0}}if(e.source==="react"&&n==="slot-content-changed"){const t=e.raw,o=t?.slot?.toComponent??"?";return{kind:/spinner|loader|loading/i.test(o)?"loading-ui":"idle-ui",at:e.timestamp,label:`slot ${t?.slot?.slotLogicalId??""} \u2192 ${o}`,eventIds:[e.id]}}if(e.source==="client-runtime"&&n==="route"){const t=e.raw;return{kind:"route-transition",at:e.timestamp,label:`route ${t?.from??""} \u2192 ${t?.url??""}`,eventIds:[e.id]}}if(e.source==="valtio"&&n==="state-update"){const t=e.raw;return{kind:"state-update",at:e.timestamp,label:`${t?.update?.storeId??"?"}.${t?.update?.path??"?"}`,eventIds:[e.id],details:t?.update}}if(e.source==="nestjs"&&n==="server-request"){const t=e.raw;return{kind:"backend-request",at:e.timestamp,label:`${t?.request?.method??"?"} ${t?.request?.route??"?"} \u2192 ${t?.request?.status??"?"}`,eventIds:[e.id],details:t?.request}}if(e.source==="chrome"&&e.category==="network")return{kind:"backend-request",at:e.timestamp,label:e.title,eventIds:[e.id]};if(e.source==="visual"&&n==="visual-violation"){const t=e.raw;return{kind:"visual-violation",at:e.timestamp,label:`${t?.violation?.ruleType??"rule"} ${t?.violation?.ruleId??""}`,eventIds:[e.id]}}}u(f,"g"),m(f,"classifyEvent");
package/lib/thresholds.js CHANGED
@@ -1,23 +1 @@
1
- export const DEFAULT_THRESHOLDS = {
2
- dbInLoop: 5,
3
- nPlusOne: 3,
4
- renderStorm: 5,
5
- slowRouteMs: 500,
6
- slowRenderMs: 16,
7
- };
8
- /**
9
- * Merge partial overrides over the defaults, ignoring any non-finite /
10
- * non-numeric values (so a malformed config can't disable detection).
11
- */
12
- export function resolveThresholds(overrides) {
13
- const out = { ...DEFAULT_THRESHOLDS };
14
- if (overrides) {
15
- for (const key of Object.keys(DEFAULT_THRESHOLDS)) {
16
- const v = overrides[key];
17
- if (typeof v === 'number' && Number.isFinite(v) && v >= 0) {
18
- out[key] = v;
19
- }
20
- }
21
- }
22
- return out;
23
- }
1
+ "use strict";var t=Object.defineProperty;var s=(e,r)=>t(e,"name",{value:r,configurable:!0});var c=Object.defineProperty,l=s((e,r)=>c(e,"name",{value:r,configurable:!0}),"s");export const DEFAULT_THRESHOLDS={dbInLoop:5,nPlusOne:3,renderStorm:5,slowRouteMs:500,slowRenderMs:16};export function resolveThresholds(e){const r={...DEFAULT_THRESHOLDS};if(e)for(const n of Object.keys(DEFAULT_THRESHOLDS)){const o=e[n];typeof o=="number"&&Number.isFinite(o)&&o>=0&&(r[n]=o)}return r}s(resolveThresholds,"resolveThresholds"),l(resolveThresholds,"resolveThresholds");
package/lib/ulid.js CHANGED
@@ -1,32 +1 @@
1
- /**
2
- * Tiny dependency-free ULID-like ID generator.
3
- *
4
- * Real ULIDs are 26 chars Crockford-Base32. Ours are 26 chars of the same
5
- * alphabet for compatibility, with the 48-bit timestamp prefix and 80
6
- * bits of `crypto.getRandomValues` randomness. Sortable by creation time.
7
- */
8
- const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
9
- const RANDOM_LEN = 16;
10
- function encodeTime(now, len) {
11
- let out = '';
12
- let n = now;
13
- for (let i = len - 1; i >= 0; i--) {
14
- const mod = n % 32;
15
- out = ALPHABET[mod] + out;
16
- n = (n - mod) / 32;
17
- }
18
- return out;
19
- }
20
- function encodeRandom(len) {
21
- const bytes = new Uint8Array(len);
22
- // Node 24 has global crypto; this is also fine in modern browsers.
23
- globalThis.crypto.getRandomValues(bytes);
24
- let out = '';
25
- for (let i = 0; i < len; i++) {
26
- out += ALPHABET[(bytes[i] ?? 0) & 31];
27
- }
28
- return out;
29
- }
30
- export function ulid(now = Date.now()) {
31
- return encodeTime(now, 10) + encodeRandom(RANDOM_LEN);
32
- }
1
+ "use strict";var d=Object.defineProperty;var r=(e,n)=>d(e,"name",{value:n,configurable:!0});var f=Object.defineProperty,c=r((e,n)=>f(e,"name",{value:n,configurable:!0}),"r");const i="0123456789ABCDEFGHJKMNPQRSTVWXYZ",b=16;function l(e,n){let o="",t=e;for(let u=n-1;u>=0;u--){const a=t%32;o=i[a]+o,t=(t-a)/32}return o}r(l,"s"),c(l,"encodeTime");function s(e){const n=new Uint8Array(e);globalThis.crypto.getRandomValues(n);let o="";for(let t=0;t<e;t++)o+=i[(n[t]??0)&31];return o}r(s,"a"),c(s,"encodeRandom");export function ulid(e=Date.now()){return l(e,10)+s(16)}r(ulid,"ulid"),c(ulid,"ulid");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/core",
3
- "version": "1.18.4",
3
+ "version": "1.18.7",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -11,10 +11,15 @@
11
11
  "types": "./index.d.ts",
12
12
  "import": "./index.js",
13
13
  "default": "./index.js"
14
+ },
15
+ "./redact": {
16
+ "types": "./lib/redact.d.ts",
17
+ "import": "./lib/redact.js",
18
+ "default": "./lib/redact.js"
14
19
  }
15
20
  },
16
21
  "dependencies": {
17
- "@lensmcp/protocol-types": "1.18.4",
22
+ "@lensmcp/protocol-types": "1.18.7",
18
23
  "tslib": "^2.3.0"
19
24
  },
20
25
  "license": "Apache-2.0",